Skip to content

Commit

Permalink
Merge pull request #593 from getformwork/refactor/user-authentication
Browse files Browse the repository at this point in the history
Refactor user authentication
  • Loading branch information
giuscris authored Oct 25, 2024
2 parents f4da7ed + 4aeeeca commit b99d313
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 62 deletions.
100 changes: 56 additions & 44 deletions formwork/src/Panel/Controllers/AuthenticationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@
use Formwork\Log\Log;
use Formwork\Log\Registry;
use Formwork\Panel\Security\AccessLimiter;
use Formwork\Users\Exceptions\AuthenticationFailedException;
use Formwork\Users\Exceptions\UserNotLoggedException;
use Formwork\Users\User;
use Formwork\Utils\FileSystem;
use RuntimeException;

class AuthenticationController extends AbstractController
{
public const SESSION_REDIRECT_KEY = '_formwork_redirect_to';

/**
* Authentication@login action
*/
public function login(AccessLimiter $accessLimiter): Response
{
if ($this->panel()->isLoggedIn()) {
return $this->redirect($this->generateRoute('panel.index'));
}

$csrfTokenName = $this->panel()->getCsrfTokenName();

if ($accessLimiter->hasReachedLimit()) {
Expand All @@ -26,82 +34,86 @@ public function login(AccessLimiter $accessLimiter): Response
return $this->error($this->translate('panel.login.attempt.tooMany', $minutes));
}

switch ($this->request->method()) {
case RequestMethod::GET:
if ($this->request->session()->has('FORMWORK_USERNAME')) {
return $this->redirect($this->generateRoute('panel.index'));
}
if ($this->request->method() === RequestMethod::POST) {
// Delay request processing for 0.5-1s
usleep(random_int(500, 1000) * 1000);

// Always generate a new CSRF token
$this->csrfToken->generate($csrfTokenName);
$data = $this->request->input();

return new Response($this->view('authentication.login', [
'title' => $this->translate('panel.login.login'),
]));
// Ensure no required data is missing
if (!$data->hasMultiple(['username', 'password'])) {
$this->csrfToken->generate($csrfTokenName);
$this->error($this->translate('panel.login.attempt.failed'));
}

case RequestMethod::POST:
// Delay request processing for 0.5-1s
usleep(random_int(500, 1000) * 1000);
$accessLimiter->registerAttempt();

$data = $this->request->input();
$username = $data->get('username');

// Ensure no required data is missing
if (!$data->hasMultiple(['username', 'password'])) {
$this->csrfToken->generate($csrfTokenName);
$this->error($this->translate('panel.login.attempt.failed'));
}
/** @var User */
$user = $this->site->users()->get($username);

$accessLimiter->registerAttempt();

$user = $this->site->users()->get($data->get('username'));

// Authenticate user
if ($user !== null && $user->authenticate($data->get('password'))) {
$this->request->session()->regenerate();
$this->request->session()->set('FORMWORK_USERNAME', $data->get('username'));
// Authenticate user
if ($user !== null) {
try {
$user->authenticate($data->get('password'));

// Regenerate CSRF token
$this->csrfToken->generate($csrfTokenName);

$accessLog = new Log(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'access.json'));
$lastAccessRegistry = new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'lastAccess.json'));

$time = $accessLog->log($data->get('username'));
$lastAccessRegistry->set($data->get('username'), $time);
$time = $accessLog->log($username);
$lastAccessRegistry->set($username, $time);

$accessLimiter->resetAttempts();

if (($destination = $this->request->session()->get('FORMWORK_REDIRECT_TO')) !== null) {
$this->request->session()->remove('FORMWORK_REDIRECT_TO');
if (($destination = $this->request->session()->get(self::SESSION_REDIRECT_KEY)) !== null) {
$this->request->session()->remove(self::SESSION_REDIRECT_KEY);
return new RedirectResponse($this->panel->uri($destination));
}

return $this->redirect($this->generateRoute('panel.index'));
} catch (AuthenticationFailedException) {
// Do nothing, the error response will be sent below
}
}

$this->csrfToken->generate($csrfTokenName);
return $this->error($this->translate('panel.login.attempt.failed'), [
'username' => $data->get('username'),
'error' => true,
]);
$this->csrfToken->generate($csrfTokenName);

return $this->error($this->translate('panel.login.attempt.failed'), [
'username' => $username,
'error' => true,
]);
}

throw new RuntimeException('Invalid Method');
// Always generate a new CSRF token
$this->csrfToken->generate($csrfTokenName);

return new Response($this->view('authentication.login', [
'title' => $this->translate('panel.login.login'),
]));
}

/**
* Authentication@logout action
*/
public function logout(): RedirectResponse
{
$this->csrfToken->destroy($this->panel()->getCsrfTokenName());
$this->request->session()->remove('FORMWORK_USERNAME');
$this->request->session()->destroy();
try {
$this->panel->user()->logout();
$this->csrfToken->destroy($this->panel()->getCsrfTokenName());

if ($this->config->get('system.panel.logoutRedirect') === 'home') {
return $this->redirect('/');
if ($this->config->get('system.panel.logoutRedirect') === 'home') {
return $this->redirect('/');
}

$this->panel()->notify($this->translate('panel.login.loggedOut'), 'info');
} catch (UserNotLoggedException) {
// Do nothing if user is not logged, the user will be redirected to the login page
}
$this->panel()->notify($this->translate('panel.login.loggedOut'), 'info');

return $this->redirect($this->generateRoute('panel.index'));
}

Expand Down
3 changes: 2 additions & 1 deletion formwork/src/Panel/Controllers/RegisterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Formwork\Panel\Security\Password;
use Formwork\Parsers\Yaml;
use Formwork\Schemes\Schemes;
use Formwork\Users\User;
use Formwork\Utils\FileSystem;
use RuntimeException;

Expand Down Expand Up @@ -57,7 +58,7 @@ public function register(Schemes $schemes): Response
Yaml::encodeToFile($userData, FileSystem::joinPaths($this->config->get('system.users.paths.accounts'), $username . '.yaml'));

$this->request->session()->regenerate();
$this->request->session()->set('FORMWORK_USERNAME', $username);
$this->request->session()->set(User::SESSION_LOGGED_USER_KEY, $username);

$accessLog = new Log(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'access.json'));
$lastAccessRegistry = new Registry(FileSystem::joinPaths($this->config->get('system.panel.paths.logs'), 'lastAccess.json'));
Expand Down
8 changes: 4 additions & 4 deletions formwork/src/Panel/Panel.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Formwork\Http\Session\MessageType;
use Formwork\Languages\LanguageCodes;
use Formwork\Users\ColorScheme;
use Formwork\Users\Exceptions\UserNotLoggedException;
use Formwork\Users\User;
use Formwork\Users\Users;
use Formwork\Utils\FileSystem;
Expand Down Expand Up @@ -41,17 +42,16 @@ public function isLoggedIn(): bool
if (!$this->request->hasPreviousSession()) {
return false;
}
$username = $this->request->session()->get('FORMWORK_USERNAME');
return !empty($username) && $this->users->has($username);
return $this->users->loggedIn() !== null;
}

