]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/OpenIdService.php
4eea3c252d6328db160519dc338df3b155c804f4
[bookstack] / app / Auth / Access / OpenIdService.php
1 <?php namespace BookStack\Auth\Access;
2
3 use BookStack\Auth\User;
4 use BookStack\Exceptions\JsonDebugException;
5 use BookStack\Exceptions\OpenIdException;
6 use BookStack\Exceptions\UserRegistrationException;
7 use Exception;
8 use Lcobucci\JWT\Token;
9 use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
10 use OpenIDConnectClient\AccessToken;
11 use OpenIDConnectClient\OpenIDConnectProvider;
12
13 /**
14  * Class OpenIdService
15  * Handles any app-specific OpenId tasks.
16  */
17 class OpenIdService extends ExternalAuthService
18 {
19     protected $config;
20
21     /**
22      * OpenIdService constructor.
23      */
24     public function __construct(RegistrationService $registrationService, User $user)
25     {
26         parent::__construct($registrationService, $user);
27         
28         $this->config = config('openid');
29     }
30
31     /**
32      * Initiate a authorization flow.
33      * @throws Error
34      */
35     public function login(): array
36     {
37         $provider = $this->getProvider();
38         return [
39             'url' => $provider->getAuthorizationUrl(),
40             'state' => $provider->getState(),
41         ];
42     }
43
44     /**
45      * Initiate a logout flow.
46      * @throws Error
47      */
48     public function logout(): array
49     {
50         $this->actionLogout();
51         $url = '/';
52         $id = null;
53
54         return ['url' => $url, 'id' => $id];
55     }
56
57     /**
58      * Refresh the currently logged in user.
59      * @throws Error
60      */
61     public function refresh(): bool
62     {
63         // Retrieve access token for current session
64         $json = session()->get('openid_token');
65
66         // If no access token was found, reject the refresh
67         if (!$json) {
68             $this->actionLogout();
69             return false;
70         }
71
72         $accessToken = new AccessToken(json_decode($json, true) ?? []);
73
74         // If the token is not expired, refreshing isn't necessary
75         if ($this->isUnexpired($accessToken)) {
76             return true;
77         }
78
79         // Try to obtain refreshed access token
80         try {
81             $newAccessToken = $this->refreshAccessToken($accessToken);
82         } catch (\Exception $e) {
83             // Log out if an unknown problem arises
84             $this->actionLogout();
85             throw $e;
86         }
87
88         // If a token was obtained, update the access token, otherwise log out
89         if ($newAccessToken !== null) {
90             session()->put('openid_token', json_encode($newAccessToken));
91             return true;
92         } else {
93             $this->actionLogout();
94             return false;
95         }
96     }
97
98     protected function isUnexpired(AccessToken $accessToken): bool
99     {
100         $idToken = $accessToken->getIdToken();
101         
102         $accessTokenUnexpired = $accessToken->getExpires() && !$accessToken->hasExpired();
103         $idTokenUnexpired = !$idToken || !$idToken->isExpired(); 
104
105         return $accessTokenUnexpired && $idTokenUnexpired;
106     }
107
108     protected function refreshAccessToken(AccessToken $accessToken): ?AccessToken
109     {
110         // If no refresh token available, abort
111         if ($accessToken->getRefreshToken() === null) {
112             return null;
113         }
114
115         // ID token or access token is expired, we refresh it using the refresh token
116         try {
117             return $this->getProvider()->getAccessToken('refresh_token', [
118                 'refresh_token' => $accessToken->getRefreshToken(),
119             ]);
120         } catch (IdentityProviderException $e) {
121             // Refreshing failed
122             return null;
123         }
124     }
125
126     /**
127      * Process the Authorization response from the authorization server and
128      * return the matching, or new if registration active, user matched to
129      * the authorization server.
130      * Returns null if not authenticated.
131      * @throws Error
132      * @throws OpenIdException
133      * @throws ValidationError
134      * @throws JsonDebugException
135      * @throws UserRegistrationException
136      */
137     public function processAuthorizeResponse(?string $authorizationCode): ?User
138     {
139         $provider = $this->getProvider();
140
141         // Try to exchange authorization code for access token
142         $accessToken = $provider->getAccessToken('authorization_code', [
143             'code' => $authorizationCode,
144         ]);
145
146         return $this->processAccessTokenCallback($accessToken);
147     }
148
149     /**
150      * Do the required actions to log a user out.
151      */
152     protected function actionLogout()
153     {
154         auth()->logout();
155         session()->invalidate();
156     }
157
158     /**
159      * Load the underlying OpenID Connect Provider.
160      * @throws Error
161      * @throws Exception
162      */
163     protected function getProvider(): OpenIDConnectProvider
164     {
165         // Setup settings
166         $settings = $this->config['openid'];
167         $overrides = $this->config['openid_overrides'] ?? [];
168
169         if ($overrides && is_string($overrides)) {
170             $overrides = json_decode($overrides, true);
171         }
172
173         $openIdSettings = $this->loadOpenIdDetails();
174         $settings = array_replace_recursive($settings, $openIdSettings, $overrides);
175
176         // Setup services
177         $services = $this->loadOpenIdServices();
178         $overrides = $this->config['openid_services'] ?? [];
179
180         $services = array_replace_recursive($services, $overrides);
181
182         return new OpenIDConnectProvider($settings, $services);
183     }
184
185     /**
186      * Load services utilized by the OpenID Connect provider.
187      */
188     protected function loadOpenIdServices(): array
189     {
190         return [
191             'signer' => new \Lcobucci\JWT\Signer\Rsa\Sha256(),
192         ];
193     }
194
195     /**
196      * Load dynamic service provider options required by the OpenID Connect provider.
197      */
198     protected function loadOpenIdDetails(): array
199     {
200         return [
201             'redirectUri' => url('/openid/redirect'),
202         ];
203     }
204
205     /**
206      * Calculate the display name
207      */
208     protected function getUserDisplayName(Token $token, string $defaultValue): string
209     {
210         $displayNameAttr = $this->config['display_name_attributes'];
211
212         $displayName = [];
213         foreach ($displayNameAttr as $dnAttr) {
214             $dnComponent = $token->getClaim($dnAttr, '');
215             if ($dnComponent !== '') {
216                 $displayName[] = $dnComponent;
217             }
218         }
219
220         if (count($displayName) == 0) {
221             $displayName = $defaultValue;
222         } else {
223             $displayName = implode(' ', $displayName);
224         }
225
226         return $displayName;
227     }
228
229     /**
230      * Get the value to use as the external id saved in BookStack
231      * used to link the user to an existing BookStack DB user.
232      */
233     protected function getExternalId(Token $token, string $defaultValue)
234     {
235         $userNameAttr = $this->config['external_id_attribute'];
236         if ($userNameAttr === null) {
237             return $defaultValue;
238         }
239
240         return $token->getClaim($userNameAttr, $defaultValue);
241     }
242
243     /**
244      * Extract the details of a user from an ID token.
245      */
246     protected function getUserDetails(Token $token): array
247     {
248         $email = null;
249         $emailAttr = $this->config['email_attribute'];
250         if ($token->hasClaim($emailAttr)) {
251             $email = $token->getClaim($emailAttr);
252         }
253
254         return [
255             'external_id' => $token->getClaim('sub'),
256             'email' => $email,
257             'name' => $this->getUserDisplayName($token, $email),
258         ];
259     }
260
261     /**
262      * Processes a received access token for a user. Login the user when
263      * they exist, optionally registering them automatically.
264      * @throws OpenIdException
265      * @throws JsonDebugException
266      * @throws UserRegistrationException
267      */
268     public function processAccessTokenCallback(AccessToken $accessToken): User
269     {
270         $userDetails = $this->getUserDetails($accessToken->getIdToken());
271         $isLoggedIn = auth()->check();
272
273         if ($this->config['dump_user_details']) {
274             throw new JsonDebugException($accessToken->jsonSerialize());
275         }
276
277         if ($userDetails['email'] === null) {
278             throw new OpenIdException(trans('errors.openid_no_email_address'));
279         }
280
281         if ($isLoggedIn) {
282             throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login');
283         }
284
285         $user = $this->getOrRegisterUser($userDetails);
286         if ($user === null) {
287             throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
288         }
289
290         auth()->login($user);
291         session()->put('openid_token', json_encode($accessToken));
292         return $user;
293     }
294 }