mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-03 22:29:33 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396eb889c4 | ||
|
|
dc29eb2844
|
||
|
|
b074ec3c0c | ||
|
|
c2493b76fd
|
||
|
|
285594e7e9 |
No files matched your search
@@ -323,30 +323,10 @@ public function boot(): void
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementing Auth
|
### Implementing Auth (API tokens)
|
||||||
|
|
||||||
This method should typically be called in the `boot` method of your `AppServiceProvider` class:
|
The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them
|
||||||
|
in `config/auth.php`:
|
||||||
```php
|
|
||||||
use Anikeen\Id\AnikeenId;
|
|
||||||
use Anikeen\Id\Providers\AnikeenIdUserProvider;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
Auth::provider('anikeen', function ($app, array $config) {
|
|
||||||
return new AnikeenIdUserProvider(
|
|
||||||
$app->make(AnikeenId::class),
|
|
||||||
$app->make(Request::class),
|
|
||||||
$config['model'],
|
|
||||||
$config['fields'] ?? [],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
reference the guard in the `guards` configuration of your `auth.php` configuration file:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
'guards' => [
|
'guards' => [
|
||||||
@@ -360,11 +340,7 @@ reference the guard in the `guards` configuration of your `auth.php` configurati
|
|||||||
'provider' => 'anikeen',
|
'provider' => 'anikeen',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
|
||||||
|
|
||||||
reference the provider in the `providers` configuration of your `auth.php` configuration file:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
@@ -374,11 +350,48 @@ reference the provider in the `providers` configuration of your `auth.php` confi
|
|||||||
'anikeen' => [
|
'anikeen' => [
|
||||||
'driver' => 'anikeen',
|
'driver' => 'anikeen',
|
||||||
'model' => App\Models\User::class,
|
'model' => App\Models\User::class,
|
||||||
'fields' => ['first_name', 'last_name', 'email'],
|
'column' => 'anikeen_id', // optional, see below
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The guard accepts `Authorization: Bearer <access token>` with an access token issued by Anikeen ID
|
||||||
|
(personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's
|
||||||
|
public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the
|
||||||
|
user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's
|
||||||
|
`column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of
|
||||||
|
unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request
|
||||||
|
unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests.
|
||||||
|
|
||||||
|
The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$request->user()->anikeenTokenCan('user'); // has the scope (or `*`)
|
||||||
|
$request->user()->anikeenTokenCanAny(['user', 'user:read']);
|
||||||
|
```
|
||||||
|
|
||||||
|
or as middleware (`403` without the scope):
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
|
||||||
|
use Anikeen\Id\Http\Middleware\CheckScopes;
|
||||||
|
|
||||||
|
Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes
|
||||||
|
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope
|
||||||
|
```
|
||||||
|
|
||||||
|
Options in `config/services.php` (`anikeen`):
|
||||||
|
|
||||||
|
| Key | Default | |
|
||||||
|
|-----|---------|-|
|
||||||
|
| `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) |
|
||||||
|
| `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used |
|
||||||
|
| `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID |
|
||||||
|
| `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable |
|
||||||
|
| `revocation_cache_ttl` | `300` | seconds that answer is cached per token |
|
||||||
|
|
||||||
|
The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token.
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
#### Setters and Getters
|
#### Setters and Getters
|
||||||
|
|||||||
+41
-28
@@ -286,30 +286,10 @@ public function boot(): void
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementing Auth
|
### Implementing Auth (API tokens)
|
||||||
|
|
||||||
This method should typically be called in the `boot` method of your `AppServiceProvider` class:
|
The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them
|
||||||
|
in `config/auth.php`:
|
||||||
```php
|
|
||||||
use Anikeen\Id\AnikeenId;
|
|
||||||
use Anikeen\Id\Providers\AnikeenIdUserProvider;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
Auth::provider('anikeen', function ($app, array $config) {
|
|
||||||
return new AnikeenIdUserProvider(
|
|
||||||
$app->make(AnikeenId::class),
|
|
||||||
$app->make(Request::class),
|
|
||||||
$config['model'],
|
|
||||||
$config['fields'] ?? [],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
reference the guard in the `guards` configuration of your `auth.php` configuration file:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
'guards' => [
|
'guards' => [
|
||||||
@@ -323,11 +303,7 @@ reference the guard in the `guards` configuration of your `auth.php` configurati
|
|||||||
'provider' => 'anikeen',
|
'provider' => 'anikeen',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
|
||||||
|
|
||||||
reference the provider in the `providers` configuration of your `auth.php` configuration file:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
@@ -337,11 +313,48 @@ reference the provider in the `providers` configuration of your `auth.php` confi
|
|||||||
'anikeen' => [
|
'anikeen' => [
|
||||||
'driver' => 'anikeen',
|
'driver' => 'anikeen',
|
||||||
'model' => App\Models\User::class,
|
'model' => App\Models\User::class,
|
||||||
'fields' => ['first_name', 'last_name', 'email'],
|
'column' => 'anikeen_id', // optional, see below
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The guard accepts `Authorization: Bearer <access token>` with an access token issued by Anikeen ID
|
||||||
|
(personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's
|
||||||
|
public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the
|
||||||
|
user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's
|
||||||
|
`column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of
|
||||||
|
unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request
|
||||||
|
unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests.
|
||||||
|
|
||||||
|
The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$request->user()->anikeenTokenCan('user'); // has the scope (or `*`)
|
||||||
|
$request->user()->anikeenTokenCanAny(['user', 'user:read']);
|
||||||
|
```
|
||||||
|
|
||||||
|
or as middleware (`403` without the scope):
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
|
||||||
|
use Anikeen\Id\Http\Middleware\CheckScopes;
|
||||||
|
|
||||||
|
Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes
|
||||||
|
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope
|
||||||
|
```
|
||||||
|
|
||||||
|
Options in `config/services.php` (`anikeen`):
|
||||||
|
|
||||||
|
| Key | Default | |
|
||||||
|
|-----|---------|-|
|
||||||
|
| `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) |
|
||||||
|
| `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used |
|
||||||
|
| `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID |
|
||||||
|
| `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable |
|
||||||
|
| `revocation_cache_ttl` | `300` | seconds that answer is cached per token |
|
||||||
|
|
||||||
|
The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token.
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
#### Setters and Getters
|
#### Setters and Getters
|
||||||
|
|||||||
+14
-14
@@ -1,14 +1,14 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAi4Ta8r01zKaGSnGi1EiD
|
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArGn4rtfKjlLtJsh61eW/
|
||||||
uMFWRXBlK4y/ZIfWBpElmS2ygv4mGeP3hT4Flm696Z2UMy56KC+c7CC/PQCiutLk
|
cZdLC6ECbSUKVTZlLLvRoYr7Li6RKRDiIj7KEy+lRL4miiMtadXNx6d89KZwr8SL
|
||||||
5NUphyX/t+0QS5Dqpw6FB33fLTNNY7GqSmGIUE4os8XYZRSyDQRgtOgq3R3vJkoV
|
DQXDbP//F3HEXVJBtr6j8p8HbJh1pinLJeKtcmeJfOIwVLyQ3sPdewkQoFtwkQyF
|
||||||
7zoavTJmSCQlG5Qf0T//iMmzQ+b+6VZm1CJSz5nGx94u1DuXNyP5Epkk0wuHrtwy
|
uZWLeh6TCfAtcQs49I6C0rYgLDQrfTzvQ195mX1HkgArndNDXBiECMYCCulmPpzv
|
||||||
kADR2lmydNodJzqpSD+8yQqnAhOZNtNF4qwQ3g13fRvHycBp3G2nlCfOn2g5PmYD
|
yq5MWS5+/uQjKmPToo5lPfKxbWD6lyLII9ZkGp7wFilSonpUN1LmwRJlgQqh+1Hb
|
||||||
KYBKqvTq4PQH4E+K3pbbMz6zf/T6Dw7zTfksqHR4hqMgN6byRRxmwuBczIumcu9b
|
celGHTIXiP3lDNIG3sR/H5v6vwGeqfu9MBQ2Ksrz259O1aojqRQ+d9k1dj8pcbuC
|
||||||
y7xbgoIGIVZXgJliALPFi+zTPTN7c8MedFs/xCBHCmzWYTCZfHgr8RPRewD19tCG
|
ZurlI4++JOsmecc4B3g8tvlsevlb0UbtW/JHKckgK/JL9PO+04WJD+hSJrEhokLq
|
||||||
NSny5R0vlArpuZCTTgedPESDeGU4eNEddg4yXFzKlpE2nNuvzZ1Ohruc5ETOSU19
|
aB5Ceg6FKwqoM0l7dtoHRka8vh/q/UU3oawUESoZ5l3TjWI4b0jZzsJqE4cyKgK3
|
||||||
RTCBUBkjeL6ESZRd/yKGjbVx4dEYxZdIz4yBl+hZ2ZOIyG7L3zPrccAWrPpG56xr
|
S3tBooj4rM4dOh4y7crD1PcsJG/IF2bg4YEAO8q8Fz6TmOzCM20oBhaLrFJOnYph
|
||||||
E5IDBXxLFhaJ5LlyEAGQehB0ShEuCdkr88Xz7ba9PHpGqY83l4//ULrqPIZPAa4Z
|
Xhhl7ZPHqCCIt6HSDbwlFJw1Iw/2nygnkAZ0iJV/EQl0M+zlDGEv41SWHQfsLTK6
|
||||||
E3AWHT1ZtXNPeA4SzZ9Y9Oij4M3chyHxqM0lL3kYP+dstZehTujStfElDIx2Ni10
|
HBSM1QSEbuMz0sF/TpolYEvYso+nCDjDzQ4MFdhGxH6OVyuSySm6nG0fK/Bio5Ow
|
||||||
73tILu4edYS0FxsL19m8gbsCAwEAAQ==
|
6SNz0wIg3RagjzTbljHsT4cCAwEAAQ==
|
||||||
-----END PUBLIC KEY-----
|
-----END PUBLIC KEY-----
|
||||||
+110
-61
@@ -9,38 +9,39 @@ use Exception;
|
|||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use Firebase\JWT\Key;
|
use Firebase\JWT\Key;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Auth\GuardHelpers;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Container\Container;
|
|
||||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
|
||||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
||||||
use Illuminate\Contracts\Encryption\Encrypter;
|
use Illuminate\Contracts\Encryption\Encrypter;
|
||||||
use Illuminate\Cookie\CookieValuePrefix;
|
use Illuminate\Cookie\CookieValuePrefix;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
use Throwable;
|
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
|
class TokenGuard
|
||||||
{
|
{
|
||||||
use GuardHelpers;
|
public function __construct(
|
||||||
|
protected UserProvider $provider,
|
||||||
private Encrypter $encrypter;
|
private Encrypter $encrypter,
|
||||||
|
private JwtParser $jwtParser
|
||||||
private JwtParser $jwtParser;
|
) {
|
||||||
|
//
|
||||||
public function __construct(UserProvider $provider, Encrypter $encrypter, JwtParser $jwtParser)
|
|
||||||
{
|
|
||||||
$this->provider = $provider;
|
|
||||||
$this->encrypter = $encrypter;
|
|
||||||
$this->jwtParser = $jwtParser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user for the incoming request.
|
* Get the user for the incoming request.
|
||||||
*
|
|
||||||
* @throws BindingResolutionException
|
|
||||||
* @throws Throwable
|
|
||||||
*/
|
*/
|
||||||
public function user(Request $request): ?Authenticatable
|
public function user(Request $request): ?Authenticatable
|
||||||
{
|
{
|
||||||
@@ -55,9 +56,6 @@ class TokenGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate the incoming request via the Bearer token.
|
* Authenticate the incoming request via the Bearer token.
|
||||||
*
|
|
||||||
* @throws BindingResolutionException
|
|
||||||
* @throws Throwable
|
|
||||||
*/
|
*/
|
||||||
protected function authenticateViaBearerToken(Request $request): ?Authenticatable
|
protected function authenticateViaBearerToken(Request $request): ?Authenticatable
|
||||||
{
|
{
|
||||||
@@ -65,51 +63,97 @@ class TokenGuard
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the access token is valid we will retrieve the user according to the user ID
|
// client credentials tokens have no user
|
||||||
// associated with the token. We will use the provider implementation which may
|
if (!is_scalar($token->sub ?? null) || (string) $token->sub === '') {
|
||||||
// be used to retrieve users from Eloquent. Next, we'll be ready to continue.
|
return null;
|
||||||
/** @var Authenticatable|HasAnikeenTokens $user */
|
}
|
||||||
$user = $this->provider->retrieveById(
|
|
||||||
$request->attributes->get('oauth_user_id') ?: null
|
|
||||||
);
|
|
||||||
|
|
||||||
return $user?->withAnikeenAccessToken($token);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate and get the incoming request via the Bearer token.
|
* Verify the Bearer token and expose its claims as request attributes.
|
||||||
*
|
|
||||||
* @throws BindingResolutionException
|
|
||||||
* @throws Throwable
|
|
||||||
*/
|
*/
|
||||||
protected function validateRequestViaBearerToken(Request $request): ?stdClass
|
protected function validateRequestViaBearerToken(Request $request): ?stdClass
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$decoded = $this->jwtParser->decode($request);
|
$decoded = $this->jwtParser->decode($request);
|
||||||
|
} catch (AuthenticationException) {
|
||||||
$request->attributes->set('oauth_access_token_id', $decoded->jti);
|
|
||||||
$request->attributes->set('oauth_client_id', $decoded->aud);
|
|
||||||
$request->attributes->set('oauth_client_trusted', $decoded->client->trusted);
|
|
||||||
$request->attributes->set('oauth_user_id', $decoded->sub);
|
|
||||||
$request->attributes->set('oauth_scopes', $decoded->scopes);
|
|
||||||
|
|
||||||
return $decoded;
|
|
||||||
} catch (AuthenticationException $e) {
|
|
||||||
$request->headers->set('Authorization', '', true);
|
|
||||||
|
|
||||||
Container::getInstance()->make(
|
|
||||||
ExceptionHandler::class
|
|
||||||
)->report($e);
|
|
||||||
|
|
||||||
return null;
|
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.
|
* Authenticate the incoming request via the token cookie.
|
||||||
*/
|
*/
|
||||||
protected function authenticateViaCookie(Request $request): mixed
|
protected function authenticateViaCookie(Request $request): ?Authenticatable
|
||||||
{
|
{
|
||||||
if (!$token = $this->getTokenViaCookie($request)) {
|
if (!$token = $this->getTokenViaCookie($request)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -118,9 +162,11 @@ class TokenGuard
|
|||||||
// If this user exists, we will return this user and attach a "transient" token to
|
// 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
|
// 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.
|
// is physically logged into the application via the application's interface.
|
||||||
/** @var Authenticatable|HasAnikeenTokens $user */
|
/** @var Authenticatable|HasAnikeenTokens|null $user */
|
||||||
if ($user = $this->provider->retrieveById($token['sub'])) {
|
if ($user = $this->retrieveUser((string) $token['sub'])) {
|
||||||
return $user->withAnikeenAccessToken((object)['scopes' => ['*']]);
|
return method_exists($user, 'withAnikeenAccessToken')
|
||||||
|
? $user->withAnikeenAccessToken((object) ['scopes' => ['*']])
|
||||||
|
: $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -136,15 +182,18 @@ class TokenGuard
|
|||||||
// database. If we can't decrypt the value we'll bail out with a null return.
|
// database. If we can't decrypt the value we'll bail out with a null return.
|
||||||
try {
|
try {
|
||||||
$token = $this->decodeJwtTokenCookie($request);
|
$token = $this->decodeJwtTokenCookie($request);
|
||||||
} catch (Exception $e) {
|
} catch (Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($token['sub'], $token['expiry'])) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We will compare the CSRF token in the decoded API token against the CSRF header
|
// 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
|
// 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.
|
// a valid source and we won't authenticate the request for further handling.
|
||||||
if (!AnikeenId::$ignoreCsrfToken && (!$this->validCsrf($token, $request) ||
|
if (time() >= $token['expiry'] || (!AnikeenId::$ignoreCsrfToken && !$this->validCsrf($token, $request))) {
|
||||||
time() >= $token['expiry'])) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +205,7 @@ class TokenGuard
|
|||||||
*/
|
*/
|
||||||
protected function decodeJwtTokenCookie(Request $request): array
|
protected function decodeJwtTokenCookie(Request $request): array
|
||||||
{
|
{
|
||||||
return (array)JWT::decode(
|
return (array) JWT::decode(
|
||||||
CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)),
|
CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)),
|
||||||
new Key(
|
new Key(
|
||||||
$this->encrypter->getKey(),
|
$this->encrypter->getKey(),
|
||||||
@@ -171,8 +220,8 @@ class TokenGuard
|
|||||||
protected function validCsrf(array $token, Request $request): bool
|
protected function validCsrf(array $token, Request $request): bool
|
||||||
{
|
{
|
||||||
return isset($token['csrf']) && hash_equals(
|
return isset($token['csrf']) && hash_equals(
|
||||||
$token['csrf'], $this->getTokenFromRequest($request)
|
$token['csrf'], $this->getTokenFromRequest($request)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +235,7 @@ class TokenGuard
|
|||||||
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
|
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $token;
|
return (string) $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,4 +245,4 @@ class TokenGuard
|
|||||||
{
|
{
|
||||||
return EncryptCookies::serialized('XSRF-TOKEN');
|
return EncryptCookies::serialized('XSRF-TOKEN');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Anikeen\Id\Auth;
|
namespace Anikeen\Id\Auth;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Auth\UserProvider as Base;
|
use Illuminate\Contracts\Auth\UserProvider as Base;
|
||||||
|
|
||||||
@@ -10,11 +11,33 @@ class UserProvider implements Base
|
|||||||
/**
|
/**
|
||||||
* Create a new AnikeenId user provider.
|
* Create a new AnikeenId user provider.
|
||||||
*/
|
*/
|
||||||
public function __construct(protected Base $provider, protected string $providerName)
|
public function __construct(protected Base $provider, protected string $providerName, protected ?string $column = null)
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column that holds a user's Anikeen ID: the provider's `column`, else
|
||||||
|
* `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`).
|
||||||
|
*/
|
||||||
|
public function column(): string
|
||||||
|
{
|
||||||
|
return $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the user of an Anikeen ID (the `sub` of an access token).
|
||||||
|
*/
|
||||||
|
public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable
|
||||||
|
{
|
||||||
|
if (method_exists($this->provider, 'retrieveByAnikeenId')) {
|
||||||
|
return $this->provider->retrieveByAnikeenId($anikeenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eloquent / database providers: a plain where on the column
|
||||||
|
return $this->provider->retrieveByCredentials([$this->column() => $anikeenId]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
@@ -65,6 +88,6 @@ class UserProvider implements Base
|
|||||||
|
|
||||||
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
||||||
{
|
{
|
||||||
// TODO: Implement rehashPasswordIfRequired() method.
|
$this->provider->rehashPasswordIfRequired($user, $credentials, $force);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,9 +26,23 @@ trait HasAnikeenTokens
|
|||||||
*/
|
*/
|
||||||
public function anikeenTokenCan(string $scope): bool
|
public function anikeenTokenCan(string $scope): bool
|
||||||
{
|
{
|
||||||
$scopes = $this->accessToken ? $this->accessToken->scopes : [];
|
$scopes = (array) ($this->accessToken->scopes ?? []);
|
||||||
|
|
||||||
return in_array('*', $scopes) || in_array($scope, $this->accessToken->scopes);
|
return in_array('*', $scopes, true) || in_array($scope, $scopes, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the current API token has any of the given scopes.
|
||||||
|
*/
|
||||||
|
public function anikeenTokenCanAny(array $scopes): bool
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
if ($this->anikeenTokenCan($scope)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,32 +7,74 @@ use Firebase\JWT\JWT;
|
|||||||
use Firebase\JWT\Key;
|
use Firebase\JWT\Key;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use RuntimeException;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies access tokens issued by Anikeen ID (Passport, RS256): signature against
|
||||||
|
* Anikeen ID's public key plus the time claims (exp, nbf, iat; 60 seconds leeway).
|
||||||
|
*
|
||||||
|
* The key is `services.anikeen.public_key` (PEM, "\n" escapes allowed) or the file at
|
||||||
|
* `services.anikeen.public_key_path`, else the key shipped with this package for the
|
||||||
|
* configured mode (production / staging). It is read once per process.
|
||||||
|
*/
|
||||||
class JwtParser
|
class JwtParser
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Public keys already read, by source.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private static array $keys = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AuthenticationException
|
* @throws AuthenticationException
|
||||||
*/
|
*/
|
||||||
public function decode(Request $request): stdClass
|
public function decode(Request $request): stdClass
|
||||||
{
|
{
|
||||||
|
return $this->decodeToken((string) $request->bearerToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws AuthenticationException
|
||||||
|
*/
|
||||||
|
public function decodeToken(string $token): stdClass
|
||||||
|
{
|
||||||
|
if ($token === '') {
|
||||||
|
throw new AuthenticationException('No access token given.');
|
||||||
|
}
|
||||||
|
|
||||||
JWT::$leeway = 60;
|
JWT::$leeway = 60;
|
||||||
|
|
||||||
|
// outside the try: a missing key is a configuration error, not an invalid token
|
||||||
|
$key = new Key($this->getOauthPublicKey(), 'RS256');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JWT::decode(
|
return JWT::decode($token, $key);
|
||||||
$request->bearerToken(),
|
} catch (Throwable) {
|
||||||
new Key($this->getOauthPublicKey(), 'RS256')
|
throw new AuthenticationException('The access token is invalid or expired.');
|
||||||
);
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
throw (new AuthenticationException());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getOauthPublicKey(): bool|string
|
/**
|
||||||
|
* Forget the keys read so far (e.g. after changing the configuration in tests).
|
||||||
|
*/
|
||||||
|
public static function flushKeys(): void
|
||||||
{
|
{
|
||||||
return AnikeenId::getMode() === 'staging'
|
self::$keys = [];
|
||||||
? file_get_contents(dirname(__DIR__, 3) . '/oauth-public.staging.key')
|
}
|
||||||
: file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key');
|
|
||||||
|
protected function getOauthPublicKey(): string
|
||||||
|
{
|
||||||
|
if ($key = config('services.anikeen.public_key')) {
|
||||||
|
return self::$keys['config:' . md5($key)] ??= str_replace('\\n', "\n", $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = config('services.anikeen.public_key_path') ?: (AnikeenId::getMode() === 'staging'
|
||||||
|
? dirname(__DIR__, 3) . '/oauth-public.staging.key'
|
||||||
|
: dirname(__DIR__, 3) . '/oauth-public.key');
|
||||||
|
|
||||||
|
return self::$keys['file:' . $path] ??= (is_readable($path) ? (string) file_get_contents($path) : throw new RuntimeException("Anikeen ID public key [$path] is not readable."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class CheckForAnyScope extends UseParameters
|
class CheckForAnyScope extends UseParameters
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,7 @@ class CheckForAnyScope extends UseParameters
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, ...$scopes): Response
|
public function handle(Request $request, Closure $next, ...$scopes): Response
|
||||||
{
|
{
|
||||||
if (!$request->user() || !$request->user()->anikeenToken()) {
|
if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) {
|
||||||
throw new AuthenticationException;
|
throw new AuthenticationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class CheckScopes extends UseParameters
|
class CheckScopes extends UseParameters
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,7 @@ class CheckScopes extends UseParameters
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next, ...$scopes): Response
|
public function handle(Request $request, Closure $next, ...$scopes): Response
|
||||||
{
|
{
|
||||||
if (!$request->user() || !$request->user()->anikeenToken()) {
|
if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) {
|
||||||
throw new AuthenticationException;
|
throw new AuthenticationException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ use Anikeen\Id\Auth\UserProvider;
|
|||||||
use Anikeen\Id\Contracts;
|
use Anikeen\Id\Contracts;
|
||||||
use Anikeen\Id\Helpers\JwtParser;
|
use Anikeen\Id\Helpers\JwtParser;
|
||||||
use Anikeen\Id\Repository;
|
use Anikeen\Id\Repository;
|
||||||
|
use Illuminate\Auth\AuthManager;
|
||||||
use Illuminate\Auth\RequestGuard;
|
use Illuminate\Auth\RequestGuard;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Contracts\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AnikeenIdServiceProvider extends ServiceProvider
|
class AnikeenIdServiceProvider extends ServiceProvider
|
||||||
@@ -38,15 +41,28 @@ class AnikeenIdServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the token guard.
|
* Register the `anikeen` guard driver and user provider.
|
||||||
|
*
|
||||||
|
* Laravel binds non-static extend() closures to the AuthManager, so these closures are
|
||||||
|
* static and use nothing but their arguments.
|
||||||
*/
|
*/
|
||||||
protected function registerGuard(): void
|
protected function registerGuard(): void
|
||||||
{
|
{
|
||||||
Auth::resolved(function ($auth) {
|
$this->callAfterResolving('auth', static function (AuthManager $auth): void {
|
||||||
$auth->extend('anikeen', function ($app, $name, array $config) {
|
$auth->provider('anikeen', static fn (Application $app, array $config) => new AnikeenIdUserProvider(
|
||||||
return tap($this->makeGuard($config), function ($guard) {
|
$app->make(AnikeenId::class),
|
||||||
$this->app->refresh('request', $guard, 'setRequest');
|
$app->make('request'),
|
||||||
});
|
$config['model'] ?? $app['config']->get('auth.providers.users.model'),
|
||||||
|
$config['fields'] ?? [],
|
||||||
|
$config['column'] ?? null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$auth->extend('anikeen', static function (Application $app, string $name, array $config): RequestGuard {
|
||||||
|
$guard = AnikeenIdServiceProvider::makeGuard($app, $config);
|
||||||
|
|
||||||
|
$app->refresh('request', $guard, 'setRequest');
|
||||||
|
|
||||||
|
return $guard;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,15 +70,20 @@ class AnikeenIdServiceProvider extends ServiceProvider
|
|||||||
/**
|
/**
|
||||||
* Make an instance of the token guard.
|
* Make an instance of the token guard.
|
||||||
*/
|
*/
|
||||||
protected function makeGuard(array $config): RequestGuard
|
public static function makeGuard(Application $app, array $config): RequestGuard
|
||||||
{
|
{
|
||||||
return new RequestGuard(function ($request) use ($config) {
|
$providerName = $config['provider'] ?? $app['config']->get('auth.defaults.provider', 'users');
|
||||||
|
$provider = $app['auth']->createUserProvider($providerName)
|
||||||
|
?? throw new InvalidArgumentException("Authentication user provider [$providerName] is not configured.");
|
||||||
|
$providerConfig = $app['config']->get("auth.providers.$providerName", []);
|
||||||
|
|
||||||
|
return new RequestGuard(static function (Request $request) use ($app, $provider, $providerName, $providerConfig, $config) {
|
||||||
return (new TokenGuard(
|
return (new TokenGuard(
|
||||||
new UserProvider(Auth::createUserProvider($config['provider']), $config['provider']),
|
new UserProvider($provider, $providerName, $config['column'] ?? $providerConfig['column'] ?? null),
|
||||||
$this->app->make('encrypter'),
|
$app->make('encrypter'),
|
||||||
$this->app->make(JwtParser::class)
|
$app->make(JwtParser::class)
|
||||||
))->user($request);
|
))->user($request);
|
||||||
}, $this->app['request']);
|
}, $app['request'], $provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class AnikeenIdUserProvider implements UserProvider
|
|||||||
private AnikeenId $anikeenId,
|
private AnikeenId $anikeenId,
|
||||||
private Request $request,
|
private Request $request,
|
||||||
private string $model,
|
private string $model,
|
||||||
private array $fields = []
|
private array $fields = [],
|
||||||
|
private ?string $column = null,
|
||||||
) {
|
) {
|
||||||
$this->accessTokenField = AnikeenId::getAccessTokenField();
|
$this->accessTokenField = AnikeenId::getAccessTokenField();
|
||||||
}
|
}
|
||||||
@@ -97,6 +98,17 @@ class AnikeenIdUserProvider implements UserProvider
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the user of an Anikeen ID (the `sub` of an access token) by the provider's `column`,
|
||||||
|
* else `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`).
|
||||||
|
*/
|
||||||
|
public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable
|
||||||
|
{
|
||||||
|
$column = $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField());
|
||||||
|
|
||||||
|
return $this->newModelQuery()->where($column, $anikeenId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class AppCheckoutSessions
|
|||||||
* description?: null|string,
|
* description?: null|string,
|
||||||
* unit: string,
|
* unit: string,
|
||||||
* price: float|string,
|
* price: float|string,
|
||||||
|
* monthly_cap?: null|float|string,
|
||||||
|
* predecessor_id?: null|string,
|
||||||
* vat_rate?: float,
|
* vat_rate?: float,
|
||||||
* payload?: null|array,
|
* payload?: null|array,
|
||||||
* ends_at?: null|string
|
* ends_at?: null|string
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ use JsonSerializable;
|
|||||||
* @property ?string $description
|
* @property ?string $description
|
||||||
* @property string $unit
|
* @property string $unit
|
||||||
* @property float $price
|
* @property float $price
|
||||||
|
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
|
||||||
|
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
|
||||||
|
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
|
||||||
* @property ?float $vat_rate
|
* @property ?float $vat_rate
|
||||||
* @property bool $active
|
* @property bool $active
|
||||||
* @property bool $autorenew
|
* @property bool $autorenew
|
||||||
|
|||||||
@@ -44,12 +44,18 @@ class AppSubscriptions
|
|||||||
* description?: null|string,
|
* description?: null|string,
|
||||||
* unit: string,
|
* unit: string,
|
||||||
* price: float|string,
|
* price: float|string,
|
||||||
|
* monthly_cap?: null|float|string,
|
||||||
|
* predecessor_id?: null|string,
|
||||||
* vat_rate?: float,
|
* vat_rate?: float,
|
||||||
* payload?: null|array,
|
* payload?: null|array,
|
||||||
* ends_at?: null|string,
|
* ends_at?: null|string,
|
||||||
* idempotency_key?: null|string
|
* idempotency_key?: null|string
|
||||||
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
|
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
|
||||||
* subscription created by the first one instead of a duplicate.
|
* subscription created by the first one instead of a duplicate.
|
||||||
|
* monthly_cap: net, at most this per calendar month (hour and day units only,
|
||||||
|
* at most 2 decimals, not below price; null = no cap).
|
||||||
|
* predecessor_id: the subscription this one replaces (a resize; same user and
|
||||||
|
* app, checked out, not cancelled): both share the monthly cap within a month.
|
||||||
*
|
*
|
||||||
* @throws ApiException
|
* @throws ApiException
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use Throwable;
|
|||||||
* @property string $description
|
* @property string $description
|
||||||
* @property string $unit
|
* @property string $unit
|
||||||
* @property float $price
|
* @property float $price
|
||||||
|
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
|
||||||
|
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
|
||||||
|
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
|
||||||
* @property float $vat_rate
|
* @property float $vat_rate
|
||||||
* @property array $payload
|
* @property array $payload
|
||||||
* @property string $ends_at
|
* @property string $ends_at
|
||||||
@@ -32,6 +35,7 @@ class Subscription extends BaseResource
|
|||||||
* description: null|string,
|
* description: null|string,
|
||||||
* unit: string,
|
* unit: string,
|
||||||
* price: float,
|
* price: float,
|
||||||
|
* monthly_cap?: null|float,
|
||||||
* vat_rate: float,
|
* vat_rate: float,
|
||||||
* payload: null|array,
|
* payload: null|array,
|
||||||
* ends_at: null|string
|
* ends_at: null|string
|
||||||
@@ -41,6 +45,7 @@ class Subscription extends BaseResource
|
|||||||
* - description: The description (optional)
|
* - description: The description (optional)
|
||||||
* - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year")
|
* - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year")
|
||||||
* - price: The price per unit (required when set)
|
* - price: The price per unit (required when set)
|
||||||
|
* - monthly_cap: Net, at most this per calendar month (hour and day units; optional)
|
||||||
* - vat_rate: The VAT rate (required when set)
|
* - vat_rate: The VAT rate (required when set)
|
||||||
* - payload: The payload (optional)
|
* - payload: The payload (optional)
|
||||||
* - ends_at: The end date (optional)
|
* - ends_at: The end date (optional)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class Subscriptions extends BaseCollection
|
|||||||
* description: null|string,
|
* description: null|string,
|
||||||
* unit: string,
|
* unit: string,
|
||||||
* price: float,
|
* price: float,
|
||||||
|
* monthly_cap?: null|float,
|
||||||
|
* predecessor_id?: null|string,
|
||||||
* vat_rate: float,
|
* vat_rate: float,
|
||||||
* payload: null|array,
|
* payload: null|array,
|
||||||
* ends_at: null|string
|
* ends_at: null|string
|
||||||
@@ -29,6 +31,8 @@ class Subscriptions extends BaseCollection
|
|||||||
* - description: The description (optional)
|
* - description: The description (optional)
|
||||||
* - unit: The unit (e.g. "hour", "day", "week", "month", "year")
|
* - unit: The unit (e.g. "hour", "day", "week", "month", "year")
|
||||||
* - price: The price per unit
|
* - price: The price per unit
|
||||||
|
* - monthly_cap: Net, at most this per calendar month (hour and day units, max. 2 decimals; optional)
|
||||||
|
* - predecessor_id: The subscription this one replaces (a resize); both share the monthly cap (optional)
|
||||||
* - vat_rate: The VAT rate (required when set)
|
* - vat_rate: The VAT rate (required when set)
|
||||||
* - payload: The payload (optional)
|
* - payload: The payload (optional)
|
||||||
* - ends_at: The end date (optional)
|
* - ends_at: The end date (optional)
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\HasAnikeenTokens;
|
||||||
|
use Anikeen\Id\Helpers\JwtParser;
|
||||||
|
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
|
||||||
|
use Anikeen\Id\Http\Middleware\CheckScopes;
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use Illuminate\Auth\RequestGuard;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TokenGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
private static ?string $privateKey = null;
|
||||||
|
|
||||||
|
private static string $publicKey;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
if (self::$privateKey === null) {
|
||||||
|
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
||||||
|
openssl_pkey_export($key, $private);
|
||||||
|
self::$privateKey = $private;
|
||||||
|
self::$publicKey = openssl_pkey_get_details($key)['key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
JwtParser::flushKeys();
|
||||||
|
config([
|
||||||
|
'services.anikeen.public_key' => self::$publicKey,
|
||||||
|
'app.key' => 'base64:'.base64_encode(str_repeat('k', 32)),
|
||||||
|
'database.default' => 'testing',
|
||||||
|
'auth.guards.api' => ['driver' => 'anikeen', 'provider' => 'anikeen'],
|
||||||
|
'auth.providers.anikeen' => ['driver' => 'anikeen', 'model' => GuardUser::class],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('anikeen_id')->nullable();
|
||||||
|
$table->string('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
GuardUser::query()->insert([
|
||||||
|
['id' => 1, 'anikeen_id' => 'ulid-of-alice', 'email' => 'alice@example.com'],
|
||||||
|
['id' => 2, 'anikeen_id' => '1', 'email' => 'bob@example.com'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::middleware('auth:api')->get('/me', fn () => ['email' => auth()->user()->email, 'scopes' => auth()->user()->anikeenToken()->scopes]);
|
||||||
|
Route::middleware(['auth:api', CheckScopes::using('user')])->post('/write', fn () => ['ok' => true]);
|
||||||
|
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get('/read', fn () => ['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like a fresh request in production: the guard remembers its user per application.
|
||||||
|
*/
|
||||||
|
public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
|
||||||
|
{
|
||||||
|
Auth::forgetGuards();
|
||||||
|
|
||||||
|
return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(array $claims = [], ?string $privateKey = null): string
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
return JWT::encode(array_merge([
|
||||||
|
'aud' => '019a0000-0000-7000-8000-000000000001',
|
||||||
|
'jti' => Str::random(40),
|
||||||
|
'iat' => $now,
|
||||||
|
'nbf' => $now,
|
||||||
|
'exp' => $now + 3600,
|
||||||
|
'sub' => 'ulid-of-alice',
|
||||||
|
'scopes' => ['user'],
|
||||||
|
], $claims), $privateKey ?? self::$privateKey, 'RS256');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_api_guard_resolves(): void
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(RequestGuard::class, Auth::guard('api'));
|
||||||
|
$this->assertNull(Auth::guard('api')->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_valid_token_resolves_the_user_by_anikeen_id(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()])
|
||||||
|
->assertOk()
|
||||||
|
->assertExactJson(['email' => 'alice@example.com', 'scopes' => ['user']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_user_is_not_looked_up_by_primary_key(): void
|
||||||
|
{
|
||||||
|
// sub "2" is bob's primary key, but nobody has the Anikeen ID "2"
|
||||||
|
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '2'])])->assertUnauthorized();
|
||||||
|
// sub "1" is bob's Anikeen ID (alice's primary key)
|
||||||
|
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '1'])])
|
||||||
|
->assertOk()->assertJsonPath('email', 'bob@example.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_lookup_column_is_configurable(): void
|
||||||
|
{
|
||||||
|
config(['auth.providers.anikeen.column' => 'email']);
|
||||||
|
|
||||||
|
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => 'bob@example.com'])])
|
||||||
|
->assertOk()->assertJsonPath('email', 'bob@example.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_eloquent_provider_works_too(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'auth.guards.api.provider' => 'users',
|
||||||
|
'auth.providers.users' => ['driver' => 'eloquent', 'model' => GuardUser::class],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()])
|
||||||
|
->assertOk()->assertJsonPath('email', 'alice@example.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_tokens_answer_401_json(): void
|
||||||
|
{
|
||||||
|
$other = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
||||||
|
openssl_pkey_export($other, $otherPrivate);
|
||||||
|
|
||||||
|
$cases = [
|
||||||
|
'no token' => [],
|
||||||
|
'garbage' => ['Authorization' => 'Bearer not-a-jwt'],
|
||||||
|
'expired' => ['Authorization' => 'Bearer '.$this->token(['exp' => time() - 120])],
|
||||||
|
'not yet valid' => ['Authorization' => 'Bearer '.$this->token(['nbf' => time() + 600])],
|
||||||
|
'foreign key' => ['Authorization' => 'Bearer '.$this->token([], $otherPrivate)],
|
||||||
|
'unknown user' => ['Authorization' => 'Bearer '.$this->token(['sub' => 'nobody'])],
|
||||||
|
'client credentials' => ['Authorization' => 'Bearer '.$this->token(['sub' => ''])],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as $case => $headers) {
|
||||||
|
$this->getJson('/me', $headers)->assertUnauthorized()->assertJson(['message' => 'Unauthenticated.'], $case);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scopes_are_enforced(): void
|
||||||
|
{
|
||||||
|
$read = $this->token(['scopes' => ['user:read']]);
|
||||||
|
$billing = $this->token(['scopes' => ['billing']]);
|
||||||
|
$all = $this->token(['scopes' => ['*']]);
|
||||||
|
|
||||||
|
$this->getJson('/read', ['Authorization' => "Bearer $read"])->assertOk();
|
||||||
|
$this->postJson('/write', [], ['Authorization' => "Bearer $read"])->assertForbidden();
|
||||||
|
$this->getJson('/read', ['Authorization' => "Bearer $billing"])->assertForbidden();
|
||||||
|
$this->postJson('/write', [], ['Authorization' => "Bearer $all"])->assertOk();
|
||||||
|
$this->postJson('/write', [], ['Authorization' => 'Bearer '.$this->token()])->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_token_can(): void
|
||||||
|
{
|
||||||
|
$user = GuardUser::query()->find(1)->withAnikeenAccessToken((object) ['scopes' => ['user:read']]);
|
||||||
|
|
||||||
|
$this->assertTrue($user->anikeenTokenCan('user:read'));
|
||||||
|
$this->assertFalse($user->anikeenTokenCan('user'));
|
||||||
|
$this->assertTrue($user->anikeenTokenCanAny(['user', 'user:read']));
|
||||||
|
$this->assertFalse((new GuardUser())->anikeenTokenCan('user'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_revoked_tokens_are_rejected_when_revocation_is_verified(): void
|
||||||
|
{
|
||||||
|
config(['services.anikeen.verify_revocation' => true]);
|
||||||
|
$active = $this->token();
|
||||||
|
$revoked = $this->token();
|
||||||
|
|
||||||
|
$this->respond(200, ['data' => ['id' => 'ulid-of-alice']]);
|
||||||
|
$this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk();
|
||||||
|
// cached: no second request to Anikeen ID
|
||||||
|
$this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk();
|
||||||
|
$this->assertCount(1, $this->sent);
|
||||||
|
$this->assertSame("Bearer $active", $this->sent[0]['request']->getHeaderLine('Authorization'));
|
||||||
|
$this->assertStringEndsWith('v1/user', (string) $this->sent[0]['request']->getUri());
|
||||||
|
|
||||||
|
$this->respond(401, ['message' => 'Unauthenticated.']);
|
||||||
|
$this->getJson('/me', ['Authorization' => "Bearer $revoked"])->assertUnauthorized();
|
||||||
|
|
||||||
|
// Anikeen ID down: fail closed, not cached
|
||||||
|
$other = $this->token();
|
||||||
|
$this->respond(503);
|
||||||
|
$this->getJson('/me', ['Authorization' => "Bearer $other"])->assertUnauthorized();
|
||||||
|
$this->respond(200, ['data' => ['id' => 'ulid-of-alice']]);
|
||||||
|
$this->getJson('/me', ['Authorization' => "Bearer $other"])->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GuardUser extends Authenticatable
|
||||||
|
{
|
||||||
|
use HasAnikeenTokens;
|
||||||
|
|
||||||
|
protected $table = 'users';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user