diff --git a/README.md b/README.md index 42c9c93d84be7446f3cdd80d846d4f15c7bdce88..58d2499c9da45778265e51ce7222b333c6320fe6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,18 @@ - This plugin creates an endpoint `/apps/ecloud-accounts/api/set_account_data` that is to be used to set user's email, quota,recovery email and create the user's folder if necessary +### Captcha Configuration for user account creation + +- Simple image based captcha is the default for human verification +- To change the value, set `ecloud-accounts.captcha_provider` + - Allowed values are `image` (default) and `hcaptcha` (https://hcaptcha.com) + +#### HCaptcha Configuration + +- For hcaptcha provider to work, set the following values correctly: + - `ecloud-accounts.hcaptcha_site_key` + - `ecloud-accounts.hcaptcha_secret` + ## Drop account - The drop account functionality plugin works in conjunction with the drop_account plugin : https://apps.nextcloud.com/apps/drop_account @@ -25,7 +37,7 @@ Please open issues here : https://gitlab.e.foundation/e/backlog/issues -## Dependancies +## Dependencies This plugin works in cunjunction with the drop_account plugin : https://apps.nextcloud.com/apps/drop_account diff --git a/appinfo/routes.php b/appinfo/routes.php index e89d7d76f68ff12c5b1ecada5f09c215fba1ab52..eeeec68a0d28bc10ea95abc3ba3981dd78e5ccfe 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,7 +10,7 @@ return ['routes' => [ ['name' => 'shop_account#check_shop_email_post_delete', 'url' => '/shop-accounts/check_shop_email_post_delete', 'verb' => 'GET'], [ 'name' => 'user#preflighted_cors', 'url' => '/api/{path}', - 'verb' => 'OPTIONS', 'requirements' => array('path' => '.+') + 'verb' => 'OPTIONS', 'requirements' => ['path' => '.+'] ], [ 'name' => 'beta_user#remove_user_in_group', diff --git a/lib/Controller/AccountController.php b/lib/Controller/AccountController.php index 239a98a307942e5e32cf112f7ea6edf0e3b5c9fa..95c4bb47f04251006e21778128ec8035c092115f 100644 --- a/lib/Controller/AccountController.php +++ b/lib/Controller/AccountController.php @@ -12,6 +12,7 @@ use OCA\EcloudAccounts\Exception\AddUsernameToCommonStoreException; use OCA\EcloudAccounts\Exception\LDAPUserCreationException; use OCA\EcloudAccounts\Exception\RecoveryEmailValidationException; use OCA\EcloudAccounts\Service\CaptchaService; +use OCA\EcloudAccounts\Service\HCaptchaService; use OCA\EcloudAccounts\Service\NewsLetterService; use OCA\EcloudAccounts\Service\UserService; use OCP\AppFramework\Controller; @@ -34,6 +35,7 @@ class AccountController extends Controller { private $userService; private $newsletterService; private $captchaService; + private HCaptchaService $hCaptchaService; protected $l10nFactory; private $session; private $userSession; @@ -43,6 +45,11 @@ class AccountController extends Controller { private IInitialState $initialState; private const SESSION_USERNAME_CHECK = 'username_check_passed'; private const CAPTCHA_VERIFIED_CHECK = 'captcha_verified'; + private const ALLOWED_CAPTCHA_PROVIDERS = ['image', 'hcaptcha']; + private const DEFAULT_CAPTCHA_PROVIDER = 'image'; + private const HCAPTCHA_PROVIDER = 'hcaptcha'; + private const HCAPTCHA_DOMAINS = ['https://hcaptcha.com', 'https://*.hcaptcha.com']; + private ILogger $logger; public function __construct( $AppName, @@ -50,19 +57,21 @@ class AccountController extends Controller { UserService $userService, NewsLetterService $newsletterService, CaptchaService $captchaService, + HCaptchaService $hCaptchaService, IFactory $l10nFactory, IUserSession $userSession, IURLGenerator $urlGenerator, ISession $session, IConfig $config, - IInitialState $initialState, - ILogger $logger + ILogger $logger, + IInitialState $initialState ) { parent::__construct($AppName, $request); $this->appName = $AppName; $this->userService = $userService; $this->newsletterService = $newsletterService; $this->captchaService = $captchaService; + $this->hCaptchaService = $hCaptchaService; $this->l10nFactory = $l10nFactory; $this->session = $session; $this->userSession = $userSession; @@ -88,12 +97,30 @@ class AccountController extends Controller { $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $lang; $this->initialState->provideInitialState('lang', $lang); - return new TemplateResponse( + + $response = new TemplateResponse( Application::APP_ID, 'signup', ['appName' => Application::APP_ID, 'lang' => $lang], TemplateResponse::RENDER_AS_GUEST ); + + $captchaProvider = $this->getCaptchaProvider(); + $this->initialState->provideInitialState('captchaProvider', $captchaProvider); + + if ($captchaProvider === self::HCAPTCHA_PROVIDER) { + $csp = $response->getContentSecurityPolicy(); + foreach (self::HCAPTCHA_DOMAINS as $domain) { + $csp->addAllowedScriptDomain($domain); + $csp->addAllowedFrameDomain($domain); + $csp->addAllowedStyleDomain($domain); + $csp->addAllowedConnectDomain($domain); + } + $response->setContentSecurityPolicy($csp); + $hcaptchaSiteKey = $this->config->getSystemValue(Application::APP_ID . '.hcaptcha_site_key'); + $this->initialState->provideInitialState('hCaptchaSiteKey', $hcaptchaSiteKey); + } + return $response; } /** @@ -198,7 +225,7 @@ class AccountController extends Controller { * * @return string|null If validation fails, a string describing the error; otherwise, null. */ - public function validateInput(string $inputName, string $value, int $maxLength = null) : ?string { + public function validateInput(string $inputName, string $value, ?int $maxLength = null) : ?string { if ($value === '') { return "$inputName is required."; } @@ -249,10 +276,15 @@ class AccountController extends Controller { * @NoCSRFRequired */ public function captcha(): Http\DataDisplayResponse { - $captchaValue = $this->captchaService->generateCaptcha(); + // Don't allow requests to image captcha if different provider is set + if ($this->getCaptchaProvider() !== self::DEFAULT_CAPTCHA_PROVIDER) { + $response = new DataResponse(); + $response->setStatus(400); + return $response; + } + $captchaValue = $this->captchaService->generateCaptcha(); $response = new Http\DataDisplayResponse($captchaValue, Http::STATUS_OK, ['Content-Type' => 'image/png']); - return $response; } /** @@ -262,24 +294,51 @@ class AccountController extends Controller { * @PublicPage * @NoCSRFRequired * - * @param string $captchaInput The user-provided human verification input. + * @param string $token The user-provided human verification input. + * @param string $bypassToken Token to bypass captcha for automation testing * * @return \OCP\AppFramework\Http\DataResponse */ - public function verifyCaptcha(string $captchaInput = '', string $bypassToken = '') : DataResponse { + public function verifyCaptcha(string $userToken = '', string $bypassToken = '') : DataResponse { $response = new DataResponse(); - $captchaToken = $this->config->getSystemValue('bypass_captcha_token', ''); - // Initialize the default status to 400 (Bad Request) + + // Check if the input matches the bypass token + $bypassTokenInConfig = $this->config->getSystemValue('bypass_captcha_token', ''); + if ((!empty($bypassTokenInConfig) && $bypassToken === $bypassTokenInConfig)) { + $this->session->set(self::CAPTCHA_VERIFIED_CHECK, true); + $response->setStatus(200); + } + $response->setStatus(400); - // Check if the input matches the bypass token or the stored captcha result - $captchaResult = (string) $this->session->get(CaptchaService::CAPTCHA_RESULT_KEY, ''); - if ((!empty($captchaToken) && $bypassToken === $captchaToken) || (!empty($captchaResult) && $captchaInput === $captchaResult)) { + $captchaProvider = $this->getCaptchaProvider(); + + // Check for default captcha provider + if ($captchaProvider === self::DEFAULT_CAPTCHA_PROVIDER && $this->verifyImageCaptcha($userToken)) { $this->session->set(self::CAPTCHA_VERIFIED_CHECK, true); + $this->session->remove(CaptchaService::CAPTCHA_RESULT_KEY); $response->setStatus(200); } - $this->session->remove(CaptchaService::CAPTCHA_RESULT_KEY); + // Check for hcaptcha provider + if ($captchaProvider === self::HCAPTCHA_PROVIDER && $this->hCaptchaService->verify($userToken)) { + $this->session->set(self::CAPTCHA_VERIFIED_CHECK, true); + $response->setStatus(200); + } return $response; } + private function verifyImageCaptcha(string $captchaInput = '') : bool { + $captchaResult = (string) $this->session->get(CaptchaService::CAPTCHA_RESULT_KEY, ''); + return (!empty($captchaResult) && $captchaInput === $captchaResult); + } + + private function getCaptchaProvider() : string { + $captchaProvider = $this->config->getSystemValue('ecloud-accounts.captcha_provider', self::DEFAULT_CAPTCHA_PROVIDER); + + if (!in_array($captchaProvider, self::ALLOWED_CAPTCHA_PROVIDERS)) { + $captchaProvider = self::DEFAULT_CAPTCHA_PROVIDER; + } + return $captchaProvider; + } + } diff --git a/lib/Listeners/BeforeUserDeletedListener.php b/lib/Listeners/BeforeUserDeletedListener.php index aa4a21eb8401d6ea24d4234b9abd407153df3a04..a2ab50073b85a40c9f0b1ece4a7cacbb5bc8014f 100644 --- a/lib/Listeners/BeforeUserDeletedListener.php +++ b/lib/Listeners/BeforeUserDeletedListener.php @@ -46,7 +46,7 @@ class BeforeUserDeletedListener implements IEventListener { $isUserOnLDAP = $this->LDAPConnectionService->isUserOnLDAPBackend($user); try { - $this->logger->info("PostDelete user {user}", array('user' => $uid)); + $this->logger->info("PostDelete user {user}", ['user' => $uid]); $this->userService->deleteEmailAccount($email); } catch (Exception $e) { $this->logger->error('Error deleting mail folder for user '. $uid . ' :' . $e->getMessage()); diff --git a/lib/Service/CurlService.php b/lib/Service/CurlService.php index 2c6678da7740e7df80b7ada31701033a5663fdd4..090e77440784d5c80a326b18c4f677d91b988c91 100644 --- a/lib/Service/CurlService.php +++ b/lib/Service/CurlService.php @@ -25,7 +25,7 @@ class CurlService { * @param array $userOptions * @return mixed */ - public function get($url, $params = array(), $headers = array(), $userOptions = array()) { + public function get($url, $params = [], $headers = [], $userOptions = []) { return $this->request('GET', $url, $params, $headers, $userOptions); } @@ -38,7 +38,7 @@ class CurlService { * @param array $userOptions * @return mixed */ - public function post($url, $params = array(), $headers = array(), $userOptions = array()) { + public function post($url, $params = [], $headers = [], $userOptions = []) { return $this->request('POST', $url, $params, $headers, $userOptions); } @@ -88,13 +88,13 @@ class CurlService { * @return mixed * @throws Exception */ - private function request($method, $url, $params = array(), $headers = array(), $userOptions = array()) { + private function request($method, $url, $params = [], $headers = [], $userOptions = []) { $ch = curl_init(); $method = strtoupper($method); - $options = array( + $options = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers - ); + ]; foreach ($userOptions as $key => $value) { $options[$key] = $value; } diff --git a/lib/Service/HCaptchaService.php b/lib/Service/HCaptchaService.php new file mode 100644 index 0000000000000000000000000000000000000000..53757a50d3d5bfe8c869da615b1bb88261677560 --- /dev/null +++ b/lib/Service/HCaptchaService.php @@ -0,0 +1,35 @@ +session = $session; + $this->config = $config; + $this->curl = $curlService; + } + + public function verify(string $token) : bool { + $secret = $this->config->getSystemValue(Application::APP_ID . '.hcaptcha_secret'); + $data = [ + 'response' => $token, + 'secret' => $secret + ]; + + $data = http_build_query($data); + $response = $this->curl->post(self::VERIFY_URL, $data); + $response = json_decode($response, true); + + return $response['success']; + } +} diff --git a/lib/Service/UserService.php b/lib/Service/UserService.php index fea70cb843591954529eb5296fe8f1037c282044..7cca2d81f14eeafa4934d8f1633858365a5f23fe 100644 --- a/lib/Service/UserService.php +++ b/lib/Service/UserService.php @@ -311,9 +311,9 @@ class UserService { $endpoint = $commonApiVersion . '/aliases/hide-my-email/'; $url = $commonServicesURL . $endpoint . $resultmail; - $data = array( + $data = [ "domain" => $aliasDomain - ); + ]; $headers = [ "Authorization: Bearer $token" ]; @@ -348,10 +348,10 @@ class UserService { $endpoint = $commonApiVersion . '/aliases/'; $url = $commonServicesURL . $endpoint . $userEmail; - $data = array( + $data = [ "alias" => $username, "domain" => $domain - ); + ]; $headers = [ "Authorization: Bearer $token" ]; diff --git a/package-lock.json b/package-lock.json index 6a1bd3d6a819945298b8eeebbcee46bb731d4660..fbfa795e81bfd2fa5964a29ab6a95bf727c7ee9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "ecloud-accounts", "version": "3.1.0", "dependencies": { + "@hcaptcha/vue-hcaptcha": "^1.3.0", "@nextcloud/axios": "^2.1.0", "@nextcloud/dialogs": "^3.2.0", "@nextcloud/initial-state": "^2.0.0", @@ -1786,6 +1787,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@hcaptcha/vue-hcaptcha": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hcaptcha/vue-hcaptcha/-/vue-hcaptcha-1.3.0.tgz", + "integrity": "sha512-aUSWyhRucgFeBOBUC3nWBZuE0TkeoSH5QIVFwiTLnNsYpIaxD1tKBbI5Tdoy0TdpkuXKsB4KqyElbvoMZ9reGw==", + "peerDependencies": { + "vue": "^2.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz", @@ -13001,6 +13010,12 @@ } } }, + "@hcaptcha/vue-hcaptcha": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hcaptcha/vue-hcaptcha/-/vue-hcaptcha-1.3.0.tgz", + "integrity": "sha512-aUSWyhRucgFeBOBUC3nWBZuE0TkeoSH5QIVFwiTLnNsYpIaxD1tKBbI5Tdoy0TdpkuXKsB4KqyElbvoMZ9reGw==", + "requires": {} + }, "@humanwhocodes/config-array": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz", diff --git a/package.json b/package.json index 30435c15ee6f0ee927d8856961c466e7f18c41ad..7d599379ff19876c74915c1b6e3faee44a72c224 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ecloud-accounts", - "version": "3.1.0", + "version": "6.1.1", "description": "App for ecloud account management.", "repository": { "type": "git", @@ -18,6 +18,7 @@ "stylelint:fix": "stylelint {src,css}/**/{*.scss,*.css} --fix --allow-empty-input" }, "dependencies": { + "@hcaptcha/vue-hcaptcha": "^1.3.0", "@nextcloud/axios": "^2.1.0", "@nextcloud/dialogs": "^3.2.0", "@nextcloud/initial-state": "^2.0.0", diff --git a/src/Signup.vue b/src/Signup.vue index 82e3158cab96d8ac9729abbb341b7a93fd3e393e..ccdd0b5309cd5a8b663efe5092e198ba32364fd4 100644 --- a/src/Signup.vue +++ b/src/Signup.vue @@ -3,9 +3,13 @@
- +
@@ -16,8 +20,10 @@ + +