aboutsummaryrefslogtreecommitdiffstats
path: root/apps/oauth2/lib/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'apps/oauth2/lib/Controller')
-rw-r--r--apps/oauth2/lib/Controller/LoginRedirectorController.php123
-rw-r--r--apps/oauth2/lib/Controller/OauthApiController.php214
-rw-r--r--apps/oauth2/lib/Controller/SettingsController.php80
3 files changed, 417 insertions, 0 deletions
diff --git a/apps/oauth2/lib/Controller/LoginRedirectorController.php b/apps/oauth2/lib/Controller/LoginRedirectorController.php
new file mode 100644
index 00000000000..7241b35cdcf
--- /dev/null
+++ b/apps/oauth2/lib/Controller/LoginRedirectorController.php
@@ -0,0 +1,123 @@
+<?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\Core\Controller\ClientFlowLoginController;
+use OCA\OAuth2\Db\ClientMapper;
+use OCA\OAuth2\Exceptions\ClientNotFoundException;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\Attribute\PublicPage;
+use OCP\AppFramework\Http\Attribute\UseSession;
+use OCP\AppFramework\Http\RedirectResponse;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\ISession;
+use OCP\IURLGenerator;
+use OCP\Security\ISecureRandom;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
+class LoginRedirectorController extends Controller {
+ /**
+ * @param string $appName
+ * @param IRequest $request
+ * @param IURLGenerator $urlGenerator
+ * @param ClientMapper $clientMapper
+ * @param ISession $session
+ * @param IL10N $l
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IURLGenerator $urlGenerator,
+ private ClientMapper $clientMapper,
+ private ISession $session,
+ private IL10N $l,
+ private ISecureRandom $random,
+ private IAppConfig $appConfig,
+ private IConfig $config,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Authorize the user
+ *
+ * @param string $client_id Client ID
+ * @param string $state State of the flow
+ * @param string $response_type Response type for the flow
+ * @param string $redirect_uri URI to redirect to after the flow (is only used for legacy ownCloud clients)
+ * @return TemplateResponse<Http::STATUS_OK, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
+ *
+ * 200: Client not found
+ * 303: Redirect to login URL
+ */
+ #[PublicPage]
+ #[NoCSRFRequired]
+ #[UseSession]
+ public function authorize($client_id,
+ $state,
+ $response_type,
+ string $redirect_uri = ''): TemplateResponse|RedirectResponse {
+ try {
+ $client = $this->clientMapper->getByIdentifier($client_id);
+ } catch (ClientNotFoundException $e) {
+ $params = [
+ 'content' => $this->l->t('Your client is not authorized to connect. Please inform the administrator of your client.'),
+ ];
+ return new TemplateResponse('core', '404', $params, 'guest');
+ }
+
+ if ($response_type !== 'code') {
+ //Fail
+ $url = $client->getRedirectUri() . '?error=unsupported_response_type&state=' . $state;
+ return new RedirectResponse($url);
+ }
+
+ $enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false);
+
+ $providedRedirectUri = '';
+ if ($enableOcClients && $client->getRedirectUri() === 'http://localhost:*') {
+ $providedRedirectUri = $redirect_uri;
+ }
+
+ $this->session->set('oauth.state', $state);
+
+ if (in_array($client->getName(), $this->appConfig->getValueArray('oauth2', 'skipAuthPickerApplications', []))) {
+ /** @see ClientFlowLoginController::showAuthPickerPage **/
+ $stateToken = $this->random->generate(
+ 64,
+ ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS
+ );
+ $this->session->set(ClientFlowLoginController::STATE_NAME, $stateToken);
+ $targetUrl = $this->urlGenerator->linkToRouteAbsolute(
+ 'core.ClientFlowLogin.grantPage',
+ [
+ 'stateToken' => $stateToken,
+ 'clientIdentifier' => $client->getClientIdentifier(),
+ 'providedRedirectUri' => $providedRedirectUri,
+ ]
+ );
+ } else {
+ $targetUrl = $this->urlGenerator->linkToRouteAbsolute(
+ 'core.ClientFlowLogin.showAuthPickerPage',
+ [
+ 'clientIdentifier' => $client->getClientIdentifier(),
+ 'providedRedirectUri' => $providedRedirectUri,
+ ]
+ );
+ }
+ return new RedirectResponse($targetUrl);
+ }
+}
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(),
+ ]
+ );
+ }
+}
diff --git a/apps/oauth2/lib/Controller/SettingsController.php b/apps/oauth2/lib/Controller/SettingsController.php
new file mode 100644
index 00000000000..9bd02c8a2cd
--- /dev/null
+++ b/apps/oauth2/lib/Controller/SettingsController.php
@@ -0,0 +1,80 @@
+<?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 OCA\OAuth2\Db\AccessTokenMapper;
+use OCA\OAuth2\Db\Client;
+use OCA\OAuth2\Db\ClientMapper;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\Authentication\Token\IProvider as IAuthTokenProvider;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Security\ICrypto;
+use OCP\Security\ISecureRandom;
+
+class SettingsController extends Controller {
+
+ public const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private ClientMapper $clientMapper,
+ private ISecureRandom $secureRandom,
+ private AccessTokenMapper $accessTokenMapper,
+ private IL10N $l,
+ private IAuthTokenProvider $tokenProvider,
+ private IUserManager $userManager,
+ private ICrypto $crypto,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ public function addClient(string $name,
+ string $redirectUri): JSONResponse {
+ if (filter_var($redirectUri, FILTER_VALIDATE_URL) === false) {
+ return new JSONResponse(['message' => $this->l->t('Your redirect URL needs to be a full URL for example: https://yourdomain.com/path')], Http::STATUS_BAD_REQUEST);
+ }
+
+ $client = new Client();
+ $client->setName($name);
+ $client->setRedirectUri($redirectUri);
+ $secret = $this->secureRandom->generate(64, self::validChars);
+ $hashedSecret = bin2hex($this->crypto->calculateHMAC($secret));
+ $client->setSecret($hashedSecret);
+ $client->setClientIdentifier($this->secureRandom->generate(64, self::validChars));
+ $client = $this->clientMapper->insert($client);
+
+ $result = [
+ 'id' => $client->getId(),
+ 'name' => $client->getName(),
+ 'redirectUri' => $client->getRedirectUri(),
+ 'clientId' => $client->getClientIdentifier(),
+ 'clientSecret' => $secret,
+ ];
+
+ return new JSONResponse($result);
+ }
+
+ public function deleteClient(int $id): JSONResponse {
+ $client = $this->clientMapper->getByUid($id);
+
+ $this->userManager->callForSeenUsers(function (IUser $user) use ($client): void {
+ $this->tokenProvider->invalidateTokensOfUser($user->getUID(), $client->getName());
+ });
+
+ $this->accessTokenMapper->deleteByClientId($id);
+ $this->clientMapper->delete($client);
+ return new JSONResponse([]);
+ }
+}