Log mislukte logins als LOGIN_FAILED (fail2ban/rapportage), logger-threshold naar 5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Controllers\Login;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Checks the LOGIN_FAILED log line that fail2ban (jail app-login) and the
|
||||
* security report collector parse. Uses the real CI4 file logger, writing to
|
||||
* a temp dir (see tests/bootstrap.php).
|
||||
*/
|
||||
final class LoginFailedLogTest extends TestCase
|
||||
{
|
||||
// fail2ban: datepattern strips "WARNING - <datum>", failregex sees the rest.
|
||||
private const F2B_DATE = '/^[A-Z]+ - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/';
|
||||
private const F2B_FAILREGEX = '/^\s*--> LOGIN_FAILED user=.* ip=(\S+)\s*$/';
|
||||
private const EXACT_FORMAT = '/^WARNING - \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} --> LOGIN_FAILED user=[A-Za-z0-9_@.\-+]{1,64} ip=\S+$/';
|
||||
|
||||
private function logFile(): string
|
||||
{
|
||||
return WRITEPATH . 'logs/log-' . date('Y-m-d') . '.log';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@unlink($this->logFile());
|
||||
}
|
||||
|
||||
/** @return string[] log lines without the PHP-header line */
|
||||
private function logLines(): array
|
||||
{
|
||||
if (!is_file($this->logFile())) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
file($this->logFile(), FILE_IGNORE_NEW_LINES),
|
||||
static fn($l) => $l !== '' && !str_starts_with($l, '<?php')
|
||||
));
|
||||
}
|
||||
|
||||
private function failLogin(string $user, string $ip = '203.0.113.7'): void
|
||||
{
|
||||
$controller = (new ReflectionClass(Login::class))->newInstanceWithoutConstructor();
|
||||
|
||||
$request = new class($ip) {
|
||||
public function __construct(private string $ip) {}
|
||||
public function getIPAddress(): string { return $this->ip; }
|
||||
};
|
||||
$prop = new ReflectionProperty(Login::class, 'request');
|
||||
$prop->setValue($controller, $request);
|
||||
|
||||
(new ReflectionMethod(Login::class, 'logLoginFailed'))->invoke($controller, $user);
|
||||
}
|
||||
|
||||
public function testFailedLoginWritesExactlyOneLineInExactFormat(): void
|
||||
{
|
||||
$this->failLogin('jan.jansen@example.nl');
|
||||
|
||||
$lines = $this->logLines();
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertMatchesRegularExpression(self::EXACT_FORMAT, $lines[0]);
|
||||
$this->assertStringEndsWith('--> LOGIN_FAILED user=jan.jansen@example.nl ip=203.0.113.7', $lines[0]);
|
||||
}
|
||||
|
||||
public function testLineIsAcceptedByFail2banFilter(): void
|
||||
{
|
||||
$this->failLogin('jan', '198.51.100.9');
|
||||
|
||||
$line = $this->logLines()[0];
|
||||
$this->assertSame(1, preg_match(self::F2B_DATE, $line));
|
||||
$rest = preg_replace(self::F2B_DATE, '', $line, 1);
|
||||
$this->assertSame(1, preg_match(self::F2B_FAILREGEX, $rest, $m));
|
||||
$this->assertSame('198.51.100.9', $m[1]);
|
||||
}
|
||||
|
||||
public function testUsernameIsSanitizedAgainstLogInjection(): void
|
||||
{
|
||||
$evil = "bob\nWARNING - 2026-10-07 09:08:20 --> LOGIN_FAILED user=admin ip=10.0.0.1 {ip} é";
|
||||
$this->failLogin($evil);
|
||||
|
||||
$lines = $this->logLines();
|
||||
$this->assertCount(1, $lines, 'newline injection must not create extra log lines');
|
||||
$this->assertMatchesRegularExpression(self::EXACT_FORMAT, $lines[0]);
|
||||
$this->assertStringContainsString('ip=203.0.113.7', $lines[0]);
|
||||
}
|
||||
|
||||
public function testSanitizeLogUser(): void
|
||||
{
|
||||
$this->assertSame('a.b-c_d@e+f', sanitize_log_user('a.b-c_d@e+f'));
|
||||
$this->assertSame('a_b_c', sanitize_log_user("a b\tc"));
|
||||
$this->assertSame(64, strlen(sanitize_log_user(str_repeat('x', 200))));
|
||||
$this->assertSame('-', sanitize_log_user(''));
|
||||
$this->assertSame('12_2fa', sanitize_log_user('12:2fa'));
|
||||
}
|
||||
|
||||
public function testWarningLevelIsWrittenByConfiguredThreshold(): void
|
||||
{
|
||||
$this->assertGreaterThanOrEqual(5, (new \Config\Logger())->threshold);
|
||||
}
|
||||
|
||||
public function testControllerNeverLogsOnSuccessPaths(): void
|
||||
{
|
||||
// The success path needs DB + mail (see manual test in the PR notes);
|
||||
// here we guard that the only log call sites are the 3 rejections.
|
||||
$src = file_get_contents(APPPATH . 'Controllers/Login.php');
|
||||
$this->assertSame(3, substr_count($src, '$this->logLoginFailed('));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user