Issues (490)

src/Auth/Saml.php (12 issues)

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
The type Defuse\Crypto\Key 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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

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
$Users is always a sub-type of Elabftw\Models\Users.
Loading history...
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