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