Skip to content
This repository has been archived by the owner on Jun 29, 2022. It is now read-only.

AutoLoginMiddleware #218 #235

Merged
merged 39 commits into from May 6, 2020
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4092e59
AutoLoginMiddleware #218
mapeveri Apr 1, 2020
08ba4fd
Fix types #218
mapeveri Apr 3, 2020
00c872e
More tests and general adjustments #218
mapeveri Apr 4, 2020
d5f52bf
Adjustments in methods and tests #218
mapeveri Apr 4, 2020
6d2685d
Change name of method and fix description #218
mapeveri Apr 6, 2020
f1654e2
Logger interface for warning in error authentication #218
mapeveri Apr 7, 2020
61632e1
Cookie login #218
mapeveri Apr 8, 2020
a07ecbc
Fix style #218
mapeveri Apr 8, 2020
e7de628
Remove cookie remeber in logout #218
mapeveri Apr 11, 2020
80442d2
Change to DateTimeImmutable and fix tests #218
mapeveri Apr 12, 2020
a1841aa
Improvements tests #218
mapeveri Apr 12, 2020
02558b9
Fix typos in exception messages, remove unused import
samdark Apr 16, 2020
47969ef
Response cookie #218
mapeveri Apr 18, 2020
b6ccd4a
Fix type hinting #218
mapeveri Apr 18, 2020
0447c0b
Merge branch 'master' of https://github.com/yiisoft/yii-web
mapeveri Apr 18, 2020
6a33a34
Fix remove cookie #218
mapeveri Apr 18, 2020
ff1099d
Merge branch 'master' of https://github.com/yiisoft/yii-web
mapeveri Apr 25, 2020
24a3a6f
Remove IdentityInterface from User #218
mapeveri Apr 25, 2020
f46eeb8
Rename AuthenticationKeyInterface -> AutoLoginIdentityInterface
samdark May 5, 2020
be8f65b
Lay out feature structure
samdark May 5, 2020
5f39cab
Merge branch 'master' into master-mapeveri
samdark May 5, 2020
9e111ec
Adjust AutoLogin to use new cookie methods
samdark May 5, 2020
35d2427
Adjust cookie name
samdark May 5, 2020
7a1be2b
Rename variable
samdark May 5, 2020
bba7ab6
Throw exception if repository does not return an interface needed
samdark May 5, 2020
9a82efd
Cleanup, adjust tests
samdark May 5, 2020
9f1dc6a
Use better names
samdark May 6, 2020
d3b128f
Fixes
samdark May 6, 2020
9386ec5
Make duration required argument of AutoLogin
samdark May 6, 2020
ecb4587
Automatically deal with Cookie in AutoLoginMiddleware
samdark May 6, 2020
6dfc980
Fix tests
samdark May 6, 2020
af47ab7
Add AutoLogin tests, fix code
samdark May 6, 2020
53c0d03
Fix code style
samdark May 6, 2020
d70bde1
Add strict types
samdark May 6, 2020
a93c8d6
Finalize AutoLogin, add test stubs
samdark May 6, 2020
ada9831
Remove tests stubs I have no idea on how to implement :(
samdark May 6, 2020
4db5e3a
Merge branch 'master' into master-mapeveri
samdark May 6, 2020
72d71f1
Do not encode cookie manually
samdark May 6, 2020
e89d374
Add strict types
samdark May 6, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
80 changes: 77 additions & 3 deletions src/User/AutoLoginMiddleware.php
@@ -1,16 +1,90 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Yii\Web\User;

use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Log\LoggerInterface;
use Yiisoft\Auth\IdentityRepositoryInterface;
use Yiisoft\Yii\Web\User\User;
samdark marked this conversation as resolved.
Show resolved Hide resolved

/**
* AutoLoginMiddleware automatically logs user in based on "remember me" cookie
*/
class AutoLoginMiddleware
final class AutoLoginMiddleware implements MiddlewareInterface
{
private User $user;
private IdentityRepositoryInterface $identityRepository;
private LoggerInterface $logger;

public function __construct(User $user)
{
public function __construct(
User $user,
IdentityRepositoryInterface $identityRepository,
LoggerInterface $logger
) {
$this->user = $user;
$this->identityRepository = $identityRepository;
$this->logger = $logger;
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
samdark marked this conversation as resolved.
Show resolved Hide resolved
{
if (!$this->authenticateUserFromRequest($request)) {
$this->logger->warning('Unable to authenticate used by cookie.');
}

return $handler->handle($request);
}

/**
* Parse and determines if an identity cookie has a valid format.
* @param ServerRequestInterface $request Request to handle
* @return array Returns an array of 'identity' and 'duration' if valid, otherwise [].
*/
private function parseCredentials(ServerRequestInterface $request): array
{
try {
$cookies = $request->getCookieParams();
$data = json_decode($cookies['remember'], true, 512, JSON_THROW_ON_ERROR);
} catch (\Exception $e) {
samdark marked this conversation as resolved.
Show resolved Hide resolved
return [];
}

if (!is_array($data) || count($data) !== 3) {
return [];
}

[$id, $authKey, $duration] = $data;
$identity = $this->identityRepository->findIdentity($id);
if ($identity === null) {
return [];
}

if (!$this->user->validateAuthKey($authKey)) {
$this->logger->warning('Unable to authenticate used by cookie. Invalid auth key.');
samdark marked this conversation as resolved.
Show resolved Hide resolved
return [];
}

return ['identity' => $identity, 'duration' => $duration];
}

/**
* Check if the user can authenticate and if everything is ok, authenticate
* @param ServerRequestInterface $request Request to handle
* @return bool
*/
private function authenticateUserFromRequest(ServerRequestInterface $request): bool
{
$data = $this->parseCredentials($request);

if ($data === []) {
return false;
}

return $this->user->login($data['identity'], $data['duration']);
}
}
57 changes: 56 additions & 1 deletion src/User/User.php
Expand Up @@ -2,17 +2,20 @@

namespace Yiisoft\Yii\Web\User;

use Nyholm\Psr7\Response;
use Psr\EventDispatcher\EventDispatcherInterface;
use Yiisoft\Access\AccessCheckerInterface;
use Yiisoft\Auth\IdentityInterface;
use Yiisoft\Auth\IdentityRepositoryInterface;
use Yiisoft\Yii\Web\Cookie;
use Yiisoft\Yii\Web\Session\SessionInterface;
use Yiisoft\Yii\Web\User\AuthenticationKeyInterface;
use Yiisoft\Yii\Web\User\Event\AfterLogin;
use Yiisoft\Yii\Web\User\Event\AfterLogout;
use Yiisoft\Yii\Web\User\Event\BeforeLogin;
use Yiisoft\Yii\Web\User\Event\BeforeLogout;

class User
class User implements AuthenticationKeyInterface
samdark marked this conversation as resolved.
Show resolved Hide resolved
{
private const SESSION_AUTH_ID = '__auth_id';
private const SESSION_AUTH_EXPIRE = '__auth_expire';
Expand Down Expand Up @@ -111,6 +114,51 @@ public function setIdentity(IdentityInterface $identity): void
$this->identity = $identity;
}

/**
* Return the auth key value
*
* @return string Auth key value
*/
public function getAuthKey(): string
{
return 'ABCD1234';
}

/**
* Validate auth key
*
* @param String $authKey Auth key to validate
* @return bool True if is valid
*/
public function validateAuthKey($authKey): bool
{
return $authKey === 'ABCD1234';
}

/**
* Sends an identity cookie.
*
* @param IdentityInterface $identity
* @param int $duration number of seconds that the user can remain in logged-in status.
*/
protected function sendIdentityCookie(IdentityInterface $identity, int $duration): void
{
$data = json_encode(
[
$identity->getId(),
$this->getAuthKey(),
$duration,
],
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);

$expireDateTime = new \DateTime();
$expireDateTime->setTimestamp(time() + $duration);
$cookieIdentity = (new Cookie('remember', $data))->expireAt($expireDateTime);
samdark marked this conversation as resolved.
Show resolved Hide resolved
$response = new Response();
samdark marked this conversation as resolved.
Show resolved Hide resolved
samdark marked this conversation as resolved.
Show resolved Hide resolved
$cookieIdentity->addToResponse($response);
}

/**
* Logs in a user.
*
Expand All @@ -135,6 +183,7 @@ public function login(IdentityInterface $identity, int $duration = 0): bool
if ($this->beforeLogin($identity, $duration)) {
$this->switchIdentity($identity);
$this->afterLogin($identity, $duration);
$this->sendIdentityCookie($identity, $duration);
}
return !$this->isGuest();
}
Expand Down Expand Up @@ -179,6 +228,12 @@ public function logout($destroySession = true): bool
if ($destroySession && $this->session) {
$this->session->destroy();
}

// Remove the cookie
samdark marked this conversation as resolved.
Show resolved Hide resolved
$expireDateTime = new \DateTime();
samdark marked this conversation as resolved.
Show resolved Hide resolved
$expireDateTime->modify("-1 day");
(new Cookie('remember', ""))->expireAt($expireDateTime);

$this->afterLogout($identity);
}
return $this->isGuest();
Expand Down
201 changes: 201 additions & 0 deletions tests/User/AutoLoginMiddlewareTest.php
@@ -0,0 +1,201 @@
<?php

roxblnfk marked this conversation as resolved.
Show resolved Hide resolved
namespace Yiisoft\Yii\Web\Tests\User;

use Nyholm\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LogLevel;
use Yiisoft\Log\Logger;
use Yiisoft\Auth\IdentityInterface;
use Yiisoft\Auth\IdentityRepositoryInterface;
use Yiisoft\Yii\Web\User\User;
use Yiisoft\Yii\Web\User\AutoLoginMiddleware;

class AutoLoginMiddlewareTest extends TestCase
samdark marked this conversation as resolved.
Show resolved Hide resolved
{
/**
* @var RequestHandlerInterface
*/
private $requestHandlerMock;

/**
* @var ServerRequestInterface
*/
private $requestMock;

/**
* @var AutoLoginMiddleware
*/
private $autoLoginMiddlewareMock;

/**
* @var IdentityRepositoryInterface
*/
private $identityRepositoryInterfaceMock;

/**
* @var IdentityInterface
*/
private $identityInterfaceMock;

/**
* @var Logger
*/
private $loggerMock;

/**
* @var User
*/
private $userMock;

public function testProcessOK(): void
{
$this->mockDataRequest();
$this->mockDataCookie(["remember" => json_encode(['1', 'ABCD1234', 60])]);
$this->mockFindIdentity();

$this->userMock
->expects($this->once())
->method('validateAuthKey')
->willReturn(true);

$this->userMock
->expects($this->once())
->method('login')
->willReturn(true);

$response = new Response();
$this->requestHandlerMock
->expects($this->once())
->method('handle')
->willReturn($response);

$this->assertEquals($this->autoLoginMiddlewareMock->process($this->requestMock, $this->requestHandlerMock), $response);
}

public function testProcessErrorLogin(): void
{
$this->mockDataRequest();
$this->mockDataCookie(["remember" => json_encode(['1', 'ABCD1234', 60])]);
$this->mockFindIdentity();

$this->userMock
->expects($this->once())
->method('validateAuthKey')
->willReturn(true);

$this->userMock
->expects($this->once())
->method('login')
->willReturn(false);

$memory = memory_get_usage();
$this->loggerMock->setTraceLevel(3);

$this->autoLoginMiddlewareMock->process($this->requestMock, $this->requestHandlerMock);

$messages = $this->getInaccessibleProperty($this->loggerMock, 'messages');
$this->assertEquals($messages[0][1], 'Unable to authenticate used by cookie.');
}

public function testProcessInvalidAuthKey(): void
{
$this->mockDataRequest();
$this->mockDataCookie(["remember" => json_encode(['1', '123456', 60])]);
$this->mockFindIdentity();

$memory = memory_get_usage();
$this->loggerMock->setTraceLevel(3);

$this->autoLoginMiddlewareMock->process($this->requestMock, $this->requestHandlerMock);

$messages = $this->getInaccessibleProperty($this->loggerMock, 'messages');
$this->assertEquals($messages[0][1], 'Unable to authenticate used by cookie. Invalid auth key.');
}

public function testProcessCookieEmpty(): void
{
$this->mockDataRequest();
$this->mockDataCookie([]);
$this->mockFindIdentity();

$memory = memory_get_usage();
samdark marked this conversation as resolved.
Show resolved Hide resolved
$this->loggerMock->setTraceLevel(3);

$this->autoLoginMiddlewareMock->process($this->requestMock, $this->requestHandlerMock);

$messages = $this->getInaccessibleProperty($this->loggerMock, 'messages');
$this->assertEquals($messages[0][1], 'Unable to authenticate used by cookie.');
}

public function testProcessCookieWithInvalidParams(): void
{
$this->mockDataRequest();
$this->mockDataCookie(["remember" => json_encode(['1', '123456', 60, "paramInvalid"])]);
$this->mockFindIdentity();

$memory = memory_get_usage();
$this->loggerMock->setTraceLevel(3);

$this->autoLoginMiddlewareMock->process($this->requestMock, $this->requestHandlerMock);

$messages = $this->getInaccessibleProperty($this->loggerMock, 'messages');
$this->assertEquals($messages[0][1], 'Unable to authenticate used by cookie.');
}

private function mockDataRequest(): void
{
$this->requestHandlerMock = $this->createMock(RequestHandlerInterface::class);
$this->userMock = $this->createMock(User::class);
$this->identityInterfaceMock = $this->createMock(IdentityInterface::class);

$this->loggerMock = $this->getMockBuilder(Logger::class)
->onlyMethods(['dispatch'])
->getMock();

$this->identityRepositoryInterfaceMock = $this->createMock(IdentityRepositoryInterface::class);
$this->autoLoginMiddlewareMock = new AutoLoginMiddleware($this->userMock, $this->identityRepositoryInterfaceMock, $this->loggerMock);
$this->requestMock = $this->createMock(ServerRequestInterface::class);
}

private function mockDataCookie(array $cookie): void
{
$this->requestMock
->expects($this->any())
->method('getCookieParams')
->willReturn($cookie);
}

private function mockFindIdentity(): void
{
$this->identityRepositoryInterfaceMock
samdark marked this conversation as resolved.
Show resolved Hide resolved
->expects($this->any())
->method('findIdentity')
->willReturn($this->identityInterfaceMock);
}

/**
* Gets an inaccessible object property.
* @param $object
* @param $propertyName
* @param bool $revoke whether to make property inaccessible after getting
* @return mixed
* @throws \ReflectionException
*/
protected 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;
}
}