Authenticator: Improve auth methods handling, esp. for api endpoints

This commit is contained in:
Igor Scheller 2023-01-28 00:41:29 +01:00 committed by Michael Weimann
parent ac97413f3f
commit a9cd00c37a
11 changed files with 242 additions and 119 deletions

View File

@ -217,7 +217,7 @@ test:
- ./bin/migrate - ./bin/migrate
script: script:
- >- - >-
php -d pcov.enabled=1 -d pcov.directory=. vendor/bin/phpunit -vvv --colors=never php -d memory_limit=1024M -d pcov.enabled=1 -d pcov.directory=. vendor/bin/phpunit -vvv --colors=never
--coverage-text --coverage-html "${HOMEDIR}/coverage/" --coverage-text --coverage-html "${HOMEDIR}/coverage/"
--log-junit "${HOMEDIR}/unittests.xml" --log-junit "${HOMEDIR}/unittests.xml"
after_script: after_script:

View File

@ -386,15 +386,10 @@ function shift_next_controller()
*/ */
function shifts_json_export_controller() function shifts_json_export_controller()
{ {
$request = request(); $user = auth()->userFromApi();
$user = auth()->apiUser('key');
if ( if (!$user) {
!$request->has('key') throw new HttpForbidden('{"error":"Missing or invalid ?key="}', ['content-type' => 'application/json']);
|| !$request->input('key')
|| !$user
) {
throw new HttpForbidden('{"error":"Missing or invalid key"}', ['content-type' => 'application/json']);
} }
if (!auth()->can('shifts_json_export')) { if (!auth()->can('shifts_json_export')) {

View File

@ -11,14 +11,10 @@ use Illuminate\Support\Collection as SupportCollection;
function user_atom() function user_atom()
{ {
$request = request(); $request = request();
$user = auth()->apiUser('key'); $user = auth()->userFromApi();
if ( if (!$user) {
!$request->has('key') throw new HttpForbidden('Missing or invalid ?key=', ['content-type' => 'text/text']);
|| !$request->input('key')
|| empty($user)
) {
throw new HttpForbidden('Missing or invalid key', ['content-type' => 'text/text']);
} }
if (!auth()->can('atom')) { if (!auth()->can('atom')) {

View File

@ -9,15 +9,10 @@ use Illuminate\Support\Collection;
*/ */
function user_ical() function user_ical()
{ {
$request = request(); $user = auth()->userFromApi();
$user = auth()->apiUser('key');
if ( if (!$user) {
!$request->has('key') throw new HttpForbidden('Missing or invalid ?key=', ['content-type' => 'text/text']);
|| !$request->input('key')
|| !$user
) {
throw new HttpForbidden('Missing or invalid key', ['content-type' => 'text/text']);
} }
if (!auth()->can('ical')) { if (!auth()->can('ical')) {

View File

@ -6,6 +6,7 @@ use Carbon\Carbon;
use Engelsystem\Models\Group; use Engelsystem\Models\Group;
use Engelsystem\Models\User\User; use Engelsystem\Models\User\User;
use Engelsystem\Models\User\User as UserRepository; use Engelsystem\Models\User\User as UserRepository;
use Illuminate\Support\Str;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Session;
@ -30,7 +31,7 @@ class Authenticator
} }
/** /**
* Load the user from session * Load the user from session or api auth
*/ */
public function user(): ?User public function user(): ?User
{ {
@ -38,47 +39,50 @@ class Authenticator
return $this->user; return $this->user;
} }
$this->user = $this->userFromSession();
if (!$this->user && request()->getAttribute('route-api', false)) {
$this->user = $this->userFromApi();
}
return $this->user;
}
/**
* Load the user from session
*/
public function userFromSession(): ?User
{
if ($this->user) {
return $this->user;
}
$userId = $this->session->get('user_id'); $userId = $this->session->get('user_id');
if (!$userId) { if (!$userId) {
return null; return null;
} }
$user = $this $this->user = $this
->userRepository ->userRepository
->find($userId); ->find($userId);
if (!$user) {
return null;
}
$this->user = $user;
return $this->user; return $this->user;
} }
/** /**
* Get the user by his api key * Get the user by its api key
*/ */
public function apiUser(string $parameter = 'api_key'): ?User public function userFromApi(): ?User
{ {
if ($this->user) { if ($this->user) {
return $this->user; return $this->user;
} }
$params = $this->request->getQueryParams(); $this->user = $this->userByHeaders();
if (!isset($params[$parameter])) { if ($this->user) {
return null; return $this->user;
} }
/** @var User|null $user */ $this->user = $this->userByQueryParam();
$user = $this
->userRepository
->whereApiKey($params[$parameter])
->first();
if (!$user) {
return $this->user();
}
$this->user = $user;
return $this->user; return $this->user;
} }
@ -150,6 +154,50 @@ class Authenticator
return true; return true;
} }
/**
* Get the user by authorization bearer or x-api-key headers
*/
protected function userByHeaders(): ?User
{
$header = $this->request->getHeader('authorization');
if (!empty($header) && Str::startsWith(Str::lower($header[0]), 'bearer ')) {
return $this->userByApiKey(Str::substr($header[0], 7));
}
$header = $this->request->getHeader('x-api-key');
if (!empty($header)) {
return $this->userByApiKey($header[0]);
}
return null;
}
/**
* Get the user by query parameters
*/
protected function userByQueryParam(): ?User
{
$params = $this->request->getQueryParams();
if (!empty($params['key'])) {
$this->user = $this->userByApiKey($params['key']);
}
return $this->user;
}
/**
* Get the user by its api key
*/
protected function userByApiKey(string $key): ?User
{
$this->user = $this
->userRepository
->whereApiKey($key)
->first();
return $this->user;
}
public function setPassword(User $user, string $password): void public function setPassword(User $user, string $password): void
{ {
$user->password = password_hash($password, $this->passwordAlgorithm); $user->password = password_hash($password, $this->passwordAlgorithm);

View File

@ -26,6 +26,8 @@ class RequestHandler implements MiddlewareInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
$requestHandler = $request->getAttribute('route-request-handler'); $requestHandler = $request->getAttribute('route-request-handler');
$this->container->instance(ServerRequestInterface::class, $request);
$this->container->instance('request', $request);
/** @var CallableHandler|MiddlewareInterface|RequestHandlerInterface $requestHandler */ /** @var CallableHandler|MiddlewareInterface|RequestHandlerInterface $requestHandler */
$requestHandler = $this->resolveRequestHandler($requestHandler); $requestHandler = $this->resolveRequestHandler($requestHandler);

View File

@ -18,13 +18,15 @@ class SessionHandler implements MiddlewareInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
$requestPath = $request->getAttribute('route-request-path'); $requestPath = $request->getAttribute('route-request-path');
$isApi = in_array($requestPath, $this->paths);
$request = $request->withAttribute('route-api', $isApi);
$return = $handler->handle($request); $return = $handler->handle($request);
$cookies = $request->getCookieParams(); $cookies = $request->getCookieParams();
if ( if (
$this->session instanceof NativeSessionStorage $isApi
&& in_array($requestPath, $this->paths) && $this->session instanceof NativeSessionStorage
&& !isset($cookies[$this->session->getName()]) && !isset($cookies[$this->session->getName()])
) { ) {
$this->destroyNative(); $this->destroyNative();

View File

@ -14,6 +14,8 @@ use Symfony\Component\HttpFoundation\Session\SessionInterface;
/** /**
* Get the global app instance * Get the global app instance
* @return mixed|Application
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.UselessAnnotation
*/ */
function app(string $id = null): mixed function app(string $id = null): mixed
{ {
@ -44,6 +46,8 @@ function back(int $status = 302, array $headers = []): Response
/** /**
* Get or set config values * Get or set config values
* @return mixed|Config
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.UselessAnnotation
*/ */
function config(string|array $key = null, mixed $default = null): mixed function config(string|array $key = null, mixed $default = null): mixed
{ {
@ -87,6 +91,10 @@ function redirect(string $path, int $status = 302, array $headers = []): Respons
return $redirect->to($path, $status, $headers); return $redirect->to($path, $status, $headers);
} }
/**
* @return mixed|Request
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.UselessAnnotation
*/
function request(string $key = null, mixed $default = null): mixed function request(string $key = null, mixed $default = null): mixed
{ {
/** @var Request $request */ /** @var Request $request */
@ -114,6 +122,10 @@ function response(mixed $content = '', int $status = 200, array $headers = []):
return $response; return $response;
} }
/**
* @return mixed|SessionInterface
* @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.UselessAnnotation
*/
function session(string $key = null, mixed $default = null): mixed function session(string $key = null, mixed $default = null): mixed
{ {
/** @var SessionInterface $session */ /** @var SessionInterface $session */
@ -175,9 +187,6 @@ function url(string $path = null, array $parameters = []): UrlGeneratorInterface
return $urlGenerator->to($path, $parameters); return $urlGenerator->to($path, $parameters);
} }
/**
* @param mixed[] $data
*/
function view(string $template = null, array $data = []): Renderer|string function view(string $template = null, array $data = []): Renderer|string
{ {
/** @var Renderer $renderer */ /** @var Renderer $renderer */

View File

@ -3,6 +3,7 @@
namespace Engelsystem\Test\Unit\Helpers; namespace Engelsystem\Test\Unit\Helpers;
use Engelsystem\Helpers\Authenticator; use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Engelsystem\Models\Group; use Engelsystem\Models\Group;
use Engelsystem\Models\Privilege; use Engelsystem\Models\Privilege;
use Engelsystem\Models\User\User; use Engelsystem\Models\User\User;
@ -12,97 +13,164 @@ use Engelsystem\Test\Unit\ServiceProviderTest;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class AuthenticatorTest extends ServiceProviderTest class AuthenticatorTest extends ServiceProviderTest
{ {
use HasDatabase; use HasDatabase;
/** /**
* @covers \Engelsystem\Helpers\Authenticator::__construct
* @covers \Engelsystem\Helpers\Authenticator::user * @covers \Engelsystem\Helpers\Authenticator::user
* @covers \Engelsystem\Helpers\Authenticator::__construct
*/ */
public function testUser(): void public function testUserNotAuthorized(): void
{ {
/** @var ServerRequestInterface|MockObject $request */ $request = new Request();
$request = $this->getMockForAbstractClass(ServerRequestInterface::class); $session = new Session(new MockArraySessionStorage());
/** @var Session|MockObject $session */
$session = $this->createMock(Session::class);
/** @var UserModelImplementation|MockObject $userRepository */ /** @var UserModelImplementation|MockObject $userRepository */
$userRepository = new UserModelImplementation(); $userRepository = new UserModelImplementation();
/** @var User|MockObject $user */ $this->app->instance('request', $request);
$user = $this->createMock(User::class);
$session->expects($this->exactly(3))
->method('get')
->with('user_id')
->willReturnOnConsecutiveCalls(
null,
42,
1337
);
$auth = new Authenticator($request, $session, $userRepository); $auth = new Authenticator($request, $session, $userRepository);
$user = $auth->user();
// Not in session $this->assertNull($user);
$this->assertNull($auth->user());
// Unknown user
UserModelImplementation::$id = 42;
$this->assertNull($auth->user());
// User found
UserModelImplementation::$id = 1337;
UserModelImplementation::$user = $user;
$this->assertEquals($user, $auth->user());
// User cached
UserModelImplementation::$id = null;
UserModelImplementation::$user = null;
$this->assertEquals($user, $auth->user());
} }
/** /**
* @covers \Engelsystem\Helpers\Authenticator::apiUser * @covers \Engelsystem\Helpers\Authenticator::user
* @covers \Engelsystem\Helpers\Authenticator::userFromSession
*/ */
public function testApiUser(): void public function testUserViaFromSession(): void
{ {
/** @var ServerRequestInterface|MockObject $request */ $this->initDatabase();
$request = $this->getMockForAbstractClass(ServerRequestInterface::class);
/** @var Session|MockObject $session */
$session = $this->createMock(Session::class);
/** @var UserModelImplementation|MockObject $userRepository */
$userRepository = new UserModelImplementation();
/** @var User|MockObject $user */
$user = $this->createMock(User::class);
$request->expects($this->exactly(3)) $request = new Request();
->method('getQueryParams') $session = new Session(new MockArraySessionStorage());
->with()
->willReturnOnConsecutiveCalls(
[],
['api_key' => 'iMaNot3xiSt1nGAp1Key!'],
['foo_key' => 'SomeSecretApiKey']
);
/** @var Authenticator|MockObject $auth */ $session->set('user_id', 42);
$auth = new Authenticator($request, $session, $userRepository); User::factory()->create(['id' => 42]);
// No key $auth = new Authenticator($request, $session, new User());
$this->assertNull($auth->apiUser()); $user = $auth->user();
// Unknown user $this->assertInstanceOf(User::class, $user);
UserModelImplementation::$apiKey = 'iMaNot3xiSt1nGAp1Key!'; $this->assertEquals(42, $user->id);
$this->assertNull($auth->apiUser());
// User found // Cached in user()
UserModelImplementation::$apiKey = 'SomeSecretApiKey'; $user2 = $auth->user();
UserModelImplementation::$user = $user; $this->assertEquals($user, $user2);
$this->assertEquals($user, $auth->apiUser('foo_key'));
// User cached // Cached in userFromSession()
UserModelImplementation::$apiKey = null; $user3 = $auth->userFromSession();
UserModelImplementation::$user = null; $this->assertEquals($user, $user3);
$this->assertEquals($user, $auth->apiUser()); }
/**
* @covers \Engelsystem\Helpers\Authenticator::user
* @covers \Engelsystem\Helpers\Authenticator::userFromApi
* @covers \Engelsystem\Helpers\Authenticator::userByHeaders
*/
public function testUserViaFromApi(): void
{
$this->initDatabase();
$request = new Request();
$session = new Session(new MockArraySessionStorage());
$request = $request->withHeader('Authorization', 'Bearer F00Bar');
$request = $request->withAttribute('route-api', true);
$this->app->instance('request', $request);
User::factory()->create(['api_key' => 'F00Bar']);
$auth = new Authenticator($request, $session, new User());
$user = $auth->user();
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('F00Bar', $user->api_key);
// Cached in userFromApi()
$user2 = $auth->userFromApi();
$this->assertEquals($user, $user2);
}
/**
* @covers \Engelsystem\Helpers\Authenticator::userFromSession
*/
public function testUserFromSessionNotFound(): void
{
$this->initDatabase();
$request = new Request();
$session = new Session(new MockArraySessionStorage());
$auth = new Authenticator($request, $session, new User());
$user = $auth->userFromSession();
$this->assertNull($user);
$session->set('user_id', 42);
$user2 = $auth->userFromSession();
$this->assertNull($user2);
}
/**
* @covers \Engelsystem\Helpers\Authenticator::userFromApi
* @covers \Engelsystem\Helpers\Authenticator::userByQueryParam
* @covers \Engelsystem\Helpers\Authenticator::userByApiKey
*/
public function testUserFromApiByQueryParam(): void
{
$this->initDatabase();
$request = new Request();
$session = new Session(new MockArraySessionStorage());
$request = $request->withQueryParams(['key' => 'F00Bar']);
$auth = new Authenticator($request, $session, new User());
// User not found
$user = $auth->userFromApi();
$this->assertNull($user);
// User exists
User::factory()->create(['api_key' => 'F00Bar']);
$user2 = $auth->userFromApi();
$this->assertInstanceOf(User::class, $user2);
$this->assertEquals('F00Bar', $user2->api_key);
}
/**
* @covers \Engelsystem\Helpers\Authenticator::userByHeaders
*/
public function testUserByHeaders(): void
{
$this->initDatabase();
$request = new Request();
$request = $request->withAttribute('route-api', true);
$session = new Session(new MockArraySessionStorage());
$this->app->instance('request', $request);
$auth = new Authenticator($request, $session, new User());
// Header not set
$user = $auth->userFromApi();
$this->assertNull($user);
// User not found
$request = $request->withHeader('x-api-key', 'SomeWrongKey');
$auth = new Authenticator($request, $session, new User());
$user = $auth->userFromApi();
$this->assertNull($user);
$request = $request->withHeader('x-api-key', 'F00Bar');
$auth = new Authenticator($request, $session, new User());
User::factory()->create(['api_key' => 'F00Bar']);
$user = $auth->user();
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('F00Bar', $user->api_key);
} }
/** /**

View File

@ -130,6 +130,9 @@ class RequestHandlerTest extends TestCase
->method('make') ->method('make')
->with($className) ->with($className)
->willReturn($middlewareInterface); ->willReturn($middlewareInterface);
$container->expects($this->exactly(2))
->method('instance')
->withConsecutive([ServerRequestInterface::class, $request], ['request', $request]);
$return = $middleware->process($request, $handler); $return = $middleware->process($request, $handler);
$this->assertEquals($return, $response); $this->assertEquals($return, $response);

View File

@ -39,9 +39,14 @@ class SessionHandlerTest extends TestCase
$request->expects($this->exactly(2)) $request->expects($this->exactly(2))
->method('getAttribute') ->method('getAttribute')
->with('route-request-path') ->with('route-request-path')
->willReturn('/foo'); ->willReturnOnConsecutiveCalls('/foo', '/lorem');
$sessionStorage->expects($this->exactly(2)) $request->expects($this->exactly(2))
->method('withAttribute')
->withConsecutive(['route-api', true], ['route-api', false])
->willReturn($request);
$sessionStorage->expects($this->once())
->method('getName') ->method('getName')
->willReturn('SESSION'); ->willReturn('SESSION');