]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/OpenIdService.php
First basic OpenID Connect implementation
[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 Illuminate\Support\Str;
9 use Lcobucci\JWT\Token;
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     protected $registrationService;
21     protected $user;
22
23     /**
24      * OpenIdService constructor.
25      */
26     public function __construct(RegistrationService $registrationService, User $user)
27     {
28         $this->config = config('openid');
29         $this->registrationService = $registrationService;
30         $this->user = $user;
31     }
32
33     /**
34      * Initiate a authorization flow.
35      * @throws Error
36      */
37     public function login(): array
38     {
39         $provider = $this->getProvider();
40         return [
41             'url' => $provider->getAuthorizationUrl(),
42             'state' => $provider->getState(),
43         ];
44     }
45
46     /**
47      * Initiate a logout flow.
48      * @throws Error
49      */
50     public function logout(): array
51     {
52         $this->actionLogout();
53         $url = '/';
54         $id = null;
55
56         return ['url' => $url, 'id' => $id];
57     }
58
59     /**
60      * Process the Authorization response from the authorization server and
61      * return the matching, or new if registration active, user matched to
62      * the authorization server.
63      * Returns null if not authenticated.
64      * @throws Error
65      * @throws OpenIdException
66      * @throws ValidationError
67      * @throws JsonDebugException
68      * @throws UserRegistrationException
69      */
70     public function processAuthorizeResponse(?string $authorizationCode): ?User
71     {
72         $provider = $this->getProvider();
73
74         // Try to exchange authorization code for access token
75         $accessToken = $provider->getAccessToken('authorization_code', [
76             'code' => $authorizationCode,
77         ]);
78
79         return $this->processAccessTokenCallback($accessToken);
80     }
81
82     /**
83      * Do the required actions to log a user out.
84      */
85     protected function actionLogout()
86     {
87         auth()->logout();
88         session()->invalidate();
89     }
90
91     /**
92      * Load the underlying Onelogin SAML2 toolkit.
93      * @throws Error
94      * @throws Exception
95      */
96     protected function getProvider(): OpenIDConnectProvider
97     {
98         $settings = $this->config['openid'];
99         $overrides = $this->config['openid_overrides'] ?? [];
100
101         if ($overrides && is_string($overrides)) {
102             $overrides = json_decode($overrides, true);
103         }
104
105         $openIdSettings = $this->loadOpenIdDetails();
106         $settings = array_replace_recursive($settings, $openIdSettings, $overrides);
107
108         $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256();
109         return new OpenIDConnectProvider($settings, ['signer' => $signer]);
110     }
111
112     /**
113      * Load dynamic service provider options required by the onelogin toolkit.
114      */
115     protected function loadOpenIdDetails(): array
116     {
117         return [
118             'redirectUri' => url('/openid/redirect'),
119         ];
120     }
121
122     /**
123      * Calculate the display name
124      */
125     protected function getUserDisplayName(Token $token, string $defaultValue): string
126     {
127         $displayNameAttr = $this->config['display_name_attributes'];
128
129         $displayName = [];
130         foreach ($displayNameAttr as $dnAttr) {
131             $dnComponent = $token->getClaim($dnAttr, '');
132             if ($dnComponent !== '') {
133                 $displayName[] = $dnComponent;
134             }
135         }
136
137         if (count($displayName) == 0) {
138             $displayName = $defaultValue;
139         } else {
140             $displayName = implode(' ', $displayName);
141         }
142
143         return $displayName;
144     }
145
146     /**
147      * Get the value to use as the external id saved in BookStack
148      * used to link the user to an existing BookStack DB user.
149      */
150     protected function getExternalId(Token $token, string $defaultValue)
151     {
152         $userNameAttr = $this->config['external_id_attribute'];
153         if ($userNameAttr === null) {
154             return $defaultValue;
155         }
156
157         return $token->getClaim($userNameAttr, $defaultValue);
158     }
159
160     /**
161      * Extract the details of a user from a SAML response.
162      */
163     protected function getUserDetails(Token $token): array
164     {
165         $email = null;
166         $emailAttr = $this->config['email_attribute'];
167         if ($token->hasClaim($emailAttr)) {
168             $email = $token->getClaim($emailAttr);
169         }
170
171         return [
172             'external_id' => $token->getClaim('sub'),
173             'email' => $email,
174             'name' => $this->getUserDisplayName($token, $email),
175         ];
176     }
177
178     /**
179      * Get the user from the database for the specified details.
180      * @throws OpenIdException
181      * @throws UserRegistrationException
182      */
183     protected function getOrRegisterUser(array $userDetails): ?User
184     {
185         $user = $this->user->newQuery()
186           ->where('external_auth_id', '=', $userDetails['external_id'])
187           ->first();
188
189         if (is_null($user)) {
190             $userData = [
191                 'name' => $userDetails['name'],
192                 'email' => $userDetails['email'],
193                 'password' => Str::random(32),
194                 'external_auth_id' => $userDetails['external_id'],
195             ];
196
197             $user = $this->registrationService->registerUser($userData, null, false);
198         }
199
200         return $user;
201     }
202
203     /**
204      * Processes a received access token for a user. Login the user when
205      * they exist, optionally registering them automatically.
206      * @throws OpenIdException
207      * @throws JsonDebugException
208      * @throws UserRegistrationException
209      */
210     public function processAccessTokenCallback(AccessToken $accessToken): User
211     {
212         $userDetails = $this->getUserDetails($accessToken->getIdToken());
213         $isLoggedIn = auth()->check();
214
215         if ($this->config['dump_user_details']) {
216             throw new JsonDebugException($accessToken->jsonSerialize());
217         }
218
219         if ($userDetails['email'] === null) {
220             throw new OpenIdException(trans('errors.openid_no_email_address'));
221         }
222
223         if ($isLoggedIn) {
224             throw new OpenIdException(trans('errors.openid_already_logged_in'), '/login');
225         }
226
227         $user = $this->getOrRegisterUser($userDetails);
228         if ($user === null) {
229             throw new OpenIdException(trans('errors.openid_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
230         }
231
232         auth()->login($user);
233         return $user;
234     }
235 }