GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.

Issues (999)

src/Auth/Saml.php (1 issue)

Labels
Severity
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
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 50 at column 22
Loading history...
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