123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- <?php
- declare(strict_types=1);
- /**
- * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
- namespace OCA\OAuth2\Controller;
- use OC\Authentication\Token\IProvider as TokenProvider;
- use OCA\OAuth2\Db\AccessTokenMapper;
- use OCA\OAuth2\Db\ClientMapper;
- use OCA\OAuth2\Exceptions\AccessTokenNotFoundException;
- use OCA\OAuth2\Exceptions\ClientNotFoundException;
- use OCP\AppFramework\Controller;
- use OCP\AppFramework\Http;
- use OCP\AppFramework\Http\Attribute\BruteForceProtection;
- use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
- use OCP\AppFramework\Http\Attribute\PublicPage;
- use OCP\AppFramework\Http\JSONResponse;
- use OCP\AppFramework\Utility\ITimeFactory;
- use OCP\Authentication\Exceptions\ExpiredTokenException;
- use OCP\Authentication\Exceptions\InvalidTokenException;
- use OCP\DB\Exception;
- use OCP\IRequest;
- use OCP\Security\Bruteforce\IThrottler;
- use OCP\Security\ICrypto;
- use OCP\Security\ISecureRandom;
- use Psr\Log\LoggerInterface;
- class OauthApiController extends Controller {
- // the authorization code expires after 10 minutes
- public const AUTHORIZATION_CODE_EXPIRES_AFTER = 10 * 60;
- public function __construct(
- string $appName,
- IRequest $request,
- private ICrypto $crypto,
- private AccessTokenMapper $accessTokenMapper,
- private ClientMapper $clientMapper,
- private TokenProvider $tokenProvider,
- private ISecureRandom $secureRandom,
- private ITimeFactory $time,
- private LoggerInterface $logger,
- private IThrottler $throttler,
- private ITimeFactory $timeFactory,
- ) {
- parent::__construct($appName, $request);
- }
- /**
- * Get a token
- *
- * @param string $grant_type Token type that should be granted
- * @param ?string $code Code of the flow
- * @param ?string $refresh_token Refresh token
- * @param ?string $client_id Client ID
- * @param ?string $client_secret Client secret
- * @throws Exception
- * @return JSONResponse<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int, refresh_token: string, user_id: string}, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
- *
- * 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', 'code' => $code]);
- 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);
- $appToken = $this->tokenProvider->rotate(
- $appToken,
- $decryptedToken,
- $newToken
- );
- // Expiration is in 1 hour again
- $appToken->setExpires($this->time->getTime() + 3600);
- $this->tokenProvider->updateToken($appToken);
- // Generate a new refresh token and encrypt the new apptoken in the DB
- $newCode = $this->secureRandom->generate(128, ISecureRandom::CHAR_ALPHANUMERIC);
- $accessToken->setHashedCode(hash('sha512', $newCode));
- $accessToken->setEncryptedToken($this->crypto->encrypt($newToken, $newCode));
- // increase the number of delivered oauth token
- // this helps with cleaning up DB access token when authorization code has expired
- // and it never delivered any oauth token
- $tokenCount = $accessToken->getTokenCount();
- $accessToken->setTokenCount($tokenCount + 1);
- $this->accessTokenMapper->update($accessToken);
- $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(),
- ]
- );
- }
- }
|