aboutsummaryrefslogtreecommitdiffstats
path: root/apps/oauth2/lib/Controller/OauthApiController.php
diff options
context:
space:
mode:
Diffstat (limited to 'apps/oauth2/lib/Controller/OauthApiController.php')
-rw-r--r--apps/oauth2/lib/Controller/OauthApiController.php214
1 files changed, 214 insertions, 0 deletions
diff --git a/apps/oauth2/lib/Controller/OauthApiController.php b/apps/oauth2/lib/Controller/OauthApiController.php
new file mode 100644
index 00000000000..11f17fda4bf
--- /dev/null
+++ b/apps/oauth2/lib/Controller/OauthApiController.php
@@ -0,0 +1,214 @@
+<?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\OpenAPI;
+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;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+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(),
+ ]
+ );
+ }
+}