|JSONResponse * * 200: Token returned * 400: Getting token is not possible */ #[PublicPage] #[NoCSRFRequired] #[BruteForceProtection(action: 'oauth2GetToken')] public function getToken( string $grant_type, ?string $code, ?string $refresh_token, ?string $client_id, ?string $client_secret, ): JSONResponse { // We only handle two types if ($grant_type !== 'authorization_code' && $grant_type !== 'refresh_token') { $response = new JSONResponse([ 'error' => 'invalid_grant', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_grant' => $grant_type]); return $response; } // We handle the initial and refresh tokens the same way if ($grant_type === 'refresh_token') { $code = $refresh_token; } try { $accessToken = $this->accessTokenMapper->getByCode($code); } catch (AccessTokenNotFoundException $e) { $response = new JSONResponse([ 'error' => 'invalid_request', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_request' => 'token not found']); return $response; } if ($grant_type === 'authorization_code') { // check this token is in authorization code state $deliveredTokenCount = $accessToken->getTokenCount(); if ($deliveredTokenCount > 0) { $response = new JSONResponse([ 'error' => 'invalid_request', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_request' => 'authorization_code_received_for_active_token']); return $response; } // check authorization code expiration $now = $this->timeFactory->now()->getTimestamp(); $codeCreatedAt = $accessToken->getCodeCreatedAt(); if ($codeCreatedAt < $now - self::AUTHORIZATION_CODE_EXPIRES_AFTER) { // we know this token is not useful anymore $this->accessTokenMapper->delete($accessToken); $response = new JSONResponse([ 'error' => 'invalid_request', ], Http::STATUS_BAD_REQUEST); $expiredSince = $now - self::AUTHORIZATION_CODE_EXPIRES_AFTER - $codeCreatedAt; $response->throttle(['invalid_request' => 'authorization_code_expired', 'expired_since' => $expiredSince]); return $response; } } try { $client = $this->clientMapper->getByUid($accessToken->getClientId()); } catch (ClientNotFoundException $e) { $response = new JSONResponse([ 'error' => 'invalid_request', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_request' => 'client not found', 'client_id' => $accessToken->getClientId()]); return $response; } if (isset($this->request->server['PHP_AUTH_USER'])) { $client_id = $this->request->server['PHP_AUTH_USER']; $client_secret = $this->request->server['PHP_AUTH_PW']; } try { $storedClientSecretHash = $client->getSecret(); $clientSecretHash = bin2hex($this->crypto->calculateHMAC($client_secret)); } catch (\Exception $e) { $this->logger->error('OAuth client secret decryption error', ['exception' => $e]); // we don't throttle here because it might not be a bruteforce attack return new JSONResponse([ 'error' => 'invalid_client', ], Http::STATUS_BAD_REQUEST); } // The client id and secret must match. Else we don't provide an access token! if ($client->getClientIdentifier() !== $client_id || $storedClientSecretHash !== $clientSecretHash) { $response = new JSONResponse([ 'error' => 'invalid_client', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_client' => 'client ID or secret does not match']); return $response; } $decryptedToken = $this->crypto->decrypt($accessToken->getEncryptedToken(), $code); // Obtain the appToken associated try { $appToken = $this->tokenProvider->getTokenById($accessToken->getTokenId()); } catch (ExpiredTokenException $e) { $appToken = $e->getToken(); } catch (InvalidTokenException $e) { //We can't do anything... $this->accessTokenMapper->delete($accessToken); $response = new JSONResponse([ 'error' => 'invalid_request', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_request' => 'token is invalid']); return $response; } // Rotate the apptoken (so the old one becomes invalid basically) $newToken = $this->secureRandom->generate(72, ISecureRandom::CHAR_ALPHANUMERIC); $newCode = $this->secureRandom->generate(128, ISecureRandom::CHAR_ALPHANUMERIC); $newEncryptedToken = $this->crypto->encrypt($newToken, $newCode); $redeemedThrottleReason = $grant_type === 'authorization_code' ? 'authorization_code_already_redeemed' : 'refresh_token_already_redeemed'; $this->db->beginTransaction(); try { $updatedRows = $this->accessTokenMapper->rotateToken( $accessToken->getId(), $code, $newCode, $newEncryptedToken, $grant_type === 'authorization_code', ); if ($updatedRows !== 1) { $this->db->rollBack(); $response = new JSONResponse([ 'error' => 'invalid_request', ], Http::STATUS_BAD_REQUEST); $response->throttle(['invalid_request' => $redeemedThrottleReason]); return $response; } $appToken = $this->tokenProvider->rotate( $appToken, $decryptedToken, $newToken ); // Expiration is in 1 hour again $appToken->setExpires($this->time->getTime() + 3600); $this->tokenProvider->updateToken($appToken); $this->db->commit(); } catch (\Throwable $e) { if ($this->db->inTransaction()) { $this->db->rollBack(); } // rotate() and updateToken() write the auth token to the cache, // so if we are past rotate() we must invalidate the new token $this->tokenProvider->invalidateToken($newToken); throw $e; } $this->throttler->resetDelay($this->request->getRemoteAddress(), 'login', ['user' => $appToken->getUID()]); return new JSONResponse( [ 'access_token' => $newToken, 'token_type' => 'Bearer', 'expires_in' => 3600, 'refresh_token' => $newCode, 'user_id' => $appToken->getUID(), ] ); } }