getRequestBody($request); $this->requireFields($body, ['username', 'password']); $username = (string) $body['username']; $password = (string) $body['password']; $this->enforceLoginRateLimit($username); // Route through the Login plugin when available so the full // onUserLoginAuthenticate / onUserLoginAuthorize / onUserLogin chain // fires. This is what lets LDAP (and any other auth plugin that // subscribes to onUserLoginAuthenticate at higher priority) validate // the credentials and map groups to access levels. // // `authorize` is passed as `[]` rather than `admin.login`: the API // plugin runs its own permission gate further down that handles both // legacy and Flex users correctly (admin.super, api.access, etc.). // Letting the Login plugin gate on `admin.login` here breaks logins // on regular (non-flex) accounts whose legacy User::authorize() lacks // an admin.super fallback — even super admins are denied unless they // also have an explicit access.admin.login: true. // // Falls back to the legacy User::authenticate() path on sites without // the Login plugin. if (class_exists(Login::class) && isset($this->grav['login'])) { /** @var Login $login */ $login = $this->grav['login']; $event = $login->login( ['username' => $username, 'password' => $password], ['admin' => true, 'twofa' => false], ['authorize' => [], 'return_event' => true] ); $user = $event->getUser(); if (!$user || !$user->authenticated) { $this->fireEvent('onApiUserLoginFailure', [ 'username' => $username, 'reason' => 'password', 'ip' => $this->getRequestIp($request), ]); throw new UnauthorizedException('Invalid username or password.'); } } else { /** @var UserCollectionInterface $accounts */ $accounts = $this->grav['accounts']; $user = $accounts->load($username); // Delegate to User::authenticate() so the core trait's plaintext-password // fallback fires (auto-hashes a yaml-declared `password:` field on first // successful login, then saves — same behavior admin-classic and the Login // plugin have always had). if (!$user->exists() || !$user->authenticate($password)) { $this->fireEvent('onApiUserLoginFailure', [ 'username' => $username, 'reason' => 'password', 'ip' => $this->getRequestIp($request), ]); throw new UnauthorizedException('Invalid username or password.'); } } // Gate API access AFTER the event chain has run, so any onUserLogin // handlers (LDAP group→access mapping, etc.) have had a chance to // populate the user's access matrix. Mirrors admin-classic's // `admin.login` gate but additionally accepts `api.access` for users // who are API-only and shouldn't be granted full admin entry. if ( !$this->isSuperAdmin($user) && !$user->authorize('admin.login') && !$this->hasPermission($user, 'api.access') ) { $this->fireEvent('onApiUserLoginFailure', [ 'username' => $username, 'reason' => 'no_api_access', 'ip' => $this->getRequestIp($request), ]); throw new ForbiddenException('API access is not enabled for this user.'); } if ($user->get('state', 'enabled') === 'disabled') { $this->fireEvent('onApiUserLoginFailure', [ 'username' => $username, 'reason' => 'disabled', 'ip' => $this->getRequestIp($request), ]); throw new ForbiddenException('This user account is disabled.'); } $jwt = new JwtAuthenticator($this->grav, $this->config); if ($this->userRequiresTwoFactor($user)) { // Password was valid — issue a challenge token. Do NOT reset the // rate limiter yet: the login only counts as successful after the // 2FA code verifies in /auth/2fa/verify. $challengeToken = $jwt->generateChallengeToken($user, self::CHALLENGE_2FA, self::CHALLENGE_TTL); return ApiResponse::create([ 'requires_2fa' => true, 'challenge_token' => $challengeToken, 'expires_in' => self::CHALLENGE_TTL, 'token_type' => 'Challenge', ]); } $this->resetLoginRateLimit($username); $this->fireEvent('onApiUserLogin', [ 'user' => $user, 'method' => 'password', 'ip' => $this->getRequestIp($request), 'request' => $request, ]); return $this->issueTokenPair($jwt, $user); } public function verify2fa(ServerRequestInterface $request): ResponseInterface { $body = $this->getRequestBody($request); $this->requireFields($body, ['challenge_token', 'code']); $jwt = new JwtAuthenticator($this->grav, $this->config); $user = $jwt->validateChallengeToken($body['challenge_token'], self::CHALLENGE_2FA); if ($user === null) { throw new UnauthorizedException('Invalid or expired challenge token.'); } $username = $user->username; $this->enforceLoginRateLimit($username); if ($user->get('state', 'enabled') === 'disabled') { throw new ForbiddenException('This user account is disabled.'); } if (!class_exists(TwoFactorAuth::class)) { throw new ForbiddenException('2FA support is not available.'); } $secret = (string) $user->get('twofa_secret'); $code = (string) $body['code']; $twoFa = new TwoFactorAuth(); if (!$secret || !$twoFa->verifyCode($secret, $code)) { $this->fireEvent('onApiUserLoginFailure', [ 'username' => $username, 'reason' => '2fa', 'ip' => $this->getRequestIp($request), ]); throw new UnauthorizedException('Invalid 2FA code.'); } // Burn the challenge token so it cannot be replayed. $jwt->revokeToken($body['challenge_token']); $this->resetLoginRateLimit($username); $this->fireEvent('onApiUserLogin', [ 'user' => $user, 'method' => '2fa', 'ip' => $this->getRequestIp($request), 'request' => $request, ]); return $this->issueTokenPair($jwt, $user); } public function refresh(ServerRequestInterface $request): ResponseInterface { $body = $this->getRequestBody($request); $this->requireFields($body, ['refresh_token']); $jwt = new JwtAuthenticator($this->grav, $this->config); $user = $jwt->validateRefreshToken($body['refresh_token']); if ($user === null) { throw new UnauthorizedException('Invalid or expired refresh token.'); } if ($user->get('state', 'enabled') === 'disabled') { throw new ForbiddenException('This user account is disabled.'); } // Revoke the old refresh token (rotation) $jwt->revokeToken($body['refresh_token']); return $this->issueTokenPair($jwt, $user); } public function revoke(ServerRequestInterface $request): ResponseInterface { $body = $this->getRequestBody($request); $this->requireFields($body, ['refresh_token']); $jwt = new JwtAuthenticator($this->grav, $this->config); // Best-effort: decode to capture the subject for the logout event. $user = $jwt->validateRefreshToken($body['refresh_token']); $jwt->revokeToken($body['refresh_token']); if ($user !== null) { $this->fireEvent('onApiUserLogout', [ 'user' => $user, 'ip' => $this->getRequestIp($request), 'request' => $request, ]); } return ApiResponse::noContent(); } /** * POST /auth/forgot-password * * Accepts { email } and sends a password reset email if the address * matches a user. Always returns a neutral success message to prevent * account enumeration. Rate limited per-username via the login plugin's * `pw_resets` bucket so enumeration + flood attacks share the login * plugin's limits. */ public function forgotPassword(ServerRequestInterface $request): ResponseInterface { $body = $this->getRequestBody($request); $this->requireFields($body, ['email']); $email = htmlspecialchars(strip_tags((string) $body['email']), ENT_QUOTES, 'UTF-8'); $neutralResponse = ApiResponse::create([ 'message' => 'If an account exists for that email, a reset link has been sent.', ]); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return $neutralResponse; } /** @var UserCollectionInterface $accounts */ $accounts = $this->grav['accounts']; $user = $accounts->find($email, ['email']); if (!$user || !$user->exists()) { return $neutralResponse; } if (!isset($this->grav['Email']) || empty($this->config->get('plugins.email.from'))) { $this->grav['log']->warning('api.auth: forgot-password skipped — email plugin not configured.'); return $neutralResponse; } if (!class_exists(Login::class) || !isset($this->grav['login'])) { $this->grav['log']->warning('api.auth: forgot-password skipped — login plugin not available.'); return $neutralResponse; } /** @var Login $login */ $login = $this->grav['login']; $rateLimiter = $login->getRateLimiter('pw_resets'); $userKey = (string) $user->username; $rateLimiter->registerRateLimitedAction($userKey); if ($rateLimiter->isRateLimited($userKey)) { throw new TooManyRequestsException( sprintf('Too many password reset requests. Try again in %d minutes.', $rateLimiter->getInterval()), $rateLimiter->getInterval() * 60, ); } try { $randomBytes = random_bytes(16); } catch (\Exception) { $randomBytes = (string) mt_rand(); } $token = md5(uniqid($randomBytes, true)); $expire = time() + 86400; // 24 hours // Same storage format as the login plugin's Controller::taskForgot, // so the reset token is compatible with either admin or site flows. $user->set('reset', $token . '::' . $expire); $user->save(); try { $this->sendAdminNextResetEmail($user, $token, $body['admin_base_url'] ?? null, $request); } catch (\Throwable $e) { $this->grav['log']->error('api.auth: failed to send reset email: ' . $e->getMessage()); // Still return neutral success — do not leak mail infrastructure errors. } return $neutralResponse; } /** * Send the admin-next password reset email. Self-contained: builds the * admin-next reset URL (pointing at its own /reset route, not the Grav * frontend login plugin's /reset_password page) and renders via the * API plugin's own template so the reset loop never leaves the admin UI. */ private function sendAdminNextResetEmail( UserInterface $user, string $token, mixed $clientBaseUrl, ServerRequestInterface $request, ): void { if (!isset($this->grav['Email'])) { throw new \RuntimeException('Email service not available.'); } $adminBase = $this->resolveAdminBaseUrl($clientBaseUrl, $request); $resetLink = rtrim($adminBase, '/') . '/reset?user=' . rawurlencode((string) $user->username) . '&token=' . rawurlencode($token); $cfg = $this->grav['config']; $siteHost = (string) ($cfg->get('plugins.login.site_host') ?: ($this->grav['uri']->host() ?? '')); $context = [ 'reset_link' => $resetLink, 'user' => $user, 'site_name' => $cfg->get('site.title', 'Website'), 'site_host' => $siteHost, 'author' => $cfg->get('site.author.name', ''), ]; $params = [ 'to' => $user->email, 'body' => [ [ 'content_type' => 'text/html', 'template' => 'emails/api/reset-password.html.twig', 'body' => '', ], ], ]; /** @var \Grav\Plugin\Email\Email $email */ $email = $this->grav['Email']; $message = $email->buildMessage($params, $context); $email->send($message); } /** * POST /auth/reset-password * * Accepts { username, token, password } and completes the password reset. * All failures return a deliberately vague error so token probing cannot * distinguish "no such user" from "wrong token" from "expired token". IP * is rate-limited via the login plugin's standard login bucket to cap * token brute-forcing. */ public function resetPassword(ServerRequestInterface $request): ResponseInterface { $body = $this->getRequestBody($request); $this->requireFields($body, ['username', 'token', 'password']); $username = (string) $body['username']; $token = (string) $body['token']; $password = (string) $body['password']; $this->enforceLoginRateLimit($username); $invalidMessage = 'Invalid or expired reset link.'; /** @var UserCollectionInterface $accounts */ $accounts = $this->grav['accounts']; $user = $accounts->load($username); if (!$user->exists()) { throw new ValidationException($invalidMessage); } $storedReset = (string) $user->get('reset', ''); if (!str_contains($storedReset, '::')) { throw new ValidationException($invalidMessage); } [$goodToken, $expire] = explode('::', $storedReset, 2); if (!hash_equals($goodToken, $token) || time() > (int) $expire) { throw new ValidationException($invalidMessage); } // Match the login plugin's reset sequence exactly (Controller::taskReset). unset($user->hashed_password, $user->reset); $user->password = $password; $user->save(); $this->resetLoginRateLimit($username); $this->fireEvent('onApiPasswordReset', [ 'user' => $user, 'ip' => $this->getRequestIp($request), ]); return ApiResponse::create([ 'message' => 'Password reset successfully.', ]); } /** * GET /me — Return the authenticated user's profile and resolved permissions. */ public function me(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.access'); $user = $this->getUser($request); $isSuperAdmin = $this->isSuperAdmin($user); $resolver = $this->getPermissionResolver(); $resolvedAccess = $resolver->resolvedMap($user, $isSuperAdmin); return ApiResponse::create([ 'username' => $user->username, 'fullname' => $user->get('fullname'), 'email' => $user->get('email'), 'avatar_url' => UserSerializer::resolveAvatarUrl($user), 'super_admin' => $isSuperAdmin, 'access' => $resolvedAccess, 'content_editor' => $user->get('content_editor', ''), 'grav_version' => GRAV_VERSION, 'admin_version' => $this->getAdminPluginVersion(), ]); } private function getAdminPluginVersion(): ?string { foreach (['admin2', 'admin'] as $slug) { if (!$this->config->get("plugins.{$slug}.enabled", false)) { continue; } $blueprintFile = $this->grav['locator']->findResource("plugins://{$slug}/blueprints.yaml"); if (!$blueprintFile || !file_exists($blueprintFile)) { continue; } $data = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile)); $version = $data['version'] ?? null; if ($version) { return (string) $version; } } return null; } private function userRequiresTwoFactor(UserInterface $user): bool { if (!class_exists(TwoFactorAuth::class)) { return false; } if (!$this->config->get('plugins.login.twofa_enabled', false)) { return false; } return (bool) $user->get('twofa_enabled') && (bool) $user->get('twofa_secret'); } /** * Call the login plugin's checkLoginRateLimit() which both registers and * checks attempts against max_login_count / max_login_interval using the * same cache store the frontend login uses. Throws 429 if the caller is * currently locked out. */ private function enforceLoginRateLimit(string $username): void { if (!class_exists(Login::class) || !isset($this->grav['login'])) { return; } /** @var Login $login */ $login = $this->grav['login']; $interval = $login->checkLoginRateLimit($username); if ($interval > 0) { throw new TooManyRequestsException( sprintf('Too many login attempts. Try again in %d minutes.', $interval), $interval * 60, ); } } private function resetLoginRateLimit(string $username): void { if (!class_exists(Login::class) || !isset($this->grav['login'])) { return; } /** @var Login $login */ $login = $this->grav['login']; $login->resetLoginRateLimit($username); } private function getRequestIp(ServerRequestInterface $request): string { $server = $request->getServerParams(); return (string) ($server['REMOTE_ADDR'] ?? ''); } }