1969b14c4SAndreas Gohr<?php 2969b14c4SAndreas Gohr 3969b14c4SAndreas Gohrnamespace dokuwiki\plugin\captcha; 4969b14c4SAndreas Gohr 5969b14c4SAndreas Gohr/** 6969b14c4SAndreas Gohr * A simple mechanism to count login failures for IP addresses 7*194d3386SAndreas Gohr * 8*194d3386SAndreas Gohr * Counter files are stored in date-based directories for easy cleanup. 9*194d3386SAndreas Gohr * Note: Counters reset at midnight when a new directory is used. 10969b14c4SAndreas Gohr */ 11969b14c4SAndreas Gohrclass IpCounter 12969b14c4SAndreas Gohr{ 13*194d3386SAndreas Gohr /** @var string The client IP address being tracked */ 14969b14c4SAndreas Gohr protected $ip; 15*194d3386SAndreas Gohr 16*194d3386SAndreas Gohr /** @var string File path where the failure counter is stored */ 17969b14c4SAndreas Gohr protected $store; 18969b14c4SAndreas Gohr 19*194d3386SAndreas Gohr /** @var int Base delay in seconds for exponential timeout calculation */ 20*194d3386SAndreas Gohr protected $base; 21*194d3386SAndreas Gohr 22*194d3386SAndreas Gohr /** @var int Maximum delay in seconds (cap for exponential timeout) */ 23*194d3386SAndreas Gohr protected $max; 24*194d3386SAndreas Gohr 25969b14c4SAndreas Gohr /** 26969b14c4SAndreas Gohr * Initialize the counter 27969b14c4SAndreas Gohr */ 28969b14c4SAndreas Gohr public function __construct() 29969b14c4SAndreas Gohr { 30*194d3386SAndreas Gohr global $conf; 31969b14c4SAndreas Gohr $this->ip = clientIP(true); 32*194d3386SAndreas Gohr $this->store = $conf['tmpdir'] . '/captcha/ip/' . date('Y-m-d') . '/' . md5($this->ip) . '.ip'; 33*194d3386SAndreas Gohr io_makeFileDir($this->store); 34*194d3386SAndreas Gohr 35*194d3386SAndreas Gohr $this->base = (int)($conf['plugin']['captcha']['logindenial'] ?? 0); 36*194d3386SAndreas Gohr $this->max = (int)($conf['plugin']['captcha']['logindenial_max'] ?? 3600); 37969b14c4SAndreas Gohr } 38969b14c4SAndreas Gohr 39969b14c4SAndreas Gohr /** 40969b14c4SAndreas Gohr * Increases the counter by adding a byte 41969b14c4SAndreas Gohr * 42969b14c4SAndreas Gohr * @return void 43969b14c4SAndreas Gohr */ 44969b14c4SAndreas Gohr public function increment() 45969b14c4SAndreas Gohr { 46969b14c4SAndreas Gohr io_saveFile($this->store, '1', true); 47969b14c4SAndreas Gohr } 48969b14c4SAndreas Gohr 49969b14c4SAndreas Gohr /** 50969b14c4SAndreas Gohr * Return the current counter 51969b14c4SAndreas Gohr * 52969b14c4SAndreas Gohr * @return int 53969b14c4SAndreas Gohr */ 54969b14c4SAndreas Gohr public function get() 55969b14c4SAndreas Gohr { 56969b14c4SAndreas Gohr return (int)@filesize($this->store); 57969b14c4SAndreas Gohr } 58969b14c4SAndreas Gohr 59969b14c4SAndreas Gohr /** 60969b14c4SAndreas Gohr * Reset the counter to zero 61969b14c4SAndreas Gohr * 62969b14c4SAndreas Gohr * @return void 63969b14c4SAndreas Gohr */ 64969b14c4SAndreas Gohr public function reset() 65969b14c4SAndreas Gohr { 66969b14c4SAndreas Gohr @unlink($this->store); 67969b14c4SAndreas Gohr } 68563fb566SAndreas Gohr 69563fb566SAndreas Gohr /** 70563fb566SAndreas Gohr * Get timestamp of last failed attempt 71563fb566SAndreas Gohr * 72563fb566SAndreas Gohr * @return int Unix timestamp, 0 if no attempts 73563fb566SAndreas Gohr */ 74563fb566SAndreas Gohr public function getLastAttempt() 75563fb566SAndreas Gohr { 76563fb566SAndreas Gohr return (int)@filemtime($this->store); 77563fb566SAndreas Gohr } 78563fb566SAndreas Gohr 79563fb566SAndreas Gohr /** 80563fb566SAndreas Gohr * Calculate required timeout in seconds based on failure count 81563fb566SAndreas Gohr * 82*194d3386SAndreas Gohr * Uses exponential backoff: base * 2^(count-1), capped at max. 83*194d3386SAndreas Gohr * First failed attempt is okay, second requires base seconds wait. 84563fb566SAndreas Gohr * 85*194d3386SAndreas Gohr * @return int Timeout in seconds (0 if no failures or feature disabled) 86563fb566SAndreas Gohr */ 87*194d3386SAndreas Gohr public function calculateTimeout() 88563fb566SAndreas Gohr { 89*194d3386SAndreas Gohr if ($this->base < 1) return 0; 90563fb566SAndreas Gohr $count = $this->get(); 91563fb566SAndreas Gohr if ($count < 1) return 0; 92*194d3386SAndreas Gohr $timeout = $this->base * pow(2, $count - 1); // -1 because first failure is free 93*194d3386SAndreas Gohr return (int)min($timeout, $this->max); 94563fb566SAndreas Gohr } 95563fb566SAndreas Gohr 96563fb566SAndreas Gohr /** 97563fb566SAndreas Gohr * Get remaining wait time in seconds 98563fb566SAndreas Gohr * 99*194d3386SAndreas Gohr * @return int Seconds remaining (0 if no wait needed or feature disabled) 100563fb566SAndreas Gohr */ 101*194d3386SAndreas Gohr public function getRemainingTime() 102563fb566SAndreas Gohr { 103*194d3386SAndreas Gohr $timeout = $this->calculateTimeout(); 104563fb566SAndreas Gohr if ($timeout === 0) return 0; 105563fb566SAndreas Gohr $elapsed = time() - $this->getLastAttempt(); 106563fb566SAndreas Gohr return max(0, $timeout - $elapsed); 107563fb566SAndreas Gohr } 108*194d3386SAndreas Gohr 109*194d3386SAndreas Gohr /** 110*194d3386SAndreas Gohr * Remove all outdated IP counter directories 111*194d3386SAndreas Gohr * 112*194d3386SAndreas Gohr * Deletes counter directories older than today, similar to FileCookie::clean() 113*194d3386SAndreas Gohr * 114*194d3386SAndreas Gohr * @return void 115*194d3386SAndreas Gohr */ 116*194d3386SAndreas Gohr public static function clean() 117*194d3386SAndreas Gohr { 118*194d3386SAndreas Gohr global $conf; 119*194d3386SAndreas Gohr $path = $conf['tmpdir'] . '/captcha/ip/'; 120*194d3386SAndreas Gohr $dirs = glob("$path/*", GLOB_ONLYDIR); 121*194d3386SAndreas Gohr if (!$dirs) return; 122*194d3386SAndreas Gohr 123*194d3386SAndreas Gohr $today = date('Y-m-d'); 124*194d3386SAndreas Gohr foreach ($dirs as $dir) { 125*194d3386SAndreas Gohr if (basename($dir) === $today) continue; 126*194d3386SAndreas Gohr if (!preg_match('/\/captcha\/ip\//', $dir)) continue; // safety net 127*194d3386SAndreas Gohr io_rmdir($dir, true); 128*194d3386SAndreas Gohr } 129*194d3386SAndreas Gohr } 130969b14c4SAndreas Gohr} 131