1 | <?php |
||
2 | |||
3 | /** |
||
4 | * @author Nicolas CARPi <[email protected]> |
||
5 | * @copyright 2012 Nicolas CARPi |
||
6 | * @see https://www.elabftw.net Official website |
||
7 | * @license AGPL-3.0 |
||
8 | * @package elabftw |
||
9 | */ |
||
10 | |||
11 | declare(strict_types=1); |
||
12 | |||
13 | namespace Elabftw\Auth; |
||
14 | |||
15 | use DateTimeImmutable; |
||
16 | use Defuse\Crypto\Key; |
||
17 | use Elabftw\Elabftw\AuthResponse; |
||
18 | use Elabftw\Elabftw\Tools; |
||
19 | use Elabftw\Exceptions\ImproperActionException; |
||
20 | use Elabftw\Exceptions\ResourceNotFoundException; |
||
21 | use Elabftw\Exceptions\UnauthorizedException; |
||
22 | use Elabftw\Interfaces\AuthInterface; |
||
23 | use Elabftw\Models\Config; |
||
24 | use Elabftw\Models\ExistingUser; |
||
25 | use Elabftw\Models\Teams; |
||
26 | use Elabftw\Models\Users; |
||
27 | use Elabftw\Models\ValidatedUser; |
||
28 | use Elabftw\Params\UserParams; |
||
29 | use Elabftw\Services\UsersHelper; |
||
30 | use Lcobucci\JWT\Configuration; |
||
31 | use Lcobucci\JWT\Encoding\CannotDecodeContent; |
||
32 | use Lcobucci\JWT\Signer\Hmac\Sha256; |
||
33 | use Lcobucci\JWT\Signer\Key\InMemory; |
||
34 | use Lcobucci\JWT\Token\InvalidTokenStructure; |
||
35 | use Lcobucci\JWT\UnencryptedToken; |
||
36 | use Lcobucci\JWT\Validation\Constraint\PermittedFor; |
||
37 | use Lcobucci\JWT\Validation\Constraint\SignedWith; |
||
38 | use Lcobucci\JWT\Validation\RequiredConstraintsViolated; |
||
39 | use OneLogin\Saml2\Auth as SamlAuthLib; |
||
40 | use Override; |
||
41 | |||
42 | use function is_array; |
||
43 | use function is_string; |
||
44 | |||
45 | /** |
||
46 | * SAML auth service |
||
47 | */ |
||
48 | final class Saml implements AuthInterface |
||
49 | { |
||
50 | private const int TEAM_SELECTION_REQUIRED = 1; |
||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
51 | |||
52 | 24 | private const string UNKNOWN_VALUE = 'Unknown'; |
|
53 | |||
54 | 24 | private AuthResponse $AuthResponse; |
|
55 | |||
56 | private array $samlUserdata = array(); |
||
57 | 4 | ||
58 | private ?string $samlSessionIdx; |
||
59 | 4 | ||
60 | public function __construct(private SamlAuthLib $SamlAuthLib, private array $configArr, private array $settings) |
||
61 | 4 | { |
|
62 | 4 | $this->AuthResponse = new AuthResponse(); |
|
63 | 4 | } |
|
64 | 4 | ||
65 | public static function getJWTConfig(): Configuration |
||
66 | 4 | { |
|
67 | 4 | $secretKey = Key::loadFromAsciiSafeString(Config::fromEnv('SECRET_KEY')); |
|
68 | 4 | /** @psalm-suppress ArgumentTypeCoercion */ |
|
69 | $config = Configuration::forSymmetricSigner( |
||
70 | new Sha256(), |
||
71 | 1 | InMemory::plainText($secretKey->getRawBytes()), // @phpstan-ignore-line |
|
72 | ); |
||
73 | 1 | // TODO validate the userid claim and other stuff |
|
74 | 1 | $config->setValidationConstraints(new PermittedFor('saml-session')); |
|
75 | 1 | $config->setValidationConstraints(new SignedWith($config->signer(), $config->signingKey())); |
|
76 | 1 | return $config; |
|
77 | 1 | } |
|
78 | 1 | ||
79 | 1 | public function encodeToken(int $idpId): string |
|
80 | 1 | { |
|
81 | 1 | $now = new DateTimeImmutable(); |
|
82 | 1 | $config = self::getJWTConfig(); |
|
83 | 1 | $token = $config->builder() |
|
84 | 1 | // Configures the audience (aud claim) |
|
85 | 1 | ->permittedFor('saml-session') |
|
86 | 1 | // Configures the time that the token was issue (iat claim) |
|
87 | 1 | ->issuedAt($now) |
|
88 | 1 | // Configures the expiration time of the token (exp claim) |
|
89 | // @psalm-suppress PossiblyFalseArgument |
||
90 | ->expiresAt($now->modify('+1 months')) |
||
91 | 4 | // Configures a new claim, called "uid" |
|
92 | ->withClaim('sid', $this->getSessionIndex()) |
||
93 | 4 | ->withClaim('idp_id', $idpId) |
|
94 | ->withClaim('nameid', $this->SamlAuthLib->getNameId()) |
||
95 | ->withClaim('nameid_format', $this->SamlAuthLib->getNameIdFormat()) |
||
96 | 4 | // Builds a new token |
|
97 | 1 | ->getToken($config->signer(), $config->signingKey()); |
|
98 | return $token->toString(); |
||
99 | 3 | } |
|
100 | 1 | ||
101 | public static function decodeToken(string $token): array |
||
102 | { |
||
103 | 1 | $conf = self::getJWTConfig(); |
|
104 | |||
105 | 1 | try { |
|
106 | 3 | if (empty($token)) { |
|
107 | 2 | throw new UnauthorizedException('Decoding JWT Token failed'); |
|
108 | } |
||
109 | $parsedToken = $conf->parser()->parse($token); |
||
110 | if (!$parsedToken instanceof UnencryptedToken) { |
||
111 | 1 | throw new UnauthorizedException('Decoding JWT Token failed'); |
|
112 | } |
||
113 | 1 | $conf->validator()->assert($parsedToken, ...$conf->validationConstraints()); |
|
114 | 1 | ||
115 | 1 | return array( |
|
116 | $parsedToken->claims()->get('sid'), |
||
117 | $parsedToken->claims()->get('idp_id'), |
||
118 | 23 | $parsedToken->claims()->get('nameid'), |
|
119 | $parsedToken->claims()->get('nameid_format'), |
||
120 | 23 | ); |
|
121 | 23 | } catch (CannotDecodeContent | InvalidTokenStructure | RequiredConstraintsViolated) { |
|
122 | throw new UnauthorizedException('Decoding JWT Token failed'); |
||
123 | } |
||
124 | 23 | } |
|
125 | 2 | ||
126 | #[Override] |
||
127 | 2 | public function tryAuth(): AuthResponse |
|
128 | 1 | { |
|
129 | $returnUrl = $this->settings['baseurl'] . '/index.php?acs'; |
||
130 | 2 | // adding stay: true to login() will make psalm/phpstan happy but breaks saml auth |
|
131 | $this->SamlAuthLib->login($returnUrl); |
||
132 | // ^-- this will run exit() |
||
133 | 21 | /** @psalm-suppress UnevaluatedCode */ |
|
134 | 1 | return $this->AuthResponse; // @phpstan-ignore-line |
|
135 | } |
||
136 | |||
137 | public function assertIdpResponse(): AuthResponse |
||
138 | 20 | { |
|
139 | $this->SamlAuthLib->processResponse(); |
||
140 | $errors = $this->SamlAuthLib->getErrors(); |
||
141 | 20 | ||
142 | // Display the errors if we are in debug mode |
||
143 | if (!empty($errors)) { |
||
144 | 20 | $error = Tools::error(); |
|
145 | // get more verbose if debug mode is active |
||
146 | if ($this->configArr['debug']) { |
||
147 | 19 | $error = implode(', ', $errors); |
|
148 | 17 | } |
|
149 | 1 | throw new UnauthorizedException($error); |
|
150 | 1 | } |
|
151 | 1 | ||
152 | 1 | if (!$this->SamlAuthLib->isAuthenticated()) { |
|
153 | 1 | throw new UnauthorizedException('Authentication with IDP failed!'); |
|
154 | 1 | } |
|
155 | 1 | ||
156 | 1 | // get the user information sent by IDP |
|
157 | $this->samlUserdata = $this->SamlAuthLib->getAttributes(); |
||
158 | |||
159 | 16 | // get session index |
|
160 | $this->samlSessionIdx = $this->SamlAuthLib->getSessionIndex(); |
||
161 | 16 | ||
162 | 16 | // GET EMAIL |
|
163 | 16 | $email = $this->extractAttribute($this->settings['idp']['emailAttr']); |
|
164 | |||
165 | // GET ORGID |
||
166 | $orgid = $this->getOrgid(); |
||
167 | 16 | ||
168 | 4 | // GET POPULATED USERS OBJECT |
|
169 | 4 | $Users = $this->getUsers($email, $orgid); |
|
170 | if (!$Users instanceof Users) { |
||
171 | $this->AuthResponse->userid = 0; |
||
172 | $this->AuthResponse->initTeamRequired = true; |
||
173 | 14 | $this->AuthResponse->initTeamUserInfo = array( |
|
174 | 'email' => $email, |
||
175 | 14 | 'firstname' => $this->getName(), |
|
176 | 'lastname' => $this->getName(true), |
||
177 | 'orgid' => $orgid, |
||
178 | 2 | ); |
|
179 | return $this->AuthResponse; |
||
180 | 2 | } |
|
181 | |||
182 | $userid = $Users->userData['userid']; |
||
183 | 20 | ||
184 | $this->AuthResponse->userid = $userid; |
||
185 | 20 | $this->AuthResponse->mfaSecret = $Users->userData['mfa_secret']; |
|
186 | 20 | $this->AuthResponse->isValidated = (bool) $Users->userData['validated']; |
|
187 | 1 | ||
188 | // synchronize the teams from the IDP |
||
189 | 19 | // because teams can change since the time the user was created |
|
190 | if ($this->configArr['saml_sync_teams']) { |
||
191 | 19 | $Teams = new Teams($Users); |
|
192 | 1 | $Teams->synchronize($userid, $this->getTeamsFromIdpResponse()); |
|
193 | } |
||
194 | |||
195 | 19 | // update some user attributes with value from IDP |
|
196 | $firstname = $this->getName(); |
||
197 | $lastname = $this->getName(true); |
||
198 | 19 | if ($firstname !== self::UNKNOWN_VALUE && $lastname !== self::UNKNOWN_VALUE) { |
|
199 | $Users->update(new UserParams('firstname', $firstname)); |
||
200 | $Users->update(new UserParams('lastname', $lastname)); |
||
201 | } |
||
202 | if ($orgid !== null) { |
||
203 | $Users->update(new UserParams('orgid', $orgid)); |
||
204 | 5 | } |
|
205 | |||
206 | // load the teams from db |
||
207 | 5 | $UsersHelper = new UsersHelper($this->AuthResponse->userid); |
|
208 | $this->AuthResponse->setTeams($UsersHelper); |
||
209 | 5 | ||
210 | 5 | return $this->AuthResponse; |
|
211 | } |
||
212 | |||
213 | 5 | public function getSessionIndex(): ?string |
|
214 | { |
||
215 | return $this->samlSessionIdx; |
||
216 | 4 | } |
|
217 | |||
218 | 4 | private function extractAttribute(string $attribute): string |
|
219 | 1 | { |
|
220 | $err = sprintf('Could not find attribute "%s" in response from IDP! Aborting.', $attribute); |
||
221 | 3 | if (!isset($this->samlUserdata[$attribute])) { |
|
222 | 3 | throw new ImproperActionException($err); |
|
223 | 1 | } |
|
224 | $attr = $this->samlUserdata[$attribute]; |
||
225 | |||
226 | 2 | if (is_array($attr)) { |
|
227 | 2 | $attr = $attr[0]; |
|
228 | 1 | } |
|
229 | |||
230 | if ($attr === null) { |
||
231 | 1 | throw new ImproperActionException($err); |
|
232 | } |
||
233 | 1 | return $attr; |
|
234 | } |
||
235 | |||
236 | private function getOrgid(): ?string |
||
237 | { |
||
238 | 6 | $orgid = $this->samlUserdata[$this->settings['idp']['orgidAttr'] ?? self::UNKNOWN_VALUE] ?? null; |
|
239 | if (is_array($orgid)) { |
||
240 | 6 | return $orgid[0]; |
|
241 | } |
||
242 | return $orgid; |
||
243 | 6 | } |
|
244 | |||
245 | 4 | /** |
|
246 | 4 | * Get firstname or lastname from idp |
|
247 | 1 | **/ |
|
248 | private function getName(bool $last = false): string |
||
249 | { |
||
250 | 3 | // toggle firstname or lastname |
|
251 | 1 | $selector = $last ? 'lnameAttr' : 'fnameAttr'; |
|
252 | |||
253 | 2 | $name = $this->samlUserdata[$this->settings['idp'][$selector] ?? self::UNKNOWN_VALUE] ?? self::UNKNOWN_VALUE; |
|
254 | if (is_array($name)) { |
||
255 | return $name[0]; |
||
256 | 2 | } |
|
257 | 1 | return $name; |
|
258 | } |
||
259 | |||
260 | 1 | private function getTeamsFromIdpResponse(): array |
|
261 | { |
||
262 | 1 | if (empty($this->settings['idp']['teamAttr'])) { |
|
263 | throw new ImproperActionException('Cannot synchronize team(s) from IDP if no value is set for looking up team(s) in IDP response!'); |
||
264 | } |
||
265 | $teams = $this->samlUserdata[$this->settings['idp']['teamAttr']]; |
||
266 | if (empty($teams)) { |
||
267 | 19 | throw new ImproperActionException('Could not find team(s) in IDP response!'); |
|
268 | } |
||
269 | |||
270 | $Teams = new Teams(new Users()); |
||
271 | 19 | $allowTeamCreation = ($this->configArr['saml_team_create'] ?? '1') === '1'; |
|
272 | 9 | return $Teams->getTeamsFromIdOrNameOrOrgidArray($teams, $allowTeamCreation); |
|
273 | } |
||
274 | |||
275 | 9 | private function getTeams(): array | int |
|
276 | 3 | { |
|
277 | $teams = $this->samlUserdata[$this->settings['idp']['teamAttr'] ?? 'Nope'] ?? array(); |
||
278 | 3 | ||
279 | // if no team attribute is sent by the IDP, use the default team |
||
280 | 2 | if (empty($teams)) { |
|
281 | 1 | // we directly get the id from the stored config |
|
282 | $teamId = $this->configArr['saml_team_default']; |
||
283 | 2 | if ($teamId === '0') { |
|
284 | 1 | throw new ImproperActionException('Could not find team ID to assign user!'); |
|
285 | 1 | } |
|
286 | // this setting is when we want to allow the user to make team selection |
||
287 | if ($teamId === '-1') { |
||
288 | 6 | return self::TEAM_SELECTION_REQUIRED; |
|
289 | } |
||
290 | return array((int) $teamId); |
||
291 | } |
||
292 | 19 | if (is_string($teams)) { |
|
293 | return array($teams); |
||
294 | 19 | } |
|
295 | 19 | ||
296 | return $teams; |
||
297 | } |
||
298 | 7 | ||
299 | 1 | private function getExistingUser(string $email, ?string $orgid = null): Users | false |
|
300 | 1 | { |
|
301 | 1 | try { |
|
302 | // we first try to match a local user with the email |
||
303 | 1 | return ExistingUser::fromEmail($email); |
|
304 | } catch (ResourceNotFoundException) { |
||
305 | // try finding the user with the orgid because email didn't work |
||
306 | // but only if we explicitly want to |
||
307 | 6 | if ($this->configArr['saml_fallback_orgid'] === '1' && $orgid) { |
|
308 | try { |
||
309 | $Users = ExistingUser::fromOrgid($orgid); |
||
310 | 5 | // ok we found our user thanks to the orgid, maybe we want to update our stored email? |
|
311 | 1 | if ($this->configArr['saml_sync_email_idp'] === '1') { |
|
312 | $Users->update(new UserParams('email', $email)); |
||
313 | } |
||
314 | return $Users; |
||
315 | 4 | } catch (ResourceNotFoundException) { |
|
316 | return false; |
||
317 | 16 | } |
|
318 | } |
||
319 | return false; |
||
320 | } |
||
321 | } |
||
322 | |||
323 | private function getUsers(string $email, ?string $orgid = null): Users | int |
||
324 | { |
||
325 | $Users = $this->getExistingUser($email, $orgid); |
||
326 | if ($Users === false) { |
||
327 | // the user doesn't exist yet in the db |
||
328 | // what do we do? Lookup the config setting for that case |
||
329 | if ($this->configArr['saml_user_default'] === '0') { |
||
330 | $msg = _('Could not find an existing user. Ask a Sysadmin to create your account.'); |
||
331 | if ($this->configArr['user_msg_need_local_account_created']) { |
||
332 | $msg = $this->configArr['user_msg_need_local_account_created']; |
||
333 | } |
||
334 | throw new ImproperActionException($msg); |
||
335 | } |
||
336 | |||
337 | // now try and get the teams |
||
338 | $teams = $this->getTeams(); |
||
339 | |||
340 | if (is_int($teams)) { |
||
341 | return $teams; |
||
342 | } |
||
343 | |||
344 | // CREATE USER (and force validation of user, with user permissions) |
||
345 | $allowTeamCreation = ($this->configArr['saml_team_create'] ?? '1') === '1'; |
||
346 | /** @psalm-suppress PossiblyInvalidArgument */ |
||
347 | $Users = ValidatedUser::fromExternal($email, $teams, $this->getName(), $this->getName(true), orgid: $orgid, allowTeamCreation: $allowTeamCreation); |
||
348 | } |
||
349 | return $Users; |
||
350 | } |
||
351 | } |
||
352 |