mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-30 04:23:11 +03:00
@ -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';
|
||||
}
|
||||
|
@ -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;
|
||||
@ -19,6 +20,11 @@ class ApiTokenGuard implements Guard
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The last auth exception thrown in this request.
|
||||
*
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -95,6 +102,10 @@ class ApiTokenGuard implements Guard
|
||||
|
||||
$this->validateToken($token, $secret);
|
||||
|
||||
if ($this->loginService->awaitingEmailConfirmation($token->user)) {
|
||||
throw new ApiAuthException(trans('errors.email_confirmation_awaiting'));
|
||||
}
|
||||
|
||||
return $token->user;
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
@ -33,8 +30,6 @@ class EmailConfirmationService extends UserTokenService
|
||||
|
||||
/**
|
||||
* Check if confirmation is required in this instance.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function confirmationRequired(): bool
|
||||
{
|
||||
|
@ -186,12 +186,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
160
app/Auth/Access/LoginService.php
Normal file
160
app/Auth/Access/LoginService.php
Normal file
@ -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
app/Auth/Access/Mfa/BackupCodeService.php
Normal file
60
app/Auth/Access/Mfa/BackupCodeService.php
Normal file
@ -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
app/Auth/Access/Mfa/MfaSession.php
Normal file
61
app/Auth/Access/Mfa/MfaSession.php
Normal file
@ -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;
|
||||
}
|
||||
|
||||
}
|
76
app/Auth/Access/Mfa/MfaValue.php
Normal file
76
app/Auth/Access/Mfa/MfaValue.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $method
|
||||
* @property string $value
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class MfaValue extends Model
|
||||
{
|
||||
protected static $unguarded = true;
|
||||
|
||||
const METHOD_TOTP = 'totp';
|
||||
const METHOD_BACKUP_CODES = 'backup_codes';
|
||||
|
||||
/**
|
||||
* Get all the MFA methods available.
|
||||
*/
|
||||
public static function allMethods(): array
|
||||
{
|
||||
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a new MFA value for the given user and method
|
||||
* using the provided value.
|
||||
*/
|
||||
public static function upsertWithValue(User $user, string $method, string $value): void
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()->firstOrNew([
|
||||
'user_id' => $user->id,
|
||||
'method' => $method
|
||||
]);
|
||||
$mfaVal->setValue($value);
|
||||
$mfaVal->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Easily get the decrypted MFA value for the given user and method.
|
||||
*/
|
||||
public static function getValueForUser(User $user, string $method): ?string
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('method', '=', $method)
|
||||
->first();
|
||||
|
||||
return $mfaVal ? $mfaVal->getValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the value attribute upon access.
|
||||
*/
|
||||
protected function getValue(): string
|
||||
{
|
||||
return decrypt($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the value attribute upon access.
|
||||
*/
|
||||
protected function setValue($value): void
|
||||
{
|
||||
$this->value = encrypt($value);
|
||||
}
|
||||
}
|
71
app/Auth/Access/Mfa/TotpService.php
Normal file
71
app/Auth/Access/Mfa/TotpService.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use BaconQrCode\Renderer\Color\Rgb;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\Fill;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
class TotpService
|
||||
{
|
||||
protected $google2fa;
|
||||
|
||||
public function __construct(Google2FA $google2fa)
|
||||
{
|
||||
$this->google2fa = $google2fa;
|
||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||
// many apps lack support for other algorithms yet still will scan
|
||||
// the code causing a confusing UX.
|
||||
$this->google2fa->setAlgorithm(Constants::SHA1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new totp secret key.
|
||||
*/
|
||||
public function generateSecret(): string
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return $this->google2fa->generateSecretKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP URL from secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret): string
|
||||
{
|
||||
return $this->google2fa->getQRCodeUrl(
|
||||
setting('app-name'),
|
||||
user()->email,
|
||||
$secret
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code to display a TOTP URL.
|
||||
*/
|
||||
public function generateQrCodeSvg(string $url): string
|
||||
{
|
||||
$color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
|
||||
return (new Writer(
|
||||
new ImageRenderer(
|
||||
new RendererStyle(192, 0, null, null, $color),
|
||||
new SvgImageBackEnd
|
||||
)
|
||||
))->writeString($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the user provided code is valid for the secret.
|
||||
* The secret must be known, not user-provided.
|
||||
*/
|
||||
public function verifyCode(string $code, string $secret): bool
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
return $this->google2fa->verifyKey($secret, $code);
|
||||
}
|
||||
}
|
38
app/Auth/Access/Mfa/TotpValidationRule.php
Normal file
38
app/Auth/Access/Mfa/TotpValidationRule.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Mfa;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
|
||||
class TotpValidationRule implements Rule
|
||||
{
|
||||
|
||||
protected $secret;
|
||||
protected $totpService;
|
||||
|
||||
/**
|
||||
* Create a new rule instance.
|
||||
* Takes the TOTP secret that must be system provided, not user provided.
|
||||
*/
|
||||
public function __construct(string $secret)
|
||||
{
|
||||
$this->secret = $secret;
|
||||
$this->totpService = app()->make(TotpService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the validation rule passes.
|
||||
*/
|
||||
public function passes($attribute, $value)
|
||||
{
|
||||
return $this->totpService->verifyCode($value, $this->secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation error message.
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
return trans('validation.totp');
|
||||
}
|
||||
}
|
@ -88,7 +88,6 @@ class RegistrationService
|
||||
session()->flash('sent-email-confirmation', true);
|
||||
} catch (Exception $e) {
|
||||
$message = trans('auth.email_confirm_send_error');
|
||||
|
||||
throw new UserRegistrationException($message, '/register/confirm');
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\SamlException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@ -25,16 +26,16 @@ class Saml2Service extends ExternalAuthService
|
||||
{
|
||||
protected $config;
|
||||
protected $registrationService;
|
||||
protected $user;
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* Saml2Service constructor.
|
||||
*/
|
||||
public function __construct(RegistrationService $registrationService, User $user)
|
||||
public function __construct(RegistrationService $registrationService, LoginService $loginService)
|
||||
{
|
||||
$this->config = config('saml2');
|
||||
$this->registrationService = $registrationService;
|
||||
$this->user = $user;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -332,7 +333,7 @@ class Saml2Service extends ExternalAuthService
|
||||
*/
|
||||
protected function getOrRegisterUser(array $userDetails): ?User
|
||||
{
|
||||
$user = $this->user->newQuery()
|
||||
$user = User::query()
|
||||
->where('external_auth_id', '=', $userDetails['external_id'])
|
||||
->first();
|
||||
|
||||
@ -357,6 +358,7 @@ class Saml2Service extends ExternalAuthService
|
||||
* @throws SamlException
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
public function processLoginCallback(string $samlID, array $samlAttributes): User
|
||||
{
|
||||
@ -389,10 +391,7 @@ class Saml2Service extends ExternalAuthService
|
||||
$this->syncWithGroups($user, $groups);
|
||||
}
|
||||
|
||||
auth()->login($user);
|
||||
Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, 'saml2', $user);
|
||||
|
||||
$this->loginService->login($user, 'saml2');
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
@ -2,15 +2,11 @@
|
||||
|
||||
namespace BookStack\Auth\Access;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\SocialAccount;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
@ -28,6 +24,11 @@ class SocialAuthService
|
||||
*/
|
||||
protected $socialite;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
@ -59,9 +60,10 @@ class SocialAuthService
|
||||
/**
|
||||
* SocialAuthService constructor.
|
||||
*/
|
||||
public function __construct(Socialite $socialite)
|
||||
public function __construct(Socialite $socialite, LoginService $loginService)
|
||||
{
|
||||
$this->socialite = $socialite;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,10 +141,7 @@ class SocialAuthService
|
||||
// When a user is not logged in and a matching SocialAccount exists,
|
||||
// Simply log the user into the application.
|
||||
if (!$isLoggedIn && $socialAccount !== null) {
|
||||
auth()->login($socialAccount->user);
|
||||
Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $socialAccount->user);
|
||||
|
||||
$this->loginService->login($socialAccount->user, $socialAccount);
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
|
@ -57,6 +57,7 @@ class PermissionsRepo
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
$role = $this->role->newInstance($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
@ -90,6 +91,7 @@ class PermissionsRepo
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
|
@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property string $description
|
||||
* @property string $external_auth_id
|
||||
* @property string $system_name
|
||||
* @property bool $mfa_enforced
|
||||
*/
|
||||
class Role extends Model implements Loggable
|
||||
{
|
||||
|
@ -4,6 +4,7 @@ namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
@ -38,6 +39,7 @@ use Illuminate\Support\Collection;
|
||||
* @property string $external_auth_id
|
||||
* @property string $system_name
|
||||
* @property Collection $roles
|
||||
* @property Collection $mfaValues
|
||||
*/
|
||||
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
|
||||
{
|
||||
@ -265,6 +267,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $this->hasMany(Favourite::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MFA values belonging to this use.
|
||||
*/
|
||||
public function mfaValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(MfaValue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last activity time for this user.
|
||||
*/
|
||||
|
@ -71,6 +71,7 @@ class UserRepo
|
||||
$query = User::query()->select(['*'])
|
||||
->withLastActivityAt()
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
|
||||
if ($sortData['search']) {
|
||||
@ -188,6 +189,7 @@ class UserRepo
|
||||
$user->socialAccounts()->delete();
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
|
74
app/Console/Commands/ResetMfa.php
Normal file
74
app/Console/Commands/ResetMfa.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ResetMfa extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:reset-mfa
|
||||
{--id= : Numeric ID of the user to reset MFA for}
|
||||
{--email= : Email address of the user to reset MFA for}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reset & Clear any configured MFA methods for the given user';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$id = $this->option('id');
|
||||
$email = $this->option('email');
|
||||
if (!$id && !$email) {
|
||||
$this->error('Either a --id=<number> or --email=<email> option must be provided.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$field = $id ? 'id' : 'email';
|
||||
$value = $id ?: $email;
|
||||
$user = User::query()
|
||||
->where($field, '=', $value)
|
||||
->first();
|
||||
|
||||
if (!$user) {
|
||||
$this->error("A user where {$field}={$value} could not be found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("This will delete any configure multi-factor authentication methods for user: \n- ID: {$user->id}\n- Name: {$user->name}\n- Email: {$user->email}\n");
|
||||
$this->info('If multi-factor authentication is required for this user they will be asked to reconfigure their methods on next login.');
|
||||
$confirm = $this->confirm('Are you sure you want to proceed?');
|
||||
if ($confirm) {
|
||||
$user->mfaValues()->delete();
|
||||
$this->info('User MFA methods have been reset.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
65
app/Exceptions/StoppedAuthenticationException.php
Normal file
65
app/Exceptions/StoppedAuthenticationException.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StoppedAuthenticationException extends \Exception implements Responsable
|
||||
{
|
||||
|
||||
protected $user;
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* StoppedAuthenticationException constructor.
|
||||
*/
|
||||
public function __construct(User $user, LoginService $loginService)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->loginService = $loginService;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function toResponse($request)
|
||||
{
|
||||
$redirect = '/login';
|
||||
|
||||
if ($this->loginService->awaitingEmailConfirmation($this->user)) {
|
||||
return $this->awaitingEmailConfirmationResponse($request);
|
||||
}
|
||||
|
||||
if ($this->loginService->needsMfaVerification($this->user)) {
|
||||
$redirect = '/mfa/verify';
|
||||
}
|
||||
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide an error response for when the current user's email is not confirmed
|
||||
* in a system which requires it.
|
||||
*/
|
||||
protected function awaitingEmailConfirmationResponse(Request $request)
|
||||
{
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 401,
|
||||
'message' => trans('errors.email_confirmation_awaiting'),
|
||||
],
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (session()->get('sent-email-confirmation') === true) {
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
return redirect('/register/confirm/awaiting');
|
||||
}
|
||||
}
|
@ -2,15 +2,13 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\EmailConfirmationService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
use BookStack\Exceptions\UserTokenExpiredException;
|
||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Exception;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -20,14 +18,20 @@ use Illuminate\View\View;
|
||||
class ConfirmEmailController extends Controller
|
||||
{
|
||||
protected $emailConfirmationService;
|
||||
protected $loginService;
|
||||
protected $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
|
||||
public function __construct(
|
||||
EmailConfirmationService $emailConfirmationService,
|
||||
LoginService $loginService,
|
||||
UserRepo $userRepo
|
||||
)
|
||||
{
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
@ -43,12 +47,11 @@ class ConfirmEmailController extends Controller
|
||||
/**
|
||||
* Shows a notice that a user's email address has not been confirmed,
|
||||
* Also has the option to re-send the confirmation email.
|
||||
*
|
||||
* @return View
|
||||
*/
|
||||
public function showAwaiting()
|
||||
{
|
||||
return view('auth.user-unconfirmed');
|
||||
$user = $this->loginService->getLastLoginAttemptUser();
|
||||
return view('auth.user-unconfirmed', ['user' => $user]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -87,11 +90,9 @@ class ConfirmEmailController extends Controller
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
auth()->login($user);
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
|
||||
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
|
||||
$this->showSuccessNotification(trans('auth.email_confirm_success'));
|
||||
$this->emailConfirmationService->deleteByUser($user);
|
||||
$this->showSuccessNotification(trans('auth.email_confirm_success'));
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
25
app/Http/Controllers/Auth/HandlesPartialLogins.php
Normal file
25
app/Http/Controllers/Auth/HandlesPartialLogins.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
|
||||
trait HandlesPartialLogins
|
||||
{
|
||||
/**
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
protected function currentOrLastAttemptedUser(): User
|
||||
{
|
||||
$loginService = app()->make(LoginService::class);
|
||||
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFoundException('A user for this action could not be found');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
@ -3,13 +3,11 @@
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Activity;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -37,16 +35,19 @@ class LoginController extends Controller
|
||||
protected $redirectAfterLogout = '/login';
|
||||
|
||||
protected $socialAuthService;
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(SocialAuthService $socialAuthService)
|
||||
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
|
||||
{
|
||||
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
$this->redirectPath = url('/');
|
||||
$this->redirectAfterLogout = url('/login');
|
||||
}
|
||||
@ -140,6 +141,19 @@ class LoginController extends Controller
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log the user into the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return bool
|
||||
*/
|
||||
protected function attemptLogin(Request $request)
|
||||
{
|
||||
return $this->loginService->attempt(
|
||||
$this->credentials($request), auth()->getDefaultDriver(), $request->filled('remember')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has been authenticated.
|
||||
*
|
||||
@ -150,17 +164,6 @@ class LoginController extends Controller
|
||||
*/
|
||||
protected function authenticated(Request $request, $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);
|
||||
}
|
||||
}
|
||||
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
|
||||
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
|
||||
|
||||
return redirect()->intended($this->redirectPath());
|
||||
}
|
||||
|
||||
|
95
app/Http/Controllers/Auth/MfaBackupCodesController.php
Normal file
95
app/Http/Controllers/Auth/MfaBackupCodesController.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\Mfa\BackupCodeService;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MfaBackupCodesController extends Controller
|
||||
{
|
||||
use HandlesPartialLogins;
|
||||
|
||||
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes';
|
||||
|
||||
/**
|
||||
* Show a view that generates and displays backup codes
|
||||
*/
|
||||
public function generate(BackupCodeService $codeService)
|
||||
{
|
||||
$codes = $codeService->generateNewSet();
|
||||
session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($codes));
|
||||
|
||||
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
|
||||
|
||||
return view('mfa.backup-codes-generate', [
|
||||
'codes' => $codes,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the setup of backup codes, storing them against the user.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirm()
|
||||
{
|
||||
if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) {
|
||||
return response('No generated codes found in the session', 500);
|
||||
}
|
||||
|
||||
$codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY));
|
||||
MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
|
||||
|
||||
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes');
|
||||
|
||||
if (!auth()->check()) {
|
||||
$this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return redirect('/mfa/setup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the MFA method submission on check.
|
||||
* @throws NotFoundException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService)
|
||||
{
|
||||
$user = $this->currentOrLastAttemptedUser();
|
||||
$codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]';
|
||||
|
||||
$this->validate($request, [
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:8',
|
||||
function ($attribute, $value, $fail) use ($codeService, $codes) {
|
||||
if (!$codeService->inputCodeExistsInSet($value, $codes)) {
|
||||
$fail(trans('validation.backup_codes'));
|
||||
}
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
|
||||
MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
$loginService->reattemptLoginFor($user);
|
||||
|
||||
if ($codeService->countCodesInSet($updatedCodes) < 5) {
|
||||
$this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning'));
|
||||
}
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
}
|
69
app/Http/Controllers/Auth/MfaController.php
Normal file
69
app/Http/Controllers/Auth/MfaController.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MfaController extends Controller
|
||||
{
|
||||
use HandlesPartialLogins;
|
||||
|
||||
/**
|
||||
* Show the view to setup MFA for the current user.
|
||||
*/
|
||||
public function setup()
|
||||
{
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
return view('mfa.setup', [
|
||||
'userMethods' => $userMethods,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an MFA method for the current user.
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function remove(string $method)
|
||||
{
|
||||
if (in_array($method, MfaValue::allMethods())) {
|
||||
$value = user()->mfaValues()->where('method', '=', $method)->first();
|
||||
if ($value) {
|
||||
$value->delete();
|
||||
$this->logActivity(ActivityType::MFA_REMOVE_METHOD, $method);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect('/mfa/setup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page to start an MFA verification.
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$desiredMethod = $request->get('method');
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
// Basic search for the default option for a user.
|
||||
// (Prioritises totp over backup codes)
|
||||
$method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
|
||||
$otherMethods = $userMethods->keys()->filter(function($userMethod) use ($method) {
|
||||
return $method !== $userMethod;
|
||||
})->all();
|
||||
|
||||
return view('mfa.verify', [
|
||||
'userMethods' => $userMethods,
|
||||
'method' => $method,
|
||||
'otherMethods' => $otherMethods,
|
||||
]);
|
||||
}
|
||||
}
|
94
app/Http/Controllers/Auth/MfaTotpController.php
Normal file
94
app/Http/Controllers/Auth/MfaTotpController.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Auth\Access\Mfa\TotpService;
|
||||
use BookStack\Auth\Access\Mfa\TotpValidationRule;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MfaTotpController extends Controller
|
||||
{
|
||||
use HandlesPartialLogins;
|
||||
|
||||
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
|
||||
|
||||
/**
|
||||
* Show a view that generates and displays a TOTP QR code.
|
||||
*/
|
||||
public function generate(TotpService $totp)
|
||||
{
|
||||
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
|
||||
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
||||
} else {
|
||||
$totpSecret = $totp->generateSecret();
|
||||
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
|
||||
}
|
||||
|
||||
$qrCodeUrl = $totp->generateUrl($totpSecret);
|
||||
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
|
||||
|
||||
return view('mfa.totp-generate', [
|
||||
'secret' => $totpSecret,
|
||||
'svg' => $svg,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the setup of TOTP and save the auth method secret
|
||||
* against the current user.
|
||||
* @throws ValidationException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
||||
$this->validate($request, [
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:4',
|
||||
new TotpValidationRule($totpSecret),
|
||||
]
|
||||
]);
|
||||
|
||||
MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_TOTP, $totpSecret);
|
||||
session()->remove(static::SETUP_SECRET_SESSION_KEY);
|
||||
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp');
|
||||
|
||||
if (!auth()->check()) {
|
||||
$this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return redirect('/mfa/setup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the MFA method submission on check.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession)
|
||||
{
|
||||
$user = $this->currentOrLastAttemptedUser();
|
||||
$totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP);
|
||||
|
||||
$this->validate($request, [
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:4',
|
||||
new TotpValidationRule($totpSecret),
|
||||
]
|
||||
]);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
$loginService->reattemptLoginFor($user);
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
}
|
@ -2,14 +2,13 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@ -32,6 +31,7 @@ class RegisterController extends Controller
|
||||
|
||||
protected $socialAuthService;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* Where to redirect users after login / registration.
|
||||
@ -44,13 +44,18 @@ class RegisterController extends Controller
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
|
||||
public function __construct(
|
||||
SocialAuthService $socialAuthService,
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
)
|
||||
{
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
$this->redirectTo = url('/');
|
||||
$this->redirectPath = url('/');
|
||||
@ -89,6 +94,7 @@ class RegisterController extends Controller
|
||||
* Handle a registration request for the application.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
public function postRegister(Request $request)
|
||||
{
|
||||
@ -98,9 +104,7 @@ class RegisterController extends Controller
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->registerUser($userData);
|
||||
auth()->login($user);
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
|
||||
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
} catch (UserRegistrationException $exception) {
|
||||
if ($exception->getMessage()) {
|
||||
$this->showErrorNotification($exception->getMessage());
|
||||
|
@ -2,16 +2,14 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\SocialSignInException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
@ -20,15 +18,21 @@ class SocialController extends Controller
|
||||
{
|
||||
protected $socialAuthService;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* SocialController constructor.
|
||||
*/
|
||||
public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
|
||||
public function __construct(
|
||||
SocialAuthService $socialAuthService,
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
)
|
||||
{
|
||||
$this->middleware('guest')->only(['getRegister', 'postRegister']);
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -136,11 +140,8 @@ class SocialController extends Controller
|
||||
}
|
||||
|
||||
$user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
|
||||
auth()->login($user);
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $user);
|
||||
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
|
||||
|
||||
$this->showSuccessNotification(trans('auth.register_success'));
|
||||
$this->loginService->login($user, $socialDriver);
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
@ -2,14 +2,12 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\UserInviteService;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\UserTokenExpiredException;
|
||||
use BookStack\Exceptions\UserTokenNotFoundException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Exception;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -18,17 +16,19 @@ use Illuminate\Routing\Redirector;
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
protected $inviteService;
|
||||
protected $loginService;
|
||||
protected $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
|
||||
public function __construct(UserInviteService $inviteService, LoginService $loginService, UserRepo $userRepo)
|
||||
{
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->inviteService = $inviteService;
|
||||
$this->loginService = $loginService;
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
@ -72,11 +72,9 @@ class UserInviteController extends Controller
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
auth()->login($user);
|
||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
|
||||
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
|
||||
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
|
||||
$this->inviteService->deleteByUser($user);
|
||||
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
@ -123,17 +123,20 @@ class UserController extends Controller
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
$user = $this->user->newQuery()->with(['apiTokens'])->findOrFail($id);
|
||||
/** @var User $user */
|
||||
$user = $this->user->newQuery()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
|
||||
|
||||
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
|
||||
|
||||
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
|
||||
$mfaMethods = $user->mfaValues->groupBy('method');
|
||||
$this->setPageTitle(trans('settings.user_profile'));
|
||||
$roles = $this->userRepo->getAllRoles();
|
||||
|
||||
return view('users.edit', [
|
||||
'user' => $user,
|
||||
'activeSocialDrivers' => $activeSocialDrivers,
|
||||
'mfaMethods' => $mfaMethods,
|
||||
'authMethod' => $authMethod,
|
||||
'roles' => $roles,
|
||||
]);
|
||||
|
@ -53,5 +53,6 @@ class Kernel extends HttpKernel
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'perm' => \BookStack\Http\Middleware\PermissionMiddleware::class,
|
||||
'guard' => \BookStack\Http\Middleware\CheckGuard::class,
|
||||
'mfa-setup' => \BookStack\Http\Middleware\AuthenticatedOrPendingMfa::class,
|
||||
];
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ use Illuminate\Http\Request;
|
||||
|
||||
class ApiAuthenticate
|
||||
{
|
||||
use ChecksForEmailConfirmation;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
@ -37,7 +36,6 @@ class ApiAuthenticate
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (signedInUser() || session()->isStarted()) {
|
||||
$this->ensureEmailConfirmedIfRequested();
|
||||
if (!user()->can('access-api')) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
@ -50,7 +48,6 @@ class ApiAuthenticate
|
||||
|
||||
// Validate the token and it's users API access
|
||||
auth()->authenticate();
|
||||
$this->ensureEmailConfirmedIfRequested();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7,47 +7,18 @@ use Illuminate\Http\Request;
|
||||
|
||||
class Authenticate
|
||||
{
|
||||
use ChecksForEmailConfirmation;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if ($this->awaitingEmailConfirmation()) {
|
||||
return $this->emailConfirmationErrorResponse($request);
|
||||
}
|
||||
|
||||
if (!hasAppAccess()) {
|
||||
if ($request->ajax()) {
|
||||
return response('Unauthorized.', 401);
|
||||
} else {
|
||||
return redirect()->guest(url('/login'));
|
||||
}
|
||||
return redirect()->guest(url('/login'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide an error response for when the current user's email is not confirmed
|
||||
* in a system which requires it.
|
||||
*/
|
||||
protected function emailConfirmationErrorResponse(Request $request)
|
||||
{
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 401,
|
||||
'message' => trans('errors.email_confirmation_awaiting'),
|
||||
],
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (session()->get('sent-email-confirmation') === true) {
|
||||
return redirect('/register/confirm');
|
||||
}
|
||||
|
||||
return redirect('/register/confirm/awaiting');
|
||||
}
|
||||
}
|
||||
|
41
app/Http/Middleware/AuthenticatedOrPendingMfa.php
Normal file
41
app/Http/Middleware/AuthenticatedOrPendingMfa.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use Closure;
|
||||
|
||||
class AuthenticatedOrPendingMfa
|
||||
{
|
||||
|
||||
protected $loginService;
|
||||
protected $mfaSession;
|
||||
|
||||
public function __construct(LoginService $loginService, MfaSession $mfaSession)
|
||||
{
|
||||
$this->loginService = $loginService;
|
||||
$this->mfaSession = $mfaSession;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$loggedIn = $user !== null;
|
||||
$lastAttemptUser = $this->loginService->getLastLoginAttemptUser();
|
||||
|
||||
if ($loggedIn || ($lastAttemptUser && $this->mfaSession->isPendingMfaSetup($lastAttemptUser))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->to(url('/login'));
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use BookStack\Exceptions\UnauthorizedException;
|
||||
|
||||
trait ChecksForEmailConfirmation
|
||||
{
|
||||
/**
|
||||
* Check if the current user has a confirmed email if the instance deems it as required.
|
||||
* Throws if confirmation is required by the user.
|
||||
*
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
protected function ensureEmailConfirmedIfRequested()
|
||||
{
|
||||
if ($this->awaitingEmailConfirmation()) {
|
||||
throw new UnauthorizedException(trans('errors.email_confirmation_awaiting'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email confirmation is required and the current user is awaiting confirmation.
|
||||
*/
|
||||
protected function awaitingEmailConfirmation(): bool
|
||||
{
|
||||
if (auth()->check()) {
|
||||
$requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict'));
|
||||
if ($requireConfirmation && !auth()->user()->email_confirmed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use Blade;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Entities\BreadcrumbsViewComposer;
|
||||
use BookStack\Entities\Models\Book;
|
||||
@ -68,7 +69,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
$this->app->singleton(SocialAuthService::class, function ($app) {
|
||||
return new SocialAuthService($app->make(SocialiteFactory::class));
|
||||
return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ use BookStack\Auth\Access\ExternalBaseUserProvider;
|
||||
use BookStack\Auth\Access\Guards\LdapSessionGuard;
|
||||
use BookStack\Auth\Access\Guards\Saml2SessionGuard;
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@ -21,7 +22,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
public function boot()
|
||||
{
|
||||
Auth::extend('api-token', function ($app, $name, array $config) {
|
||||
return new ApiTokenGuard($app['request']);
|
||||
return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
|
||||
});
|
||||
|
||||
Auth::extend('ldap-session', function ($app, $name, array $config) {
|
||||
@ -30,7 +31,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
return new LdapSessionGuard(
|
||||
$name,
|
||||
$provider,
|
||||
$this->app['session.store'],
|
||||
$app['session.store'],
|
||||
$app[LdapService::class],
|
||||
$app[RegistrationService::class]
|
||||
);
|
||||
@ -42,7 +43,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
return new Saml2SessionGuard(
|
||||
$name,
|
||||
$provider,
|
||||
$this->app['session.store'],
|
||||
$app['session.store'],
|
||||
$app[RegistrationService::class]
|
||||
);
|
||||
});
|
||||
|
Reference in New Issue
Block a user