[MTM] Initial token re-work #117
10 changed files with 124 additions and 181 deletions
Replace JWT Micropub tokens with revocable opaque tokens
Tokens now store a hashed row in micropub_tokens instead of being self-contained signed JWTs, so a leaked or unwanted token can actually be revoked. Since revocation already requires a DB lookup on every request, JWT's stateless-verification benefit was gone anyway, so this also drops the lcobucci/jwt dependency entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
commit
d5706b5f8f
|
|
@ -15,7 +15,6 @@ use App\Services\Micropub\MicropubHandlerRegistry;
|
|||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Lcobucci\JWT\Token;
|
||||
|
||||
class MicropubController extends Controller
|
||||
{
|
||||
|
|
@ -135,7 +134,7 @@ class MicropubController extends Controller
|
|||
}
|
||||
|
||||
// the default response is just to return the token data
|
||||
/** @var Token $tokenData */
|
||||
/** @var array $tokenData */
|
||||
$tokenData = $request->input('token_data');
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ declare(strict_types=1);
|
|||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Http\Responses\MicropubResponses;
|
||||
use App\Models\MicropubToken;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Encoding\CannotDecodeContent;
|
||||
use Lcobucci\JWT\Token;
|
||||
use Lcobucci\JWT\Token\InvalidTokenStructure;
|
||||
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyMicropubToken
|
||||
|
|
@ -39,15 +35,17 @@ class VerifyMicropubToken
|
|||
], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$tokenData = $this->validateToken($rawToken);
|
||||
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
|
||||
$token = MicropubToken::where('token_hash', hash('sha256', $rawToken))
|
||||
->whereNull('revoked_at')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
$micropubResponses = new MicropubResponses;
|
||||
|
||||
return $micropubResponses->invalidTokenResponse();
|
||||
}
|
||||
|
||||
if ($tokenData->claims()->has('scope') === false) {
|
||||
if ($token->scope === '') {
|
||||
$micropubResponses = new MicropubResponses;
|
||||
|
||||
return $micropubResponses->tokenHasNoScopeResponse();
|
||||
|
|
@ -56,26 +54,10 @@ class VerifyMicropubToken
|
|||
return $next($request->merge([
|
||||
'access_token' => $rawToken,
|
||||
'token_data' => [
|
||||
'me' => $tokenData->claims()->get('me'),
|
||||
'scope' => $tokenData->claims()->get('scope'),
|
||||
'client_id' => $tokenData->claims()->get('client_id'),
|
||||
'me' => $token->me,
|
||||
'scope' => $token->scope,
|
||||
'client_id' => $token->client_id,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the token signature is valid.
|
||||
*/
|
||||
private function validateToken(string $bearerToken): Token
|
||||
{
|
||||
$config = resolve(Configuration::class);
|
||||
|
||||
$token = $config->parser()->parse($bearerToken);
|
||||
|
||||
$constraints = $config->validationConstraints();
|
||||
|
||||
$config->validator()->assert($token, ...$constraints);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
app/Models/MicropubToken.php
Normal file
34
app/Models/MicropubToken.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Table;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Table('micropub_tokens')]
|
||||
#[Fillable(['token_hash', 'client_id', 'me', 'scope'])]
|
||||
class MicropubToken extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function revoke(): void
|
||||
{
|
||||
$this->forceFill(['revoked_at' => now()])->save();
|
||||
}
|
||||
|
||||
protected function isRevoked(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->revoked_at !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,10 +7,6 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||
|
||||
|
|
@ -53,17 +49,6 @@ class AppServiceProvider extends ServiceProvider
|
|||
);
|
||||
});
|
||||
|
||||
// Configure JWT builder
|
||||
$this->app->bind('Lcobucci\JWT\Configuration', function () {
|
||||
$key = InMemory::plainText(config('app.key'));
|
||||
|
||||
$config = Configuration::forSymmetricSigner(new Sha256, $key);
|
||||
|
||||
$config->setValidationConstraints(new SignedWith(new Sha256, $key));
|
||||
|
||||
return $config;
|
||||
});
|
||||
|
||||
// Configure HtmlSanitizer
|
||||
$this->app->bind(HtmlSanitizer::class, function () {
|
||||
return new HtmlSanitizer(
|
||||
|
|
|
|||
|
|
@ -5,28 +5,26 @@ declare(strict_types=1);
|
|||
namespace App\Services;
|
||||
|
||||
use App\Jobs\AddClientToDatabase;
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use App\Models\MicropubToken;
|
||||
|
||||
class TokenService
|
||||
{
|
||||
/**
|
||||
* Generate a JWT token.
|
||||
* Generate a new bearer token.
|
||||
*/
|
||||
public function getNewToken(array $data): string
|
||||
{
|
||||
$config = resolve(Configuration::class);
|
||||
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
|
||||
$token = $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', $data['client_id'])
|
||||
->withClaim('me', $data['me'])
|
||||
->withClaim('scope', $data['scope'])
|
||||
->withClaim('nonce', bin2hex(random_bytes(8)))
|
||||
->getToken($config->signer(), $config->signingKey());
|
||||
MicropubToken::create([
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'client_id' => $data['client_id'],
|
||||
'me' => $data['me'],
|
||||
'scope' => $data['scope'],
|
||||
]);
|
||||
|
||||
dispatch(new AddClientToDatabase($data['client_id']));
|
||||
|
||||
return $token->toString();
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
"laravel/horizon": "^5.0",
|
||||
"laravel/scout": "^10.1",
|
||||
"laravel/tinker": "^3.0",
|
||||
"lcobucci/jwt": "^5.0",
|
||||
"league/commonmark": "^2.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"mf2/mf2": "~0.3",
|
||||
|
|
|
|||
75
composer.lock
generated
75
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "23983a4e6a8e79cb9636fe8f0e604eb7",
|
||||
"content-hash": "a2842cf95580a08ad759a92d74fae3b4",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -2431,79 +2431,6 @@
|
|||
},
|
||||
"time": "2026-03-17T14:54:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lcobucci/jwt.git",
|
||||
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
|
||||
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"ext-sodium": "*",
|
||||
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"psr/clock": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"infection/infection": "^0.29",
|
||||
"lcobucci/clock": "^3.2",
|
||||
"lcobucci/coding-standard": "^11.0",
|
||||
"phpbench/phpbench": "^1.2",
|
||||
"phpstan/extension-installer": "^1.2",
|
||||
"phpstan/phpstan": "^1.10.7",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.1.3",
|
||||
"phpstan/phpstan-phpunit": "^1.3.10",
|
||||
"phpstan/phpstan-strict-rules": "^1.5.0",
|
||||
"phpunit/phpunit": "^11.1"
|
||||
},
|
||||
"suggest": {
|
||||
"lcobucci/clock": ">= 3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Lcobucci\\JWT\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Luís Cobucci",
|
||||
"email": "lcobucci@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
|
||||
"keywords": [
|
||||
"JWS",
|
||||
"jwt"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/lcobucci/jwt/issues",
|
||||
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/lcobucci",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/lcobucci",
|
||||
"type": "patreon"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-17T11:30:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.8.3",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('micropub_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('token_hash')->unique();
|
||||
$table->string('client_id');
|
||||
$table->string('me');
|
||||
$table->string('scope');
|
||||
$table->timestamp('revoked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('client_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('micropub_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -4,18 +4,16 @@ declare(strict_types=1);
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\MicropubToken;
|
||||
use App\Services\TokenService;
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TokenServiceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Given the token is dependent on a random nonce, the time of creation and
|
||||
* the APP_KEY, to test, we shall create a token, and then verify it.
|
||||
* Given the token is dependent on a random value and stored only as a
|
||||
* hash, to test, we shall create a token, and then verify it.
|
||||
*/
|
||||
#[Test]
|
||||
public function tokenservice_creates_valid_tokens(): void
|
||||
|
|
@ -41,24 +39,29 @@ class TokenServiceTest extends TestCase
|
|||
}
|
||||
|
||||
#[Test]
|
||||
public function tokens_with_different_signing_key_are_not_valid(): void
|
||||
public function unknown_tokens_are_not_valid(): void
|
||||
{
|
||||
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.bin2hex(random_bytes(32))]);
|
||||
|
||||
$response->assertJson([
|
||||
'response' => 'error',
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'The provided token did not pass validation',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revoked_tokens_are_not_valid(): void
|
||||
{
|
||||
$tokenService = new TokenService;
|
||||
$data = [
|
||||
'me' => 'https://example.org',
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'scope' => 'post',
|
||||
];
|
||||
$token = $tokenService->getNewToken($data);
|
||||
|
||||
$config = resolve(Configuration::class);
|
||||
|
||||
$token = $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', $data['client_id'])
|
||||
->withClaim('me', $data['me'])
|
||||
->withClaim('scope', $data['scope'])
|
||||
->withClaim('nonce', bin2hex(random_bytes(8)))
|
||||
->getToken($config->signer(), InMemory::plainText(random_bytes(32)))
|
||||
->toString();
|
||||
MicropubToken::where('token_hash', hash('sha256', $token))->firstOrFail()->revoke();
|
||||
|
||||
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer '.$token]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,53 +2,39 @@
|
|||
|
||||
namespace Tests;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use App\Services\TokenService;
|
||||
|
||||
trait TestToken
|
||||
{
|
||||
public function getToken(): string
|
||||
{
|
||||
$config = $this->app->make(Configuration::class);
|
||||
|
||||
return $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', 'https://quill.p3k.io')
|
||||
->withClaim('me', 'http://jonnybarnes.localhost')
|
||||
->withClaim('scope', ['create', 'update'])
|
||||
->getToken($config->signer(), $config->signingKey())
|
||||
->toString();
|
||||
return $this->app->make(TokenService::class)->getNewToken([
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'http://jonnybarnes.localhost',
|
||||
'scope' => 'create update',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getTokenWithIncorrectScope(): string
|
||||
{
|
||||
$config = $this->app->make(Configuration::class);
|
||||
|
||||
return $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', 'https://quill.p3k.io')
|
||||
->withClaim('me', 'https://jonnybarnes.localhost')
|
||||
->withClaim('scope', 'view')
|
||||
->getToken($config->signer(), $config->signingKey())
|
||||
->toString();
|
||||
return $this->app->make(TokenService::class)->getNewToken([
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.localhost',
|
||||
'scope' => 'view',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getTokenWithNoScope()
|
||||
public function getTokenWithNoScope(): string
|
||||
{
|
||||
$config = $this->app->make(Configuration::class);
|
||||
|
||||
return $config->builder()
|
||||
->issuedAt(new DateTimeImmutable)
|
||||
->withClaim('client_id', 'https://quill.p3k.io')
|
||||
->withClaim('me', 'https://jonnybarnes.localhost')
|
||||
->getToken($config->signer(), $config->signingKey())
|
||||
->toString();
|
||||
return $this->app->make(TokenService::class)->getNewToken([
|
||||
'client_id' => 'https://quill.p3k.io',
|
||||
'me' => 'https://jonnybarnes.localhost',
|
||||
'scope' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getInvalidToken()
|
||||
public function getInvalidToken(): string
|
||||
{
|
||||
$token = $this->getToken();
|
||||
|
||||
return substr($token, 0, -5);
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue