From 29d4a6f0791c774c974a1e383248ba02165bb0cf Mon Sep 17 00:00:00 2001 From: Lukas Bestle Date: Sun, 16 Oct 2022 22:14:49 +0200 Subject: [PATCH] Switch back to spaces for indentation --- src/Cms/Auth.php | 1770 ++++++++++++------------- tests/Cms/Auth/AuthChallengeTest.php | 1084 +++++++-------- tests/Cms/Auth/AuthProtectionTest.php | 770 +++++------ tests/Cms/Auth/AuthTest.php | 460 +++---- 4 files changed, 2042 insertions(+), 2042 deletions(-) diff --git a/src/Cms/Auth.php b/src/Cms/Auth.php index d05a30ec43..809858066b 100644 --- a/src/Cms/Auth.php +++ b/src/Cms/Auth.php @@ -25,889 +25,889 @@ */ class Auth { - /** - * Available auth challenge classes - * from the core and plugins - * - * @var array - */ - public static $challenges = []; - - /** - * Currently impersonated user - * - * @var \Kirby\Cms\User|null - */ - protected $impersonate; - - /** - * Kirby instance - * - * @var \Kirby\Cms\App - */ - protected $kirby; - - /** - * Cache of the auth status object - * - * @var \Kirby\Cms\Auth\Status - */ - protected $status; - - /** - * Instance of the currently logged in user or - * `false` if the user was not yet determined - * - * @var \Kirby\Cms\User|null|false - */ - protected $user = false; - - /** - * Exception that was thrown while - * determining the current user - * - * @var \Throwable - */ - protected $userException; - - /** - * @param \Kirby\Cms\App $kirby - * @codeCoverageIgnore - */ - public function __construct(App $kirby) - { - $this->kirby = $kirby; - } - - /** - * Creates an authentication challenge - * (one-time auth code) - * @since 3.5.0 - * - * @param string $email - * @param bool $long If `true`, a long session will be created - * @param string $mode Either 'login' or 'password-reset' - * @return \Kirby\Cms\Auth\Status - * - * @throws \Kirby\Exception\LogicException If there is no suitable authentication challenge (only in debug mode) - * @throws \Kirby\Exception\NotFoundException If the user does not exist (only in debug mode) - * @throws \Kirby\Exception\PermissionException If the rate limit is exceeded - */ - public function createChallenge(string $email, bool $long = false, string $mode = 'login') - { - $email = Idn::decodeEmail($email); - - $session = $this->kirby->session([ - 'createMode' => 'cookie', - 'long' => $long === true - ]); - - // catch every exception to hide them from attackers - // unless auth debugging is enabled - try { - $this->checkRateLimit($email); - - // rate-limit the number of challenges for DoS/DDoS protection - $this->track($email, false); - - $timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60); - - // try to find the provided user - $user = $this->kirby->users()->find($email); - if ($user === null) { - $this->kirby->trigger('user.login:failed', compact('email')); - - throw new NotFoundException([ - 'key' => 'user.notFound', - 'data' => [ - 'name' => $email - ] - ]); - } - - // try to find an enabled challenge that is available for that user - $challenge = null; - foreach ($this->enabledChallenges() as $name) { - $class = static::$challenges[$name] ?? null; - if ( - $class && - class_exists($class) === true && - is_subclass_of($class, 'Kirby\Cms\Auth\Challenge') === true && - $class::isAvailable($user, $mode) === true - ) { - $challenge = $name; - $code = $class::create($user, compact('mode', 'timeout')); - - $session->set('kirby.challenge.type', $challenge); - - if ($code !== null) { - $session->set('kirby.challenge.code', password_hash($code, PASSWORD_DEFAULT)); - $session->set('kirby.challenge.timeout', time() + $timeout); - } - - break; - } - } - - // if no suitable challenge was found, `$challenge === null` at this point - if ($challenge === null) { - throw new LogicException('Could not find a suitable authentication challenge'); - } - } catch (Throwable $e) { - // only throw the exception in auth debug mode - $this->fail($e); - } - - // always set the email, even if the challenge won't be - // created to avoid leaking whether the user exists - $session->set('kirby.challenge.email', $email); - - // sleep for a random amount of milliseconds - // to make automated attacks harder and to - // avoid leaking whether the user exists - usleep(random_int(50000, 300000)); - - // clear the status cache - $this->status = null; - - return $this->status($session, false); - } - - /** - * Returns the csrf token if it exists and if it is valid - * - * @return string|false - */ - public function csrf() - { - // get the csrf from the header - $fromHeader = $this->kirby->request()->csrf(); - - // check for a predefined csrf or use the one from session - $fromSession = $this->csrfFromSession(); - - // compare both tokens - if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) { - return false; - } - - return $fromSession; - } - - /** - * Returns either predefined csrf or the one from session - * @since 3.6.0 - * - * @return string - */ - public function csrfFromSession(): string - { - $isDev = $this->kirby->option('panel.dev', false) !== false; - return $this->kirby->option('api.csrf', $isDev ? 'dev' : csrf()); - } - - /** - * Returns the logged in user by checking - * for a basic authentication header with - * valid credentials - * - * @param \Kirby\Http\Request\Auth\BasicAuth|null $auth - * @return \Kirby\Cms\User|null - * @throws \Kirby\Exception\InvalidArgumentException if the authorization header is invalid - * @throws \Kirby\Exception\PermissionException if basic authentication is not allowed - */ - public function currentUserFromBasicAuth(BasicAuth $auth = null) - { - if ($this->kirby->option('api.basicAuth', false) !== true) { - throw new PermissionException('Basic authentication is not activated'); - } - - // if logging in with password is disabled, basic auth cannot be possible either - $loginMethods = $this->kirby->system()->loginMethods(); - if (isset($loginMethods['password']) !== true) { - throw new PermissionException('Login with password is not enabled'); - } - - // if any login method requires 2FA, basic auth without 2FA would be a weakness - foreach ($loginMethods as $method) { - if (isset($method['2fa']) === true && $method['2fa'] === true) { - throw new PermissionException('Basic authentication cannot be used with 2FA'); - } - } - - $request = $this->kirby->request(); - $auth = $auth ?? $request->auth(); - - if (!$auth || $auth->type() !== 'basic') { - throw new InvalidArgumentException('Invalid authorization header'); - } - - // only allow basic auth when https is enabled or insecure requests permitted - if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) { - throw new PermissionException('Basic authentication is only allowed over HTTPS'); - } - - return $this->validatePassword($auth->username(), $auth->password()); - } - - /** - * Returns the currently impersonated user - * - * @return \Kirby\Cms\User|null - */ - public function currentUserFromImpersonation() - { - return $this->impersonate; - } - - /** - * Returns the logged in user by checking - * the current session and finding a valid - * valid user id in there - * - * @param \Kirby\Session\Session|array|null $session - * @return \Kirby\Cms\User|null - */ - public function currentUserFromSession($session = null) - { - $session = $this->session($session); - - $id = $session->data()->get('kirby.userId'); - - if (is_string($id) !== true) { - return null; - } - - if ($user = $this->kirby->users()->find($id)) { - // in case the session needs to be updated, do it now - // for better performance - $session->commit(); - return $user; - } - - return null; - } - - /** - * Returns the list of enabled challenges in the - * configured order - * @since 3.5.1 - * - * @return array - */ - public function enabledChallenges(): array - { - return A::wrap($this->kirby->option('auth.challenges', ['email'])); - } - - /** - * Become any existing user or disable the current user - * - * @param string|null $who User ID or email address, - * `null` to use the actual user again, - * `'kirby'` for a virtual admin user or - * `'nobody'` to disable the actual user - * @return \Kirby\Cms\User|null - * @throws \Kirby\Exception\NotFoundException if the given user cannot be found - */ - public function impersonate(?string $who = null) - { - // clear the status cache - $this->status = null; - - switch ($who) { - case null: - return $this->impersonate = null; - case 'kirby': - return $this->impersonate = new User([ - 'email' => 'kirby@getkirby.com', - 'id' => 'kirby', - 'role' => 'admin', - ]); - case 'nobody': - return $this->impersonate = new User([ - 'email' => 'nobody@getkirby.com', - 'id' => 'nobody', - 'role' => 'nobody', - ]); - default: - if ($user = $this->kirby->users()->find($who)) { - return $this->impersonate = $user; - } - - throw new NotFoundException('The user "' . $who . '" cannot be found'); - } - } - - /** - * Returns the hashed ip of the visitor - * which is used to track invalid logins - * - * @return string - */ - public function ipHash(): string - { - $hash = hash('sha256', $this->kirby->visitor()->ip()); - - // only use the first 50 chars to ensure privacy - return substr($hash, 0, 50); - } - - /** - * Check if logins are blocked for the current ip or email - * - * @param string $email - * @return bool - */ - public function isBlocked(string $email): bool - { - $ip = $this->ipHash(); - $log = $this->log(); - $trials = $this->kirby->option('auth.trials', 10); - - if ($entry = ($log['by-ip'][$ip] ?? null)) { - if ($entry['trials'] >= $trials) { - return true; - } - } - - if ($this->kirby->users()->find($email)) { - if ($entry = ($log['by-email'][$email] ?? null)) { - if ($entry['trials'] >= $trials) { - return true; - } - } - } - - return false; - } - - /** - * Login a user by email and password - * - * @param string $email - * @param string $password - * @param bool $long - * @return \Kirby\Cms\User - * - * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off - * @throws \Kirby\Exception\NotFoundException If the email was invalid - * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) - */ - public function login(string $email, string $password, bool $long = false) - { - // session options - $options = [ - 'createMode' => 'cookie', - 'long' => $long === true - ]; - - // validate the user and log in to the session - $user = $this->validatePassword($email, $password); - $user->loginPasswordless($options); - - // clear the status cache - $this->status = null; - - return $user; - } - - /** - * Login a user by email, password and auth challenge - * @since 3.5.0 - * - * @param string $email - * @param string $password - * @param bool $long - * @return \Kirby\Cms\Auth\Status - * - * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off - * @throws \Kirby\Exception\NotFoundException If the email was invalid - * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) - */ - public function login2fa(string $email, string $password, bool $long = false) - { - $this->validatePassword($email, $password); - return $this->createChallenge($email, $long, '2fa'); - } - - /** - * Sets a user object as the current user in the cache - * @internal - * - * @param \Kirby\Cms\User $user - * @return void - */ - public function setUser(User $user): void - { - // stop impersonating - $this->impersonate = null; - - $this->user = $user; - - // clear the status cache - $this->status = null; - } - - /** - * Returns the authentication status object - * @since 3.5.1 - * - * @param \Kirby\Session\Session|array|null $session - * @param bool $allowImpersonation If set to false, only the actually - * logged in user will be returned - * @return \Kirby\Cms\Auth\Status - */ - public function status($session = null, bool $allowImpersonation = true) - { - // try to return from cache - if ($this->status && $session === null && $allowImpersonation === true) { - return $this->status; - } - - $sessionObj = $this->session($session); - - $props = ['kirby' => $this->kirby]; - if ($user = $this->user($sessionObj, $allowImpersonation)) { - // a user is currently logged in - if ($allowImpersonation === true && $this->impersonate !== null) { - $props['status'] = 'impersonated'; - } else { - $props['status'] = 'active'; - } - - $props['email'] = $user->email(); - } elseif ($email = $sessionObj->get('kirby.challenge.email')) { - // a challenge is currently pending - $props['status'] = 'pending'; - $props['email'] = $email; - $props['challenge'] = $sessionObj->get('kirby.challenge.type'); - $props['challengeFallback'] = A::last($this->enabledChallenges()); - } else { - // no active authentication - $props['status'] = 'inactive'; - } - - $status = new Status($props); - - // only cache the default object - if ($session === null && $allowImpersonation === true) { - $this->status = $status; - } - - return $status; - } - - /** - * Ensures that the rate limit was not exceeded - * - * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded - */ - protected function checkRateLimit(string $email): void - { - // check for blocked ips - if ($this->isBlocked($email) === true) { - $this->kirby->trigger('user.login:failed', compact('email')); - - throw new PermissionException([ - 'details' => ['reason' => 'rate-limited'], - 'fallback' => 'Rate limit exceeded' - ]); - } - } - - /** - * Validates the user credentials and returns the user object on success; - * otherwise logs the failed attempt - * - * @param string $email - * @param string $password - * @return \Kirby\Cms\User - * - * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off - * @throws \Kirby\Exception\NotFoundException If the email was invalid - * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) - */ - public function validatePassword(string $email, string $password) - { - $email = Idn::decodeEmail($email); - - try { - $this->checkRateLimit($email); - - // validate the user and its password - if ($user = $this->kirby->users()->find($email)) { - if ($user->validatePassword($password) === true) { - return $user; - } - } - - throw new NotFoundException([ - 'key' => 'user.notFound', - 'data' => [ - 'name' => $email - ] - ]); - } catch (Throwable $e) { - // log invalid login trial unless the rate limit is already active - if (($e->getDetails()['reason'] ?? null) !== 'rate-limited') { - try { - $this->track($email); - } catch (Throwable $e) { - // $e is overwritten with the exception - // from the track method if there's one - } - } - - // sleep for a random amount of milliseconds - // to make automated attacks harder - usleep(random_int(10000, 2000000)); - - // keep throwing the original error in debug mode, - // otherwise hide it to avoid leaking security-relevant information - $this->fail($e, new PermissionException(['key' => 'access.login'])); - } - } - - /** - * Returns the absolute path to the logins log - * - * @return string - */ - public function logfile(): string - { - return $this->kirby->root('accounts') . '/.logins'; - } - - /** - * Read all tracked logins - * - * @return array - */ - public function log(): array - { - try { - $log = Data::read($this->logfile(), 'json'); - $read = true; - } catch (Throwable $e) { - $log = []; - $read = false; - } - - // ensure that the category arrays are defined - $log['by-ip'] = $log['by-ip'] ?? []; - $log['by-email'] = $log['by-email'] ?? []; - - // remove all elements on the top level with different keys (old structure) - $log = array_intersect_key($log, array_flip(['by-ip', 'by-email'])); - - // remove entries that are no longer needed - $originalLog = $log; - $time = time() - $this->kirby->option('auth.timeout', 3600); - foreach ($log as $category => $entries) { - $log[$category] = array_filter( - $entries, - fn ($entry) => $entry['time'] > $time - ); - } - - // write new log to the file system if it changed - if ($read === false || $log !== $originalLog) { - if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) { - F::remove($this->logfile()); - } else { - Data::write($this->logfile(), $log, 'json'); - } - } - - return $log; - } - - /** - * Logout the current user - * - * @return void - */ - public function logout(): void - { - // stop impersonating; - // ensures that we log out the actually logged in user - $this->impersonate = null; - - // logout the current user if it exists - if ($user = $this->user()) { - $user->logout(); - } - - // clear the pending challenge - $session = $this->kirby->session(); - $session->remove('kirby.challenge.code'); - $session->remove('kirby.challenge.email'); - $session->remove('kirby.challenge.timeout'); - $session->remove('kirby.challenge.type'); - - // clear the status cache - $this->status = null; - } - - /** - * Clears the cached user data after logout - * @internal - * - * @return void - */ - public function flush(): void - { - $this->impersonate = null; - $this->status = null; - $this->user = null; - } - - /** - * Tracks a login - * - * @param string|null $email - * @param bool $triggerHook If `false`, no user.login:failed hook is triggered - * @return bool - */ - public function track(?string $email, bool $triggerHook = true): bool - { - if ($triggerHook === true) { - $this->kirby->trigger('user.login:failed', compact('email')); - } - - $ip = $this->ipHash(); - $log = $this->log(); - $time = time(); - - if (isset($log['by-ip'][$ip]) === true) { - $log['by-ip'][$ip] = [ - 'time' => $time, - 'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1 - ]; - } else { - $log['by-ip'][$ip] = [ - 'time' => $time, - 'trials' => 1 - ]; - } - - if ($email !== null && $this->kirby->users()->find($email)) { - if (isset($log['by-email'][$email]) === true) { - $log['by-email'][$email] = [ - 'time' => $time, - 'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1 - ]; - } else { - $log['by-email'][$email] = [ - 'time' => $time, - 'trials' => 1 - ]; - } - } - - return Data::write($this->logfile(), $log, 'json'); - } - - /** - * Returns the current authentication type - * - * @param bool $allowImpersonation If set to false, 'impersonate' won't - * be returned as authentication type - * even if an impersonation is active - * @return string - */ - public function type(bool $allowImpersonation = true): string - { - $basicAuth = $this->kirby->option('api.basicAuth', false); - $auth = $this->kirby->request()->auth(); - - if ($basicAuth === true && $auth && $auth->type() === 'basic') { - return 'basic'; - } elseif ($allowImpersonation === true && $this->impersonate !== null) { - return 'impersonate'; - } else { - return 'session'; - } - } - - /** - * Validates the currently logged in user - * - * @param \Kirby\Session\Session|array|null $session - * @param bool $allowImpersonation If set to false, only the actually - * logged in user will be returned - * @return \Kirby\Cms\User|null - * - * @throws \Throwable If an authentication error occurred - */ - public function user($session = null, bool $allowImpersonation = true) - { - if ($allowImpersonation === true && $this->impersonate !== null) { - return $this->impersonate; - } - - // return from cache - if ($this->user === null) { - // throw the same Exception again if one was captured before - if ($this->userException !== null) { - throw $this->userException; - } - - return null; - } elseif ($this->user !== false) { - return $this->user; - } - - try { - if ($this->type() === 'basic') { - return $this->user = $this->currentUserFromBasicAuth(); - } else { - return $this->user = $this->currentUserFromSession($session); - } - } catch (Throwable $e) { - $this->user = null; - - // capture the Exception for future calls - $this->userException = $e; - - throw $e; - } - } - - /** - * Verifies an authentication code that was - * requested with the `createChallenge()` method; - * if successful, the user is automatically logged in - * @since 3.5.0 - * - * @param string $code User-provided auth code to verify - * @return \Kirby\Cms\User User object of the logged-in user - * - * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code - * is incorrect or if any other error occurred with debug mode off - * @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist - * @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active - * @throws \Kirby\Exception\LogicException If the authentication challenge is invalid - */ - public function verifyChallenge(string $code) - { - try { - $session = $this->kirby->session(); - - // first check if we have an active challenge at all - $email = $session->get('kirby.challenge.email'); - $challenge = $session->get('kirby.challenge.type'); - if (is_string($email) !== true || is_string($challenge) !== true) { - throw new InvalidArgumentException('No authentication challenge is active'); - } - - $user = $this->kirby->users()->find($email); - if ($user === null) { - throw new NotFoundException([ - 'key' => 'user.notFound', - 'data' => [ - 'name' => $email - ] - ]); - } - - // rate-limiting - $this->checkRateLimit($email); - - // time-limiting - $timeout = $session->get('kirby.challenge.timeout'); - if ($timeout !== null && time() > $timeout) { - throw new PermissionException('Authentication challenge timeout'); - } - - if ( - isset(static::$challenges[$challenge]) === true && - class_exists(static::$challenges[$challenge]) === true && - is_subclass_of(static::$challenges[$challenge], 'Kirby\Cms\Auth\Challenge') === true - ) { - $class = static::$challenges[$challenge]; - if ($class::verify($user, $code) === true) { - $this->logout(); - $user->loginPasswordless(); - - // clear the status cache - $this->status = null; - - return $user; - } else { - throw new PermissionException(['key' => 'access.code']); - } - } - - throw new LogicException('Invalid authentication challenge: ' . $challenge); - } catch (Throwable $e) { - if ( - empty($email) === false && - ($e->getDetails()['reason'] ?? null) !== 'rate-limited' - ) { - $this->track($email); - } - - // sleep for a random amount of milliseconds - // to make automated attacks harder and to - // avoid leaking whether the user exists - usleep(random_int(10000, 2000000)); - - $fallback = new PermissionException(['key' => 'access.code']); - - // keep throwing the original error in debug mode, - // otherwise hide it to avoid leaking security-relevant information - $this->fail($e, $fallback); - } - } - - /** - * Throws an exception only in debug mode, otherwise falls back - * to a public error without sensitive information - * - * @throws \Throwable Either the passed `$exception` or the `$fallback` - * (no exception if debugging is disabled and no fallback was passed) - */ - protected function fail(Throwable $exception, Throwable $fallback = null): void - { - $debug = $this->kirby->option('auth.debug', 'log'); - - // throw the original exception only in debug mode - if ($debug === true) { - throw $exception; - } - - // otherwise hide the real error and only print it to the error log - // unless disabled by setting `auth.debug` to `false` - if ($debug === 'log') { - error_log($exception); // @codeCoverageIgnore - } - - // only throw an error in production if requested by the calling method - if ($fallback !== null) { - throw $fallback; - } - } - - /** - * Creates a session object from the passed options - * - * @param \Kirby\Session\Session|array|null $session - * @return \Kirby\Session\Session - */ - protected function session($session = null) - { - // use passed session options or session object if set - if (is_array($session) === true) { - return $this->kirby->session($session); - } - - // try session in header or cookie - if (is_a($session, 'Kirby\Session\Session') === false) { - return $this->kirby->session(['detect' => true]); - } - - return $session; - } + /** + * Available auth challenge classes + * from the core and plugins + * + * @var array + */ + public static $challenges = []; + + /** + * Currently impersonated user + * + * @var \Kirby\Cms\User|null + */ + protected $impersonate; + + /** + * Kirby instance + * + * @var \Kirby\Cms\App + */ + protected $kirby; + + /** + * Cache of the auth status object + * + * @var \Kirby\Cms\Auth\Status + */ + protected $status; + + /** + * Instance of the currently logged in user or + * `false` if the user was not yet determined + * + * @var \Kirby\Cms\User|null|false + */ + protected $user = false; + + /** + * Exception that was thrown while + * determining the current user + * + * @var \Throwable + */ + protected $userException; + + /** + * @param \Kirby\Cms\App $kirby + * @codeCoverageIgnore + */ + public function __construct(App $kirby) + { + $this->kirby = $kirby; + } + + /** + * Creates an authentication challenge + * (one-time auth code) + * @since 3.5.0 + * + * @param string $email + * @param bool $long If `true`, a long session will be created + * @param string $mode Either 'login' or 'password-reset' + * @return \Kirby\Cms\Auth\Status + * + * @throws \Kirby\Exception\LogicException If there is no suitable authentication challenge (only in debug mode) + * @throws \Kirby\Exception\NotFoundException If the user does not exist (only in debug mode) + * @throws \Kirby\Exception\PermissionException If the rate limit is exceeded + */ + public function createChallenge(string $email, bool $long = false, string $mode = 'login') + { + $email = Idn::decodeEmail($email); + + $session = $this->kirby->session([ + 'createMode' => 'cookie', + 'long' => $long === true + ]); + + // catch every exception to hide them from attackers + // unless auth debugging is enabled + try { + $this->checkRateLimit($email); + + // rate-limit the number of challenges for DoS/DDoS protection + $this->track($email, false); + + $timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60); + + // try to find the provided user + $user = $this->kirby->users()->find($email); + if ($user === null) { + $this->kirby->trigger('user.login:failed', compact('email')); + + throw new NotFoundException([ + 'key' => 'user.notFound', + 'data' => [ + 'name' => $email + ] + ]); + } + + // try to find an enabled challenge that is available for that user + $challenge = null; + foreach ($this->enabledChallenges() as $name) { + $class = static::$challenges[$name] ?? null; + if ( + $class && + class_exists($class) === true && + is_subclass_of($class, 'Kirby\Cms\Auth\Challenge') === true && + $class::isAvailable($user, $mode) === true + ) { + $challenge = $name; + $code = $class::create($user, compact('mode', 'timeout')); + + $session->set('kirby.challenge.type', $challenge); + + if ($code !== null) { + $session->set('kirby.challenge.code', password_hash($code, PASSWORD_DEFAULT)); + $session->set('kirby.challenge.timeout', time() + $timeout); + } + + break; + } + } + + // if no suitable challenge was found, `$challenge === null` at this point + if ($challenge === null) { + throw new LogicException('Could not find a suitable authentication challenge'); + } + } catch (Throwable $e) { + // only throw the exception in auth debug mode + $this->fail($e); + } + + // always set the email, even if the challenge won't be + // created to avoid leaking whether the user exists + $session->set('kirby.challenge.email', $email); + + // sleep for a random amount of milliseconds + // to make automated attacks harder and to + // avoid leaking whether the user exists + usleep(random_int(50000, 300000)); + + // clear the status cache + $this->status = null; + + return $this->status($session, false); + } + + /** + * Returns the csrf token if it exists and if it is valid + * + * @return string|false + */ + public function csrf() + { + // get the csrf from the header + $fromHeader = $this->kirby->request()->csrf(); + + // check for a predefined csrf or use the one from session + $fromSession = $this->csrfFromSession(); + + // compare both tokens + if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) { + return false; + } + + return $fromSession; + } + + /** + * Returns either predefined csrf or the one from session + * @since 3.6.0 + * + * @return string + */ + public function csrfFromSession(): string + { + $isDev = $this->kirby->option('panel.dev', false) !== false; + return $this->kirby->option('api.csrf', $isDev ? 'dev' : csrf()); + } + + /** + * Returns the logged in user by checking + * for a basic authentication header with + * valid credentials + * + * @param \Kirby\Http\Request\Auth\BasicAuth|null $auth + * @return \Kirby\Cms\User|null + * @throws \Kirby\Exception\InvalidArgumentException if the authorization header is invalid + * @throws \Kirby\Exception\PermissionException if basic authentication is not allowed + */ + public function currentUserFromBasicAuth(BasicAuth $auth = null) + { + if ($this->kirby->option('api.basicAuth', false) !== true) { + throw new PermissionException('Basic authentication is not activated'); + } + + // if logging in with password is disabled, basic auth cannot be possible either + $loginMethods = $this->kirby->system()->loginMethods(); + if (isset($loginMethods['password']) !== true) { + throw new PermissionException('Login with password is not enabled'); + } + + // if any login method requires 2FA, basic auth without 2FA would be a weakness + foreach ($loginMethods as $method) { + if (isset($method['2fa']) === true && $method['2fa'] === true) { + throw new PermissionException('Basic authentication cannot be used with 2FA'); + } + } + + $request = $this->kirby->request(); + $auth = $auth ?? $request->auth(); + + if (!$auth || $auth->type() !== 'basic') { + throw new InvalidArgumentException('Invalid authorization header'); + } + + // only allow basic auth when https is enabled or insecure requests permitted + if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) { + throw new PermissionException('Basic authentication is only allowed over HTTPS'); + } + + return $this->validatePassword($auth->username(), $auth->password()); + } + + /** + * Returns the currently impersonated user + * + * @return \Kirby\Cms\User|null + */ + public function currentUserFromImpersonation() + { + return $this->impersonate; + } + + /** + * Returns the logged in user by checking + * the current session and finding a valid + * valid user id in there + * + * @param \Kirby\Session\Session|array|null $session + * @return \Kirby\Cms\User|null + */ + public function currentUserFromSession($session = null) + { + $session = $this->session($session); + + $id = $session->data()->get('kirby.userId'); + + if (is_string($id) !== true) { + return null; + } + + if ($user = $this->kirby->users()->find($id)) { + // in case the session needs to be updated, do it now + // for better performance + $session->commit(); + return $user; + } + + return null; + } + + /** + * Returns the list of enabled challenges in the + * configured order + * @since 3.5.1 + * + * @return array + */ + public function enabledChallenges(): array + { + return A::wrap($this->kirby->option('auth.challenges', ['email'])); + } + + /** + * Become any existing user or disable the current user + * + * @param string|null $who User ID or email address, + * `null` to use the actual user again, + * `'kirby'` for a virtual admin user or + * `'nobody'` to disable the actual user + * @return \Kirby\Cms\User|null + * @throws \Kirby\Exception\NotFoundException if the given user cannot be found + */ + public function impersonate(?string $who = null) + { + // clear the status cache + $this->status = null; + + switch ($who) { + case null: + return $this->impersonate = null; + case 'kirby': + return $this->impersonate = new User([ + 'email' => 'kirby@getkirby.com', + 'id' => 'kirby', + 'role' => 'admin', + ]); + case 'nobody': + return $this->impersonate = new User([ + 'email' => 'nobody@getkirby.com', + 'id' => 'nobody', + 'role' => 'nobody', + ]); + default: + if ($user = $this->kirby->users()->find($who)) { + return $this->impersonate = $user; + } + + throw new NotFoundException('The user "' . $who . '" cannot be found'); + } + } + + /** + * Returns the hashed ip of the visitor + * which is used to track invalid logins + * + * @return string + */ + public function ipHash(): string + { + $hash = hash('sha256', $this->kirby->visitor()->ip()); + + // only use the first 50 chars to ensure privacy + return substr($hash, 0, 50); + } + + /** + * Check if logins are blocked for the current ip or email + * + * @param string $email + * @return bool + */ + public function isBlocked(string $email): bool + { + $ip = $this->ipHash(); + $log = $this->log(); + $trials = $this->kirby->option('auth.trials', 10); + + if ($entry = ($log['by-ip'][$ip] ?? null)) { + if ($entry['trials'] >= $trials) { + return true; + } + } + + if ($this->kirby->users()->find($email)) { + if ($entry = ($log['by-email'][$email] ?? null)) { + if ($entry['trials'] >= $trials) { + return true; + } + } + } + + return false; + } + + /** + * Login a user by email and password + * + * @param string $email + * @param string $password + * @param bool $long + * @return \Kirby\Cms\User + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the email was invalid + * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) + */ + public function login(string $email, string $password, bool $long = false) + { + // session options + $options = [ + 'createMode' => 'cookie', + 'long' => $long === true + ]; + + // validate the user and log in to the session + $user = $this->validatePassword($email, $password); + $user->loginPasswordless($options); + + // clear the status cache + $this->status = null; + + return $user; + } + + /** + * Login a user by email, password and auth challenge + * @since 3.5.0 + * + * @param string $email + * @param string $password + * @param bool $long + * @return \Kirby\Cms\Auth\Status + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the email was invalid + * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) + */ + public function login2fa(string $email, string $password, bool $long = false) + { + $this->validatePassword($email, $password); + return $this->createChallenge($email, $long, '2fa'); + } + + /** + * Sets a user object as the current user in the cache + * @internal + * + * @param \Kirby\Cms\User $user + * @return void + */ + public function setUser(User $user): void + { + // stop impersonating + $this->impersonate = null; + + $this->user = $user; + + // clear the status cache + $this->status = null; + } + + /** + * Returns the authentication status object + * @since 3.5.1 + * + * @param \Kirby\Session\Session|array|null $session + * @param bool $allowImpersonation If set to false, only the actually + * logged in user will be returned + * @return \Kirby\Cms\Auth\Status + */ + public function status($session = null, bool $allowImpersonation = true) + { + // try to return from cache + if ($this->status && $session === null && $allowImpersonation === true) { + return $this->status; + } + + $sessionObj = $this->session($session); + + $props = ['kirby' => $this->kirby]; + if ($user = $this->user($sessionObj, $allowImpersonation)) { + // a user is currently logged in + if ($allowImpersonation === true && $this->impersonate !== null) { + $props['status'] = 'impersonated'; + } else { + $props['status'] = 'active'; + } + + $props['email'] = $user->email(); + } elseif ($email = $sessionObj->get('kirby.challenge.email')) { + // a challenge is currently pending + $props['status'] = 'pending'; + $props['email'] = $email; + $props['challenge'] = $sessionObj->get('kirby.challenge.type'); + $props['challengeFallback'] = A::last($this->enabledChallenges()); + } else { + // no active authentication + $props['status'] = 'inactive'; + } + + $status = new Status($props); + + // only cache the default object + if ($session === null && $allowImpersonation === true) { + $this->status = $status; + } + + return $status; + } + + /** + * Ensures that the rate limit was not exceeded + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded + */ + protected function checkRateLimit(string $email): void + { + // check for blocked ips + if ($this->isBlocked($email) === true) { + $this->kirby->trigger('user.login:failed', compact('email')); + + throw new PermissionException([ + 'details' => ['reason' => 'rate-limited'], + 'fallback' => 'Rate limit exceeded' + ]); + } + } + + /** + * Validates the user credentials and returns the user object on success; + * otherwise logs the failed attempt + * + * @param string $email + * @param string $password + * @return \Kirby\Cms\User + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the email was invalid + * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) + */ + public function validatePassword(string $email, string $password) + { + $email = Idn::decodeEmail($email); + + try { + $this->checkRateLimit($email); + + // validate the user and its password + if ($user = $this->kirby->users()->find($email)) { + if ($user->validatePassword($password) === true) { + return $user; + } + } + + throw new NotFoundException([ + 'key' => 'user.notFound', + 'data' => [ + 'name' => $email + ] + ]); + } catch (Throwable $e) { + // log invalid login trial unless the rate limit is already active + if (($e->getDetails()['reason'] ?? null) !== 'rate-limited') { + try { + $this->track($email); + } catch (Throwable $e) { + // $e is overwritten with the exception + // from the track method if there's one + } + } + + // sleep for a random amount of milliseconds + // to make automated attacks harder + usleep(random_int(10000, 2000000)); + + // keep throwing the original error in debug mode, + // otherwise hide it to avoid leaking security-relevant information + $this->fail($e, new PermissionException(['key' => 'access.login'])); + } + } + + /** + * Returns the absolute path to the logins log + * + * @return string + */ + public function logfile(): string + { + return $this->kirby->root('accounts') . '/.logins'; + } + + /** + * Read all tracked logins + * + * @return array + */ + public function log(): array + { + try { + $log = Data::read($this->logfile(), 'json'); + $read = true; + } catch (Throwable $e) { + $log = []; + $read = false; + } + + // ensure that the category arrays are defined + $log['by-ip'] = $log['by-ip'] ?? []; + $log['by-email'] = $log['by-email'] ?? []; + + // remove all elements on the top level with different keys (old structure) + $log = array_intersect_key($log, array_flip(['by-ip', 'by-email'])); + + // remove entries that are no longer needed + $originalLog = $log; + $time = time() - $this->kirby->option('auth.timeout', 3600); + foreach ($log as $category => $entries) { + $log[$category] = array_filter( + $entries, + fn ($entry) => $entry['time'] > $time + ); + } + + // write new log to the file system if it changed + if ($read === false || $log !== $originalLog) { + if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) { + F::remove($this->logfile()); + } else { + Data::write($this->logfile(), $log, 'json'); + } + } + + return $log; + } + + /** + * Logout the current user + * + * @return void + */ + public function logout(): void + { + // stop impersonating; + // ensures that we log out the actually logged in user + $this->impersonate = null; + + // logout the current user if it exists + if ($user = $this->user()) { + $user->logout(); + } + + // clear the pending challenge + $session = $this->kirby->session(); + $session->remove('kirby.challenge.code'); + $session->remove('kirby.challenge.email'); + $session->remove('kirby.challenge.timeout'); + $session->remove('kirby.challenge.type'); + + // clear the status cache + $this->status = null; + } + + /** + * Clears the cached user data after logout + * @internal + * + * @return void + */ + public function flush(): void + { + $this->impersonate = null; + $this->status = null; + $this->user = null; + } + + /** + * Tracks a login + * + * @param string|null $email + * @param bool $triggerHook If `false`, no user.login:failed hook is triggered + * @return bool + */ + public function track(?string $email, bool $triggerHook = true): bool + { + if ($triggerHook === true) { + $this->kirby->trigger('user.login:failed', compact('email')); + } + + $ip = $this->ipHash(); + $log = $this->log(); + $time = time(); + + if (isset($log['by-ip'][$ip]) === true) { + $log['by-ip'][$ip] = [ + 'time' => $time, + 'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1 + ]; + } else { + $log['by-ip'][$ip] = [ + 'time' => $time, + 'trials' => 1 + ]; + } + + if ($email !== null && $this->kirby->users()->find($email)) { + if (isset($log['by-email'][$email]) === true) { + $log['by-email'][$email] = [ + 'time' => $time, + 'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1 + ]; + } else { + $log['by-email'][$email] = [ + 'time' => $time, + 'trials' => 1 + ]; + } + } + + return Data::write($this->logfile(), $log, 'json'); + } + + /** + * Returns the current authentication type + * + * @param bool $allowImpersonation If set to false, 'impersonate' won't + * be returned as authentication type + * even if an impersonation is active + * @return string + */ + public function type(bool $allowImpersonation = true): string + { + $basicAuth = $this->kirby->option('api.basicAuth', false); + $auth = $this->kirby->request()->auth(); + + if ($basicAuth === true && $auth && $auth->type() === 'basic') { + return 'basic'; + } elseif ($allowImpersonation === true && $this->impersonate !== null) { + return 'impersonate'; + } else { + return 'session'; + } + } + + /** + * Validates the currently logged in user + * + * @param \Kirby\Session\Session|array|null $session + * @param bool $allowImpersonation If set to false, only the actually + * logged in user will be returned + * @return \Kirby\Cms\User|null + * + * @throws \Throwable If an authentication error occurred + */ + public function user($session = null, bool $allowImpersonation = true) + { + if ($allowImpersonation === true && $this->impersonate !== null) { + return $this->impersonate; + } + + // return from cache + if ($this->user === null) { + // throw the same Exception again if one was captured before + if ($this->userException !== null) { + throw $this->userException; + } + + return null; + } elseif ($this->user !== false) { + return $this->user; + } + + try { + if ($this->type() === 'basic') { + return $this->user = $this->currentUserFromBasicAuth(); + } else { + return $this->user = $this->currentUserFromSession($session); + } + } catch (Throwable $e) { + $this->user = null; + + // capture the Exception for future calls + $this->userException = $e; + + throw $e; + } + } + + /** + * Verifies an authentication code that was + * requested with the `createChallenge()` method; + * if successful, the user is automatically logged in + * @since 3.5.0 + * + * @param string $code User-provided auth code to verify + * @return \Kirby\Cms\User User object of the logged-in user + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code + * is incorrect or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist + * @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active + * @throws \Kirby\Exception\LogicException If the authentication challenge is invalid + */ + public function verifyChallenge(string $code) + { + try { + $session = $this->kirby->session(); + + // first check if we have an active challenge at all + $email = $session->get('kirby.challenge.email'); + $challenge = $session->get('kirby.challenge.type'); + if (is_string($email) !== true || is_string($challenge) !== true) { + throw new InvalidArgumentException('No authentication challenge is active'); + } + + $user = $this->kirby->users()->find($email); + if ($user === null) { + throw new NotFoundException([ + 'key' => 'user.notFound', + 'data' => [ + 'name' => $email + ] + ]); + } + + // rate-limiting + $this->checkRateLimit($email); + + // time-limiting + $timeout = $session->get('kirby.challenge.timeout'); + if ($timeout !== null && time() > $timeout) { + throw new PermissionException('Authentication challenge timeout'); + } + + if ( + isset(static::$challenges[$challenge]) === true && + class_exists(static::$challenges[$challenge]) === true && + is_subclass_of(static::$challenges[$challenge], 'Kirby\Cms\Auth\Challenge') === true + ) { + $class = static::$challenges[$challenge]; + if ($class::verify($user, $code) === true) { + $this->logout(); + $user->loginPasswordless(); + + // clear the status cache + $this->status = null; + + return $user; + } else { + throw new PermissionException(['key' => 'access.code']); + } + } + + throw new LogicException('Invalid authentication challenge: ' . $challenge); + } catch (Throwable $e) { + if ( + empty($email) === false && + ($e->getDetails()['reason'] ?? null) !== 'rate-limited' + ) { + $this->track($email); + } + + // sleep for a random amount of milliseconds + // to make automated attacks harder and to + // avoid leaking whether the user exists + usleep(random_int(10000, 2000000)); + + $fallback = new PermissionException(['key' => 'access.code']); + + // keep throwing the original error in debug mode, + // otherwise hide it to avoid leaking security-relevant information + $this->fail($e, $fallback); + } + } + + /** + * Throws an exception only in debug mode, otherwise falls back + * to a public error without sensitive information + * + * @throws \Throwable Either the passed `$exception` or the `$fallback` + * (no exception if debugging is disabled and no fallback was passed) + */ + protected function fail(Throwable $exception, Throwable $fallback = null): void + { + $debug = $this->kirby->option('auth.debug', 'log'); + + // throw the original exception only in debug mode + if ($debug === true) { + throw $exception; + } + + // otherwise hide the real error and only print it to the error log + // unless disabled by setting `auth.debug` to `false` + if ($debug === 'log') { + error_log($exception); // @codeCoverageIgnore + } + + // only throw an error in production if requested by the calling method + if ($fallback !== null) { + throw $fallback; + } + } + + /** + * Creates a session object from the passed options + * + * @param \Kirby\Session\Session|array|null $session + * @return \Kirby\Session\Session + */ + protected function session($session = null) + { + // use passed session options or session object if set + if (is_array($session) === true) { + return $this->kirby->session($session); + } + + // try session in header or cookie + if (is_a($session, 'Kirby\Session\Session') === false) { + return $this->kirby->session(['detect' => true]); + } + + return $session; + } } diff --git a/tests/Cms/Auth/AuthChallengeTest.php b/tests/Cms/Auth/AuthChallengeTest.php index 3b915e3e82..00b70c11cc 100644 --- a/tests/Cms/Auth/AuthChallengeTest.php +++ b/tests/Cms/Auth/AuthChallengeTest.php @@ -14,546 +14,546 @@ */ class AuthChallengeTest extends TestCase { - public $failedEmail; - - protected $app; - protected $auth; - protected $tmp; - - public function setUp(): void - { - Auth::$challenges['errorneous'] = ErrorneousChallenge::class; - Email::$debug = true; - Email::$emails = []; - $_SERVER['SERVER_NAME'] = 'kirby.test'; - - $self = $this; - - $this->app = new App([ - 'hooks' => [ - 'user.login:failed' => function ($email) use ($self) { - $self->failedEmail = $email; - } - ], - 'options' => [ - 'auth' => [ - 'challenges' => ['errorneous', 'email'], - 'debug' => true, - 'trials' => 3 - ] - ], - 'roots' => [ - 'index' => $this->tmp = __DIR__ . '/tmp' - ], - 'users' => [ - [ - 'email' => 'marge@simpsons.com', - 'id' => 'marge', - 'password' => password_hash('springfield123', PASSWORD_DEFAULT) - ], - [ - 'email' => 'test@exämple.com', - 'id' => 'idn' - ], - [ - 'email' => 'error@getkirby.com', - 'id' => 'error' - ] - ] - ]); - Dir::make($this->tmp . '/site/accounts'); - - $this->auth = new Auth($this->app); - } - - public function tearDown(): void - { - $this->app->session()->destroy(); - Dir::remove($this->tmp); - - unset(Auth::$challenges['errorneous']); - Email::$debug = false; - Email::$emails = []; - unset($_SERVER['SERVER_NAME']); - $this->failedEmail = null; - } - - /** - * @covers ::checkRateLimit - * @covers ::createChallenge - * @covers ::fail - * @covers ::status - */ - public function testCreateChallenge() - { - $this->app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'debug' => false - ] - ] - ]); - $auth = $this->app->auth(); - $session = $this->app->session(); - - $this->app->visitor()->ip('10.1.123.234'); - - // existing user - $status = $auth->createChallenge('marge@simpsons.com'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'marge@simpsons.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertSame('email', $status->challenge(false)); - $this->assertSame(1800, $session->timeout()); - $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); - $this->assertSame('email', $session->get('kirby.challenge.type')); - preg_match('/^[0-9]{3} [0-9]{3}$/m', Email::$emails[0]->body()->text(), $codeMatches); - $this->assertTrue(password_verify(str_replace(' ', '', $codeMatches[0]), $session->get('kirby.challenge.code'))); - $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); - $this->assertNull($this->failedEmail); - $session->remove('kirby.challenge.type'); - - // non-existing user - $status = $auth->createChallenge('invalid@example.com'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'invalid@example.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertNull($status->challenge(false)); - $this->assertSame('invalid@example.com', $session->get('kirby.challenge.email')); - $this->assertNull($session->get('kirby.challenge.type')); - $this->assertSame('invalid@example.com', $this->failedEmail); - - // error in the challenge - $status = $auth->createChallenge('error@getkirby.com'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'error@getkirby.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertNull($status->challenge(false)); - $this->assertSame('error@getkirby.com', $session->get('kirby.challenge.email')); - $this->assertNull($session->get('kirby.challenge.type')); - $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); - $this->assertSame('invalid@example.com', $this->failedEmail); // a challenge error is not considered a failed login - - // verify rate-limiting log - $data = [ - 'by-ip' => [ - '87084f11690867b977a611dd2c943a918c3197f4c02b25ab59' => [ - 'time' => MockTime::$time, - 'trials' => 3 - ] - ], - 'by-email' => [ - 'marge@simpsons.com' => [ - 'time' => MockTime::$time, - 'trials' => 1 - ], - 'error@getkirby.com' => [ - 'time' => MockTime::$time, - 'trials' => 1 - ] - ] - ]; - $this->assertSame($data, $auth->log()); - - // fake challenge when rate-limited - $status = $auth->createChallenge('marge@simpsons.com'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'marge@simpsons.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertNull($status->challenge(false)); - $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); - $this->assertNull($session->get('kirby.challenge.type')); - $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); - $this->assertSame('marge@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::createChallenge - * @covers ::fail - */ - public function testCreateChallengeDebugError() - { - $auth = $this->app->auth(); - - $this->expectException('Exception'); - $this->expectExceptionMessage('An error occurred in the challenge'); - $auth->createChallenge('error@getkirby.com'); - } - - /** - * @covers ::createChallenge - * @covers ::fail - */ - public function testCreateChallengeDebugNotFound() - { - $this->expectException('Kirby\Exception\NotFoundException'); - $this->expectExceptionMessage('The user "invalid@example.com" cannot be found'); - - $this->auth->createChallenge('invalid@example.com'); - } - - /** - * @covers ::checkRateLimit - * @covers ::createChallenge - * @covers ::fail - */ - public function testCreateChallengeDebugRateLimit() - { - $this->app = $this->app->clone([ - 'options' => [ - 'debug' => true - ] - ]); - $auth = $this->app->auth(); - - $auth->createChallenge('marge@simpsons.com'); - $auth->createChallenge('marge@simpsons.com'); - $auth->createChallenge('marge@simpsons.com'); - - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Rate limit exceeded'); - $auth->createChallenge('marge@simpsons.com'); - } - - /** - * @covers ::createChallenge - * @covers ::fail - * @covers ::status - */ - public function testCreateChallengeCustomTimeout() - { - $this->app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'challenge.timeout' => 10, - 'debug' => false - ] - ] - ]); - $auth = $this->app->auth(); - $session = $this->app->session(); - - $status = $auth->createChallenge('marge@simpsons.com'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'marge@simpsons.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertSame('email', $status->challenge(false)); - - $this->assertSame(MockTime::$time + 10, $session->get('kirby.challenge.timeout')); - } - - /** - * @covers ::createChallenge - * @covers ::status - */ - public function testCreateChallengeLong() - { - $session = $this->app->session(); - - $status = $this->auth->createChallenge('marge@simpsons.com', true); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'marge@simpsons.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertSame('email', $status->challenge(false)); - - $this->assertFalse($session->timeout()); - } - - /** - * @covers ::createChallenge - * @covers ::status - */ - public function testCreateChallengeWithPunycodeEmail() - { - $session = $this->app->session(); - - $status = $this->auth->createChallenge('test@xn--exmple-cua.com'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'test@exämple.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertSame('email', $status->challenge(false)); - $this->assertSame('test@exämple.com', $session->get('kirby.challenge.email')); - } - - /** - * @covers ::enabledChallenges - */ - public function testEnabledChallenges() - { - // default - $app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'challenges' => null - ] - ] - ]); - $this->assertSame(['email'], $app->auth()->enabledChallenges()); - - // a single challenge - $app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'challenges' => 'totp' - ] - ] - ]); - $this->assertSame(['totp'], $app->auth()->enabledChallenges()); - - // multiple challenges - $app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'challenges' => ['totp', 'sms'] - ] - ] - ]); - $this->assertSame(['totp', 'sms'], $app->auth()->enabledChallenges()); - } - - /** - * @covers ::login2fa - * @covers ::status - */ - public function testLogin2fa() - { - $session = $this->app->session(); - - $status = $this->auth->login2fa('marge@simpsons.com', 'springfield123'); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'marge@simpsons.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertSame('email', $status->challenge(false)); - $this->assertSame(1800, $session->timeout()); - $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); - $this->assertSame('email', $session->get('kirby.challenge.type')); - preg_match('/^[0-9]{3} [0-9]{3}$/m', Email::$emails[0]->body()->text(), $codeMatches); - $this->assertTrue(password_verify(str_replace(' ', '', $codeMatches[0]), $session->get('kirby.challenge.code'))); - $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); - $this->assertNull($this->failedEmail); - } - - /** - * @covers ::login2fa - * @covers ::status - */ - public function testLogin2faLong() - { - $session = $this->app->session(); - - $status = $this->auth->login2fa('marge@simpsons.com', 'springfield123', true); - $this->assertSame([ - 'challenge' => 'email', - 'email' => 'marge@simpsons.com', - 'status' => 'pending' - ], $status->toArray()); - $this->assertSame('email', $status->challenge(false)); - $this->assertFalse($session->timeout()); - $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); - $this->assertSame('email', $session->get('kirby.challenge.type')); - preg_match('/^[0-9]{3} [0-9]{3}$/m', Email::$emails[0]->body()->text(), $codeMatches); - $this->assertTrue(password_verify(str_replace(' ', '', $codeMatches[0]), $session->get('kirby.challenge.code'))); - $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); - $this->assertNull($this->failedEmail); - } - - /** - * @covers ::fail - * @covers ::login2fa - */ - public function testLogin2faInvalidUser() - { - $session = $this->app->session(); - - $this->expectException('Kirby\Exception\NotFoundException'); - $this->expectExceptionMessage('The user "invalid@example.com" cannot be found'); - $this->auth->login2fa('invalid@example.com', 'springfield123'); - } - - /** - * @covers ::fail - * @covers ::login2fa - */ - public function testLogin2faInvalidPassword() - { - $session = $this->app->session(); - - $this->expectException('Kirby\Exception\InvalidArgumentException'); - $this->expectExceptionMessage('Wrong password'); - $this->auth->login2fa('marge@simpsons.com', 'springfield456'); - } - - /** - * @covers ::verifyChallenge - */ - public function testVerifyChallenge() - { - $session = $this->app->session(); - - $session->set('kirby.challenge.email', 'marge@simpsons.com'); - $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); - $session->set('kirby.challenge.type', 'email'); - $session->set('kirby.challenge.timeout', MockTime::$time + 1); - - $this->assertSame( - $this->app->user('marge@simpsons.com'), - $this->auth->verifyChallenge('123456') - ); - $this->assertSame(['kirby.userId' => 'marge'], $session->data()->get()); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeNoChallenge1() - { - $this->expectException('Kirby\Exception\InvalidArgumentException'); - $this->expectExceptionMessage('No authentication challenge is active'); - - $this->auth->verifyChallenge('123456'); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeNoChallenge2() - { - $this->expectException('Kirby\Exception\InvalidArgumentException'); - $this->expectExceptionMessage('No authentication challenge is active'); - - $this->app->session()->set('kirby.challenge.email', 'marge@simpsons.com'); - $this->auth->verifyChallenge('123456'); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeNoChallengeNoDebug() - { - $this->app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'debug' => false - ] - ] - ]); - $auth = $this->app->auth(); - - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Invalid code'); - - $auth->verifyChallenge('123456'); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeInvalidEmail() - { - $this->expectException('Kirby\Exception\NotFoundException'); - $this->expectExceptionMessage('The user "invalid@example.com" cannot be found'); - - $this->app->session()->set('kirby.challenge.email', 'invalid@example.com'); - $this->app->session()->set('kirby.challenge.type', 'email'); - $this->auth->verifyChallenge('123456'); - } - - /** - * @covers ::checkRateLimit - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeRateLimited() - { - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Rate limit exceeded'); - - $session = $this->app->session(); - - $this->auth->track('marge@simpsons.com'); - $this->auth->track('homer@simpsons.com'); - $this->auth->track('homer@simpsons.com'); - $session->set('kirby.challenge.email', 'marge@simpsons.com'); - $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); - $session->set('kirby.challenge.type', 'email'); - - $this->auth->verifyChallenge('123456'); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeTimeLimited() - { - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Authentication challenge timeout'); - - $session = $this->app->session(); - - $session->set('kirby.challenge.email', 'marge@simpsons.com'); - $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); - $session->set('kirby.challenge.type', 'email'); - $session->set('kirby.challenge.timeout', MockTime::$time - 1); - - $this->auth->verifyChallenge('123456'); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeInvalidCode() - { - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Invalid code'); - - $session = $this->app->session(); - - $session->set('kirby.challenge.email', 'marge@simpsons.com'); - $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); - $session->set('kirby.challenge.type', 'email'); - $session->set('kirby.challenge.timeout', MockTime::$time + 1); - - $this->auth->verifyChallenge('654321'); - } - - /** - * @covers ::fail - * @covers ::verifyChallenge - */ - public function testVerifyChallengeInvalidChallenge() - { - $this->expectException('Kirby\Exception\LogicException'); - $this->expectExceptionMessage('Invalid authentication challenge: test'); - - $session = $this->app->session(); - - $session->set('kirby.challenge.email', 'marge@simpsons.com'); - $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); - $session->set('kirby.challenge.type', 'test'); - $session->set('kirby.challenge.timeout', MockTime::$time + 1); - - $this->auth->verifyChallenge('123456'); - } + public $failedEmail; + + protected $app; + protected $auth; + protected $tmp; + + public function setUp(): void + { + Auth::$challenges['errorneous'] = ErrorneousChallenge::class; + Email::$debug = true; + Email::$emails = []; + $_SERVER['SERVER_NAME'] = 'kirby.test'; + + $self = $this; + + $this->app = new App([ + 'hooks' => [ + 'user.login:failed' => function ($email) use ($self) { + $self->failedEmail = $email; + } + ], + 'options' => [ + 'auth' => [ + 'challenges' => ['errorneous', 'email'], + 'debug' => true, + 'trials' => 3 + ] + ], + 'roots' => [ + 'index' => $this->tmp = __DIR__ . '/tmp' + ], + 'users' => [ + [ + 'email' => 'marge@simpsons.com', + 'id' => 'marge', + 'password' => password_hash('springfield123', PASSWORD_DEFAULT) + ], + [ + 'email' => 'test@exämple.com', + 'id' => 'idn' + ], + [ + 'email' => 'error@getkirby.com', + 'id' => 'error' + ] + ] + ]); + Dir::make($this->tmp . '/site/accounts'); + + $this->auth = new Auth($this->app); + } + + public function tearDown(): void + { + $this->app->session()->destroy(); + Dir::remove($this->tmp); + + unset(Auth::$challenges['errorneous']); + Email::$debug = false; + Email::$emails = []; + unset($_SERVER['SERVER_NAME']); + $this->failedEmail = null; + } + + /** + * @covers ::checkRateLimit + * @covers ::createChallenge + * @covers ::fail + * @covers ::status + */ + public function testCreateChallenge() + { + $this->app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'debug' => false + ] + ] + ]); + $auth = $this->app->auth(); + $session = $this->app->session(); + + $this->app->visitor()->ip('10.1.123.234'); + + // existing user + $status = $auth->createChallenge('marge@simpsons.com'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'marge@simpsons.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertSame('email', $status->challenge(false)); + $this->assertSame(1800, $session->timeout()); + $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); + $this->assertSame('email', $session->get('kirby.challenge.type')); + preg_match('/^[0-9]{3} [0-9]{3}$/m', Email::$emails[0]->body()->text(), $codeMatches); + $this->assertTrue(password_verify(str_replace(' ', '', $codeMatches[0]), $session->get('kirby.challenge.code'))); + $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); + $this->assertNull($this->failedEmail); + $session->remove('kirby.challenge.type'); + + // non-existing user + $status = $auth->createChallenge('invalid@example.com'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'invalid@example.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertNull($status->challenge(false)); + $this->assertSame('invalid@example.com', $session->get('kirby.challenge.email')); + $this->assertNull($session->get('kirby.challenge.type')); + $this->assertSame('invalid@example.com', $this->failedEmail); + + // error in the challenge + $status = $auth->createChallenge('error@getkirby.com'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'error@getkirby.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertNull($status->challenge(false)); + $this->assertSame('error@getkirby.com', $session->get('kirby.challenge.email')); + $this->assertNull($session->get('kirby.challenge.type')); + $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); + $this->assertSame('invalid@example.com', $this->failedEmail); // a challenge error is not considered a failed login + + // verify rate-limiting log + $data = [ + 'by-ip' => [ + '87084f11690867b977a611dd2c943a918c3197f4c02b25ab59' => [ + 'time' => MockTime::$time, + 'trials' => 3 + ] + ], + 'by-email' => [ + 'marge@simpsons.com' => [ + 'time' => MockTime::$time, + 'trials' => 1 + ], + 'error@getkirby.com' => [ + 'time' => MockTime::$time, + 'trials' => 1 + ] + ] + ]; + $this->assertSame($data, $auth->log()); + + // fake challenge when rate-limited + $status = $auth->createChallenge('marge@simpsons.com'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'marge@simpsons.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertNull($status->challenge(false)); + $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); + $this->assertNull($session->get('kirby.challenge.type')); + $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); + $this->assertSame('marge@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::createChallenge + * @covers ::fail + */ + public function testCreateChallengeDebugError() + { + $auth = $this->app->auth(); + + $this->expectException('Exception'); + $this->expectExceptionMessage('An error occurred in the challenge'); + $auth->createChallenge('error@getkirby.com'); + } + + /** + * @covers ::createChallenge + * @covers ::fail + */ + public function testCreateChallengeDebugNotFound() + { + $this->expectException('Kirby\Exception\NotFoundException'); + $this->expectExceptionMessage('The user "invalid@example.com" cannot be found'); + + $this->auth->createChallenge('invalid@example.com'); + } + + /** + * @covers ::checkRateLimit + * @covers ::createChallenge + * @covers ::fail + */ + public function testCreateChallengeDebugRateLimit() + { + $this->app = $this->app->clone([ + 'options' => [ + 'debug' => true + ] + ]); + $auth = $this->app->auth(); + + $auth->createChallenge('marge@simpsons.com'); + $auth->createChallenge('marge@simpsons.com'); + $auth->createChallenge('marge@simpsons.com'); + + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Rate limit exceeded'); + $auth->createChallenge('marge@simpsons.com'); + } + + /** + * @covers ::createChallenge + * @covers ::fail + * @covers ::status + */ + public function testCreateChallengeCustomTimeout() + { + $this->app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'challenge.timeout' => 10, + 'debug' => false + ] + ] + ]); + $auth = $this->app->auth(); + $session = $this->app->session(); + + $status = $auth->createChallenge('marge@simpsons.com'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'marge@simpsons.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertSame('email', $status->challenge(false)); + + $this->assertSame(MockTime::$time + 10, $session->get('kirby.challenge.timeout')); + } + + /** + * @covers ::createChallenge + * @covers ::status + */ + public function testCreateChallengeLong() + { + $session = $this->app->session(); + + $status = $this->auth->createChallenge('marge@simpsons.com', true); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'marge@simpsons.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertSame('email', $status->challenge(false)); + + $this->assertFalse($session->timeout()); + } + + /** + * @covers ::createChallenge + * @covers ::status + */ + public function testCreateChallengeWithPunycodeEmail() + { + $session = $this->app->session(); + + $status = $this->auth->createChallenge('test@xn--exmple-cua.com'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'test@exämple.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertSame('email', $status->challenge(false)); + $this->assertSame('test@exämple.com', $session->get('kirby.challenge.email')); + } + + /** + * @covers ::enabledChallenges + */ + public function testEnabledChallenges() + { + // default + $app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'challenges' => null + ] + ] + ]); + $this->assertSame(['email'], $app->auth()->enabledChallenges()); + + // a single challenge + $app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'challenges' => 'totp' + ] + ] + ]); + $this->assertSame(['totp'], $app->auth()->enabledChallenges()); + + // multiple challenges + $app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'challenges' => ['totp', 'sms'] + ] + ] + ]); + $this->assertSame(['totp', 'sms'], $app->auth()->enabledChallenges()); + } + + /** + * @covers ::login2fa + * @covers ::status + */ + public function testLogin2fa() + { + $session = $this->app->session(); + + $status = $this->auth->login2fa('marge@simpsons.com', 'springfield123'); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'marge@simpsons.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertSame('email', $status->challenge(false)); + $this->assertSame(1800, $session->timeout()); + $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); + $this->assertSame('email', $session->get('kirby.challenge.type')); + preg_match('/^[0-9]{3} [0-9]{3}$/m', Email::$emails[0]->body()->text(), $codeMatches); + $this->assertTrue(password_verify(str_replace(' ', '', $codeMatches[0]), $session->get('kirby.challenge.code'))); + $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); + $this->assertNull($this->failedEmail); + } + + /** + * @covers ::login2fa + * @covers ::status + */ + public function testLogin2faLong() + { + $session = $this->app->session(); + + $status = $this->auth->login2fa('marge@simpsons.com', 'springfield123', true); + $this->assertSame([ + 'challenge' => 'email', + 'email' => 'marge@simpsons.com', + 'status' => 'pending' + ], $status->toArray()); + $this->assertSame('email', $status->challenge(false)); + $this->assertFalse($session->timeout()); + $this->assertSame('marge@simpsons.com', $session->get('kirby.challenge.email')); + $this->assertSame('email', $session->get('kirby.challenge.type')); + preg_match('/^[0-9]{3} [0-9]{3}$/m', Email::$emails[0]->body()->text(), $codeMatches); + $this->assertTrue(password_verify(str_replace(' ', '', $codeMatches[0]), $session->get('kirby.challenge.code'))); + $this->assertSame(MockTime::$time + 600, $session->get('kirby.challenge.timeout')); + $this->assertNull($this->failedEmail); + } + + /** + * @covers ::fail + * @covers ::login2fa + */ + public function testLogin2faInvalidUser() + { + $session = $this->app->session(); + + $this->expectException('Kirby\Exception\NotFoundException'); + $this->expectExceptionMessage('The user "invalid@example.com" cannot be found'); + $this->auth->login2fa('invalid@example.com', 'springfield123'); + } + + /** + * @covers ::fail + * @covers ::login2fa + */ + public function testLogin2faInvalidPassword() + { + $session = $this->app->session(); + + $this->expectException('Kirby\Exception\InvalidArgumentException'); + $this->expectExceptionMessage('Wrong password'); + $this->auth->login2fa('marge@simpsons.com', 'springfield456'); + } + + /** + * @covers ::verifyChallenge + */ + public function testVerifyChallenge() + { + $session = $this->app->session(); + + $session->set('kirby.challenge.email', 'marge@simpsons.com'); + $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); + $session->set('kirby.challenge.type', 'email'); + $session->set('kirby.challenge.timeout', MockTime::$time + 1); + + $this->assertSame( + $this->app->user('marge@simpsons.com'), + $this->auth->verifyChallenge('123456') + ); + $this->assertSame(['kirby.userId' => 'marge'], $session->data()->get()); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeNoChallenge1() + { + $this->expectException('Kirby\Exception\InvalidArgumentException'); + $this->expectExceptionMessage('No authentication challenge is active'); + + $this->auth->verifyChallenge('123456'); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeNoChallenge2() + { + $this->expectException('Kirby\Exception\InvalidArgumentException'); + $this->expectExceptionMessage('No authentication challenge is active'); + + $this->app->session()->set('kirby.challenge.email', 'marge@simpsons.com'); + $this->auth->verifyChallenge('123456'); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeNoChallengeNoDebug() + { + $this->app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'debug' => false + ] + ] + ]); + $auth = $this->app->auth(); + + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Invalid code'); + + $auth->verifyChallenge('123456'); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeInvalidEmail() + { + $this->expectException('Kirby\Exception\NotFoundException'); + $this->expectExceptionMessage('The user "invalid@example.com" cannot be found'); + + $this->app->session()->set('kirby.challenge.email', 'invalid@example.com'); + $this->app->session()->set('kirby.challenge.type', 'email'); + $this->auth->verifyChallenge('123456'); + } + + /** + * @covers ::checkRateLimit + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeRateLimited() + { + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Rate limit exceeded'); + + $session = $this->app->session(); + + $this->auth->track('marge@simpsons.com'); + $this->auth->track('homer@simpsons.com'); + $this->auth->track('homer@simpsons.com'); + $session->set('kirby.challenge.email', 'marge@simpsons.com'); + $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); + $session->set('kirby.challenge.type', 'email'); + + $this->auth->verifyChallenge('123456'); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeTimeLimited() + { + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Authentication challenge timeout'); + + $session = $this->app->session(); + + $session->set('kirby.challenge.email', 'marge@simpsons.com'); + $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); + $session->set('kirby.challenge.type', 'email'); + $session->set('kirby.challenge.timeout', MockTime::$time - 1); + + $this->auth->verifyChallenge('123456'); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeInvalidCode() + { + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Invalid code'); + + $session = $this->app->session(); + + $session->set('kirby.challenge.email', 'marge@simpsons.com'); + $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); + $session->set('kirby.challenge.type', 'email'); + $session->set('kirby.challenge.timeout', MockTime::$time + 1); + + $this->auth->verifyChallenge('654321'); + } + + /** + * @covers ::fail + * @covers ::verifyChallenge + */ + public function testVerifyChallengeInvalidChallenge() + { + $this->expectException('Kirby\Exception\LogicException'); + $this->expectExceptionMessage('Invalid authentication challenge: test'); + + $session = $this->app->session(); + + $session->set('kirby.challenge.email', 'marge@simpsons.com'); + $session->set('kirby.challenge.code', password_hash('123456', PASSWORD_DEFAULT)); + $session->set('kirby.challenge.type', 'test'); + $session->set('kirby.challenge.timeout', MockTime::$time + 1); + + $this->auth->verifyChallenge('123456'); + } } diff --git a/tests/Cms/Auth/AuthProtectionTest.php b/tests/Cms/Auth/AuthProtectionTest.php index 588227e4e2..43bc874287 100644 --- a/tests/Cms/Auth/AuthProtectionTest.php +++ b/tests/Cms/Auth/AuthProtectionTest.php @@ -14,389 +14,389 @@ */ class AuthProtectionTest extends TestCase { - public $failedEmail; - - protected $app; - protected $auth; - protected $fixtures; - - public function setUp(): void - { - $self = $this; - - $this->app = new App([ - 'options' => [ - 'auth' => [ - 'debug' => false - ] - ], - 'roots' => [ - 'index' => $this->fixtures = __DIR__ . '/fixtures/AuthTest' - ], - 'users' => [ - [ - 'email' => 'marge@simpsons.com', - 'password' => password_hash('springfield123', PASSWORD_DEFAULT) - ], - [ - 'email' => 'homer@simpsons.com', - 'password' => password_hash('springfield123', PASSWORD_DEFAULT) - ], - [ - 'email' => 'test@exämple.com', - 'password' => password_hash('springfield123', PASSWORD_DEFAULT) - ] - ], - 'hooks' => [ - 'user.login:failed' => function ($email) use ($self) { - $self->failedEmail = $email; - } - ] - ]); - Dir::make($this->fixtures . '/site/accounts'); - - $this->auth = new Auth($this->app); - } - - public function tearDown(): void - { - Dir::remove($this->fixtures); - $this->failedEmail = null; - } - - /** - * @covers ::logfile - */ - public function testLogfile() - { - $this->assertSame($this->fixtures . '/site/accounts/.logins', $this->auth->logfile()); - } - - /** - * @covers ::log - */ - public function testLog() - { - copy(__DIR__ . '/fixtures/logins.cleanup.json', $this->fixtures . '/site/accounts/.logins'); - - // should delete expired and old entries and add by-email array - $this->assertSame([ - 'by-ip' => [ - '38f0a08519792a17e18a251008f3a116977907f921b0b287c8' => [ - 'time' => 9999999999, - 'trials' => 5 - ] - ], - 'by-email' => [] - ], $this->auth->log()); - $this->assertFileEquals(__DIR__ . '/fixtures/logins.cleanup-cleaned.json', $this->fixtures . '/site/accounts/.logins'); - - // should handle missing .logins file - unlink($this->fixtures . '/site/accounts/.logins'); - $this->assertSame([ - 'by-ip' => [], - 'by-email' => [] - ], $this->auth->log()); - $this->assertFileDoesNotExist($this->fixtures . '/site/accounts/.logins'); - - // should handle invalid .logins file - file_put_contents($this->fixtures . '/site/accounts/.logins', 'some gibberish'); - $this->assertSame([ - 'by-ip' => [], - 'by-email' => [] - ], $this->auth->log()); - $this->assertFileDoesNotExist($this->fixtures . '/site/accounts/.logins'); - } - - /** - * @covers ::ipHash - */ - public function testIpHash() - { - $this->app->visitor()->ip('10.1.123.234'); - - $this->assertSame('87084f11690867b977a611dd2c943a918c3197f4c02b25ab59', $this->auth->ipHash()); - } - - /** - * @covers ::isBlocked - */ - public function testIsBlocked() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.1.123.234'); - $this->assertFalse($this->auth->isBlocked('marge@simpsons.com')); - $this->assertTrue($this->auth->isBlocked('homer@simpsons.com')); - $this->assertFalse($this->auth->isBlocked('lisa@simpsons.com')); - - $this->app->visitor()->ip('10.2.123.234'); - $this->assertTrue($this->auth->isBlocked('marge@simpsons.com')); - $this->assertTrue($this->auth->isBlocked('homer@simpsons.com')); - $this->assertTrue($this->auth->isBlocked('lisa@simpsons.com')); - } - - /** - * @covers ::track - */ - public function testTrack() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.1.123.234'); - $this->assertTrue($this->auth->track('homer@simpsons.com')); - $this->assertTrue($this->auth->track('homer@simpsons.com')); - $this->assertTrue($this->auth->track('marge@simpsons.com')); - $this->assertTrue($this->auth->track('lisa@simpsons.com')); - $this->assertTrue($this->auth->track('lisa@simpsons.com')); - - $this->app->visitor()->ip('10.2.123.234'); - $this->assertTrue($this->auth->track('homer@simpsons.com')); - $this->assertTrue($this->auth->track('marge@simpsons.com')); - $this->assertTrue($this->auth->track('lisa@simpsons.com')); - - $this->app->visitor()->ip('10.3.123.234'); - $this->assertTrue($this->auth->track('homer@simpsons.com')); - $this->assertTrue($this->auth->track('marge@simpsons.com')); - $this->assertTrue($this->auth->track('lisa@simpsons.com', false)); - $this->assertSame('marge@simpsons.com', $this->failedEmail); - - $this->assertTrue($this->auth->track(null)); - $this->assertNull($this->failedEmail); - - $data = [ - 'by-ip' => [ - '87084f11690867b977a611dd2c943a918c3197f4c02b25ab59' => [ - 'time' => MockTime::$time, - 'trials' => 14 - ], - '38f0a08519792a17e18a251008f3a116977907f921b0b287c8' => [ - 'time' => MockTime::$time, - 'trials' => 13 - ], - '85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da' => [ - 'time' => MockTime::$time, - 'trials' => 4 - ] - ], - 'by-email' => [ - 'homer@simpsons.com' => [ - 'time' => MockTime::$time, - 'trials' => 14 - ], - 'marge@simpsons.com' => [ - 'time' => MockTime::$time, - 'trials' => 3 - ] - ] - ]; - $this->assertSame($data, $this->auth->log()); - $this->assertSame(json_encode($data), file_get_contents($this->fixtures . '/site/accounts/.logins')); - } - - /** - * @covers ::validatePassword - */ - public function testValidatePasswordValid() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.3.123.234'); - $user = $this->auth->validatePassword('marge@simpsons.com', 'springfield123'); - - $this->assertInstanceOf(User::class, $user); - $this->assertSame('marge@simpsons.com', $user->email()); - $this->assertNull($this->failedEmail); - } - - /** - * @covers ::fail - * @covers ::validatePassword - */ - public function testValidatePasswordInvalid1() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.3.123.234'); - - $thrown = false; - try { - $this->auth->validatePassword('lisa@simpsons.com', 'springfield123'); - } catch (PermissionException $e) { - $this->assertSame('Invalid login', $e->getMessage()); - $thrown = true; - } - - $this->assertTrue($thrown); - $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); - $this->assertSame('lisa@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::fail - * @covers ::validatePassword - */ - public function testValidatePasswordInvalid2() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.3.123.234'); - - $thrown = false; - try { - $this->auth->validatePassword('marge@simpsons.com', 'invalid-password'); - } catch (PermissionException $e) { - $this->assertSame('Invalid login', $e->getMessage()); - $thrown = true; - } - - $this->assertTrue($thrown); - $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); - $this->assertSame(1, $this->auth->log()['by-email']['marge@simpsons.com']['trials']); - $this->assertSame('marge@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::fail - * @covers ::validatePassword - */ - public function testValidatePasswordBlocked() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.2.123.234'); - - $thrown = false; - try { - $this->auth->validatePassword('homer@simpsons.com', 'springfield123'); - } catch (PermissionException $e) { - $this->assertSame('Invalid login', $e->getMessage()); - $thrown = true; - } - - $this->assertTrue($thrown); - $this->assertSame('homer@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::fail - * @covers ::validatePassword - */ - public function testValidatePasswordDebugInvalid1() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - $this->app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'debug' => true - ] - ] - ]); - $this->auth = new Auth($this->app); - - $this->app->visitor()->ip('10.3.123.234'); - - $thrown = false; - try { - $this->auth->validatePassword('lisa@simpsons.com', 'springfield123'); - } catch (NotFoundException $e) { - $this->assertSame('The user "lisa@simpsons.com" cannot be found', $e->getMessage()); - $thrown = true; - } - - $this->assertTrue($thrown); - $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); - $this->assertSame('lisa@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::fail - * @covers ::validatePassword - */ - public function testValidatePasswordDebugInvalid2() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - $this->app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'debug' => true - ] - ] - ]); - $this->auth = new Auth($this->app); - - $this->app->visitor()->ip('10.3.123.234'); - - $thrown = false; - try { - $this->auth->validatePassword('marge@simpsons.com', 'invalid-password'); - } catch (InvalidArgumentException $e) { - $this->assertSame('Wrong password', $e->getMessage()); - $thrown = true; - } - - $this->assertTrue($thrown); - $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); - $this->assertSame(1, $this->auth->log()['by-email']['marge@simpsons.com']['trials']); - $this->assertSame('marge@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::checkRateLimit - * @covers ::fail - * @covers ::validatePassword - */ - public function testValidatePasswordDebugBlocked() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - $this->app = $this->app->clone([ - 'options' => [ - 'auth' => [ - 'debug' => true - ] - ] - ]); - $this->auth = new Auth($this->app); - - $this->app->visitor()->ip('10.2.123.234'); - - $thrown = false; - try { - $this->auth->validatePassword('homer@simpsons.com', 'springfield123'); - } catch (PermissionException $e) { - $this->assertSame('Rate limit exceeded', $e->getMessage()); - $thrown = true; - } - - $this->assertTrue($thrown); - $this->assertSame('homer@simpsons.com', $this->failedEmail); - } - - /** - * @covers ::validatePassword - */ - public function testValidatePasswordWithUnicodeEmail() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.3.123.234'); - $user = $this->auth->validatePassword('test@exämple.com', 'springfield123'); - - $this->assertInstanceOf(User::class, $user); - $this->assertSame('test@exämple.com', $user->email()); - } - - /** - * @covers ::validatePassword - */ - public function testValidatePasswordWithPunycodeEmail() - { - copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); - - $this->app->visitor()->ip('10.3.123.234'); - $user = $this->auth->validatePassword('test@xn--exmple-cua.com', 'springfield123'); - - $this->assertInstanceOf(User::class, $user); - $this->assertSame('test@exämple.com', $user->email()); - } + public $failedEmail; + + protected $app; + protected $auth; + protected $fixtures; + + public function setUp(): void + { + $self = $this; + + $this->app = new App([ + 'options' => [ + 'auth' => [ + 'debug' => false + ] + ], + 'roots' => [ + 'index' => $this->fixtures = __DIR__ . '/fixtures/AuthTest' + ], + 'users' => [ + [ + 'email' => 'marge@simpsons.com', + 'password' => password_hash('springfield123', PASSWORD_DEFAULT) + ], + [ + 'email' => 'homer@simpsons.com', + 'password' => password_hash('springfield123', PASSWORD_DEFAULT) + ], + [ + 'email' => 'test@exämple.com', + 'password' => password_hash('springfield123', PASSWORD_DEFAULT) + ] + ], + 'hooks' => [ + 'user.login:failed' => function ($email) use ($self) { + $self->failedEmail = $email; + } + ] + ]); + Dir::make($this->fixtures . '/site/accounts'); + + $this->auth = new Auth($this->app); + } + + public function tearDown(): void + { + Dir::remove($this->fixtures); + $this->failedEmail = null; + } + + /** + * @covers ::logfile + */ + public function testLogfile() + { + $this->assertSame($this->fixtures . '/site/accounts/.logins', $this->auth->logfile()); + } + + /** + * @covers ::log + */ + public function testLog() + { + copy(__DIR__ . '/fixtures/logins.cleanup.json', $this->fixtures . '/site/accounts/.logins'); + + // should delete expired and old entries and add by-email array + $this->assertSame([ + 'by-ip' => [ + '38f0a08519792a17e18a251008f3a116977907f921b0b287c8' => [ + 'time' => 9999999999, + 'trials' => 5 + ] + ], + 'by-email' => [] + ], $this->auth->log()); + $this->assertFileEquals(__DIR__ . '/fixtures/logins.cleanup-cleaned.json', $this->fixtures . '/site/accounts/.logins'); + + // should handle missing .logins file + unlink($this->fixtures . '/site/accounts/.logins'); + $this->assertSame([ + 'by-ip' => [], + 'by-email' => [] + ], $this->auth->log()); + $this->assertFileDoesNotExist($this->fixtures . '/site/accounts/.logins'); + + // should handle invalid .logins file + file_put_contents($this->fixtures . '/site/accounts/.logins', 'some gibberish'); + $this->assertSame([ + 'by-ip' => [], + 'by-email' => [] + ], $this->auth->log()); + $this->assertFileDoesNotExist($this->fixtures . '/site/accounts/.logins'); + } + + /** + * @covers ::ipHash + */ + public function testIpHash() + { + $this->app->visitor()->ip('10.1.123.234'); + + $this->assertSame('87084f11690867b977a611dd2c943a918c3197f4c02b25ab59', $this->auth->ipHash()); + } + + /** + * @covers ::isBlocked + */ + public function testIsBlocked() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.1.123.234'); + $this->assertFalse($this->auth->isBlocked('marge@simpsons.com')); + $this->assertTrue($this->auth->isBlocked('homer@simpsons.com')); + $this->assertFalse($this->auth->isBlocked('lisa@simpsons.com')); + + $this->app->visitor()->ip('10.2.123.234'); + $this->assertTrue($this->auth->isBlocked('marge@simpsons.com')); + $this->assertTrue($this->auth->isBlocked('homer@simpsons.com')); + $this->assertTrue($this->auth->isBlocked('lisa@simpsons.com')); + } + + /** + * @covers ::track + */ + public function testTrack() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.1.123.234'); + $this->assertTrue($this->auth->track('homer@simpsons.com')); + $this->assertTrue($this->auth->track('homer@simpsons.com')); + $this->assertTrue($this->auth->track('marge@simpsons.com')); + $this->assertTrue($this->auth->track('lisa@simpsons.com')); + $this->assertTrue($this->auth->track('lisa@simpsons.com')); + + $this->app->visitor()->ip('10.2.123.234'); + $this->assertTrue($this->auth->track('homer@simpsons.com')); + $this->assertTrue($this->auth->track('marge@simpsons.com')); + $this->assertTrue($this->auth->track('lisa@simpsons.com')); + + $this->app->visitor()->ip('10.3.123.234'); + $this->assertTrue($this->auth->track('homer@simpsons.com')); + $this->assertTrue($this->auth->track('marge@simpsons.com')); + $this->assertTrue($this->auth->track('lisa@simpsons.com', false)); + $this->assertSame('marge@simpsons.com', $this->failedEmail); + + $this->assertTrue($this->auth->track(null)); + $this->assertNull($this->failedEmail); + + $data = [ + 'by-ip' => [ + '87084f11690867b977a611dd2c943a918c3197f4c02b25ab59' => [ + 'time' => MockTime::$time, + 'trials' => 14 + ], + '38f0a08519792a17e18a251008f3a116977907f921b0b287c8' => [ + 'time' => MockTime::$time, + 'trials' => 13 + ], + '85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da' => [ + 'time' => MockTime::$time, + 'trials' => 4 + ] + ], + 'by-email' => [ + 'homer@simpsons.com' => [ + 'time' => MockTime::$time, + 'trials' => 14 + ], + 'marge@simpsons.com' => [ + 'time' => MockTime::$time, + 'trials' => 3 + ] + ] + ]; + $this->assertSame($data, $this->auth->log()); + $this->assertSame(json_encode($data), file_get_contents($this->fixtures . '/site/accounts/.logins')); + } + + /** + * @covers ::validatePassword + */ + public function testValidatePasswordValid() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.3.123.234'); + $user = $this->auth->validatePassword('marge@simpsons.com', 'springfield123'); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame('marge@simpsons.com', $user->email()); + $this->assertNull($this->failedEmail); + } + + /** + * @covers ::fail + * @covers ::validatePassword + */ + public function testValidatePasswordInvalid1() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.3.123.234'); + + $thrown = false; + try { + $this->auth->validatePassword('lisa@simpsons.com', 'springfield123'); + } catch (PermissionException $e) { + $this->assertSame('Invalid login', $e->getMessage()); + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); + $this->assertSame('lisa@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::fail + * @covers ::validatePassword + */ + public function testValidatePasswordInvalid2() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.3.123.234'); + + $thrown = false; + try { + $this->auth->validatePassword('marge@simpsons.com', 'invalid-password'); + } catch (PermissionException $e) { + $this->assertSame('Invalid login', $e->getMessage()); + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); + $this->assertSame(1, $this->auth->log()['by-email']['marge@simpsons.com']['trials']); + $this->assertSame('marge@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::fail + * @covers ::validatePassword + */ + public function testValidatePasswordBlocked() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.2.123.234'); + + $thrown = false; + try { + $this->auth->validatePassword('homer@simpsons.com', 'springfield123'); + } catch (PermissionException $e) { + $this->assertSame('Invalid login', $e->getMessage()); + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame('homer@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::fail + * @covers ::validatePassword + */ + public function testValidatePasswordDebugInvalid1() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + $this->app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'debug' => true + ] + ] + ]); + $this->auth = new Auth($this->app); + + $this->app->visitor()->ip('10.3.123.234'); + + $thrown = false; + try { + $this->auth->validatePassword('lisa@simpsons.com', 'springfield123'); + } catch (NotFoundException $e) { + $this->assertSame('The user "lisa@simpsons.com" cannot be found', $e->getMessage()); + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); + $this->assertSame('lisa@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::fail + * @covers ::validatePassword + */ + public function testValidatePasswordDebugInvalid2() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + $this->app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'debug' => true + ] + ] + ]); + $this->auth = new Auth($this->app); + + $this->app->visitor()->ip('10.3.123.234'); + + $thrown = false; + try { + $this->auth->validatePassword('marge@simpsons.com', 'invalid-password'); + } catch (InvalidArgumentException $e) { + $this->assertSame('Wrong password', $e->getMessage()); + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame(1, $this->auth->log()['by-ip']['85a06e36d926cb901f05d1167913ebd7ec3d8f5bce4551f5da']['trials']); + $this->assertSame(1, $this->auth->log()['by-email']['marge@simpsons.com']['trials']); + $this->assertSame('marge@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::checkRateLimit + * @covers ::fail + * @covers ::validatePassword + */ + public function testValidatePasswordDebugBlocked() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + $this->app = $this->app->clone([ + 'options' => [ + 'auth' => [ + 'debug' => true + ] + ] + ]); + $this->auth = new Auth($this->app); + + $this->app->visitor()->ip('10.2.123.234'); + + $thrown = false; + try { + $this->auth->validatePassword('homer@simpsons.com', 'springfield123'); + } catch (PermissionException $e) { + $this->assertSame('Rate limit exceeded', $e->getMessage()); + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame('homer@simpsons.com', $this->failedEmail); + } + + /** + * @covers ::validatePassword + */ + public function testValidatePasswordWithUnicodeEmail() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.3.123.234'); + $user = $this->auth->validatePassword('test@exämple.com', 'springfield123'); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame('test@exämple.com', $user->email()); + } + + /** + * @covers ::validatePassword + */ + public function testValidatePasswordWithPunycodeEmail() + { + copy(__DIR__ . '/fixtures/logins.json', $this->fixtures . '/site/accounts/.logins'); + + $this->app->visitor()->ip('10.3.123.234'); + $user = $this->auth->validatePassword('test@xn--exmple-cua.com', 'springfield123'); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame('test@exämple.com', $user->email()); + } } diff --git a/tests/Cms/Auth/AuthTest.php b/tests/Cms/Auth/AuthTest.php index 32aeb2ccb0..543d18598b 100644 --- a/tests/Cms/Auth/AuthTest.php +++ b/tests/Cms/Auth/AuthTest.php @@ -11,234 +11,234 @@ */ class AuthTest extends TestCase { - protected $app; - protected $auth; - protected $fixtures; - - public function setUp(): void - { - $this->app = new App([ - 'roots' => [ - 'index' => $this->fixtures = __DIR__ . '/fixtures/AuthTest' - ], - 'options' => [ - 'api' => [ - 'basicAuth' => true, - 'allowInsecure' => true - ], - 'auth' => [ - 'debug' => false - ] - ], - 'users' => [ - [ - 'email' => 'marge@simpsons.com', - 'id' => 'marge', - 'password' => password_hash('springfield123', PASSWORD_DEFAULT) - ], - [ - 'email' => 'homer@simpsons.com', - 'id' => 'homer', - 'password' => password_hash('springfield123', PASSWORD_DEFAULT) - ] - ] - ]); - Dir::make($this->fixtures . '/site/accounts'); - - $this->auth = $this->app->auth(); - } - - public function tearDown(): void - { - $this->app->session()->destroy(); - Dir::remove($this->fixtures); - unset($_SERVER['HTTP_AUTHORIZATION']); - } - - /** - * @covers ::currentUserFromImpersonation - * @covers ::impersonate - * @covers ::status - * @covers ::user - */ - public function testImpersonate() - { - $this->assertSame(null, $this->auth->user()); - - $user = $this->auth->impersonate('kirby'); - $this->assertSame([ - 'challenge' => null, - 'email' => 'kirby@getkirby.com', - 'status' => 'impersonated' - ], $this->auth->status()->toArray()); - $this->assertSame($user, $this->auth->user()); - $this->assertSame($user, $this->auth->currentUserFromImpersonation()); - $this->assertSame('kirby', $user->id()); - $this->assertSame('kirby@getkirby.com', $user->email()); - $this->assertSame('admin', $user->role()->name()); - $this->assertNull($this->auth->user(null, false)); - - $user = $this->auth->impersonate('homer@simpsons.com'); - $this->assertSame([ - 'challenge' => null, - 'email' => 'homer@simpsons.com', - 'status' => 'impersonated' - ], $this->auth->status()->toArray()); - $this->assertSame('homer@simpsons.com', $user->email()); - $this->assertSame($user, $this->auth->user()); - $this->assertSame($user, $this->auth->currentUserFromImpersonation()); - $this->assertNull($this->auth->user(null, false)); - - $this->assertNull($this->auth->impersonate(null)); - $this->assertSame([ - 'challenge' => null, - 'email' => null, - 'status' => 'inactive' - ], $this->auth->status()->toArray()); - $this->assertNull($this->auth->user()); - $this->assertNull($this->auth->currentUserFromImpersonation()); - $this->assertNull($this->auth->user(null, false)); - - $this->auth->setUser($actual = $this->app->user('marge@simpsons.com')); - $this->assertSame([ - 'challenge' => null, - 'email' => 'marge@simpsons.com', - 'status' => 'active' - ], $this->auth->status()->toArray()); - $this->assertSame('marge@simpsons.com', $this->auth->user()->email()); - $impersonated = $this->auth->impersonate('nobody'); - $this->assertSame([ - 'challenge' => null, - 'email' => 'nobody@getkirby.com', - 'status' => 'impersonated' - ], $this->auth->status()->toArray()); - $this->assertSame($impersonated, $this->auth->user()); - $this->assertSame($impersonated, $this->auth->currentUserFromImpersonation()); - $this->assertSame('nobody', $impersonated->id()); - $this->assertSame('nobody@getkirby.com', $impersonated->email()); - $this->assertSame('nobody', $impersonated->role()->name()); - $this->assertSame($actual, $this->auth->user(null, false)); - - $this->auth->logout(); - $this->assertSame([ - 'challenge' => null, - 'email' => null, - 'status' => 'inactive' - ], $this->auth->status()->toArray()); - $this->assertNull($this->auth->impersonate()); - $this->assertNull($this->auth->user()); - $this->assertNull($this->auth->currentUserFromImpersonation()); - $this->assertNull($this->auth->user(null, false)); - } - - /** - * @covers ::impersonate - */ - public function testImpersonateInvalidUser() - { - $this->expectException('Kirby\Exception\NotFoundException'); - $this->expectExceptionMessage('The user "lisa@simpsons.com" cannot be found'); - - $this->auth->impersonate('lisa@simpsons.com'); - } - - /** - * @covers ::status - * @covers ::user - */ - public function testUserSession1() - { - $session = $this->app->session(); - $session->set('kirby.userId', 'marge'); - - $user = $this->auth->user(); - $this->assertSame('marge@simpsons.com', $user->email()); - - $this->assertSame([ - 'challenge' => null, - 'email' => 'marge@simpsons.com', - 'status' => 'active' - ], $this->auth->status()->toArray()); - - // impersonation is not set - $this->assertNull($this->auth->currentUserFromImpersonation()); - - // value is cached - $session->set('kirby.userId', 'homer'); - $user = $this->auth->user(); - $this->assertSame('marge@simpsons.com', $user->email()); - $this->assertSame([ - 'challenge' => null, - 'email' => 'marge@simpsons.com', - 'status' => 'active' - ], $this->auth->status()->toArray()); - } - - /** - * @covers ::status - * @covers ::user - */ - public function testUserSession2() - { - $session = (new AutoSession($this->app->root('sessions')))->createManually(); - $session->set('kirby.userId', 'homer'); - - $user = $this->auth->user($session); - $this->assertSame('homer@simpsons.com', $user->email()); - $this->assertSame([ - 'challenge' => null, - 'email' => 'homer@simpsons.com', - 'status' => 'active' - ], $this->auth->status()->toArray()); - } - - /** - * @covers ::status - * @covers ::user - */ - public function testUserBasicAuth() - { - $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('homer@simpsons.com:springfield123'); - - $user = $this->auth->user(); - $this->assertSame('homer@simpsons.com', $user->email()); - - $this->assertSame([ - 'challenge' => null, - 'email' => 'homer@simpsons.com', - 'status' => 'active' - ], $this->auth->status()->toArray()); - } - - /** - * @covers ::user - */ - public function testUserBasicAuthInvalid1() - { - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Invalid login'); - - $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('homer@simpsons.com:invalid'); - - $this->auth->user(); - } - - /** - * @covers ::user - */ - public function testUserBasicAuthInvalid2() - { - $this->expectException('Kirby\Exception\PermissionException'); - $this->expectExceptionMessage('Invalid login'); - - $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('homer@simpsons.com:invalid'); - - try { - $this->auth->user(); - } catch (Throwable $e) { - // tested above, this check is for the second call - } - - $this->auth->user(); - } + protected $app; + protected $auth; + protected $fixtures; + + public function setUp(): void + { + $this->app = new App([ + 'roots' => [ + 'index' => $this->fixtures = __DIR__ . '/fixtures/AuthTest' + ], + 'options' => [ + 'api' => [ + 'basicAuth' => true, + 'allowInsecure' => true + ], + 'auth' => [ + 'debug' => false + ] + ], + 'users' => [ + [ + 'email' => 'marge@simpsons.com', + 'id' => 'marge', + 'password' => password_hash('springfield123', PASSWORD_DEFAULT) + ], + [ + 'email' => 'homer@simpsons.com', + 'id' => 'homer', + 'password' => password_hash('springfield123', PASSWORD_DEFAULT) + ] + ] + ]); + Dir::make($this->fixtures . '/site/accounts'); + + $this->auth = $this->app->auth(); + } + + public function tearDown(): void + { + $this->app->session()->destroy(); + Dir::remove($this->fixtures); + unset($_SERVER['HTTP_AUTHORIZATION']); + } + + /** + * @covers ::currentUserFromImpersonation + * @covers ::impersonate + * @covers ::status + * @covers ::user + */ + public function testImpersonate() + { + $this->assertSame(null, $this->auth->user()); + + $user = $this->auth->impersonate('kirby'); + $this->assertSame([ + 'challenge' => null, + 'email' => 'kirby@getkirby.com', + 'status' => 'impersonated' + ], $this->auth->status()->toArray()); + $this->assertSame($user, $this->auth->user()); + $this->assertSame($user, $this->auth->currentUserFromImpersonation()); + $this->assertSame('kirby', $user->id()); + $this->assertSame('kirby@getkirby.com', $user->email()); + $this->assertSame('admin', $user->role()->name()); + $this->assertNull($this->auth->user(null, false)); + + $user = $this->auth->impersonate('homer@simpsons.com'); + $this->assertSame([ + 'challenge' => null, + 'email' => 'homer@simpsons.com', + 'status' => 'impersonated' + ], $this->auth->status()->toArray()); + $this->assertSame('homer@simpsons.com', $user->email()); + $this->assertSame($user, $this->auth->user()); + $this->assertSame($user, $this->auth->currentUserFromImpersonation()); + $this->assertNull($this->auth->user(null, false)); + + $this->assertNull($this->auth->impersonate(null)); + $this->assertSame([ + 'challenge' => null, + 'email' => null, + 'status' => 'inactive' + ], $this->auth->status()->toArray()); + $this->assertNull($this->auth->user()); + $this->assertNull($this->auth->currentUserFromImpersonation()); + $this->assertNull($this->auth->user(null, false)); + + $this->auth->setUser($actual = $this->app->user('marge@simpsons.com')); + $this->assertSame([ + 'challenge' => null, + 'email' => 'marge@simpsons.com', + 'status' => 'active' + ], $this->auth->status()->toArray()); + $this->assertSame('marge@simpsons.com', $this->auth->user()->email()); + $impersonated = $this->auth->impersonate('nobody'); + $this->assertSame([ + 'challenge' => null, + 'email' => 'nobody@getkirby.com', + 'status' => 'impersonated' + ], $this->auth->status()->toArray()); + $this->assertSame($impersonated, $this->auth->user()); + $this->assertSame($impersonated, $this->auth->currentUserFromImpersonation()); + $this->assertSame('nobody', $impersonated->id()); + $this->assertSame('nobody@getkirby.com', $impersonated->email()); + $this->assertSame('nobody', $impersonated->role()->name()); + $this->assertSame($actual, $this->auth->user(null, false)); + + $this->auth->logout(); + $this->assertSame([ + 'challenge' => null, + 'email' => null, + 'status' => 'inactive' + ], $this->auth->status()->toArray()); + $this->assertNull($this->auth->impersonate()); + $this->assertNull($this->auth->user()); + $this->assertNull($this->auth->currentUserFromImpersonation()); + $this->assertNull($this->auth->user(null, false)); + } + + /** + * @covers ::impersonate + */ + public function testImpersonateInvalidUser() + { + $this->expectException('Kirby\Exception\NotFoundException'); + $this->expectExceptionMessage('The user "lisa@simpsons.com" cannot be found'); + + $this->auth->impersonate('lisa@simpsons.com'); + } + + /** + * @covers ::status + * @covers ::user + */ + public function testUserSession1() + { + $session = $this->app->session(); + $session->set('kirby.userId', 'marge'); + + $user = $this->auth->user(); + $this->assertSame('marge@simpsons.com', $user->email()); + + $this->assertSame([ + 'challenge' => null, + 'email' => 'marge@simpsons.com', + 'status' => 'active' + ], $this->auth->status()->toArray()); + + // impersonation is not set + $this->assertNull($this->auth->currentUserFromImpersonation()); + + // value is cached + $session->set('kirby.userId', 'homer'); + $user = $this->auth->user(); + $this->assertSame('marge@simpsons.com', $user->email()); + $this->assertSame([ + 'challenge' => null, + 'email' => 'marge@simpsons.com', + 'status' => 'active' + ], $this->auth->status()->toArray()); + } + + /** + * @covers ::status + * @covers ::user + */ + public function testUserSession2() + { + $session = (new AutoSession($this->app->root('sessions')))->createManually(); + $session->set('kirby.userId', 'homer'); + + $user = $this->auth->user($session); + $this->assertSame('homer@simpsons.com', $user->email()); + $this->assertSame([ + 'challenge' => null, + 'email' => 'homer@simpsons.com', + 'status' => 'active' + ], $this->auth->status()->toArray()); + } + + /** + * @covers ::status + * @covers ::user + */ + public function testUserBasicAuth() + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('homer@simpsons.com:springfield123'); + + $user = $this->auth->user(); + $this->assertSame('homer@simpsons.com', $user->email()); + + $this->assertSame([ + 'challenge' => null, + 'email' => 'homer@simpsons.com', + 'status' => 'active' + ], $this->auth->status()->toArray()); + } + + /** + * @covers ::user + */ + public function testUserBasicAuthInvalid1() + { + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Invalid login'); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('homer@simpsons.com:invalid'); + + $this->auth->user(); + } + + /** + * @covers ::user + */ + public function testUserBasicAuthInvalid2() + { + $this->expectException('Kirby\Exception\PermissionException'); + $this->expectExceptionMessage('Invalid login'); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic ' . base64_encode('homer@simpsons.com:invalid'); + + try { + $this->auth->user(); + } catch (Throwable $e) { + // tested above, this check is for the second call + } + + $this->auth->user(); + } }