]> BookStack Code Mirror - bookstack/blob - app/Access/LoginService.php
New translations notifications.php (Nepali)
[bookstack] / app / Access / LoginService.php
1 <?php
2
3 namespace BookStack\Access;
4
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;
14 use Exception;
15
16 class LoginService
17 {
18     protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
19
20     public function __construct(
21         protected MfaSession $mfaSession,
22         protected EmailConfirmationService $emailConfirmationService,
23         protected SocialDriverManager $socialDriverManager,
24     ) {
25     }
26
27     /**
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.
32      *
33      * @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
34      */
35     public function login(User $user, string $method, bool $remember = false): void
36     {
37         if ($user->isGuest()) {
38             throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
39         }
40
41         if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
42             $this->setLastLoginAttemptedForUser($user, $method, $remember);
43
44             throw new StoppedAuthenticationException($user, $this);
45         }
46
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);
51
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);
57             }
58         }
59     }
60
61     /**
62      * Reattempt a system login after a previous stopped attempt.
63      *
64      * @throws Exception
65      */
66     public function reattemptLoginFor(User $user): void
67     {
68         if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
69             throw new Exception('Login reattempt user does align with current session state');
70         }
71
72         $lastLoginDetails = $this->getLastLoginAttemptDetails();
73         $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
74     }
75
76     /**
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.
80      */
81     public function getLastLoginAttemptUser(): ?User
82     {
83         $id = $this->getLastLoginAttemptDetails()['user_id'];
84
85         return User::query()->where('id', '=', $id)->first();
86     }
87
88     /**
89      * Get the details of the last login attempt.
90      * Checks upon a ttl of about 1 hour since that last attempted login.
91      *
92      * @return array{user_id: ?string, method: ?string, remember: bool}
93      */
94     protected function getLastLoginAttemptDetails(): array
95     {
96         $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
97         if (!$value) {
98             return ['user_id' => null, 'method' => null];
99         }
100
101         [$id, $method, $remember, $time] = explode(':', $value);
102         $hourAgo = time() - (60 * 60);
103         if ($time < $hourAgo) {
104             $this->clearLastLoginAttempted();
105
106             return ['user_id' => null, 'method' => null];
107         }
108
109         return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
110     }
111
112     /**
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.
116      */
117     protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
118     {
119         session()->put(
120             self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
121             implode(':', [$user->id, $method, $remember, time()])
122         );
123     }
124
125     /**
126      * Clear the last login attempted session value.
127      */
128     protected function clearLastLoginAttempted(): void
129     {
130         session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
131     }
132
133     /**
134      * Check if MFA verification is needed.
135      */
136     public function needsMfaVerification(User $user): bool
137     {
138         return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
139     }
140
141     /**
142      * Check if the given user is awaiting email confirmation.
143      */
144     public function awaitingEmailConfirmation(User $user): bool
145     {
146         return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
147     }
148
149     /**
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.
154      *
155      * @throws StoppedAuthenticationException
156      * @throws LoginAttemptException
157      */
158     public function attempt(array $credentials, string $method, bool $remember = false): bool
159     {
160         if ($this->areCredentialsForGuest($credentials)) {
161             return false;
162         }
163
164         $result = auth()->attempt($credentials, $remember);
165         if ($result) {
166             $user = auth()->user();
167             auth()->logout();
168             try {
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.
173                 return false;
174             }
175         }
176
177         return $result;
178     }
179
180     /**
181      * Check if the given credentials are likely for the system guest account.
182      */
183     protected function areCredentialsForGuest(array $credentials): bool
184     {
185         if (isset($credentials['email'])) {
186             return User::query()->where('email', '=', $credentials['email'])
187                 ->where('system_name', '=', 'public')
188                 ->exists();
189         }
190
191         return false;
192     }
193
194     /**
195      * Logs the current user out of the application.
196      * Returns an app post-redirect path.
197      */
198     public function logout(): string
199     {
200         auth()->logout();
201         session()->invalidate();
202         session()->regenerateToken();
203
204         return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
205     }
206
207     /**
208      * Check if login auto-initiate should be active based upon authentication config.
209      */
210     public function shouldAutoInitiate(): bool
211     {
212         $autoRedirect = config('auth.auto_initiate');
213         if (!$autoRedirect) {
214             return false;
215         }
216
217         $socialDrivers = $this->socialDriverManager->getActive();
218         $authMethod = config('auth.method');
219
220         return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
221     }
222 }