]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/Oidc/OidcService.php
Fixed local_secure_restricted preventing attachment uploads
[bookstack] / app / Auth / Access / Oidc / OidcService.php
1 <?php
2
3 namespace BookStack\Auth\Access\Oidc;
4
5 use function auth;
6 use BookStack\Auth\Access\GroupSyncService;
7 use BookStack\Auth\Access\LoginService;
8 use BookStack\Auth\Access\RegistrationService;
9 use BookStack\Auth\User;
10 use BookStack\Exceptions\JsonDebugException;
11 use BookStack\Exceptions\StoppedAuthenticationException;
12 use BookStack\Exceptions\UserRegistrationException;
13 use function config;
14 use Illuminate\Support\Arr;
15 use Illuminate\Support\Facades\Cache;
16 use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
17 use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
18 use Psr\Http\Client\ClientInterface as HttpClient;
19 use function trans;
20 use function url;
21
22 /**
23  * Class OpenIdConnectService
24  * Handles any app-specific OIDC tasks.
25  */
26 class OidcService
27 {
28     protected RegistrationService $registrationService;
29     protected LoginService $loginService;
30     protected HttpClient $httpClient;
31     protected GroupSyncService $groupService;
32
33     /**
34      * OpenIdService constructor.
35      */
36     public function __construct(
37         RegistrationService $registrationService,
38         LoginService $loginService,
39         HttpClient $httpClient,
40         GroupSyncService $groupService
41     ) {
42         $this->registrationService = $registrationService;
43         $this->loginService = $loginService;
44         $this->httpClient = $httpClient;
45         $this->groupService = $groupService;
46     }
47
48     /**
49      * Initiate an authorization flow.
50      *
51      * @throws OidcException
52      *
53      * @return array{url: string, state: string}
54      */
55     public function login(): array
56     {
57         $settings = $this->getProviderSettings();
58         $provider = $this->getProvider($settings);
59
60         return [
61             'url'   => $provider->getAuthorizationUrl(),
62             'state' => $provider->getState(),
63         ];
64     }
65
66     /**
67      * Process the Authorization response from the authorization server and
68      * return the matching, or new if registration active, user matched to the
69      * authorization server. Throws if the user cannot be auth if not authenticated.
70      *
71      * @throws JsonDebugException
72      * @throws OidcException
73      * @throws StoppedAuthenticationException
74      * @throws IdentityProviderException
75      */
76     public function processAuthorizeResponse(?string $authorizationCode): User
77     {
78         $settings = $this->getProviderSettings();
79         $provider = $this->getProvider($settings);
80
81         // Try to exchange authorization code for access token
82         $accessToken = $provider->getAccessToken('authorization_code', [
83             'code' => $authorizationCode,
84         ]);
85
86         return $this->processAccessTokenCallback($accessToken, $settings);
87     }
88
89     /**
90      * @throws OidcException
91      */
92     protected function getProviderSettings(): OidcProviderSettings
93     {
94         $config = $this->config();
95         $settings = new OidcProviderSettings([
96             'issuer'                => $config['issuer'],
97             'clientId'              => $config['client_id'],
98             'clientSecret'          => $config['client_secret'],
99             'redirectUri'           => url('/oidc/callback'),
100             'authorizationEndpoint' => $config['authorization_endpoint'],
101             'tokenEndpoint'         => $config['token_endpoint'],
102         ]);
103
104         // Use keys if configured
105         if (!empty($config['jwt_public_key'])) {
106             $settings->keys = [$config['jwt_public_key']];
107         }
108
109         // Run discovery
110         if ($config['discover'] ?? false) {
111             try {
112                 $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
113             } catch (OidcIssuerDiscoveryException $exception) {
114                 throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
115             }
116         }
117
118         $settings->validate();
119
120         return $settings;
121     }
122
123     /**
124      * Load the underlying OpenID Connect Provider.
125      */
126     protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
127     {
128         $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
129             'httpClient'     => $this->httpClient,
130             'optionProvider' => new HttpBasicAuthOptionProvider(),
131         ]);
132
133         foreach ($this->getAdditionalScopes() as $scope) {
134             $provider->addScope($scope);
135         }
136
137         return $provider;
138     }
139
140     /**
141      * Get any user-defined addition/custom scopes to apply to the authentication request.
142      *
143      * @return string[]
144      */
145     protected function getAdditionalScopes(): array
146     {
147         $scopeConfig = $this->config()['additional_scopes'] ?: '';
148
149         $scopeArr = explode(',', $scopeConfig);
150         $scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
151
152         return array_filter($scopeArr);
153     }
154
155     /**
156      * Calculate the display name.
157      */
158     protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
159     {
160         $displayNameAttr = $this->config()['display_name_claims'];
161
162         $displayName = [];
163         foreach ($displayNameAttr as $dnAttr) {
164             $dnComponent = $token->getClaim($dnAttr) ?? '';
165             if ($dnComponent !== '') {
166                 $displayName[] = $dnComponent;
167             }
168         }
169
170         if (count($displayName) == 0) {
171             $displayName[] = $defaultValue;
172         }
173
174         return implode(' ', $displayName);
175     }
176
177     /**
178      * Extract the assigned groups from the id token.
179      *
180      * @return string[]
181      */
182     protected function getUserGroups(OidcIdToken $token): array
183     {
184         $groupsAttr = $this->config()['group_attribute'];
185         if (empty($groupsAttr)) {
186             return [];
187         }
188
189         $groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
190         if (!is_array($groupsList)) {
191             return [];
192         }
193
194         return array_values(array_filter($groupsList, function ($val) {
195             return is_string($val);
196         }));
197     }
198
199     /**
200      * Extract the details of a user from an ID token.
201      *
202      * @return array{name: string, email: string, external_id: string, groups: string[]}
203      */
204     protected function getUserDetails(OidcIdToken $token): array
205     {
206         $id = $token->getClaim('sub');
207
208         return [
209             'external_id' => $id,
210             'email'       => $token->getClaim('email'),
211             'name'        => $this->getUserDisplayName($token, $id),
212             'groups'      => $this->getUserGroups($token),
213         ];
214     }
215
216     /**
217      * Processes a received access token for a user. Login the user when
218      * they exist, optionally registering them automatically.
219      *
220      * @throws OidcException
221      * @throws JsonDebugException
222      * @throws StoppedAuthenticationException
223      */
224     protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
225     {
226         $idTokenText = $accessToken->getIdToken();
227         $idToken = new OidcIdToken(
228             $idTokenText,
229             $settings->issuer,
230             $settings->keys,
231         );
232
233         if ($this->config()['dump_user_details']) {
234             throw new JsonDebugException($idToken->getAllClaims());
235         }
236
237         try {
238             $idToken->validate($settings->clientId);
239         } catch (OidcInvalidTokenException $exception) {
240             throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
241         }
242
243         $userDetails = $this->getUserDetails($idToken);
244         $isLoggedIn = auth()->check();
245
246         if (empty($userDetails['email'])) {
247             throw new OidcException(trans('errors.oidc_no_email_address'));
248         }
249
250         if ($isLoggedIn) {
251             throw new OidcException(trans('errors.oidc_already_logged_in'));
252         }
253
254         try {
255             $user = $this->registrationService->findOrRegister(
256                 $userDetails['name'],
257                 $userDetails['email'],
258                 $userDetails['external_id']
259             );
260         } catch (UserRegistrationException $exception) {
261             throw new OidcException($exception->getMessage());
262         }
263
264         if ($this->shouldSyncGroups()) {
265             $groups = $userDetails['groups'];
266             $detachExisting = $this->config()['remove_from_groups'];
267             $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
268         }
269
270         $this->loginService->login($user, 'oidc');
271
272         return $user;
273     }
274
275     /**
276      * Get the OIDC config from the application.
277      */
278     protected function config(): array
279     {
280         return config('oidc');
281     }
282
283     /**
284      * Check if groups should be synced.
285      */
286     protected function shouldSyncGroups(): bool
287     {
288         return $this->config()['user_to_groups'] !== false;
289     }
290 }