diff --git a/src/User/AuthenticationKeyInterface.php b/src/User/AuthenticationKeyInterface.php deleted file mode 100644 index 8fe86dbe6..000000000 --- a/src/User/AuthenticationKeyInterface.php +++ /dev/null @@ -1,38 +0,0 @@ -duration = $duration; + } + + public function withCookieName(string $name): self + { + $new = clone $this; + $new->cookieName = $name; + return $new; + } + + /** + * Add auto-login cookie to response so the user is logged in automatically based on cookie even if session + * is expired. + */ + public function addCookie(AutoLoginIdentityInterface $identity, ResponseInterface $response): ResponseInterface + { + $data = json_encode([ + $identity->getId(), + $identity->getAutoLoginKey() + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return (new Cookie($this->cookieName, $data)) + ->withMaxAge($this->duration) + ->addToResponse($response); + } + + /** + * Expire auto-login cookie so user is not logged in automatically anymore. + */ + public function expireCookie(ResponseInterface $response): ResponseInterface + { + return (new Cookie($this->cookieName)) + ->expire() + ->addToResponse($response); + } + + public function getCookieName(): string + { + return $this->cookieName; + } +} diff --git a/src/User/AutoLoginIdentityInterface.php b/src/User/AutoLoginIdentityInterface.php new file mode 100644 index 000000000..59da4bde0 --- /dev/null +++ b/src/User/AutoLoginIdentityInterface.php @@ -0,0 +1,44 @@ +user = $user; + $this->identityRepository = $identityRepository; + $this->logger = $logger; + $this->autoLogin = $autoLogin; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->authenticateUserByCookieFromRequest($request); + $guestBeforeHandle = $this->user->isGuest(); + $response = $handler->handle($request); + $guestAfterHandle = $this->user->isGuest(); + + if ($guestBeforeHandle && !$guestAfterHandle) { + $this->autoLogin->addCookie($this->user->getIdentity(false), $response); + } + + if (!$guestBeforeHandle && $guestAfterHandle) { + $this->autoLogin->expireCookie($response); + } + + return $response; + } + + /** + * Authenticate user by auto login cookie from request. + * + * @param ServerRequestInterface $request Request instance containing auto login cookie. + */ + private function authenticateUserByCookieFromRequest(ServerRequestInterface $request): void + { + $cookieName = $this->autoLogin->getCookieName(); + $cookies = $request->getCookieParams(); + + if (!array_key_exists($cookieName, $cookies)) { + return; + } + + try { + $data = json_decode($cookies[$cookieName], true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->logger->warning('Unable to authenticate user by cookie. Invalid cookie.'); + return; + } + + if (!is_array($data) || count($data) !== 2) { + $this->logger->warning('Unable to authenticate user by cookie. Invalid cookie.'); + return; + } + + [$id, $key] = $data; + $identity = $this->identityRepository->findIdentity($id); + + if ($identity === null) { + $this->logger->warning("Unable to authenticate user by cookie. Identity \"$id\" not found."); + return; + } + + if (!$identity instanceof AutoLoginIdentityInterface) { + throw new \RuntimeException('Identity repository must return an instance of \Yiisoft\Yii\Web\User\AutoLoginIdentityInterface in order for auto-login to function.'); + } + + if (!$identity->validateAutoLoginKey($key)) { + $this->logger->warning('Unable to authenticate user by cookie. Invalid key.'); + return; + } + + $this->user->login($identity); } } diff --git a/src/User/Event/AfterLogin.php b/src/User/Event/AfterLogin.php index 42e9cc0a7..94b3db3eb 100644 --- a/src/User/Event/AfterLogin.php +++ b/src/User/Event/AfterLogin.php @@ -1,5 +1,7 @@ identity = $identity; - $this->duration = $duration; } public function getIdentity(): IdentityInterface { return $this->identity; } - - public function getDuration(): int - { - return $this->duration; - } } diff --git a/src/User/Event/AfterLogout.php b/src/User/Event/AfterLogout.php index 11fe664eb..0364b2a0b 100644 --- a/src/User/Event/AfterLogout.php +++ b/src/User/Event/AfterLogout.php @@ -1,5 +1,7 @@ identity = $identity; - $this->duration = $duration; } public function invalidate(): void @@ -30,9 +30,4 @@ public function getIdentity(): IdentityInterface { return $this->identity; } - - public function getDuration(): int - { - return $this->duration; - } } diff --git a/src/User/Event/BeforeLogout.php b/src/User/Event/BeforeLogout.php index bebc59c0b..58ea6f706 100644 --- a/src/User/Event/BeforeLogout.php +++ b/src/User/Event/BeforeLogout.php @@ -1,5 +1,7 @@ 0`: as long as the session remains active or as long as the cookie - * remains valid by it's `$duration` in seconds when [[enableAutoLogin]] is set `true`. - * - * If [[enableSession]] is `false`: - * - the `$duration` parameter will be ignored + * - the user's identity information is obtainable from the {@see getIdentity()} + * - the identity information will be stored in session and be available in the next requests as long as the session + * remains active or till the user closes the browser. Some browsers, such as Chrome, are keeping session when + * browser is re-opened. * * @param IdentityInterface $identity the user identity (which should already be authenticated) - * @param int $duration number of seconds that the user can remain in logged-in status, defaults to `0` * @return bool whether the user is logged in */ - public function login(IdentityInterface $identity, int $duration = 0): bool + public function login(IdentityInterface $identity): bool { - if ($this->beforeLogin($identity, $duration)) { + if ($this->beforeLogin($identity)) { $this->switchIdentity($identity); - $this->afterLogin($identity, $duration); + $this->afterLogin($identity); } return !$this->isGuest(); } /** * Logs in a user by the given access token. - * This method will first authenticate the user by calling [[IdentityInterface::findIdentityByAccessToken()]] - * with the provided access token. If successful, it will call [[login()]] to log in the authenticated user. - * If authentication fails or [[login()]] is unsuccessful, it will return null. + * This method will first authenticate the user by calling {@see IdentityInterface::findIdentityByToken()} + * with the provided access token. If successful, it will call {@see login()} to log in the authenticated user. + * If authentication fails or {@see login()} is unsuccessful, it will return null. * @param string $token the access token * @param string $type the type of the token. The value of this parameter depends on the implementation. - * For example, [[\yii\filters\auth\HttpBearerAuth]] will set this parameter to be `yii\filters\auth\HttpBearerAuth`. * @return IdentityInterface|null the identity associated with the given access token. Null is returned if - * the access token is invalid or [[login()]] is unsuccessful. + * the access token is invalid or {@see login()} is unsuccessful. */ public function loginByAccessToken(string $token, string $type = null): ?IdentityInterface { @@ -164,7 +151,6 @@ public function loginByAccessToken(string $token, string $type = null): ?Identit * This will remove authentication-related session data. * If `$destroySession` is true, all session data will be removed. * @param bool $destroySession whether to destroy the whole session. Defaults to true. - * This parameter is ignored if [[enableSession]] is false. * @return bool whether the user is logged out * @throws \Throwable */ @@ -179,6 +165,7 @@ public function logout($destroySession = true): bool if ($destroySession && $this->session) { $this->session->destroy(); } + $this->afterLogout($identity); } return $this->isGuest(); @@ -207,44 +194,34 @@ public function getId(): ?string /** * This method is called before logging in a user. - * The default implementation will trigger the [[EVENT_BEFORE_LOGIN]] event. + * The default implementation will trigger the {@see BeforeLogin} event. * If you override this method, make sure you call the parent implementation * so that the event is triggered. * @param IdentityInterface $identity the user identity information - * @param int $duration number of seconds that the user can remain in logged-in status. - * If 0, it means login till the user closes the browser or the session is manually destroyed. * @return bool whether the user should continue to be logged in */ - protected function beforeLogin(IdentityInterface $identity, int $duration): bool + private function beforeLogin(IdentityInterface $identity): bool { - $event = new BeforeLogin($identity, $duration); + $event = new BeforeLogin($identity); $this->eventDispatcher->dispatch($event); return $event->isValid(); } /** * This method is called after the user is successfully logged in. - * The default implementation will trigger the [[EVENT_AFTER_LOGIN]] event. - * If you override this method, make sure you call the parent implementation - * so that the event is triggered. * @param IdentityInterface $identity the user identity information - * @param int $duration number of seconds that the user can remain in logged-in status. - * If 0, it means login till the user closes the browser or the session is manually destroyed. */ - protected function afterLogin(IdentityInterface $identity, int $duration): void + private function afterLogin(IdentityInterface $identity): void { - $this->eventDispatcher->dispatch(new AfterLogin($identity, $duration)); + $this->eventDispatcher->dispatch(new AfterLogin($identity)); } /** - * This method is invoked when calling [[logout()]] to log out a user. - * The default implementation will trigger the [[EVENT_BEFORE_LOGOUT]] event. - * If you override this method, make sure you call the parent implementation - * so that the event is triggered. + * This method is invoked when calling {@see logout()} to log out a user. * @param IdentityInterface $identity the user identity information * @return bool whether the user should continue to be logged out */ - protected function beforeLogout(IdentityInterface $identity): bool + private function beforeLogout(IdentityInterface $identity): bool { $event = new BeforeLogout($identity); $this->eventDispatcher->dispatch($event); @@ -252,13 +229,10 @@ protected function beforeLogout(IdentityInterface $identity): bool } /** - * This method is invoked right after a user is logged out via [[logout()]]. - * The default implementation will trigger the [[EVENT_AFTER_LOGOUT]] event. - * If you override this method, make sure you call the parent implementation - * so that the event is triggered. + * This method is invoked right after a user is logged out via {@see logout()}. * @param IdentityInterface $identity the user identity information */ - protected function afterLogout(IdentityInterface $identity): void + private function afterLogout(IdentityInterface $identity): void { $this->eventDispatcher->dispatch(new AfterLogout($identity)); } @@ -266,10 +240,10 @@ protected function afterLogout(IdentityInterface $identity): void /** * Switches to a new identity for the current user. * - * When [[enableSession]] is true, this method may use session and/or cookie to store the user identity information, - * according to the value of `$duration`. Please refer to [[login()]] for more details. + * This method use session to store the user identity information. + * Please refer to {@see login()} for more details. * - * This method is mainly called by [[login()]], [[logout()]] and [[loginByCookie()]] + * This method is mainly called by {@see login()} and {@see logout()} * when the current user needs to be associated with the corresponding identity information. * * @param IdentityInterface $identity the identity information to be associated with the current user. @@ -300,17 +274,15 @@ public function switchIdentity(IdentityInterface $identity): void } /** - * Updates the authentication status using the information from session and cookie. + * Updates the authentication status using the information from session. * * This method will try to determine the user identity using a session variable. * - * If [[authTimeout]] is set, this method will refresh the timer. + * If {@see authTimeout} is set, this method will refresh the timer. * - * If the user identity cannot be determined by session, this method will try to [[loginByCookie()|login by cookie]] - * if [[enableAutoLogin]] is true. * @throws \Throwable */ - protected function renewAuthStatus(): void + private function renewAuthStatus(): void { $id = $this->session->get(self::SESSION_AUTH_ID); diff --git a/tests/User/AutoLoginIdentity.php b/tests/User/AutoLoginIdentity.php new file mode 100644 index 000000000..e1ad662ab --- /dev/null +++ b/tests/User/AutoLoginIdentity.php @@ -0,0 +1,29 @@ +getAutoLoginKey(); + } + + public function getId(): ?string + { + return self::ID; + } +} diff --git a/tests/User/AutoLoginMiddlewareTest.php b/tests/User/AutoLoginMiddlewareTest.php new file mode 100644 index 000000000..62b6fc355 --- /dev/null +++ b/tests/User/AutoLoginMiddlewareTest.php @@ -0,0 +1,277 @@ +logger = $this->getMockBuilder(Logger::class) + ->onlyMethods(['dispatch']) + ->getMock(); + } + + private function getLastLogMessage(): ?string + { + $messages = $this->getInaccessibleProperty($this->logger, 'messages'); + return $messages[0][1] ?? null; + } + + public function testCorrectLogin(): void + { + $user = $this->getUserWithLoginExpected(); + + $autoLogin = $this->getAutoLogin(); + $middleware = new AutoLoginMiddleware( + $user, + $this->getAutoLoginIdentityRepository(), + $this->logger, + $autoLogin + ); + $request = $this->getRequestWithAutoLoginCookie(AutoLoginIdentity::ID, AutoLoginIdentity::KEY_CORRECT); + + $middleware->process($request, $this->getRequestHandler()); + + $this->assertNull($this->getLastLogMessage()); + } + + public function testInvalidKey(): void + { + $user = $this->getUserWithoutLoginExpected(); + + $autoLogin = $this->getAutoLogin(); + $middleware = new AutoLoginMiddleware( + $user, + $this->getAutoLoginIdentityRepository(), + $this->logger, + $autoLogin + ); + $request = $this->getRequestWithAutoLoginCookie(AutoLoginIdentity::ID, AutoLoginIdentity::KEY_INCORRECT); + + $middleware->process($request, $this->getRequestHandler()); + + $this->assertSame('Unable to authenticate user by cookie. Invalid key.', $this->getLastLogMessage()); + } + + public function testNoCookie(): void + { + $user = $this->getUserWithoutLoginExpected(); + + $autoLogin = $this->getAutoLogin(); + $middleware = new AutoLoginMiddleware( + $user, + $this->getAutoLoginIdentityRepository(), + $this->logger, + $autoLogin + ); + $request = $this->getRequestWithCookies([]); + + $middleware->process($request, $this->getRequestHandler()); + + $this->assertNull($this->getLastLogMessage()); + } + + public function testEmptyCookie(): void + { + $user = $this->getUserWithoutLoginExpected(); + + $autoLogin = $this->getAutoLogin(); + $middleware = new AutoLoginMiddleware( + $user, + $this->getAutoLoginIdentityRepository(), + $this->logger, + $autoLogin + ); + $request = $this->getRequestWithCookies(['autoLogin' => '']); + + $middleware->process($request, $this->getRequestHandler()); + + $this->assertSame('Unable to authenticate user by cookie. Invalid cookie.', $this->getLastLogMessage()); + } + + public function testInvalidCookie(): void + { + $user = $this->getUserWithoutLoginExpected(); + + $autoLogin = $this->getAutoLogin(); + $middleware = new AutoLoginMiddleware( + $user, + $this->getAutoLoginIdentityRepository(), + $this->logger, + $autoLogin + ); + $request = $this->getRequestWithCookies( + [ + 'autoLogin' => json_encode([AutoLoginIdentity::ID, AutoLoginIdentity::KEY_CORRECT, 'weird stuff']) + ] + ); + + $middleware->process($request, $this->getRequestHandler()); + + $this->assertSame('Unable to authenticate user by cookie. Invalid cookie.', $this->getLastLogMessage()); + } + + public function testIncorrectIdentity(): void + { + $user = $this->getUserWithoutLoginExpected(); + + $middleware = new AutoLoginMiddleware( + $user, + $this->getIncorrectIdentityRepository(), + $this->logger, + $this->getAutoLogin() + ); + + $request = $this->getRequestWithAutoLoginCookie(AutoLoginIdentity::ID, AutoLoginIdentity::KEY_CORRECT); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Identity repository must return an instance of \Yiisoft\Yii\Web\User\AutoLoginIdentityInterface in order for auto-login to function.'); + + $middleware->process($request, $this->getRequestHandlerThatIsNotCalled()); + } + + public function testIdentityNotFound(): void + { + $user = $this->getUserWithoutLoginExpected(); + + $middleware = new AutoLoginMiddleware( + $user, + $this->getEmptyIdentityRepository(), + $this->logger, + $this->getAutoLogin() + ); + + $identityId = AutoLoginIdentity::ID; + $request = $this->getRequestWithAutoLoginCookie($identityId, AutoLoginIdentity::KEY_CORRECT); + + $middleware->process($request, $this->getRequestHandler()); + + $this->assertSame("Unable to authenticate user by cookie. Identity \"$identityId\" not found.", $this->getLastLogMessage()); + } + + private function getRequestHandler(): RequestHandlerInterface + { + $requestHandler = $this->createMock(RequestHandlerInterface::class); + + $requestHandler + ->expects($this->once()) + ->method('handle'); + + return $requestHandler; + } + + private function getRequestHandlerThatIsNotCalled(): RequestHandlerInterface + { + $requestHandler = $this->createMock(RequestHandlerInterface::class); + + $requestHandler + ->expects($this->never()) + ->method('handle'); + + return $requestHandler; + } + + private function getIncorrectIdentityRepository(): IdentityRepositoryInterface + { + return $this->getIdentityRepository($this->createMock(IdentityInterface::class)); + } + + private function getAutoLoginIdentityRepository(): IdentityRepositoryInterface + { + return $this->getIdentityRepository(new AutoLoginIdentity()); + } + + private function getEmptyIdentityRepository(): IdentityRepositoryInterface + { + return $this->createMock(IdentityRepositoryInterface::class); + } + + private function getIdentityRepository(IdentityInterface $identity): IdentityRepositoryInterface + { + $identityRepository = $this->createMock(IdentityRepositoryInterface::class); + + $identityRepository + ->expects($this->any()) + ->method('findIdentity') + ->willReturn($identity); + + return $identityRepository; + } + + private function getRequestWithAutoLoginCookie(string $userId, string $authKey): ServerRequestInterface + { + return $this->getRequestWithCookies(['autoLogin' => json_encode([$userId, $authKey])]); + } + + private function getRequestWithCookies(array $cookies): ServerRequestInterface + { + $request = $this->createMock(ServerRequestInterface::class); + + $request + ->expects($this->any()) + ->method('getCookieParams') + ->willReturn($cookies); + + return $request; + } + + private function getUserWithoutLoginExpected(): User + { + $user = $this->createMock(User::class); + $user->expects($this->never())->method('login'); + return $user; + } + + private function getUserWithLoginExpected(): User + { + $user = $this->createMock(User::class); + $user + ->expects($this->once()) + ->method('login') + ->willReturn(true); + + return $user; + } + + private function getAutoLogin(): AutoLogin + { + return new AutoLogin(new \DateInterval('P1W')); + } + + /** + * Gets an inaccessible object property. + * @param $object + * @param $propertyName + * @param bool $revoke whether to make property inaccessible after getting + * @return mixed + * @throws \ReflectionException + */ + private function getInaccessibleProperty($object, $propertyName, bool $revoke = true) + { + $class = new \ReflectionClass($object); + while (!$class->hasProperty($propertyName)) { + $class = $class->getParentClass(); + } + $property = $class->getProperty($propertyName); + $property->setAccessible(true); + $result = $property->getValue($object); + if ($revoke) { + $property->setAccessible(false); + } + return $result; + } +} diff --git a/tests/User/AutoLoginTest.php b/tests/User/AutoLoginTest.php new file mode 100644 index 000000000..36e6aa57e --- /dev/null +++ b/tests/User/AutoLoginTest.php @@ -0,0 +1,60 @@ +addCookie($identity, $response); + + $this->assertMatchesRegularExpression('#autoLogin=%5B%2242%22%2C%22auto-login-key-correct%22%5D; Expires=.*?; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Lax#', $response->getHeaderLine('Set-Cookie')); + } + + public function testRemoveCookie(): void + { + $autoLogin = new AutoLogin(new \DateInterval('P1W')); + + $response = new Response(); + $response = $autoLogin->expireCookie($response); + + $this->assertMatchesRegularExpression('#autoLogin=; Expires=.*?; Max-Age=-31622400; Path=/; Secure; HttpOnly; SameSite=Lax#', $response->getHeaderLine('Set-Cookie')); + } + + public function testAddCookieWithCustomName(): void + { + $cookieName = 'testName'; + $autoLogin = (new AutoLogin(new \DateInterval('P1W'))) + ->withCookieName($cookieName); + + $identity = new AutoLoginIdentity(); + + $response = new Response(); + $response = $autoLogin->addCookie($identity, $response); + + $this->assertMatchesRegularExpression('#' . $cookieName . '=%5B%2242%22%2C%22auto-login-key-correct%22%5D; Expires=.*?; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Lax#', $response->getHeaderLine('Set-Cookie')); + } + + public function testRemoveCookieWithCustomName(): void + { + $cookieName = 'testName'; + $autoLogin = (new AutoLogin(new \DateInterval('P1W'))) + ->withCookieName($cookieName); + + $response = new Response(); + $response = $autoLogin->expireCookie($response); + + $this->assertMatchesRegularExpression('#' . $cookieName . '=; Expires=.*?; Max-Age=-31622400; Path=/; Secure; HttpOnly; SameSite=Lax#', $response->getHeaderLine('Set-Cookie')); + } +}