]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/OpenIdConnect/OpenIdConnectService.php
0f9fed006b794bf0a238f3d4441bbd1f78a0ec86
[bookstack] / app / Auth / Access / OpenIdConnect / OpenIdConnectService.php
1 <?php namespace BookStack\Auth\Access\OpenIdConnect;
2
3 use BookStack\Auth\Access\LoginService;
4 use BookStack\Auth\Access\RegistrationService;
5 use BookStack\Auth\User;
6 use BookStack\Exceptions\JsonDebugException;
7 use BookStack\Exceptions\OpenIdConnectException;
8 use BookStack\Exceptions\StoppedAuthenticationException;
9 use BookStack\Exceptions\UserRegistrationException;
10 use Exception;
11 use function auth;
12 use function config;
13 use function trans;
14 use function url;
15
16 /**
17  * Class OpenIdConnectService
18  * Handles any app-specific OIDC tasks.
19  */
20 class OpenIdConnectService
21 {
22     protected $registrationService;
23     protected $loginService;
24     protected $config;
25
26     /**
27      * OpenIdService constructor.
28      */
29     public function __construct(RegistrationService $registrationService, LoginService $loginService)
30     {
31         $this->config = config('oidc');
32         $this->registrationService = $registrationService;
33         $this->loginService = $loginService;
34     }
35
36     /**
37      * Initiate an authorization flow.
38      * @return array{url: string, state: string}
39      */
40     public function login(): array
41     {
42         $provider = $this->getProvider();
43         return [
44             'url' => $provider->getAuthorizationUrl(),
45             'state' => $provider->getState(),
46         ];
47     }
48
49     /**
50      * Process the Authorization response from the authorization server and
51      * return the matching, or new if registration active, user matched to
52      * the authorization server.
53      * Returns null if not authenticated.
54      * @throws Exception
55      */
56     public function processAuthorizeResponse(?string $authorizationCode): ?User
57     {
58         $provider = $this->getProvider();
59
60         // Try to exchange authorization code for access token
61         $accessToken = $provider->getAccessToken('authorization_code', [
62             'code' => $authorizationCode,
63         ]);
64
65         return $this->processAccessTokenCallback($accessToken);
66     }
67
68     /**
69      * Load the underlying OpenID Connect Provider.
70      */
71     protected function getProvider(): OpenIdConnectOAuthProvider
72     {
73         // Setup settings
74         $settings = [
75             'clientId' => $this->config['client_id'],
76             'clientSecret' => $this->config['client_secret'],
77             'redirectUri' => url('/oidc/redirect'),
78             'authorizationEndpoint' => $this->config['authorization_endpoint'],
79             'tokenEndpoint' => $this->config['token_endpoint'],
80         ];
81
82         return new OpenIdConnectOAuthProvider($settings);
83     }
84
85     /**
86      * Calculate the display name
87      */
88     protected function getUserDisplayName(OpenIdConnectIdToken $token, string $defaultValue): string
89     {
90         $displayNameAttr = $this->config['display_name_claims'];
91
92         $displayName = [];
93         foreach ($displayNameAttr as $dnAttr) {
94             $dnComponent = $token->getClaim($dnAttr) ?? '';
95             if ($dnComponent !== '') {
96                 $displayName[] = $dnComponent;
97             }
98         }
99
100         if (count($displayName) == 0) {
101             $displayName[] = $defaultValue;
102         }
103
104         return implode(' ', $displayName);
105     }
106
107     /**
108      * Extract the details of a user from an ID token.
109      * @return array{name: string, email: string, external_id: string}
110      */
111     protected function getUserDetails(OpenIdConnectIdToken $token): array
112     {
113         $id = $token->getClaim('sub');
114         return [
115             'external_id' => $id,
116             'email' => $token->getClaim('email'),
117             'name' => $this->getUserDisplayName($token, $id),
118         ];
119     }
120
121     /**
122      * Processes a received access token for a user. Login the user when
123      * they exist, optionally registering them automatically.
124      * @throws OpenIdConnectException
125      * @throws JsonDebugException
126      * @throws UserRegistrationException
127      * @throws StoppedAuthenticationException
128      */
129     protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken): User
130     {
131         $idTokenText = $accessToken->getIdToken();
132         $idToken = new OpenIdConnectIdToken(
133             $idTokenText,
134             $this->config['issuer'],
135             [$this->config['jwt_public_key']]
136         );
137
138         if ($this->config['dump_user_details']) {
139             throw new JsonDebugException($idToken->claims());
140         }
141
142         try {
143             $idToken->validate($this->config['client_id']);
144         } catch (InvalidTokenException $exception) {
145             throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
146         }
147
148         $userDetails = $this->getUserDetails($idToken);
149         $isLoggedIn = auth()->check();
150
151         if ($userDetails['email'] === null) {
152             throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
153         }
154
155         if ($isLoggedIn) {
156             throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
157         }
158
159         $user = $this->registrationService->findOrRegister(
160             $userDetails['name'], $userDetails['email'], $userDetails['external_id']
161         );
162
163         if ($user === null) {
164             throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
165         }
166
167         $this->loginService->login($user, 'oidc');
168         return $user;
169     }
170 }