Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
efb6a6b
Started barebones work of MFA system
ssddanbrown Jun 28, 2021
d25cd83
Added TOTP generation view and started verification stage
ssddanbrown Jun 29, 2021
916a826
Complete base flow for TOTP setup
ssddanbrown Jun 30, 2021
83c8f73
Covered TOTP setup with testing
ssddanbrown Jul 2, 2021
529971c
Added backup code setup flow
ssddanbrown Jul 2, 2021
09c2814
Added role based MFA control
ssddanbrown Jul 3, 2021
bb43ace
Added MFA setup link on user edit view
ssddanbrown Jul 14, 2021
cfc0c59
Added MFA indicator to user list
ssddanbrown Jul 14, 2021
7c86c26
Added command to reset user MFA
ssddanbrown Jul 14, 2021
f696aa5
Added the ability to remove an MFA method
ssddanbrown Jul 14, 2021
78f9c01
Started on some MFA access-time checks
ssddanbrown Jul 16, 2021
9249add
Updated all login events to route through single service
ssddanbrown Jul 17, 2021
1278fb4
Started moving MFA and email confirmation to new login flow
ssddanbrown Jul 17, 2021
1af5bbf
Added login redirect system to confirm/mfa
ssddanbrown Jul 18, 2021
a3f19eb
Added TOTP verification upon access
ssddanbrown Aug 2, 2021
4597069
Added Backup code verification logic
ssddanbrown Aug 2, 2021
9b271e5
Worked on MFA setup required flow
ssddanbrown Aug 2, 2021
70f3975
Updated API auth handling of email confirmations
ssddanbrown Aug 5, 2021
39a205e
Quick test of email confirmation routes and fix of tests
ssddanbrown Aug 7, 2021
ef9354a
Verified mfa session expires on logout
ssddanbrown Aug 7, 2021
773be96
Updated auth changes to work with remember me
ssddanbrown Aug 7, 2021
f1f59cf
Extracted text to translation files
ssddanbrown Aug 8, 2021
622ea03
Added attribution for new libs added
ssddanbrown Aug 8, 2021
78e94bb
Improved login redirect and setup experience
ssddanbrown Aug 21, 2021
2d30694
Cleaned some unused elements during testing
ssddanbrown Aug 21, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Actions/ActivityType.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,7 @@ class ActivityType
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
const AUTH_LOGIN = 'auth_login';
const AUTH_REGISTER = 'auth_register';

const MFA_SETUP_METHOD = 'mfa_setup_method';
const MFA_REMOVE_METHOD = 'mfa_remove_method';
}
13 changes: 12 additions & 1 deletion app/Api/ApiTokenGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace BookStack\Api;

use BookStack\Auth\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
Expand All @@ -19,6 +20,11 @@ class ApiTokenGuard implements Guard
*/
protected $request;

/**
* @var LoginService
*/
protected $loginService;

/**
* The last auth exception thrown in this request.
*
Expand All @@ -29,9 +35,10 @@ class ApiTokenGuard implements Guard
/**
* ApiTokenGuard constructor.
*/
public function __construct(Request $request)
public function __construct(Request $request, LoginService $loginService)
{
$this->request = $request;
$this->loginService = $loginService;
}

/**
Expand Down Expand Up @@ -95,6 +102,10 @@ protected function getAuthorisedUserFromRequest(): Authenticatable

$this->validateToken($token, $secret);

if ($this->loginService->awaitingEmailConfirmation($token->user)) {
throw new ApiAuthException(trans('errors.email_confirmation_awaiting'));
}

return $token->user;
}

Expand Down
5 changes: 0 additions & 5 deletions app/Auth/Access/EmailConfirmationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ class EmailConfirmationService extends UserTokenService
/**
* Create new confirmation for a user,
* Also removes any existing old ones.
*
* @param User $user
*
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
Expand All @@ -33,8 +30,6 @@ public function sendConfirmation(User $user)

/**
* Check if confirmation is required in this instance.
*
* @return bool
*/
public function confirmationRequired(): bool
{
Expand Down
8 changes: 2 additions & 6 deletions app/Auth/Access/Guards/ExternalBaseSessionGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,8 @@ public function attempt(array $credentials = [], $remember = false)
*/
public function loginUsingId($id, $remember = false)
{
if (!is_null($user = $this->provider->retrieveById($id))) {
$this->login($user, $remember);

return $user;
}

// Always return false as to disable this method,
// Logins should route through LoginService.
return false;
}

Expand Down
160 changes: 160 additions & 0 deletions app/Auth/Access/LoginService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

namespace BookStack\Auth\Access;

use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception;

class LoginService
{

protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';

protected $mfaSession;
protected $emailConfirmationService;

public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
{
$this->mfaSession = $mfaSession;
$this->emailConfirmationService = $emailConfirmationService;
}

/**
* Log the given user into the system.
* Will start a login of the given user but will prevent if there's
* a reason to (MFA or Unconfirmed Email).
* Returns a boolean to indicate the current login result.
* @throws StoppedAuthenticationException
*/
public function login(User $user, string $method, bool $remember = false): void
{
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
$this->setLastLoginAttemptedForUser($user, $method, $remember);
throw new StoppedAuthenticationException($user, $this);
}

$this->clearLastLoginAttempted();
auth()->login($user, $remember);
Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);

// Authenticate on all session guards if a likely admin
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
$guards = ['standard', 'ldap', 'saml2'];
foreach ($guards as $guard) {
auth($guard)->login($user);
}
}
}

