Skip to content

Commit 8a3cb0e

Browse files
committed
allow storing multiple states, cleanup on failure is still missing
Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
1 parent 2a7b869 commit 8a3cb0e

1 file changed

Lines changed: 40 additions & 14 deletions

File tree

lib/Controller/LoginController.php

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,24 @@
6767

6868
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
6969
class LoginController extends BaseOidcController {
70+
// these keys (state, nonce, login_providerid, redirect, code_verifier, timestamp)
71+
// are suffixed with the state value so they can be stored once per login flow
7072
private const STATE = 'oidc.state';
7173
private const NONCE = 'oidc.nonce';
72-
public const PROVIDERID = 'oidc.providerid';
74+
// this is the provider ID we store during the login flow (set by login, get by code)
75+
public const LOGIN_PROVIDERID = 'oidc.login.providerid';
7376
public const REDIRECT_AFTER_LOGIN = 'oidc.redirect';
74-
private const ID_TOKEN = 'oidc.id_token';
7577
private const CODE_VERIFIER = 'oidc.code_verifier';
78+
private const TIMESTAMP = 'oidc.timestamp';
79+
80+
// this is the provider ID we store once the authentication was successful
81+
// it is used by the singleLogout endpoint and the user backend
82+
public const PROVIDERID = 'oidc.providerid';
83+
// this id token is used to send id_token_hint to the IdP logout endpoint
84+
private const ID_TOKEN = 'oidc.id_token';
85+
86+
// we consider that a login flow should complete within 5 minutes
87+
private const LOGIN_FLOW_TIMEOUT = 300;
7688

7789
public function __construct(
7890
IRequest $request,
@@ -189,12 +201,15 @@ public function login(int $providerId, ?string $redirectUrl = null) {
189201
}
190202

191203
$state = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER);
192-
$this->session->set(self::STATE, $state);
204+
$sessionKeySuffix = '-' . $state;
205+
$this->session->set(self::STATE . $sessionKeySuffix, $state);
193206
$this->logger->debug('Storing OIDC state', ['state' => $state]);
194-
$this->session->set(self::REDIRECT_AFTER_LOGIN, $redirectUrl);
207+
$timestamp = $this->timeFactory->getTime();
208+
$this->session->set(self::TIMESTAMP . $sessionKeySuffix, $timestamp);
209+
$this->session->set(self::REDIRECT_AFTER_LOGIN . $sessionKeySuffix, $redirectUrl);
195210

196211
$nonce = $this->random->generate(32, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER);
197-
$this->session->set(self::NONCE, $nonce);
212+
$this->session->set(self::NONCE . $sessionKeySuffix, $nonce);
198213

199214
$oidcSystemConfig = $this->config->getSystemValue('user_oidc', []);
200215
$isPkceSupported = in_array('S256', $discovery['code_challenge_methods_supported'] ?? [], true);
@@ -203,10 +218,10 @@ public function login(int $providerId, ?string $redirectUrl = null) {
203218
if ($isPkceEnabled) {
204219
// PKCE code_challenge see https://datatracker.ietf.org/doc/html/rfc7636
205220
$code_verifier = $this->random->generate(128, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER);
206-
$this->session->set(self::CODE_VERIFIER, $code_verifier);
221+
$this->session->set(self::CODE_VERIFIER . $sessionKeySuffix, $code_verifier);
207222
}
208223

209-
$this->session->set(self::PROVIDERID, $providerId);
224+
$this->session->set(self::LOGIN_PROVIDERID . $sessionKeySuffix, $providerId);
210225
$this->session->close();
211226

212227
// get attribute mapping settings
@@ -360,13 +375,22 @@ public function code(string $state = '', string $code = '', string $scope = '',
360375
return $this->build403TemplateResponse($message, Http::STATUS_BAD_REQUEST, [], false);
361376
}
362377

363-
$storedState = $this->session->get(self::STATE);
378+
$sessionKeySuffix = '-' . $state;
379+
$storedState = $this->session->get(self::STATE . $sessionKeySuffix);
380+
381+
$currentTimestamp = $this->timeFactory->getTime();
382+
$sessionTimestamp = $this->session->get(self::TIMESTAMP . $sessionKeySuffix);
383+
if ($currentTimestamp - $sessionTimestamp > self::LOGIN_FLOW_TIMEOUT) {
384+
// the state, nonce etc... were stored too long ago, the login flow has expired
385+
$message = $this->l10n->t('The received state has expired.');
386+
return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, [], false);
387+
}
364388

365389
if ($storedState !== $state) {
366390
$this->logger->warning('state does not match', [
367391
'got' => $state,
368392
'expected' => $storedState,
369-
'state_exists_in_session' => $this->session->exists(self::STATE),
393+
'state_exists_in_session' => $this->session->exists(self::STATE . $sessionKeySuffix),
370394
]);
371395

372396
$message = $this->l10n->t('The received state does not match the expected value.');
@@ -376,15 +400,15 @@ public function code(string $state = '', string $code = '', string $scope = '',
376400
'error_description' => $message,
377401
'got' => $state,
378402
'expected' => $storedState,
379-
'state_exists_in_session' => $this->session->exists(self::STATE),
403+
'state_exists_in_session' => $this->session->exists(self::STATE . $sessionKeySuffix),
380404
];
381405
return new JSONResponse($responseData, Http::STATUS_FORBIDDEN);
382406
}
383407
// we know debug mode is off, always throttle
384408
return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'state does not match'], true);
385409
}
386410

