You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

262 lines
8.5 KiB

  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OC\Core\Controller;
  8. use OC\Authentication\TwoFactorAuth\Manager;
  9. use OC_User;
  10. use OCP\AppFramework\Controller;
  11. use OCP\AppFramework\Http\Attribute\FrontpageRoute;
  12. use OCP\AppFramework\Http\Attribute\NoAdminRequired;
  13. use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
  14. use OCP\AppFramework\Http\Attribute\OpenAPI;
  15. use OCP\AppFramework\Http\Attribute\UserRateLimit;
  16. use OCP\AppFramework\Http\Attribute\UseSession;
  17. use OCP\AppFramework\Http\RedirectResponse;
  18. use OCP\AppFramework\Http\StandaloneTemplateResponse;
  19. use OCP\Authentication\TwoFactorAuth\IActivatableAtLogin;
  20. use OCP\Authentication\TwoFactorAuth\IProvider;
  21. use OCP\Authentication\TwoFactorAuth\IProvidesCustomCSP;
  22. use OCP\Authentication\TwoFactorAuth\TwoFactorException;
  23. use OCP\IRequest;
  24. use OCP\ISession;
  25. use OCP\IURLGenerator;
  26. use OCP\IUserSession;
  27. use OCP\Util;
  28. use Psr\Log\LoggerInterface;
  29. #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
  30. class TwoFactorChallengeController extends Controller {
  31. public function __construct(
  32. string $appName,
  33. IRequest $request,
  34. private Manager $twoFactorManager,
  35. private IUserSession $userSession,
  36. private ISession $session,
  37. private IURLGenerator $urlGenerator,
  38. private LoggerInterface $logger,
  39. ) {
  40. parent::__construct($appName, $request);
  41. }
  42. /**
  43. * @return string
  44. */
  45. protected function getLogoutUrl() {
  46. return OC_User::getLogoutUrl($this->urlGenerator);
  47. }
  48. /**
  49. * @param IProvider[] $providers
  50. */
  51. private function splitProvidersAndBackupCodes(array $providers): array {
  52. $regular = [];
  53. $backup = null;
  54. foreach ($providers as $provider) {
  55. if ($provider->getId() === 'backup_codes') {
  56. $backup = $provider;
  57. } else {
  58. $regular[] = $provider;
  59. }
  60. }
  61. return [$regular, $backup];
  62. }
  63. /**
  64. * @TwoFactorSetUpDoneRequired
  65. *
  66. * @param string $redirect_url
  67. * @return StandaloneTemplateResponse
  68. */
  69. #[NoAdminRequired]
  70. #[NoCSRFRequired]
  71. #[FrontpageRoute(verb: 'GET', url: '/login/selectchallenge')]
  72. public function selectChallenge($redirect_url) {
  73. $user = $this->userSession->getUser();
  74. $providerSet = $this->twoFactorManager->getProviderSet($user);
  75. $allProviders = $providerSet->getProviders();
  76. [$providers, $backupProvider] = $this->splitProvidersAndBackupCodes($allProviders);
  77. $setupProviders = $this->twoFactorManager->getLoginSetupProviders($user);
  78. $data = [
  79. 'providers' => $providers,
  80. 'backupProvider' => $backupProvider,
  81. 'providerMissing' => $providerSet->isProviderMissing(),
  82. 'redirect_url' => $redirect_url,
  83. 'logout_url' => $this->getLogoutUrl(),
  84. 'hasSetupProviders' => !empty($setupProviders),
  85. ];
  86. Util::addScript('core', 'twofactor-request-token');
  87. return new StandaloneTemplateResponse($this->appName, 'twofactorselectchallenge', $data, 'guest');
  88. }
  89. /**
  90. * @TwoFactorSetUpDoneRequired
  91. *
  92. * @param string $challengeProviderId
  93. * @param string $redirect_url
  94. * @return StandaloneTemplateResponse|RedirectResponse
  95. */
  96. #[NoAdminRequired]
  97. #[NoCSRFRequired]
  98. #[UseSession]
  99. #[FrontpageRoute(verb: 'GET', url: '/login/challenge/{challengeProviderId}')]
  100. public function showChallenge($challengeProviderId, $redirect_url) {
  101. $user = $this->userSession->getUser();
  102. $providerSet = $this->twoFactorManager->getProviderSet($user);
  103. $provider = $providerSet->getProvider($challengeProviderId);
  104. if (is_null($provider)) {
  105. return new RedirectResponse($this->urlGenerator->linkToRoute('core.TwoFactorChallenge.selectChallenge'));
  106. }
  107. $backupProvider = $providerSet->getProvider('backup_codes');
  108. if (!is_null($backupProvider) && $backupProvider->getId() === $provider->getId()) {
  109. // Don't show the backup provider link if we're already showing that provider's challenge
  110. $backupProvider = null;
  111. }
  112. $errorMessage = '';
  113. $error = false;
  114. if ($this->session->exists('two_factor_auth_error')) {
  115. $this->session->remove('two_factor_auth_error');
  116. $error = true;
  117. $errorMessage = $this->session->get('two_factor_auth_error_message');
  118. $this->session->remove('two_factor_auth_error_message');
  119. }
  120. $tmpl = $provider->getTemplate($user);
  121. $tmpl->assign('redirect_url', $redirect_url);
  122. $data = [
  123. 'error' => $error,
  124. 'error_message' => $errorMessage,
  125. 'provider' => $provider,
  126. 'backupProvider' => $backupProvider,
  127. 'logout_url' => $this->getLogoutUrl(),
  128. 'redirect_url' => $redirect_url,
  129. 'template' => $tmpl->fetchPage(),
  130. ];
  131. $response = new StandaloneTemplateResponse($this->appName, 'twofactorshowchallenge', $data, 'guest');
  132. if ($provider instanceof IProvidesCustomCSP) {
  133. $response->setContentSecurityPolicy($provider->getCSP());
  134. }
  135. Util::addScript('core', 'twofactor-request-token');
  136. return $response;
  137. }
  138. /**
  139. * @TwoFactorSetUpDoneRequired
  140. *
  141. *
  142. * @param string $challengeProviderId
  143. * @param string $challenge
  144. * @param string $redirect_url
  145. * @return RedirectResponse
  146. */
  147. #[NoAdminRequired]
  148. #[NoCSRFRequired]
  149. #[UseSession]
  150. #[FrontpageRoute(verb: 'POST', url: '/login/challenge/{challengeProviderId}')]
  151. #[UserRateLimit(limit: 5, period: 100)]
  152. public function solveChallenge($challengeProviderId, $challenge, $redirect_url = null) {
  153. $user = $this->userSession->getUser();
  154. $provider = $this->twoFactorManager->getProvider($user, $challengeProviderId);
  155. if (is_null($provider)) {
  156. return new RedirectResponse($this->urlGenerator->linkToRoute('core.TwoFactorChallenge.selectChallenge'));
  157. }
  158. try {
  159. if ($this->twoFactorManager->verifyChallenge($challengeProviderId, $user, $challenge)) {
  160. if (!is_null($redirect_url)) {
  161. return new RedirectResponse($this->urlGenerator->getAbsoluteURL(urldecode($redirect_url)));
  162. }
  163. return new RedirectResponse($this->urlGenerator->linkToDefaultPageUrl());
  164. }
  165. } catch (TwoFactorException $e) {
  166. /*
  167. * The 2FA App threw an TwoFactorException. Now we display more
  168. * information to the user. The exception text is stored in the
  169. * session to be used in showChallenge()
  170. */
  171. $this->session->set('two_factor_auth_error_message', $e->getMessage());
  172. }
  173. $ip = $this->request->getRemoteAddress();
  174. $uid = $user->getUID();
  175. $this->logger->warning("Two-factor challenge failed: $uid (Remote IP: $ip)");
  176. $this->session->set('two_factor_auth_error', true);
  177. return new RedirectResponse($this->urlGenerator->linkToRoute('core.TwoFactorChallenge.showChallenge', [
  178. 'challengeProviderId' => $provider->getId(),
  179. 'redirect_url' => $redirect_url,
  180. ]));
  181. }
  182. #[NoAdminRequired]
  183. #[NoCSRFRequired]
  184. #[FrontpageRoute(verb: 'GET', url: 'login/setupchallenge')]
  185. public function setupProviders(?string $redirect_url = null): StandaloneTemplateResponse {
  186. $user = $this->userSession->getUser();
  187. $setupProviders = $this->twoFactorManager->getLoginSetupProviders($user);
  188. $data = [
  189. 'providers' => $setupProviders,
  190. 'logout_url' => $this->getLogoutUrl(),
  191. 'redirect_url' => $redirect_url,
  192. ];
  193. Util::addScript('core', 'twofactor-request-token');
  194. return new StandaloneTemplateResponse($this->appName, 'twofactorsetupselection', $data, 'guest');
  195. }
  196. #[NoAdminRequired]
  197. #[NoCSRFRequired]
  198. #[FrontpageRoute(verb: 'GET', url: 'login/setupchallenge/{providerId}')]
  199. public function setupProvider(string $providerId, ?string $redirect_url = null) {
  200. $user = $this->userSession->getUser();
  201. $providers = $this->twoFactorManager->getLoginSetupProviders($user);
  202. $provider = null;
  203. foreach ($providers as $p) {
  204. if ($p->getId() === $providerId) {
  205. $provider = $p;
  206. break;
  207. }
  208. }
  209. if ($provider === null) {
  210. return new RedirectResponse($this->urlGenerator->linkToRoute('core.TwoFactorChallenge.selectChallenge'));
  211. }
  212. /** @var IActivatableAtLogin $provider */
  213. $tmpl = $provider->getLoginSetup($user)->getBody();
  214. $data = [
  215. 'provider' => $provider,
  216. 'logout_url' => $this->getLogoutUrl(),
  217. 'redirect_url' => $redirect_url,
  218. 'template' => $tmpl->fetchPage(),
  219. ];
  220. $response = new StandaloneTemplateResponse($this->appName, 'twofactorsetupchallenge', $data, 'guest');
  221. Util::addScript('core', 'twofactor-request-token');
  222. return $response;
  223. }
  224. /**
  225. * @todo handle the extreme edge case of an invalid provider ID and redirect to the provider selection page
  226. */
  227. #[NoAdminRequired]
  228. #[NoCSRFRequired]
  229. #[FrontpageRoute(verb: 'POST', url: 'login/setupchallenge/{providerId}')]
  230. public function confirmProviderSetup(string $providerId, ?string $redirect_url = null) {
  231. return new RedirectResponse($this->urlGenerator->linkToRoute(
  232. 'core.TwoFactorChallenge.showChallenge',
  233. [
  234. 'challengeProviderId' => $providerId,
  235. 'redirect_url' => $redirect_url,
  236. ]
  237. ));
  238. }
  239. }