/**
* Return currently logged in user
*/
public function user(): User
{
$username = $this->request->session()->get('FORMWORK_USERNAME');
return $this->users->get($username);
return $this->users->loggedIn()
?? throw new UserNotLoggedException('No user is logged in');
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Formwork\Users\Exceptions;

use RuntimeException;

class AuthenticationFailedException extends RuntimeException
{
}
9 changes: 9 additions & 0 deletions formwork/src/Users/Exceptions/UserImageNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Formwork\Users\Exceptions;

use RuntimeException;

class UserImageNotFoundException extends RuntimeException
{
}
9 changes: 9 additions & 0 deletions formwork/src/Users/Exceptions/UserNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Formwork\Users\Exceptions;

use RuntimeException;

class UserNotFoundException extends RuntimeException
{
}
9 changes: 9 additions & 0 deletions formwork/src/Users/Exceptions/UserNotLoggedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Formwork\Users\Exceptions;

use RuntimeException;

class UserNotLoggedException extends RuntimeException
{
}
53 changes: 43 additions & 10 deletions formwork/src/Users/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@
use Formwork\Log\Registry;
use Formwork\Model\Model;
use Formwork\Panel\Security\Password;
use Formwork\Users\Exceptions\AuthenticationFailedException;
use Formwork\Users\Exceptions\UserImageNotFoundException;
use Formwork\Users\Exceptions\UserNotLoggedException;
use Formwork\Utils\FileSystem;
use UnexpectedValueException;
use SensitiveParameter;

class User extends Model
{
public const SESSION_LOGGED_USER_KEY = '_formwork_logged_user';

protected const MODEL_IDENTIFIER = 'user';

/**
Expand Down Expand Up @@ -86,7 +91,7 @@ public function image(): Image
$file = $this->fileFactory->make($path);

if (!($file instanceof Image)) {
throw new UnexpectedValueException('Invalid user image');
throw new UserImageNotFoundException('Invalid user image');
}

return $this->image = $file;
Expand Down Expand Up @@ -115,20 +120,48 @@ public function permissions(): Permissions
return $this->role->permissions();
}

/**
* Authenticate the user
*/
public function authenticate(
#[SensitiveParameter]
string $password
): void {
if (!$this->verifyPassword($password)) {
throw new AuthenticationFailedException(sprintf('Authentication failed for user "%s"', $this->username()));
}
$this->request->session()->regenerate();
$this->request->session()->set(self::SESSION_LOGGED_USER_KEY, $this->username());
}

/**
* Return whether a given password authenticates the user
*/
public function authenticate(string $password): bool
{
public function verifyPassword(
#[SensitiveParameter]
string $password
): bool {
return Password::verify($password, $this->hash());
}

/**
* Log out the user
*/
public function logout(): void
{
if (!$this->isLoggedIn()) {
throw new UserNotLoggedException(sprintf('Cannot logout user "%s": user not logged', $this->username()));
}
$this->request->session()->remove(self::SESSION_LOGGED_USER_KEY);
$this->request->session()->destroy();
}

/**
* Return whether the user is logged or not
*/
public function isLogged(): bool
public function isLoggedIn(): bool
{
return $this->request->session()->get('FORMWORK_USERNAME') === $this->username();
return $this->request->session()->get(self::SESSION_LOGGED_USER_KEY) === $this->username();
}

/**
Expand All @@ -144,7 +177,7 @@ public function isAdmin(): bool
*/
public function canDeleteUser(User $user): bool
{
return $this->isAdmin() && !$user->isLogged();
return $this->isAdmin() && !$user->isLoggedIn();
}

/**
Expand All @@ -155,7 +188,7 @@ public function canChangeOptionsOf(User $user): bool
if ($this->isAdmin()) {
return true;
}
return $user->isLogged();
return $user->isLoggedIn();
}

/**
Expand All @@ -166,15 +199,15 @@ public function canChangePasswordOf(User $user): bool
if ($this->isAdmin()) {
return true;
}
return $user->isLogged();
return $user->isLoggedIn();
}

/**
* Return whether the user can change the role of a given user
*/
public function canChangeRoleOf(User $user): bool
{
return $this->isAdmin() && !$user->isLogged();
return $this->isAdmin() && !$user->isLoggedIn();
}

/**
Expand Down
8 changes: 8 additions & 0 deletions formwork/src/Users/UserCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,12 @@ public function availableRoles(): array
{
return $this->roleCollection->everyItem()->title()->toArray();
}

/**
* Get logged in user or null if no user is authenticated
*/
public function loggedIn(): ?User
{
return $this->find(fn (User $user): bool => $user->isLoggedIn());
}
}
7 changes: 4 additions & 3 deletions panel/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Formwork\Http\Request;
use Formwork\Http\Response;
use Formwork\Http\ResponseStatus;
use Formwork\Panel\Controllers\AuthenticationController;
use Formwork\Panel\Panel;
use Formwork\Security\CsrfToken;
use Formwork\Site;
Expand Down Expand Up @@ -271,7 +272,7 @@

if (!$csrfToken->validate($tokenName, $token)) {
$csrfToken->destroy($tokenName);
$request->session()->remove('FORMWORK_USERNAME');
$panel->user()->logout();

$panel->notify(
$translations->getCurrent()->translate('panel.login.suspiciousRequestDetected'),
Expand Down Expand Up @@ -323,8 +324,8 @@
'panel.redirectToLogin' => [
'action' => static function (Request $request, Site $site, Panel $panel) {
// Redirect to login if no user is logged
if (!$site->users()->isEmpty() && !$panel->isLoggedIn() && $panel->route() !== '/login/') {
$request->session()->set('FORMWORK_REDIRECT_TO', $panel->route());
if (!$site->users()->isEmpty() && !$panel->isLoggedIn() && !in_array($panel->route(), ['/login/', '/logout/'], true)) {
$request->session()->set(AuthenticationController::SESSION_REDIRECT_KEY, $panel->route());
return new RedirectResponse($panel->uri('/login/'));
}
},
Expand Down

0 comments on commit b99d313

Please sign in to comment.