Refactor micropub token verification

This commit is contained in:
Jonny Barnes 2025-04-12 11:47:30 +01:00
parent 70f90dd456
commit 23c275945a
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
5 changed files with 101 additions and 136 deletions

View file

@ -10,19 +10,14 @@ use App\Models\SyndicationTarget;
use App\Services\Micropub\HCardService;
use App\Services\Micropub\HEntryService;
use App\Services\Micropub\UpdateService;
use App\Services\TokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Lcobucci\JWT\Token;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
class MicropubController extends Controller
{
protected TokenService $tokenService;
protected HEntryService $hentryService;
protected HCardService $hcardService;
@ -30,12 +25,10 @@ class MicropubController extends Controller
protected UpdateService $updateService;
public function __construct(
TokenService $tokenService,
HEntryService $hentryService,
HCardService $hcardService,
UpdateService $updateService
) {
$this->tokenService = $tokenService;
$this->hentryService = $hentryService;
$this->hcardService = $hcardService;
$this->updateService = $updateService;
@ -47,34 +40,24 @@ class MicropubController extends Controller
*/
public function post(Request $request): JsonResponse
{
try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$micropubResponses = new MicropubResponses;
$this->logMicropubRequest($request->except('token_data'));
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$this->logMicropubRequest($request->all());
/** @var Token $tokenData */
$tokenData = $request->input('token_data');
if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) {
$scopes = $tokenData->claims()->get('scope');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes)) {
if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
$location = $this->hentryService->process($request->all(), $this->getCLientId());
$location = $this->hentryService->process($request->all(), $tokenData['client_id']);
return response()->json([
'response' => 'created',
@ -83,7 +66,7 @@ class MicropubController extends Controller
}
if ($request->input('h') === 'card' || $request->input('type.0') === 'h-card') {
$scopes = $tokenData->claims()->get('scope');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
@ -101,7 +84,7 @@ class MicropubController extends Controller
}
if ($request->input('action') === 'update') {
$scopes = $tokenData->claims()->get('scope');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
@ -130,12 +113,6 @@ class MicropubController extends Controller
*/
public function get(Request $request): JsonResponse
{
try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
return (new MicropubResponses)->invalidTokenResponse();
}
if ($request->input('q') === 'syndicate-to') {
return response()->json([
'syndicate-to' => SyndicationTarget::all(),
@ -168,28 +145,18 @@ class MicropubController extends Controller
}
// default response is just to return the token data
/** @var Token $tokenData */
$tokenData = $request->input('token_data');
return response()->json([
'response' => 'token',
'token' => [
'me' => $tokenData->claims()->get('me'),
'scope' => $tokenData->claims()->get('scope'),
'client_id' => $tokenData->claims()->get('client_id'),
'me' => $tokenData['me'],
'scope' => $tokenData['scope'],
'client_id' => $tokenData['client_id'],
],
]);
}
/**
* Determine the client id from the access token sent with the request.
*
* @throws RequiredConstraintsViolated
*/
private function getClientId(): string
{
return resolve(TokenService::class)
->validateToken(app('request')->input('access_token'))
->claims()->get('client_id');
}
/**
* Save the details of the micropub request to a log file.
*/

View file

@ -7,10 +7,8 @@ namespace App\Http\Controllers;
use App\Http\Responses\MicropubResponses;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use App\Services\TokenService;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -18,43 +16,20 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller
{
protected TokenService $tokenService;
public function __construct(TokenService $tokenService)
{
$this->tokenService = $tokenService;
}
public function getHandler(Request $request): JsonResponse
{
try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
$tokenData = $request->input('token_data');
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
if ($request->input('q') === 'last') {
@ -105,28 +80,14 @@ class MicropubMediaController extends Controller
*/
public function media(Request $request): JsonResponse
{
try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
$tokenData = $request->input('token_data');
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
$scopes = $tokenData['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
if ($request->hasFile('file') === false) {
@ -161,7 +122,7 @@ class MicropubMediaController extends Controller
}
$media = Media::create([
'token' => $request->bearerToken(),
'token' => $request->input('access_token'),
'path' => $filename,
'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
'image_widths' => $width,

View file

@ -4,31 +4,78 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use Closure;
use Illuminate\Http\Request;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response;
use Lcobucci\JWT\Configuration;
class VerifyMicropubToken
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$rawToken = null;
if ($request->input('access_token')) {
return $next($request);
$rawToken = $request->input('access_token');
} elseif ($request->bearerToken()) {
$rawToken = $request->bearerToken();
}
if ($request->bearerToken()) {
return $next($request->merge([
'access_token' => $request->bearerToken(),
]));
if (! $rawToken) {
return response()->json([
'response' => 'error',
'error' => 'unauthorized',
'error_description' => 'No access token was provided in the request',
], 401);
}
return response()->json([
'response' => 'error',
'error' => 'unauthorized',
'error_description' => 'No access token was provided in the request',
], 401);
try {
$tokenData = $this->validateToken($rawToken);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
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'),
],
]));
}
/**
* 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;
}
}

View file

@ -7,7 +7,6 @@ namespace App\Services;
use App\Jobs\AddClientToDatabase;
use DateTimeImmutable;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Token;
class TokenService
{
@ -30,20 +29,4 @@ class TokenService
return $token->toString();
}
/**
* Check the token signature is valid.
*/
public function validateToken(string $bearerToken): Token
{
$config = resolve('Lcobucci\JWT\Configuration');
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
}
}

View file

@ -19,7 +19,7 @@ class TokenServiceTest extends TestCase
* the APP_KEY, to test, we shall create a token, and then verify it.
*/
#[Test]
public function tokenservice_creates_and_validates_tokens(): void
public function tokenservice_creates_valid_tokens(): void
{
$tokenService = new TokenService;
$data = [
@ -28,20 +28,22 @@ class TokenServiceTest extends TestCase
'scope' => 'post',
];
$token = $tokenService->getNewToken($data);
$valid = $tokenService->validateToken($token);
$validData = [
'me' => $valid->claims()->get('me'),
'client_id' => $valid->claims()->get('client_id'),
'scope' => $valid->claims()->get('scope'),
];
$this->assertSame($data, $validData);
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer ' . $token]);
$response->assertJson([
'response' => 'token',
'token' => [
'me' => $data['me'],
'client_id' => $data['client_id'],
'scope' => $data['scope'],
]
]);
}
#[Test]
public function tokens_with_different_signing_key_throws_exception(): void
public function tokens_with_different_signing_key_are_not_valid(): void
{
$this->expectException(RequiredConstraintsViolated::class);
$data = [
'me' => 'https://example.org',
'client_id' => 'https://quill.p3k.io',
@ -59,7 +61,12 @@ class TokenServiceTest extends TestCase
->getToken($config->signer(), InMemory::plainText(random_bytes(32)))
->toString();
$service = new TokenService;
$service->validateToken($token);
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer ' . $token]);
$response->assertJson([
'response' => 'error',
'error' => 'invalid_token',
'error_description' => 'The provided token did not pass validation',
]);
}
}