/**
* Reattempt a system login after a previous stopped attempt.
* @throws Exception
*/
public function reattemptLoginFor(User $user)
{
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
throw new Exception('Login reattempt user does align with current session state');
}

$lastLoginDetails = $this->getLastLoginAttemptDetails();
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
}

/**
* Get the last user that was attempted to be logged in.
* Only exists if the last login attempt had correct credentials
* but had been prevented by a secondary factor.
*/
public function getLastLoginAttemptUser(): ?User
{
$id = $this->getLastLoginAttemptDetails()['user_id'];
return User::query()->where('id', '=', $id)->first();
}

/**
* Get the details of the last login attempt.
* Checks upon a ttl of about 1 hour since that last attempted login.
* @return array{user_id: ?string, method: ?string, remember: bool}
*/
protected function getLastLoginAttemptDetails(): array
{
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
if (!$value) {
return ['user_id' => null, 'method' => null];
}

[$id, $method, $remember, $time] = explode(':', $value);
$hourAgo = time() - (60*60);
if ($time < $hourAgo) {
$this->clearLastLoginAttempted();
return ['user_id' => null, 'method' => null];
}

return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
}

/**
* Set the last login attempted user.
* Must be only used when credentials are correct and a login could be
* achieved but a secondary factor has stopped the login.
*/
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
{
session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
implode(':', [$user->id, $method, $remember, time()])
);
}

/**
* Clear the last login attempted session value.
*/
protected function clearLastLoginAttempted(): void
{
session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
}

/**
* Check if MFA verification is needed.
*/
public function needsMfaVerification(User $user): bool
{
return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
}

/**
* Check if the given user is awaiting email confirmation.
*/
public function awaitingEmailConfirmation(User $user): bool
{
return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
}

/**
* Attempt the login of a user using the given credentials.
* Meant to mirror Laravel's default guard 'attempt' method
* but in a manner that always routes through our login system.
* May interrupt the flow if extra authentication requirements are imposed.
*
* @throws StoppedAuthenticationException
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
{
$result = auth()->attempt($credentials, $remember);
if ($result) {
$user = auth()->user();
auth()->logout();
$this->login($user, $method, $remember);
}

return $result;
}

}
60 changes: 60 additions & 0 deletions app/Auth/Access/Mfa/BackupCodeService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace BookStack\Auth\Access\Mfa;

use Illuminate\Support\Str;

class BackupCodeService
{
/**
* Generate a new set of 16 backup codes.
*/
public function generateNewSet(): array
{
$codes = [];
while (count($codes) < 16) {
$code = Str::random(5) . '-' . Str::random(5);
if (!in_array($code, $codes)) {
$codes[] = strtolower($code);
}
}

return $codes;
}

/**
* Check if the given code matches one of the available options.
*/
public function inputCodeExistsInSet(string $code, string $codeSet): bool
{
$cleanCode = $this->cleanInputCode($code);
$codes = json_decode($codeSet);
return in_array($cleanCode, $codes);
}

/**
* Remove the given input code from the given available options.
* Will return a JSON string containing the codes.
*/
public function removeInputCodeFromSet(string $code, string $codeSet): string
{
$cleanCode = $this->cleanInputCode($code);
$codes = json_decode($codeSet);
$pos = array_search($cleanCode, $codes, true);
array_splice($codes, $pos, 1);
return json_encode($codes);
}

/**
* Count the number of codes in the given set.
*/
public function countCodesInSet(string $codeSet): int
{
return count(json_decode($codeSet));
}

protected function cleanInputCode(string $code): string
{
return strtolower(str_replace(' ', '-', trim($code)));
}
}
61 changes: 61 additions & 0 deletions app/Auth/Access/Mfa/MfaSession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace BookStack\Auth\Access\Mfa;

use BookStack\Auth\User;

class MfaSession
{
/**
* Check if MFA is required for the given user.
*/
public function isRequiredForUser(User $user): bool
{
// TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
}

/**
* Check if the given user is pending MFA setup.
* (MFA required but not yet configured).
*/
public function isPendingMfaSetup(User $user): bool
{
return $this->isRequiredForUser($user) && !$user->mfaValues()->exists();
}

/**
* Check if a role of the given user enforces MFA.
*/
protected function userRoleEnforcesMfa(User $user): bool
{
return $user->roles()
->where('mfa_enforced', '=', true)
->exists();
}

/**
* Check if the current MFA session has already been verified for the given user.
*/
public function isVerifiedForUser(User $user): bool
{
return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true';
}

/**
* Mark the current session as MFA-verified.
*/
public function markVerifiedForUser(User $user): void
{
session()->put($this->getMfaVerifiedSessionKey($user), 'true');
}

/**
* Get the session key in which the MFA verification status is stored.
*/
protected function getMfaVerifiedSessionKey(User $user): string
{
return 'mfa-verification-passed:' . $user->id;
}

}
Loading