387-
$providerId = (int)$this->session->get(self::PROVIDERID);
411+
$providerId = (int)$this->session->get(self::LOGIN_PROVIDERID . $sessionKeySuffix);
388412
$provider = $this->providerMapper->getProvider($providerId);
389413
try {
390414
$providerClientSecret = $this->crypto->decrypt($provider->getClientSecret());
@@ -409,7 +433,8 @@ public function code(string $state = '', string $code = '', string $scope = '',
409433
'grant_type' => 'authorization_code',
410434
];
411435
if ($isPkceEnabled) {
412-
$requestBody['code_verifier'] = $this->session->get(self::CODE_VERIFIER); // Set for the PKCE flow
436+
// Set for the PKCE flow
437+
$requestBody['code_verifier'] = $this->session->get(self::CODE_VERIFIER . $sessionKeySuffix);
413438
}
414439

415440
$headers = [];
@@ -558,7 +583,7 @@ public function code(string $state = '', string $code = '', string $scope = '',
558583
}
559584
}
560585

561-
if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE)) {
586+
if (isset($idTokenPayload->nonce) && $idTokenPayload->nonce !== $this->session->get(self::NONCE . $sessionKeySuffix)) {
562587
$this->logger->debug('Nonce does not match');
563588
$message = $this->l10n->t('The nonce does not match');
564589
return $this->build403TemplateResponse($message, Http::STATUS_FORBIDDEN, ['reason' => 'invalid nonce']);
@@ -627,6 +652,7 @@ public function code(string $state = '', string $code = '', string $scope = '',
627652
}
628653

629654
$this->session->set(self::ID_TOKEN, $idTokenRaw);
655+
$this->session->set(self::PROVIDERID, $providerId);
630656

631657
$this->logger->debug('Logging user in');
632658

@@ -692,7 +718,7 @@ public function code(string $state = '', string $code = '', string $scope = '',
692718

693719
$this->logger->debug('Redirecting user');
694720

695-
$redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN);
721+
$redirectUrl = $this->session->get(self::REDIRECT_AFTER_LOGIN . $sessionKeySuffix);
696722
if ($redirectUrl) {
697723
return $this->getRedirectResponse($redirectUrl);
698724
}

0 commit comments

Comments
 (0)