Refactor micropub token verification
This commit is contained in:
parent
70f90dd456
commit
23c275945a
5 changed files with 101 additions and 136 deletions
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue