feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Controllers;
|
||||
|
||||
use Grav\Common\User\Interfaces\UserCollectionInterface;
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Grav\Plugin\Api\Auth\JwtAuthenticator;
|
||||
use Grav\Plugin\Api\Exceptions\ForbiddenException;
|
||||
use Grav\Plugin\Api\Exceptions\TooManyRequestsException;
|
||||
use Grav\Plugin\Api\Exceptions\UnauthorizedException;
|
||||
use Grav\Plugin\Api\Exceptions\ValidationException;
|
||||
use Grav\Plugin\Api\Response\ApiResponse;
|
||||
use Grav\Plugin\Api\Serializers\UserSerializer;
|
||||
use Grav\Plugin\Login\Login;
|
||||
use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class AuthController extends AbstractApiController
|
||||
{
|
||||
use ResolvesAdminBaseUrl;
|
||||
|
||||
private const CHALLENGE_2FA = '2fa_challenge';
|
||||
private const CHALLENGE_TTL = 300;
|
||||
|
||||
public function token(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = $this->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'] ?? '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user