mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 22:59:41 +00:00
- Register the guard driver and the anikeen user provider with static closures: Laravel binds extend() closures to the AuthManager, so $this->makeGuard() failed. - Look the token's sub up in the user's Anikeen ID column (anikeen_id by default; provider 'column', services.anikeen.user_id_column or useUserIdField()), not in the primary key; works with the anikeen, eloquent and database providers. - JwtParser: key from services.anikeen.public_key(_path) or the bundled key, read once per process; misconfiguration is an error, not a 401. Passport tokens have no client claim, so it is optional now. - Optional revocation check against Anikeen ID (verify_revocation), cached per token. - anikeenTokenCan() without a token, anikeenTokenCanAny(), scope middleware accept JSON responses. - Update the bundled staging public key to the current staging key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
249 lines
8.0 KiB
PHP
249 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace Anikeen\Id\Auth;
|
|
|
|
use Anikeen\Id\AnikeenId;
|
|
use Anikeen\Id\HasAnikeenTokens;
|
|
use Anikeen\Id\Helpers\JwtParser;
|
|
use Exception;
|
|
use Firebase\JWT\JWT;
|
|
use Firebase\JWT\Key;
|
|
use Illuminate\Auth\AuthenticationException;
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
use Illuminate\Contracts\Encryption\Encrypter;
|
|
use Illuminate\Cookie\CookieValuePrefix;
|
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use stdClass;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Resolves the user of a request from an Anikeen ID access token (JWT, "Authorization: Bearer …")
|
|
* or from the encrypted token cookie of CreateFreshApiToken.
|
|
*
|
|
* A bearer token is verified with the JwtParser (signature, expiry). Its `sub` is the user's
|
|
* Anikeen ID, looked up by the provider (AnikeenIdUserProvider::retrieveByAnikeenId(), column
|
|
* `anikeen_id` by default). The token is attached to the user: `$user->anikeenTokenCan('scope')`.
|
|
*
|
|
* With `services.anikeen.verify_revocation` the token is also checked against Anikeen ID
|
|
* (GET v1/user, cached per token for `services.anikeen.revocation_cache_ttl` seconds), so
|
|
* revoked tokens stop working before they expire.
|
|
*/
|
|
class TokenGuard
|
|
{
|
|
public function __construct(
|
|
protected UserProvider $provider,
|
|
private Encrypter $encrypter,
|
|
private JwtParser $jwtParser
|
|
) {
|
|
//
|
|
}
|
|
|
|
/**
|
|
* Get the user for the incoming request.
|
|
*/
|
|
public function user(Request $request): ?Authenticatable
|
|
{
|
|
if ($request->bearerToken()) {
|
|
return $this->authenticateViaBearerToken($request);
|
|
} elseif ($request->cookie(AnikeenId::cookie())) {
|
|
return $this->authenticateViaCookie($request);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Authenticate the incoming request via the Bearer token.
|
|
*/
|
|
protected function authenticateViaBearerToken(Request $request): ?Authenticatable
|
|
{
|
|
if (!$token = $this->validateRequestViaBearerToken($request)) {
|
|
return null;
|
|
}
|
|
|
|
// client credentials tokens have no user
|
|
if (!is_scalar($token->sub ?? null) || (string) $token->sub === '') {
|
|
return null;
|
|
}
|
|
|
|
if (!$this->tokenIsActive($token, (string) $request->bearerToken())) {
|
|
return null;
|
|
}
|
|
|
|
/** @var Authenticatable|HasAnikeenTokens|null $user */
|
|
$user = $this->retrieveUser((string) $token->sub);
|
|
|
|
return $user && method_exists($user, 'withAnikeenAccessToken')
|
|
? $user->withAnikeenAccessToken($token)
|
|
: $user;
|
|
}
|
|
|
|
/**
|
|
* Verify the Bearer token and expose its claims as request attributes.
|
|
*/
|
|
protected function validateRequestViaBearerToken(Request $request): ?stdClass
|
|
{
|
|
try {
|
|
$decoded = $this->jwtParser->decode($request);
|
|
} catch (AuthenticationException) {
|
|
return null;
|
|
}
|
|
|
|
$decoded->scopes = array_values(array_filter((array) ($decoded->scopes ?? []), 'is_string'));
|
|
|
|
$request->attributes->set('oauth_access_token_id', $decoded->jti ?? null);
|
|
$request->attributes->set('oauth_client_id', $decoded->aud ?? null);
|
|
$request->attributes->set('oauth_client_trusted', $decoded->client->trusted ?? null);
|
|
$request->attributes->set('oauth_user_id', $decoded->sub ?? null);
|
|
$request->attributes->set('oauth_scopes', $decoded->scopes);
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
/**
|
|
* Whether Anikeen ID still accepts the token (only with services.anikeen.verify_revocation).
|
|
*/
|
|
protected function tokenIsActive(stdClass $token, string $jwt): bool
|
|
{
|
|
if (!config('services.anikeen.verify_revocation')) {
|
|
return true;
|
|
}
|
|
|
|
$key = 'anikeen-id:token-active:' . hash('sha256', $jwt);
|
|
|
|
if (($cached = Cache::get($key)) !== null) {
|
|
return (bool) $cached;
|
|
}
|
|
|
|
try {
|
|
$result = (clone app(AnikeenId::class))->withToken($jwt)->getAuthedUser();
|
|
} catch (Throwable) {
|
|
return false; // Anikeen ID unreachable: fail closed, ask again next time
|
|
}
|
|
|
|
$status = $result->response?->getStatusCode();
|
|
|
|
if ($result->success()) {
|
|
$active = true;
|
|
} elseif (in_array($status, [401, 403], true)) {
|
|
$active = false;
|
|
} else {
|
|
return false; // server error: fail closed without caching
|
|
}
|
|
|
|
$ttl = (int) config('services.anikeen.revocation_cache_ttl', 300);
|
|
if (isset($token->exp)) {
|
|
$ttl = max(1, min($ttl, (int) $token->exp - time()));
|
|
}
|
|
Cache::put($key, $active, $ttl);
|
|
|
|
return $active;
|
|
}
|
|
|
|
/**
|
|
* Find the local user of an Anikeen ID.
|
|
*/
|
|
protected function retrieveUser(string $anikeenId): ?Authenticatable
|
|
{
|
|
return $this->provider->retrieveByAnikeenId($anikeenId);
|
|
}
|
|
|
|
/**
|
|
* Authenticate the incoming request via the token cookie.
|
|
*/
|
|
protected function authenticateViaCookie(Request $request): ?Authenticatable
|
|
{
|
|
if (!$token = $this->getTokenViaCookie($request)) {
|
|
return null;
|
|
}
|
|
|
|
// If this user exists, we will return this user and attach a "transient" token to
|
|
// the user model. The transient token assumes it has all scopes since the user
|
|
// is physically logged into the application via the application's interface.
|
|
/** @var Authenticatable|HasAnikeenTokens|null $user */
|
|
if ($user = $this->retrieveUser((string) $token['sub'])) {
|
|
return method_exists($user, 'withAnikeenAccessToken')
|
|
? $user->withAnikeenAccessToken((object) ['scopes' => ['*']])
|
|
: $user;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the token cookie via the incoming request.
|
|
*/
|
|
protected function getTokenViaCookie(Request $request): ?array
|
|
{
|
|
// If we need to retrieve the token from the cookie, it'll be encrypted so we must
|
|
// first decrypt the cookie and then attempt to find the token value within the
|
|
// database. If we can't decrypt the value we'll bail out with a null return.
|
|
try {
|
|
$token = $this->decodeJwtTokenCookie($request);
|
|
} catch (Exception) {
|
|
return null;
|
|
}
|
|
|
|
if (!isset($token['sub'], $token['expiry'])) {
|
|
return null;
|
|
}
|
|
|
|
// We will compare the CSRF token in the decoded API token against the CSRF header
|
|
// sent with the request. If they don't match then this request isn't sent from
|
|
// a valid source and we won't authenticate the request for further handling.
|
|
if (time() >= $token['expiry'] || (!AnikeenId::$ignoreCsrfToken && !$this->validCsrf($token, $request))) {
|
|
return null;
|
|
}
|
|
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* Decode and decrypt the JWT token cookie.
|
|
*/
|
|
protected function decodeJwtTokenCookie(Request $request): array
|
|
{
|
|
return (array) JWT::decode(
|
|
CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)),
|
|
new Key(
|
|
$this->encrypter->getKey(),
|
|
'HS256'
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determine if the CSRF / header are valid and match.
|
|
*/
|
|
protected function validCsrf(array $token, Request $request): bool
|
|
{
|
|
return isset($token['csrf']) && hash_equals(
|
|
$token['csrf'], $this->getTokenFromRequest($request)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the CSRF token from the request.
|
|
*/
|
|
protected function getTokenFromRequest(Request $request): string
|
|
{
|
|
$token = $request->header('X-CSRF-TOKEN');
|
|
|
|
if (!$token && $header = $request->header('X-XSRF-TOKEN')) {
|
|
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
|
|
}
|
|
|
|
return (string) $token;
|
|
}
|
|
|
|
/**
|
|
* Determine if the cookie contents should be serialized.
|
|
*/
|
|
public static function serialized(): bool
|
|
{
|
|
return EncryptCookies::serialized('XSRF-TOKEN');
|
|
}
|
|
}
|