0
0
Fork 0
mirror of https://github.com/nextcloud/server.git synced 2025-02-07 09:59:46 +00:00
nextcloud_server/apps/oauth2/lib/Controller/OauthApiController.php
provokateurin 085d4c9364
refactor(OpenAPI): Adjust scopes to match previous behavior
Signed-off-by: provokateurin <kate@provokateurin.de>
2025-01-06 14:30:40 +01:00

214 lines
7.5 KiB
PHP

<?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(),
]
);
}
}