diff --git a/src/Api/ApiKey.php b/src/Api/ApiKey.php index e8ebb48ec..1adfb9b83 100644 --- a/src/Api/ApiKey.php +++ b/src/Api/ApiKey.php @@ -11,7 +11,9 @@ namespace Flarum\Api; +use Carbon\Carbon; use Flarum\Database\AbstractModel; +use Flarum\User\User; /** * @property int $id @@ -19,11 +21,14 @@ use Flarum\Database\AbstractModel; * @property string|null $allowed_ips * @property string|null $scopes * @property int|null $user_id + * @property \Flarum\User\User|null $user * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon|null $last_activity_at */ class ApiKey extends AbstractModel { + protected $dates = ['last_activity_at']; + /** * Generate an API key. * @@ -37,4 +42,16 @@ class ApiKey extends AbstractModel return $key; } + + public function touch() + { + $this->last_activity_at = Carbon::now(); + + return $this->save(); + } + + public function user() + { + return $this->belongsTo(User::class); + } } diff --git a/src/Http/Middleware/AuthenticateWithHeader.php b/src/Http/Middleware/AuthenticateWithHeader.php index 658d064ab..787470c90 100644 --- a/src/Http/Middleware/AuthenticateWithHeader.php +++ b/src/Http/Middleware/AuthenticateWithHeader.php @@ -32,13 +32,14 @@ class AuthenticateWithHeader implements Middleware if (isset($parts[0]) && starts_with($parts[0], self::TOKEN_PREFIX)) { $id = substr($parts[0], strlen(self::TOKEN_PREFIX)); - if (isset($parts[1])) { - if ($key = ApiKey::find($id)) { - $actor = $this->getUser($parts[1]); + if ($key = ApiKey::where('key', $id)->first()) { + $key->touch(); - $request = $request->withAttribute('apiKey', $key); - $request = $request->withAttribute('bypassFloodgate', true); - } + $userId = $parts[1] ?? ''; + $actor = $key->user ?? $this->getUser($userId); + + $request = $request->withAttribute('apiKey', $key); + $request = $request->withAttribute('bypassFloodgate', true); } elseif ($token = AccessToken::find($id)) { $token->touch(); diff --git a/tests/Api/Auth/AuthenticateWithApiKeyTest.php b/tests/Api/Auth/AuthenticateWithApiKeyTest.php new file mode 100644 index 000000000..0d2f179a5 --- /dev/null +++ b/tests/Api/Auth/AuthenticateWithApiKeyTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Tests\Api\Auth; + +use Carbon\Carbon; +use Flarum\Api\ApiKey; +use Flarum\Api\Controller\CreateGroupController; +use Flarum\Tests\Test\Concerns\RetrievesAuthorizedUsers; +use Flarum\Tests\Test\TestCase; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Zend\Diactoros\Response; +use Zend\Diactoros\ServerRequestFactory; +use Zend\Stratigility\MiddlewarePipe; + +class AuthenticateWithApiKeyTest extends TestCase +{ + use RetrievesAuthorizedUsers; + + protected function key(int $user_id = null): ApiKey + { + return ApiKey::unguarded(function () use ($user_id) { + return ApiKey::query()->firstOrCreate([ + 'key' => str_random(), + 'user_id' => $user_id, + 'created_at' => Carbon::now() + ]); + }); + } + + /** + * @test + * @expectedException \Flarum\User\Exception\PermissionDeniedException + */ + public function cannot_authorize_without_key() + { + $this->call( + CreateGroupController::class + ); + } + + /** + * @test + */ + public function master_token_can_authenticate_as_anyone() + { + $key = $this->key(); + + $request = ServerRequestFactory::fromGlobals() + ->withAddedHeader('Authorization', "Token {$key->key}; userId=1"); + + $pipe = $this->injectAuthorizationPipeline(); + + $response = $pipe->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(1, $response->getHeader('X-Authenticated-As')[0]); + + $key = $key->refresh(); + + $this->assertNotNull($key->last_activity_at); + + $key->delete(); + } + + /** + * @test + */ + public function personal_api_token_cannot_authenticate_as_anyone() + { + $user = $this->getNormalUser(); + + $key = $this->key($user->id); + + $request = ServerRequestFactory::fromGlobals() + ->withAddedHeader('Authorization', "Token {$key->key}; userId=1"); + + $pipe = $this->injectAuthorizationPipeline(); + + $response = $pipe->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($user->id, $response->getHeader('X-Authenticated-As')[0]); + + $key = $key->refresh(); + + $this->assertNotNull($key->last_activity_at); + + $key->delete(); + } + + /** + * @test + */ + public function personal_api_token_authenticates_user() + { + $user = $this->getNormalUser(); + + $key = $this->key($user->id); + + $request = ServerRequestFactory::fromGlobals() + ->withAddedHeader('Authorization', "Token {$key->key}"); + + $pipe = $this->injectAuthorizationPipeline(); + + $response = $pipe->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($user->id, $response->getHeader('X-Authenticated-As')[0]); + + $key = $key->refresh(); + + $this->assertNotNull($key->last_activity_at); + + $key->delete(); + } + + protected function injectAuthorizationPipeline(): MiddlewarePipe + { + app()->resolving('flarum.api.middleware', function ($pipeline) { + $pipeline->pipe(new class implements MiddlewareInterface { + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + if ($actor = $request->getAttribute('actor')) { + return new Response\EmptyResponse(200, [ + 'X-Authenticated-As' => $actor->id + ]); + } + } + }); + }); + + $pipe = app('flarum.api.middleware'); + + return $pipe; + } +} diff --git a/tests/tmp/storage/sessions/.gitkeep b/tests/tmp/storage/sessions/.gitkeep new file mode 100644 index 000000000..e69de29bb