* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license */ 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 ]); $timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60); // 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); // 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, Challenge::class) === 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)); } 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 and timeout, even if the challenge // won't be created; this avoids leaking whether the user exists $session->set('kirby.challenge.email', $email); $session->set('kirby.challenge.timeout', time() + $timeout); // 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; $fallback = $isDev ? 'dev' : $this->kirby->csrf(); return $this->kirby->option('api.csrf', $fallback); } /** * 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|null $who = null) { // clear the status cache $this->status = null; return $this->impersonate = match ($who) { null => null, 'kirby' => new User([ 'email' => 'kirby@getkirby.com', 'id' => 'kirby', 'role' => 'admin', ]), 'nobody' => new User([ 'email' => 'nobody@getkirby.com', 'id' => 'nobody', 'role' => 'nobody', ]), default => ($this->kirby->users()->find($who) ?? 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 * * @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, #[SensitiveParameter] string $password, bool $long = false ): User { // 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 * * @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, #[SensitiveParameter] string $password, bool $long = false ): Status { $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 * * @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, #[SensitiveParameter] string $password ): User { $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) { $details = $e instanceof Exception ? $e->getDetails() : []; // log invalid login trial unless the rate limit is already active if (($details['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) { $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 $this->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|null $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); $request = $this->kirby->request(); if ( $basicAuth === true && // only get the auth object if the option is enabled // to avoid triggering `$responder->usesAuth()` if // the option is disabled $request->auth() && $request->auth()->type() === 'basic' ) { return 'basic'; } if ($allowImpersonation === true && $this->impersonate !== null) { return 'impersonate'; } 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; } if ($this->user !== false) { return $this->user; } try { if ($this->type() === 'basic') { return $this->user = $this->currentUserFromBasicAuth(); } 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( #[SensitiveParameter] string $code ) { try { $session = $this->kirby->session(); // time-limiting; check this early so that we can destroy the session no // matter if the user exists (avoids leaking user information to attackers) $timeout = $session->get('kirby.challenge.timeout'); if ($timeout !== null && time() > $timeout) { // this challenge can never be completed, // so delete it immediately $this->logout(); throw new PermissionException([ 'details' => ['challengeDestroyed' => true], 'fallback' => 'Authentication challenge timeout' ]); } // check if we have an active challenge $email = $session->get('kirby.challenge.email'); $challenge = $session->get('kirby.challenge.type'); if (is_string($email) !== true || is_string($challenge) !== true) { // if the challenge timed out on the previous request, the // challenge data was already deleted from the session, so we can // set `challengeDestroyed` to `true` in this response as well; // however we must only base this on the email, not the type // (otherwise "faked" challenges would be leaked) $challengeDestroyed = is_string($email) !== true; throw new InvalidArgumentException([ 'details' => compact('challengeDestroyed'), 'fallback' => '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); if ( isset(static::$challenges[$challenge]) === true && class_exists(static::$challenges[$challenge]) === true && is_subclass_of(static::$challenges[$challenge], Challenge::class) === true ) { $class = static::$challenges[$challenge]; if ($class::verify($user, $code) === true) { $this->logout(); $user->loginPasswordless(); // clear the status cache $this->status = null; return $user; } throw new PermissionException(['key' => 'access.code']); } throw new LogicException('Invalid authentication challenge: ' . $challenge); } catch (Throwable $e) { $details = $e instanceof \Kirby\Exception\Exception ? $e->getDetails() : []; if ( empty($email) === false && ($details['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)); // specifically copy over the marker for a destroyed challenge // even in production (used by the Panel to reset to the login form) $challengeDestroyed = $details['challengeDestroyed'] ?? false; $fallback = new PermissionException([ 'details' => compact('challengeDestroyed'), '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 ($session instanceof Session === false) { return $this->kirby->session(['detect' => true]); } return $session; } }