3 namespace BookStack\Access;
5 use BookStack\Access\Mfa\MfaSession;
6 use BookStack\Activity\ActivityType;
7 use BookStack\Exceptions\LoginAttemptException;
8 use BookStack\Exceptions\LoginAttemptInvalidUserException;
9 use BookStack\Exceptions\StoppedAuthenticationException;
10 use BookStack\Facades\Activity;
11 use BookStack\Facades\Theme;
12 use BookStack\Theming\ThemeEvents;
13 use BookStack\Users\Models\User;
18 protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
20 public function __construct(
21 protected MfaSession $mfaSession,
22 protected EmailConfirmationService $emailConfirmationService,
23 protected SocialDriverManager $socialDriverManager,
28 * Log the given user into the system.
29 * Will start a login of the given user but will prevent if there's
30 * a reason to (MFA or Unconfirmed Email).
31 * Returns a boolean to indicate the current login result.
33 * @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
35 public function login(User $user, string $method, bool $remember = false): void
37 if ($user->isGuest()) {
38 throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
41 if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
42 $this->setLastLoginAttemptedForUser($user, $method, $remember);
44 throw new StoppedAuthenticationException($user, $this);
47 $this->clearLastLoginAttempted();
48 auth()->login($user, $remember);
49 Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
50 Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
52 // Authenticate on all session guards if a likely admin
53 if ($user->can('users-manage') && $user->can('user-roles-manage')) {
54 $guards = ['standard', 'ldap', 'saml2', 'oidc'];
55 foreach ($guards as $guard) {
56 auth($guard)->login($user);
62 * Reattempt a system login after a previous stopped attempt.
66 public function reattemptLoginFor(User $user): void
68 if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
69 throw new Exception('Login reattempt user does align with current session state');
72 $lastLoginDetails = $this->getLastLoginAttemptDetails();
73 $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
77 * Get the last user that was attempted to be logged in.
78 * Only exists if the last login attempt had correct credentials
79 * but had been prevented by a secondary factor.
81 public function getLastLoginAttemptUser(): ?User
83 $id = $this->getLastLoginAttemptDetails()['user_id'];
85 return User::query()->where('id', '=', $id)->first();
89 * Get the details of the last login attempt.
90 * Checks upon a ttl of about 1 hour since that last attempted login.
92 * @return array{user_id: ?string, method: ?string, remember: bool}
94 protected function getLastLoginAttemptDetails(): array
96 $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
98 return ['user_id' => null, 'method' => null];
101 [$id, $method, $remember, $time] = explode(':', $value);
102 $hourAgo = time() - (60 * 60);
103 if ($time < $hourAgo) {
104 $this->clearLastLoginAttempted();
106 return ['user_id' => null, 'method' => null];
109 return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
113 * Set the last login attempted user.
114 * Must be only used when credentials are correct and a login could be
115 * achieved but a secondary factor has stopped the login.
117 protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
120 self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
121 implode(':', [$user->id, $method, $remember, time()])
126 * Clear the last login attempted session value.
128 protected function clearLastLoginAttempted(): void
130 session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
134 * Check if MFA verification is needed.
136 public function needsMfaVerification(User $user): bool
138 return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
142 * Check if the given user is awaiting email confirmation.
144 public function awaitingEmailConfirmation(User $user): bool
146 return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
150 * Attempt the login of a user using the given credentials.
151 * Meant to mirror Laravel's default guard 'attempt' method
152 * but in a manner that always routes through our login system.
153 * May interrupt the flow if extra authentication requirements are imposed.
155 * @throws StoppedAuthenticationException
156 * @throws LoginAttemptException
158 public function attempt(array $credentials, string $method, bool $remember = false): bool
160 if ($this->areCredentialsForGuest($credentials)) {
164 $result = auth()->attempt($credentials, $remember);
166 $user = auth()->user();
169 $this->login($user, $method, $remember);
170 } catch (LoginAttemptInvalidUserException $e) {
171 // Catch and return false for non-login accounts
172 // so it looks like a normal invalid login.
181 * Check if the given credentials are likely for the system guest account.
183 protected function areCredentialsForGuest(array $credentials): bool
185 if (isset($credentials['email'])) {
186 return User::query()->where('email', '=', $credentials['email'])
187 ->where('system_name', '=', 'public')
195 * Logs the current user out of the application.
196 * Returns an app post-redirect path.
198 public function logout(): string
201 session()->invalidate();
202 session()->regenerateToken();
204 return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
208 * Check if login auto-initiate should be active based upon authentication config.
210 public function shouldAutoInitiate(): bool
212 $autoRedirect = config('auth.auto_initiate');
213 if (!$autoRedirect) {
217 $socialDrivers = $this->socialDriverManager->getActive();
218 $authMethod = config('auth.method');
220 return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);