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