diff --git a/README.md b/README.md index 6685ef4..76ca504 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Authentication, authorization and access control for PHP. * Authorization [context](#context) (eg. "is the user an _admin_ of this _team_"). * PSR-14 [events](#events) for login and logout. * PSR-15 [middleware](#access-control-middleware) for access control. +* Session invalidation after password change. * [Confirmation tokens](#confirmation) for signup confirmation and forgot-password. * Customizable to meet the requirements of your application. @@ -244,9 +245,11 @@ Triggers a [logout event](#events). #### user - Auth::user(): UserInterface|null + Auth::user(): UserInterface -Get the current user. Returns `null` if no user is logged in. +Get the current user. + +Use `isLoggedIn()` to see if there is a logged in user. This function throws an `AuthException` if no user is logged in. ### Events @@ -431,11 +434,17 @@ $auth->user()->getAuthRole(); // Returns 'admin' which supersedes 'moderator'. ### Methods +#### isLoggedIn + + Auth::isLoggedIn(): bool + +Check if a user if logged in. + #### is Auth::is(string $role): bool -Check if a user has a specific role or superseding role +Check if a user has a specific role or superseding role. #### getAvailableRoles @@ -451,13 +460,13 @@ Returns a copy of the `Authz` service with the current user and context. #### forUser - Auth::authz(User $user): Authz + Auth::authz(User|null $user): Authz Returns a copy of the `Authz` service with the given user, in the current context. #### inContextOf - Auth::inContextOf(Context $context): Authz + Auth::inContextOf(Context|null $context): Authz Returns a copy of the `Authz` service with the current user, in the given context. diff --git a/src/Auth.php b/src/Auth.php index c4f7fad..9a551c9 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -121,6 +121,17 @@ final public function getAvailableRoles(): array return $this->authz->getAvailableRoles(); } + + /** + * Check if the current user is logged in. + */ + final public function isLoggedIn(): bool + { + $this->assertInitialized(); + + return $this->authz->isLoggedIn(); + } + /** * Check if the current user is logged in and has specified role. * @@ -142,9 +153,9 @@ final public function is(string $role): bool /** * Get current authenticated user. * - * @return User|null + * @throws AuthException if no user is logged in. */ - final public function user(): ?User + final public function user(): User { $this->assertInitialized(); @@ -171,7 +182,7 @@ public function loginAs(User $user): void { $this->assertInitialized(); - if ($this->authz->user() !== null) { + if ($this->authz->isLoggedIn()) { throw new \LogicException("Already logged in"); } @@ -187,7 +198,7 @@ public function login(string $username, string $password): void { $this->assertInitialized(); - if ($this->authz->user() !== null) { + if ($this->authz->isLoggedIn()) { throw new \LogicException("Already logged in"); } @@ -226,12 +237,12 @@ public function logout(): void { $this->assertInitialized(); - $user = $this->authz->user(); - - if ($user === null) { - return; // already logged out + if (!$this->authz()->isLoggedIn()) { + return; } + $user = $this->authz->user(); + $this->authz = $this->authz->forUser(null)->inContextOf(null); $this->updateSession(); @@ -268,14 +279,14 @@ public function recalc(): self */ protected function updateSession(): void { - $user = $this->authz->user(); - $context = $this->authz->context(); - - if ($user === null) { + if (!$this->authz->isLoggedIn()) { $this->session->clear(); return; } + $user = $this->authz->user(); + $context = $this->authz->context(); + $uid = $user->getAuthId(); $cid = $context !== null ? $context->getAuthId() : null; $checksum = $user->getAuthChecksum(); diff --git a/src/AuthException.php b/src/AuthException.php new file mode 100644 index 0000000..b8dc5cc --- /dev/null +++ b/src/AuthException.php @@ -0,0 +1,12 @@ +auth->user() !== null) === $requiredRole; + return $this->auth->isLoggedIn() === $requiredRole; } return Pipeline::with(is_array($requiredRole) ? $requiredRole : [$requiredRole]) @@ -106,9 +106,7 @@ protected function isAllowed(ServerRequest $request): bool */ protected function forbidden(ServerRequest $request, ?Response $response = null): Response { - $unauthorized = $this->auth->user() === null; - - $forbiddenResponse = $this->createResponse($unauthorized ? 401 : 403, $response) + $forbiddenResponse = $this->createResponse($this->auth->isLoggedIn() ? 403 : 401, $response) ->withProtocolVersion($request->getProtocolVersion()); $forbiddenResponse->getBody()->write('Access denied'); @@ -126,11 +124,16 @@ protected function createResponse(int $status, ?Response $originalResponse = nul { if ($this->responseFactory !== null) { return $this->responseFactory->createResponse($status); - } elseif ($originalResponse !== null) { - return $originalResponse->withStatus($status)->withBody(clone $originalResponse->getBody()); - ; - } else { - throw new \LogicException('Response factory not set'); } + + if ($originalResponse !== null) { + // There is no standard way to get an empty body without a factory. One of these methods may work. + $body = clone $originalResponse->getBody(); + $body->rewind(); + + return $originalResponse->withStatus($status)->withBody($body); + } + + throw new \LogicException('Response factory not set'); } } diff --git a/src/Authz/StateTrait.php b/src/Authz/StateTrait.php index 073550d..10dfdf5 100644 --- a/src/Authz/StateTrait.php +++ b/src/Authz/StateTrait.php @@ -4,6 +4,7 @@ namespace Jasny\Auth\Authz; +use Jasny\Auth\AuthException; use Jasny\Auth\AuthzInterface as Authz; use Jasny\Auth\ContextInterface as Context; use Jasny\Auth\UserInterface as User; @@ -53,10 +54,14 @@ public function inContextOf(?Context $context): Authz /** * Get current authenticated user. * - * @return User|null + * @throws AuthException if no user is logged in. */ - final public function user(): ?User + final public function user(): User { + if ($this->user === null) { + throw new AuthException("The user is not logged in"); + } + return $this->user; } @@ -67,4 +72,12 @@ final public function context(): ?Context { return $this->context; } + + /** + * Check if the current user is logged in. + */ + public function isLoggedIn(): bool + { + return $this->user !== null; + } } diff --git a/src/AuthzInterface.php b/src/AuthzInterface.php index 8c055de..19d255c 100644 --- a/src/AuthzInterface.php +++ b/src/AuthzInterface.php @@ -46,10 +46,8 @@ public function recalc(): self; /** * Get current authenticated user. - * - * @return User|null */ - public function user(): ?User; + public function user(): User; /** * Get the current context. @@ -57,6 +55,11 @@ public function user(): ?User; public function context(): ?Context; + /** + * Check if the current user is logged in. + */ + public function isLoggedIn(): bool; + /** * Check if the current user is logged in and has specified role. */ diff --git a/src/LoginException.php b/src/LoginException.php index cb450c3..4f0ae5d 100644 --- a/src/LoginException.php +++ b/src/LoginException.php @@ -7,7 +7,7 @@ /** * Exception on failed login attempt. */ -class LoginException extends \RuntimeException +class LoginException extends AuthException { public const CANCELLED = 0; public const INVALID_CREDENTIALS = 1; diff --git a/tests/AuthMiddlewareDoublePassTest.php b/tests/AuthMiddlewareDoublePassTest.php index afc4a14..5e248b5 100644 --- a/tests/AuthMiddlewareDoublePassTest.php +++ b/tests/AuthMiddlewareDoublePassTest.php @@ -37,7 +37,7 @@ public function setUp(): void public function testNoRequirements() { - $this->authz->expects($this->never())->method('user'); + $this->authz->expects($this->never())->method($this->anything()); $request = $this->createMock(ServerRequest::class); $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(null); @@ -61,13 +61,13 @@ public function testNoRequirements() public function testRequireUser() { - $user = $this->createMock(User::class); - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(true); $request = $this->createMock(ServerRequest::class); $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true); $initialResp = $this->createMock(Response::class); + $initialResp->expects($this->never())->method($this->anything()); $response = $this->createMock(Response::class); $response->expects($this->never())->method($this->anything()); @@ -86,7 +86,8 @@ public function testRequireUser() public function testRequireNoUser() { - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $request = $this->createMock(ServerRequest::class); $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(false); @@ -110,7 +111,8 @@ public function testRequireNoUser() public function testLoginRequired() { - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $request = $this->createMock(ServerRequest::class); $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true); @@ -162,9 +164,7 @@ public function testAccessGranted() public function testAccessDenied() { - $user = $this->createMock(User::class); - - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->once())->method('is')->with('foo')->willReturn(false); $request = $this->createMock(ServerRequest::class); diff --git a/tests/AuthMiddlewareTest.php b/tests/AuthMiddlewareTest.php index 32e6732..d966d08 100644 --- a/tests/AuthMiddlewareTest.php +++ b/tests/AuthMiddlewareTest.php @@ -40,7 +40,7 @@ public function setUp(): void public function testNoRequirements() { - $this->authz->expects($this->never())->method('user'); + $this->authz->expects($this->never())->method($this->anything()); $this->responseFactory->expects($this->never())->method('createResponse'); @@ -62,8 +62,7 @@ public function testNoRequirements() public function testRequireUser() { - $user = $this->createMock(User::class); - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(true); $this->responseFactory->expects($this->never())->method('createResponse'); @@ -85,7 +84,7 @@ public function testRequireUser() public function testRequireNoUser() { - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(false); $this->responseFactory->expects($this->never())->method('createResponse'); @@ -107,7 +106,7 @@ public function testRequireNoUser() public function testLoginRequired() { - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(false); $request = $this->createMock(ServerRequest::class); $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true); @@ -158,7 +157,7 @@ public function testAccessDenied() { $user = $this->createMock(User::class); - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn($user); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->once())->method('is')->with('foo')->willReturn(false); $request = $this->createMock(ServerRequest::class); @@ -192,7 +191,7 @@ public function testMissingResponseFactory() fn(ServerRequest $request) => $request->getAttribute('auth'), ); - $this->authz->expects($this->atLeastOnce())->method('user')->willReturn(null); + $this->authz->expects($this->atLeastOnce())->method('isLoggedIn')->willReturn(false); $request = $this->createMock(ServerRequest::class); $request->expects($this->once())->method('getAttribute')->with('auth')->willReturn(true); diff --git a/tests/AuthTest.php b/tests/AuthTest.php index dd74aab..70ae2ad 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -49,10 +49,32 @@ public function setUp(): void ->withEventDispatcher($this->dispatcher); } + /** + * @return Authz&MockObject + */ + protected function createNewAuthzMock(?User $user, ?Context $context) + { + $newAuthz = $this->createMock(Authz::class); + + if ($user === null) { + $newAuthz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $newAuthz->expects($this->never())->method('user'); + } else { + $newAuthz->expects($this->any())->method('isLoggedIn')->willReturn(true); + $newAuthz->expects($this->any())->method('user')->willReturn($user); + } + $newAuthz->expects($this->any())->method('context')->willReturn($context); + + return $newAuthz; + } + + /** + * @return Authz&MockObject + */ protected function expectInitAuthz(?User $user, ?Context $context) { - $newAuthz = $this->createMock(Authz::class); + $newAuthz = $this->createNewAuthzMock($user, $context); $this->authz->expects($this->once())->method('forUser') ->with($this->identicalTo($user)) @@ -64,6 +86,36 @@ protected function expectInitAuthz(?User $user, ?Context $context) return $newAuthz; } + + /** + * @return Authz&MockObject + */ + protected function expectSetAuthzUser(?User $user, ?Context $context = null) + { + $newAuthz = $this->createNewAuthzMock($user, $context); + + $this->authz->expects($this->once())->method('forUser') + ->with($this->identicalTo($user)) + ->willReturn($newAuthz); + + return $newAuthz; + } + + /** + * @return Authz&MockObject + */ + protected function expectSetAuthzContext(?User $user, ?Context $context) + { + $newAuthz = $this->createNewAuthzMock($user, $context); + + $this->authz->expects($this->once())->method('inContextOf') + ->with($this->identicalTo($context)) + ->willReturn($newAuthz); + + return $newAuthz; + } + + public function testInitializeWithoutSession() { // @@ -193,15 +245,26 @@ public function testGetAvailableRoles() $this->assertEquals(['user', 'manager', 'admin'], $this->service->getAvailableRoles()); } - public function testIs() + public function testIsLoggedIn() { - $this->authz->expects($this->once())->method('is') - ->with('foo') + $this->authz->expects($this->once())->method('isLoggedIn') ->willReturn(true); $this->setPrivateProperty($this->service, 'initialized', true); + $this->assertTrue($this->service->isLoggedIn()); + } + + public function testIs() + { + $this->authz->expects($this->exactly(2))->method('is') + ->withConsecutive(['foo'], ['bar']) + ->willReturn(true, false); + + $this->setPrivateProperty($this->service, 'initialized', true); + $this->assertTrue($this->service->is('foo')); + $this->assertFalse($this->service->is('bar')); } public function testUser() @@ -224,45 +287,6 @@ public function testContext() $this->assertSame($context, $this->service->context()); } - - /** - * @return Authz&MockObject - */ - protected function expectSetAuthzUser(?User $user, ?Context $context = null) - { - $newAuthz = $this->createMock(Authz::class); - - $this->authz->expects($this->once())->method('forUser') - ->with($this->identicalTo($user)) - ->willReturn($newAuthz); - - $newAuthz->expects($this->any())->method('user') - ->willReturn($user); - $newAuthz->expects($this->any())->method('context') - ->willReturn($context); - - return $newAuthz; - } - - /** - * @return Authz&MockObject - */ - protected function expectSetAuthzContext(?User $user, ?Context $context) - { - $newAuthz = $this->createMock(Authz::class); - - $this->authz->expects($this->once())->method('inContextOf') - ->with($this->identicalTo($context)) - ->willReturn($newAuthz); - - $newAuthz->expects($this->any())->method('user') - ->willReturn($user); - $newAuthz->expects($this->any())->method('context') - ->willReturn($context); - - return $newAuthz; - } - public function testLoginAs() { // @@ -278,7 +302,8 @@ public function testLoginAs() })) ->willReturnArgument(0); - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->expectSetAuthzUser($user); $this->session->expects($this->once())->method('persist') @@ -302,7 +327,8 @@ public function testCancelLogin() ->willReturnArgument(0); // - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->authz->expects($this->never())->method('forUser'); $this->authz->expects($this->never())->method('inContextOf'); $this->session->expects($this->never())->method('persist'); @@ -322,6 +348,7 @@ public function testLoginAsTwice() $user = $this->createMock(User::class); // + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->any())->method('user')->willReturn($user); $this->expectException(\LogicException::class); @@ -357,7 +384,8 @@ public function testLogin() })) ->willReturnArgument(0); - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->expectSetAuthzUser($user); $this->session->expects($this->once())->method('persist') @@ -378,7 +406,8 @@ public function testLoginWithIncorrectUsername() // $this->dispatcher->expects($this->never())->method('dispatch'); - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->authz->expects($this->never())->method('forUser'); $this->authz->expects($this->never())->method('inContextOf'); $this->session->expects($this->never())->method('persist'); @@ -410,7 +439,8 @@ public function testLoginWithInvalidPassword() $this->dispatcher->expects($this->never())->method('dispatch'); - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->authz->expects($this->never())->method('forUser'); $this->authz->expects($this->never())->method('inContextOf'); $this->session->expects($this->never())->method('persist'); @@ -429,6 +459,7 @@ public function testLoginTwice() { // $user = $this->createMock(User::class); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->any())->method('user')->willReturn($user); $this->setPrivateProperty($this->service, 'initialized', true); @@ -454,6 +485,7 @@ public function testLogout() })) ->willReturnArgument(0); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->any())->method('user')->willReturn($user); $this->session->expects($this->once())->method('clear'); @@ -471,7 +503,8 @@ public function testLogoutTwice() { $this->setPrivateProperty($this->service, 'initialized', true); - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->authz->expects($this->never())->method('forUser'); $this->authz->expects($this->never())->method('inContextOf'); $this->session->expects($this->never())->method('persist'); @@ -485,6 +518,7 @@ public function testSetContext() $context = $this->createConfiguredMock(Context::class, ['getAuthId' => 'foo']); // + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->any())->method('user')->willReturn($user); $this->session->expects($this->once())->method('persist') @@ -505,6 +539,7 @@ public function testClearContext() $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']); // + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(true); $this->authz->expects($this->any())->method('user')->willReturn($user); $this->session->expects($this->once())->method('persist') @@ -525,15 +560,13 @@ public function testRecalc() $user = $this->createConfiguredMock(User::class, ['getAuthId' => 42, 'getAuthChecksum' => 'abc']); $context = $this->createConfiguredMock(Context::class, ['getAuthId' => 'foo']); - $newAuthz = $this->createMock(Authz::class); + $newAuthz = $this->createNewAuthzMock($user, $context); $this->authz->expects($this->once())->method('recalc')->willReturn($newAuthz); // $this->authz->expects($this->never())->method('user'); $this->authz->expects($this->never())->method('context'); - $newAuthz->expects($this->any())->method('user')->willReturn($user); - $newAuthz->expects($this->any())->method('context')->willReturn($context); $this->session->expects($this->never())->method('clear'); $this->session->expects($this->once())->method('persist') @@ -549,7 +582,8 @@ public function testRecalcWithoutUser() { // $this->authz->expects($this->once())->method('recalc')->willReturnSelf(); - $this->authz->expects($this->any())->method('user')->willReturn(null); + $this->authz->expects($this->any())->method('isLoggedIn')->willReturn(false); + $this->authz->expects($this->never())->method('user'); $this->authz->expects($this->any())->method('context')->willReturn(null); $this->session->expects($this->once())->method('clear'); diff --git a/tests/Authz/LevelsTest.php b/tests/Authz/LevelsTest.php index 9d2c02d..c5fc311 100644 --- a/tests/Authz/LevelsTest.php +++ b/tests/Authz/LevelsTest.php @@ -2,6 +2,7 @@ namespace Jasny\Auth\Tests\Authz; +use Jasny\Auth\AuthException; use Jasny\Auth\ContextInterface as Context; use Jasny\Auth\UserInterface as User; use Jasny\Auth\Authz\Levels; @@ -36,16 +37,23 @@ public function testGetAvailableRoles() $this->assertEquals(['user', 'mod', 'admin'], $this->authz->getAvailableRoles()); } + public function testNoUser() + { + $this->assertFalse($this->authz->isLoggedIn()); + + $this->expectException(AuthException::class); + $this->authz->user(); + } public function testUser() { - $this->assertNull($this->authz->user()); - $user = $this->createConfiguredMock(User::class, ['getAuthRole' => 'user']); $userAuthz = $this->authz->forUser($user); + $this->assertTrue($userAuthz->isLoggedIn()); + $this->assertFalse($this->authz->isLoggedIn()); + $this->assertNotSame($this->authz, $userAuthz); - $this->assertNull($this->authz->user()); $this->assertSame($user, $userAuthz->user()); }