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\HCardService;
use App\Services\Micropub\HEntryService; use App\Services\Micropub\HEntryService;
use App\Services\Micropub\UpdateService; use App\Services\Micropub\UpdateService;
use App\Services\TokenService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Encoding\CannotDecodeContent; use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Logger; use Monolog\Logger;
class MicropubController extends Controller class MicropubController extends Controller
{ {
protected TokenService $tokenService;
protected HEntryService $hentryService; protected HEntryService $hentryService;
protected HCardService $hcardService; protected HCardService $hcardService;
@ -30,12 +25,10 @@ class MicropubController extends Controller
protected UpdateService $updateService; protected UpdateService $updateService;
public function __construct( public function __construct(
TokenService $tokenService,
HEntryService $hentryService, HEntryService $hentryService,
HCardService $hcardService, HCardService $hcardService,
UpdateService $updateService UpdateService $updateService
) { ) {
$this->tokenService = $tokenService;
$this->hentryService = $hentryService; $this->hentryService = $hentryService;
$this->hcardService = $hcardService; $this->hcardService = $hcardService;
$this->updateService = $updateService; $this->updateService = $updateService;
@ -47,34 +40,24 @@ class MicropubController extends Controller
*/ */
public function post(Request $request): JsonResponse public function post(Request $request): JsonResponse
{ {
try { $this->logMicropubRequest($request->except('token_data'));
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); /** @var Token $tokenData */
} $tokenData = $request->input('token_data');
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$this->logMicropubRequest($request->all());
if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) { if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) {
$scopes = $tokenData->claims()->get('scope'); $scopes = $tokenData['scope'];
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; $micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse(); return $micropubResponses->insufficientScopeResponse();
} }
$location = $this->hentryService->process($request->all(), $this->getCLientId());
$location = $this->hentryService->process($request->all(), $tokenData['client_id']);
return response()->json([ return response()->json([
'response' => 'created', 'response' => 'created',
@ -83,7 +66,7 @@ class MicropubController extends Controller
} }
if ($request->input('h') === 'card' || $request->input('type.0') === 'h-card') { if ($request->input('h') === 'card' || $request->input('type.0') === 'h-card') {
$scopes = $tokenData->claims()->get('scope'); $scopes = $tokenData['scope'];
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
@ -101,7 +84,7 @@ class MicropubController extends Controller
} }
if ($request->input('action') === 'update') { if ($request->input('action') === 'update') {
$scopes = $tokenData->claims()->get('scope'); $scopes = $tokenData['scope'];
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
@ -130,12 +113,6 @@ class MicropubController extends Controller
*/ */
public function get(Request $request): JsonResponse 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') { if ($request->input('q') === 'syndicate-to') {
return response()->json([ return response()->json([
'syndicate-to' => SyndicationTarget::all(), 'syndicate-to' => SyndicationTarget::all(),
@ -168,28 +145,18 @@ class MicropubController extends Controller
} }
// default response is just to return the token data // default response is just to return the token data
/** @var Token $tokenData */
$tokenData = $request->input('token_data');
return response()->json([ return response()->json([
'response' => 'token', 'response' => 'token',
'token' => [ 'token' => [
'me' => $tokenData->claims()->get('me'), 'me' => $tokenData['me'],
'scope' => $tokenData->claims()->get('scope'), 'scope' => $tokenData['scope'],
'client_id' => $tokenData->claims()->get('client_id'), '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. * 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\Http\Responses\MicropubResponses;
use App\Jobs\ProcessMedia; use App\Jobs\ProcessMedia;
use App\Models\Media; use App\Models\Media;
use App\Services\TokenService;
use Exception; use Exception;
use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@ -18,43 +16,20 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller class MicropubMediaController extends Controller
{ {
protected TokenService $tokenService;
public function __construct(TokenService $tokenService)
{
$this->tokenService = $tokenService;
}
public function getHandler(Request $request): JsonResponse public function getHandler(Request $request): JsonResponse
{ {
try { $tokenData = $request->input('token_data');
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); $scopes = $tokenData['scope'];
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; return (new MicropubResponses)->insufficientScopeResponse();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->input('q') === 'last') { if ($request->input('q') === 'last') {
@ -105,28 +80,14 @@ class MicropubMediaController extends Controller
*/ */
public function media(Request $request): JsonResponse public function media(Request $request): JsonResponse
{ {
try { $tokenData = $request->input('token_data');
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); $scopes = $tokenData['scope'];
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; return (new MicropubResponses)->insufficientScopeResponse();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->hasFile('file') === false) { if ($request->hasFile('file') === false) {
@ -161,7 +122,7 @@ class MicropubMediaController extends Controller
} }
$media = Media::create([ $media = Media::create([
'token' => $request->bearerToken(), 'token' => $request->input('access_token'),
'path' => $filename, 'path' => $filename,
'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
'image_widths' => $width, 'image_widths' => $width,

View file

@ -4,31 +4,78 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use Closure; use Closure;
use Illuminate\Http\Request; 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 Symfony\Component\HttpFoundation\Response;
use Lcobucci\JWT\Configuration;
class VerifyMicropubToken class VerifyMicropubToken
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$rawToken = null;
if ($request->input('access_token')) { 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([ return response()->json([
'response' => 'error', 'response' => 'error',
'error' => 'unauthorized', 'error' => 'unauthorized',
'error_description' => 'No access token was provided in the request', 'error_description' => 'No access token was provided in the request',
], 401); ], 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 App\Jobs\AddClientToDatabase;
use DateTimeImmutable; use DateTimeImmutable;
use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Token;
class TokenService class TokenService
{ {
@ -30,20 +29,4 @@ class TokenService
return $token->toString(); 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. * the APP_KEY, to test, we shall create a token, and then verify it.
*/ */
#[Test] #[Test]
public function tokenservice_creates_and_validates_tokens(): void public function tokenservice_creates_valid_tokens(): void
{ {
$tokenService = new TokenService; $tokenService = new TokenService;
$data = [ $data = [
@ -28,20 +28,22 @@ class TokenServiceTest extends TestCase
'scope' => 'post', 'scope' => 'post',
]; ];
$token = $tokenService->getNewToken($data); $token = $tokenService->getNewToken($data);
$valid = $tokenService->validateToken($token);
$validData = [ $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer ' . $token]);
'me' => $valid->claims()->get('me'),
'client_id' => $valid->claims()->get('client_id'), $response->assertJson([
'scope' => $valid->claims()->get('scope'), 'response' => 'token',
]; 'token' => [
$this->assertSame($data, $validData); 'me' => $data['me'],
'client_id' => $data['client_id'],
'scope' => $data['scope'],
]
]);
} }
#[Test] #[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 = [ $data = [
'me' => 'https://example.org', 'me' => 'https://example.org',
'client_id' => 'https://quill.p3k.io', 'client_id' => 'https://quill.p3k.io',
@ -59,7 +61,12 @@ class TokenServiceTest extends TestCase
->getToken($config->signer(), InMemory::plainText(random_bytes(32))) ->getToken($config->signer(), InMemory::plainText(random_bytes(32)))
->toString(); ->toString();
$service = new TokenService; $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer ' . $token]);
$service->validateToken($token);
$response->assertJson([
'response' => 'error',
'error' => 'invalid_token',
'error_description' => 'The provided token did not pass validation',
]);
} }
} }