diff --git a/README.md b/README.md index 8f5a8a11..184c4a34 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Release tarballs are hosted at https://github.com/nextcloud-releases/registratio ## ✨ Features * 👥 Add users to a given group -* 🛃 Allow-list with email domains (including wildcard) to register with +* 🛃 Allow-list with email domains (including wildcard) or exact email addresses to register with +* 🎟️ Invitation codes and invitation links: restrict who can register, limit the number of uses per link, set a storage quota for invited users and optionally skip the email verification step * 🔔 Administrator will be notified via email for new user creation or require approval * 📱 Supports Nextcloud's Client [Login Flow v1 and v2](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/LoginFlow/index.html) - allowing registration in the mobile Apps and Desktop clients * 📜 Integrates with [Terms of service](https://apps.nextcloud.com/apps/terms_of_service) diff --git a/appinfo/routes.php b/appinfo/routes.php index 75f08c4f..66e9a1c4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -11,9 +11,14 @@ ['name' => 'settings#admin', 'url' => '/settings', 'verb' => 'POST'], ['name' => 'register#showEmailForm', 'url' => '/', 'verb' => 'GET'], ['name' => 'register#submitEmailForm', 'url' => '/', 'verb' => 'POST'], + ['name' => 'register#showInviteForm', 'url' => '/invite/{code}', 'verb' => 'GET'], + ['name' => 'register#submitInviteForm', 'url' => '/invite/{code}', 'verb' => 'POST'], ['name' => 'register#showVerificationForm', 'url' => '/verify/{secret}', 'verb' => 'GET'], ['name' => 'register#submitVerificationForm', 'url' => '/verify/{secret}', 'verb' => 'POST'], ['name' => 'register#showUserForm', 'url' => '/register/{secret}/{token}', 'verb' => 'GET'], ['name' => 'register#submitUserForm', 'url' => '/register/{secret}/{token}', 'verb' => 'POST'], + ['name' => 'invitation#index', 'url' => '/admin/invitations', 'verb' => 'GET'], + ['name' => 'invitation#create', 'url' => '/admin/invitations', 'verb' => 'POST'], + ['name' => 'invitation#destroy', 'url' => '/admin/invitations/{id}', 'verb' => 'DELETE'], ], ]; diff --git a/lib/Controller/InvitationController.php b/lib/Controller/InvitationController.php new file mode 100644 index 00000000..005674f3 --- /dev/null +++ b/lib/Controller/InvitationController.php @@ -0,0 +1,109 @@ + $this->serialize($invitation), + $this->invitationService->getAll() + ); + + return new DataResponse($invitations); + } + + #[AdminRequired] + public function create(string $code = '', string $email = '', string $domain = '', string $quota = '', string $max_uses = '', string $expires = '', string $skip_email_verification = '', string $skip_admin_approval = ''): DataResponse { + if ($code === '') { + $code = $this->invitationService->generateCode(); + } + + try { + $invitation = $this->invitationService->createInvitation( + $code, + $email, + $domain, + $quota, + $max_uses !== '' ? (int)$max_uses : null, + $expires !== '' ? $expires : null, + $skip_email_verification === 'true' || $skip_email_verification === '1', + $skip_admin_approval === 'true' || $skip_admin_approval === '1' + ); + } catch (RegistrationException $e) { + return new DataResponse( + [ + 'status' => 'error', + 'message' => $e->getMessage(), + ], + Http::STATUS_BAD_REQUEST + ); + } + + return new DataResponse($this->serialize($invitation)); + } + + #[AdminRequired] + public function destroy(int $id): DataResponse { + $this->invitationService->deleteById($id); + + return new DataResponse([ + 'status' => 'success', + ]); + } + + private function serialize(Invitation $invitation): array { + return [ + 'id' => $invitation->getId(), + 'code' => $invitation->getCode(), + 'email' => $invitation->getEmail(), + 'domain' => $invitation->getDomain(), + 'quota' => $invitation->getQuota(), + 'max_uses' => $invitation->getMaxUses(), + 'uses' => $invitation->getUses(), + 'expires' => $this->formatDateTime($invitation->getExpires()), + 'created_at' => $this->formatDateTime($invitation->getCreatedAt()), + 'skip_email_verification' => $invitation->getSkipEmailVerification() === true, + 'skip_admin_approval' => $invitation->getSkipAdminApproval() === true, + 'link' => $this->invitationService->generateLink($invitation), + ]; + } + + private function formatDateTime(mixed $value): ?string { + if ($value === null) { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string)$value; + } +} \ No newline at end of file diff --git a/lib/Controller/RegisterController.php b/lib/Controller/RegisterController.php index 997c3a5a..004aa4ca 100644 --- a/lib/Controller/RegisterController.php +++ b/lib/Controller/RegisterController.php @@ -12,10 +12,12 @@ namespace OCA\Registration\Controller; use Exception; +use OCA\Registration\Db\Invitation; use OCA\Registration\Db\Registration; use OCA\Registration\Events\PassedFormEvent; use OCA\Registration\Events\ShowFormEvent; use OCA\Registration\Events\ValidateFormEvent; +use OCA\Registration\Service\InvitationService; use OCA\Registration\Service\LoginFlowService; use OCA\Registration\Service\MailService; use OCA\Registration\Service\RegistrationException; @@ -51,6 +53,7 @@ public function __construct( private RegistrationService $registrationService, private LoginFlowService $loginFlowService, private MailService $mailService, + private InvitationService $invitationService, private IEventDispatcher $eventDispatcher, private IInitialState $initialState, ) { @@ -59,7 +62,7 @@ public function __construct( #[PublicPage] #[NoCSRFRequired] - public function showEmailForm(string $email = '', string $message = ''): TemplateResponse { + public function showEmailForm(string $email = '', string $message = '', string $code = ''): TemplateResponse { $emailHint = ''; $domainList = $this->registrationService->getAllowedDomains(); if (!empty($domainList) && $this->config->getAppValueBool('show_domains')) { @@ -77,44 +80,82 @@ public function showEmailForm(string $email = '', string $message = ''): Templat } } + $emailList = $this->registrationService->getAllowedEmails(); + if (!empty($emailList) && $this->config->getAppValueBool('show_domains')) { + $emailHint = $this->l10n->t( + 'Registration is only allowed with the following email addresses: %s', + [implode(', ', $emailList)] + ); + } + $this->eventDispatcher->dispatchTyped(new ShowFormEvent(ShowFormEvent::STEP_EMAIL)); $this->initialState->provideInitialState('email', $email); $this->initialState->provideInitialState('message', $message ?: $emailHint); $this->initialState->provideInitialState('emailIsOptional', $this->config->getAppValueBool('email_is_optional')); - $this->initialState->provideInitialState('disableEmailVerification', $this->config->getAppValueBool('disable_email_verification')); + $this->initialState->provideInitialState('disableEmailVerification', $this->config->getAppValueBool('disable_email_verification') || $this->invitationSkipsVerification($code)); $this->initialState->provideInitialState('isLoginFlow', $this->loginFlowService->isUsingLoginFlow()); $this->initialState->provideInitialState('loginFormLink', $this->urlGenerator->linkToRoute('core.login.showLoginForm')); + $this->initialState->provideInitialState('invitationCode', $code); + $this->initialState->provideInitialState('invitationCodeRequired', $this->config->getAppValueBool('invitation_code_required')); + $this->initialState->provideInitialState('invitationCodeLocked', $code !== ''); + $this->initialState->provideInitialState('invitationsEnabled', $this->config->getAppValueBool('invitation_code_required') || $code !== ''); return new TemplateResponse('registration', 'form/email', [], 'guest'); } + #[PublicPage] + #[NoCSRFRequired] + public function showInviteForm(string $code): Response { + try { + $invitation = $this->invitationService->getByCode($code); + $this->invitationService->assertUsable($invitation); + } catch (DoesNotExistException $e) { + return $this->validateSecretAndTokenErrorPage(); + } catch (RegistrationException $e) { + return $this->showEmailForm('', $e->getMessage(), $code); + } + + return $this->showEmailForm('', '', $code); + } + #[PublicPage] #[AnonRateLimit(limit: 5, period: 300)] - public function submitEmailForm(string $email): Response { + public function submitEmailForm(string $email, string $code = ''): Response { $validateFormEvent = new ValidateFormEvent(ValidateFormEvent::STEP_EMAIL); $this->eventDispatcher->dispatchTyped($validateFormEvent); if (!empty($validateFormEvent->getErrors())) { - return $this->showEmailForm($email, implode(' ', $validateFormEvent->getErrors())); + return $this->showEmailForm($email, implode(' ', $validateFormEvent->getErrors()), $code); + } + + try { + $invitation = $this->resolveInvitation($email, $code); + } catch (RegistrationException $e) { + return $this->showEmailForm($email, $e->getMessage(), $code); } try { // Registration already in progress, update token and continue with verification $registration = $this->registrationService->getRegistrationForEmail($email); $this->registrationService->generateNewToken($registration); + if ($invitation !== null && $registration->getInvitationId() === null) { + $registration->setInvitationId($invitation->getId()); + $this->registrationService->updateInvitation($registration); + } } catch (DoesNotExistException $e) { // No registration in progress try { $email = trim($email); - $this->registrationService->validateEmail($email); + $this->registrationService->validateEmail($email, $invitation); } catch (RegistrationException $e) { - return $this->showEmailForm($email, $e->getMessage()); + return $this->showEmailForm($email, $e->getMessage(), $code); } - $registration = $this->registrationService->createRegistration($email); + $registration = $this->registrationService->createRegistration($email, '', '', '', $invitation?->getId()); } - if ($this->config->getAppValueBool('disable_email_verification')) { + if ($this->config->getAppValueBool('disable_email_verification') + || ($invitation !== null && $invitation->getSkipEmailVerification())) { $this->eventDispatcher->dispatchTyped(new PassedFormEvent(PassedFormEvent::STEP_EMAIL, $registration->getClientSecret())); return new RedirectResponse( @@ -131,9 +172,9 @@ public function submitEmailForm(string $email): Response { try { $this->mailService->sendTokenByMail($registration); } catch (RegistrationException $e) { - return $this->showEmailForm($email, $e->getMessage()); + return $this->showEmailForm($email, $e->getMessage(), $code); } catch (\Exception $e) { - return $this->showEmailForm($email, $this->l10n->t('A problem occurred sending email, please contact your administrator.')); + return $this->showEmailForm($email, $this->l10n->t('A problem occurred sending email, please contact your administrator.'), $code); } $this->eventDispatcher->dispatchTyped(new PassedFormEvent(PassedFormEvent::STEP_EMAIL, $registration->getClientSecret())); @@ -146,6 +187,12 @@ public function submitEmailForm(string $email): Response { ); } + #[PublicPage] + #[AnonRateLimit(limit: 5, period: 300)] + public function submitInviteForm(string $code, string $email): Response { + return $this->submitEmailForm($email, $code); + } + #[PublicPage] #[NoCSRFRequired] public function showVerificationForm(string $secret, string $message = ''): TemplateResponse { @@ -327,4 +374,51 @@ protected function validateSecretAndTokenErrorPage(): TemplateResponse { ], ], 'error'); } + + /** + * Resolve and validate the invitation code, if any is required + * + * @param string $email + * @param string $code + * @return Invitation|null + * @throws RegistrationException + */ + protected function resolveInvitation(string $email, string $code): ?Invitation { + if ($code !== '') { + try { + $invitation = $this->invitationService->getByCode($code); + } catch (DoesNotExistException $e) { + throw new RegistrationException($this->l10n->t('This invitation code is not valid.')); + } + + $this->invitationService->validate($invitation, $email); + return $invitation; + } + + if ($this->config->getAppValueBool('invitation_code_required')) { + throw new RegistrationException($this->l10n->t('Please provide an invitation code.')); + } + + return null; + } + + /** + * Check whether an invitation code would skip the email verification step + * + * @param string $code + * @return bool + */ + protected function invitationSkipsVerification(string $code): bool { + if ($code === '') { + return false; + } + + try { + $invitation = $this->invitationService->getByCode($code); + } catch (DoesNotExistException $e) { + return false; + } + + return $invitation->getSkipEmailVerification() === true; + } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index a32ba5da..a1be8eca 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -34,6 +34,7 @@ public function __construct( * * @param string|null $registered_user_group all newly registered user will be put in this group * @param string $allowed_domains Registrations are only allowed for E-Mailadresses with these domains + * @param string $allowed_emails Registrations are only allowed for these exact E-Mailadresses * @param string $additional_hint show Text at user-creation form * @param string $email_verification_hint if filled embed Text in Verification mail send to user * @param string $username_policy_regex optional regex to check usernames against a pattern @@ -42,10 +43,12 @@ public function __construct( * @param bool|null $email_is_login email address is forced as user id * @param bool|null $domains_is_blocklist is the domain list an allow or block list * @param bool|null $show_domains should the email list be shown to the user or not + * @param bool|null $invitation_code_required all registrations need an invitation code * @return DataResponse */ public function admin(?string $registered_user_group, string $allowed_domains, + string $allowed_emails, string $additional_hint, string $email_verification_hint, string $username_policy_regex, @@ -58,7 +61,8 @@ public function admin(?string $registered_user_group, ?bool $enforce_phone, ?bool $domains_is_blocklist, ?bool $show_domains, - ?bool $disable_email_verification): DataResponse { + ?bool $disable_email_verification, + ?bool $invitation_code_required): DataResponse { // handle domains if ($allowed_domains === '') { $this->config->deleteAppValue('allowed_domains'); @@ -66,6 +70,13 @@ public function admin(?string $registered_user_group, $this->config->setAppValueString('allowed_domains', $allowed_domains); } + // handle allowed email addresses + if ($allowed_emails === '') { + $this->config->deleteAppValue('allowed_emails'); + } else { + $this->config->setAppValueString('allowed_emails', $allowed_emails); + } + // handle hints if ($additional_hint === '') { $this->config->deleteAppValue('additional_hint'); @@ -104,6 +115,7 @@ public function admin(?string $registered_user_group, $this->config->setAppValueBool('domains_is_blocklist', $domains_is_blocklist); $this->config->setAppValueBool('show_domains', $show_domains); $this->config->setAppValueBool('disable_email_verification', $disable_email_verification); + $this->config->setAppValueBool('invitation_code_required', $invitation_code_required); if ($registered_user_group === null) { $this->config->deleteAppValue('registered_user_group'); diff --git a/lib/Db/Invitation.php b/lib/Db/Invitation.php new file mode 100644 index 00000000..3f3717ca --- /dev/null +++ b/lib/Db/Invitation.php @@ -0,0 +1,66 @@ +addType('code', 'string'); + $this->addType('email', 'string'); + $this->addType('domain', 'string'); + $this->addType('quota', 'string'); + $this->addType('maxUses', 'integer'); + $this->addType('uses', 'integer'); + $this->addType('expires', 'datetime'); + $this->addType('createdBy', 'string'); + $this->addType('createdAt', 'datetime'); + $this->addType('skipEmailVerification', 'boolean'); + $this->addType('skipAdminApproval', 'boolean'); + $this->skipEmailVerification = false; + $this->skipAdminApproval = false; + } +} \ No newline at end of file diff --git a/lib/Db/InvitationMapper.php b/lib/Db/InvitationMapper.php new file mode 100644 index 00000000..c7a9ffe2 --- /dev/null +++ b/lib/Db/InvitationMapper.php @@ -0,0 +1,102 @@ + + */ +class InvitationMapper extends QBMapper { + public function __construct( + IDBConnection $db, + protected ISecureRandom $random, + ) { + parent::__construct($db, 'registration_invitation', Invitation::class); + } + + /** + * @param string $code + * @return Invitation + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findByCode(string $code): Entity { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('code', $query->createNamedParameter($code))); + + return $this->findEntity($query); + } + + /** + * @param int $id + * @return Invitation + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(int $id): Entity { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($query); + } + + /** + * @return Invitation[] + */ + public function findAllInvitations(): array { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->orderBy('created_at', 'DESC'); + + return $this->findEntities($query); + } + + /** + * @param int $id + */ + public function deleteById(int $id): void { + $query = $this->db->getQueryBuilder(); + $query->delete($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + #[\Override] + public function insert(Entity $entity): Entity { + $entity->setCreatedAt(date('Y-m-d H:i:s')); + return parent::insert($entity); + } + + /** + * @param Invitation $invitation + */ + public function incrementUses(Invitation $invitation): void { + $query = $this->db->getQueryBuilder(); + $query->update($this->getTableName()) + ->set('uses', $query->createFunction('uses + 1')) + ->where($query->expr()->eq('id', $query->createNamedParameter($invitation->getId(), IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + public function generateCode(): string { + return $this->random->generate(8, ISecureRandom::CHAR_HUMAN_READABLE); + } +} \ No newline at end of file diff --git a/lib/Db/Registration.php b/lib/Db/Registration.php index 17575658..f8441bc0 100644 --- a/lib/Db/Registration.php +++ b/lib/Db/Registration.php @@ -27,6 +27,8 @@ * @method void setClientSecret(string $clientSecret) * @method string getRequested() * @method void setRequested(string $requested) + * @method int|null getInvitationId() + * @method void setInvitationId(?int $invitationId) */ class Registration extends Entity { public $id; @@ -38,6 +40,7 @@ class Registration extends Entity { protected $requested; protected $emailConfirmed; protected $clientSecret; + protected $invitationId; public function __construct() { $this->addType('email', 'string'); @@ -48,5 +51,6 @@ public function __construct() { $this->addType('token', 'string'); $this->addType('clientSecret', 'string'); $this->addType('requested', 'datetime'); + $this->addType('invitationId', 'integer'); } } diff --git a/lib/Migration/Version0006Date20260814120000.php b/lib/Migration/Version0006Date20260814120000.php new file mode 100644 index 00000000..aa27b40d --- /dev/null +++ b/lib/Migration/Version0006Date20260814120000.php @@ -0,0 +1,78 @@ +hasTable('registration_invitation')) { + $table = $schema->createTable('registration_invitation'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('code', Types::STRING, [ + 'notnull' => true, + ]); + $table->addColumn('email', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('domain', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('quota', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('max_uses', Types::INTEGER, [ + 'notnull' => false, + ]); + $table->addColumn('uses', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('expires', Types::DATETIME, [ + 'notnull' => false, + ]); + $table->addColumn('created_by', Types::STRING, [ + 'notnull' => false, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['code'], 'registration_invitation_code_idx'); + } + + $registrationTable = $schema->getTable('registration'); + if (!$registrationTable->hasColumn('invitation_id')) { + $registrationTable->addColumn('invitation_id', Types::INTEGER, [ + 'notnull' => false, + 'unsigned' => true, + ]); + } + + return $schema; + } +} \ No newline at end of file diff --git a/lib/Migration/Version0007Date20260814190000.php b/lib/Migration/Version0007Date20260814190000.php new file mode 100644 index 00000000..9c91ca9a --- /dev/null +++ b/lib/Migration/Version0007Date20260814190000.php @@ -0,0 +1,41 @@ +hasTable('registration_invitation')) { + $table = $schema->getTable('registration_invitation'); + if (!$table->hasColumn('skip_email_verification')) { + $table->addColumn('skip_email_verification', Types::BOOLEAN, [ + 'notnull' => true, + 'default' => false, + ]); + } + } + + return $schema; + } +} \ No newline at end of file diff --git a/lib/Migration/Version0008Date20260815000000.php b/lib/Migration/Version0008Date20260815000000.php new file mode 100644 index 00000000..4565ed91 --- /dev/null +++ b/lib/Migration/Version0008Date20260815000000.php @@ -0,0 +1,41 @@ +hasTable('registration_invitation')) { + $table = $schema->getTable('registration_invitation'); + if (!$table->hasColumn('skip_admin_approval')) { + $table->addColumn('skip_admin_approval', Types::BOOLEAN, [ + 'notnull' => true, + 'default' => false, + ]); + } + } + + return $schema; + } +} \ No newline at end of file diff --git a/lib/Service/InvitationService.php b/lib/Service/InvitationService.php new file mode 100644 index 00000000..7be7e787 --- /dev/null +++ b/lib/Service/InvitationService.php @@ -0,0 +1,185 @@ +l10n->t('Please provide an invitation code.')); + } + + try { + $this->invitationMapper->findByCode($code); + throw new RegistrationException($this->l10n->t('An invitation with this code already exists.')); + } catch (DoesNotExistException $e) { + } + + if ($maxUses !== null && $maxUses < 1) { + throw new RegistrationException($this->l10n->t('The maximum number of uses needs to be at least one.')); + } + + $invitation = new Invitation(); + $invitation->setCode($code); + $invitation->setEmail($email !== null && $email !== '' ? strtolower($email) : null); + $invitation->setDomain($domain !== null && $domain !== '' ? strtolower($domain) : null); + $invitation->setQuota($quota !== null && $quota !== '' ? $quota : null); + $invitation->setMaxUses($maxUses); + $invitation->setUses(0); + $invitation->setExpires($expires); + $invitation->setSkipEmailVerification($skipEmailVerification); + $invitation->setSkipAdminApproval($skipAdminApproval); + + return $this->invitationMapper->insert($invitation); + } + + /** + * @param string $code + * @return Invitation + * @throws DoesNotExistException + */ + public function getByCode(string $code): Invitation { + return $this->invitationMapper->findByCode($code); + } + + /** + * @param int $id + * @return Invitation + * @throws DoesNotExistException + */ + public function getById(int $id): Invitation { + return $this->invitationMapper->findById($id); + } + + /** + * @return Invitation[] + */ + public function getAll(): array { + return $this->invitationMapper->findAllInvitations(); + } + + public function deleteById(int $id): void { + $this->invitationMapper->deleteById($id); + } + + public function generateCode(): string { + return $this->invitationMapper->generateCode(); + } + + public function generateLink(Invitation $invitation): string { + return $this->urlGenerator->linkToRouteAbsolute('registration.register.showInviteForm', [ + 'code' => $invitation->getCode(), + ]); + } + + public function isExpired(Invitation $invitation): bool { + $expires = $invitation->getExpires(); + if ($expires === null) { + return false; + } + + if ($expires instanceof \DateTimeInterface) { + $expireTimestamp = $expires->getTimestamp(); + } else { + $expireTimestamp = strtotime((string)$expires); + } + + return $expireTimestamp !== false && $expireTimestamp < $this->timeFactory->getTime(); + } + + public function isMaxUsesReached(Invitation $invitation): bool { + $maxUses = $invitation->getMaxUses(); + if ($maxUses === null) { + return false; + } + + return $invitation->getUses() >= $maxUses; + } + + /** + * @param Invitation $invitation + * @throws RegistrationException + */ + public function assertUsable(Invitation $invitation): void { + if ($this->isExpired($invitation)) { + throw new RegistrationException($this->l10n->t('This invitation is no longer valid.')); + } + + if ($this->isMaxUsesReached($invitation)) { + throw new RegistrationException($this->l10n->t('This invitation has already been used up.')); + } + } + + /** + * @param Invitation $invitation + * @param string $email + * @throws RegistrationException + */ + public function validate(Invitation $invitation, string $email): void { + $this->assertUsable($invitation); + + $allowedEmail = $invitation->getEmail(); + if ($allowedEmail !== null && strtolower($email) !== strtolower($allowedEmail)) { + throw new RegistrationException($this->l10n->t('This invitation is not valid for this email address.')); + } + + $allowedDomain = $invitation->getDomain(); + if ($allowedDomain !== null && !$this->domainMatches($email, $allowedDomain)) { + throw new RegistrationException($this->l10n->t('This invitation is not valid for this email domain.')); + } + } + + public function incrementUses(Invitation $invitation): void { + $this->invitationMapper->incrementUses($invitation); + } + + private function domainMatches(string $email, string $allowedDomain): bool { + $parts = explode('@', strtolower($email), 2); + if (count($parts) !== 2 || $parts[1] === '') { + return false; + } + $mailDomain = $parts[1]; + + if (str_contains($allowedDomain, '*')) { + $regexDomain = preg_quote($allowedDomain, '\\'); + $regexDomain = '/^' . str_replace('\\*', '.+', $regexDomain) . '$/'; + return (bool)preg_match($regexDomain, $mailDomain); + } + + return $mailDomain === $allowedDomain; + } +} \ No newline at end of file diff --git a/lib/Service/RegistrationService.php b/lib/Service/RegistrationService.php index 94ebe847..ed06a32c 100644 --- a/lib/Service/RegistrationService.php +++ b/lib/Service/RegistrationService.php @@ -14,6 +14,7 @@ use OC\Authentication\Exceptions\PasswordlessTokenException; use OC\Authentication\Token\IProvider; use OCA\Registration\AppInfo\Application; +use OCA\Registration\Db\Invitation; use OCA\Registration\Db\Registration; use OCA\Registration\Db\RegistrationMapper; use OCA\Settings\Mailer\NewUserMailHelper; @@ -58,6 +59,7 @@ public function __construct( private IProvider $tokenProvider, private ICrypto $crypto, private IPhoneNumberUtil $phoneNumberUtil, + private InvitationService $invitationService, ) { } @@ -71,10 +73,14 @@ public function generateNewToken(Registration $registration): void { $this->registrationMapper->update($registration); } + public function updateInvitation(Registration $registration): void { + $this->registrationMapper->update($registration); + } + /** * Create registration request, used by both the API and form */ - public function createRegistration(string $email, string $username = '', string $password = '', string $displayname = ''): Registration { + public function createRegistration(string $email, string $username = '', string $password = '', string $displayname = '', ?int $invitationId = null): Registration { $registration = new Registration(); $registration->setEmail($email); $registration->setUsername($username); @@ -83,6 +89,7 @@ public function createRegistration(string $email, string $username = '', string $password = $this->crypto->encrypt($password); $registration->setPassword($password); } + $registration->setInvitationId($invitationId); $this->registrationMapper->generateNewToken($registration); $this->registrationMapper->generateClientSecret($registration); $this->registrationMapper->insert($registration); @@ -91,9 +98,10 @@ public function createRegistration(string $email, string $username = '', string /** * @param string $email + * @param Invitation|null $invitation an admin-issued invitation bypasses the general allow-list * @throws RegistrationException */ - public function validateEmail(string $email): void { + public function validateEmail(string $email, ?Invitation $invitation = null): void { if ($email === '' && $this->appConfig->getAppValueBool('email_is_optional')) { return; } @@ -116,9 +124,20 @@ public function validateEmail(string $email): void { ); } + // An admin-issued invitation bypasses the general allow-list + if ($invitation !== null) { + return; + } + $allowedDomains = $this->getAllowedDomains(); + $allowedEmails = $this->getAllowedEmails(); + $emailIsInEmailList = in_array(strtolower($email), $allowedEmails, true); - if (empty($allowedDomains)) { + if ($emailIsInEmailList) { + return; + } + + if (empty($allowedDomains) && empty($allowedEmails)) { return; } @@ -248,6 +267,17 @@ public function getAllowedDomains(): array { return array_map('strtolower', $allowedDomains); } + /** + * @return string[] exact email addresses allowed to register + */ + public function getAllowedEmails(): array { + $allowedEmails = $this->appConfig->getAppValueString('allowed_emails'); + $allowedEmails = explode(';', $allowedEmails); + $allowedEmails = array_map('trim', $allowedEmails); + $allowedEmails = array_filter($allowedEmails); + return array_map('strtolower', $allowedEmails); + } + /** * @param Registration $registration * @param string|null $loginName @@ -277,6 +307,18 @@ public function createAccount(Registration $registration, ?string $loginName = n $this->validateDisplayname($fullName); } + // Load the invitation and re-validate it before creating the account + $invitation = null; + $invitationId = $registration->getInvitationId(); + if ($invitationId !== null) { + try { + $invitation = $this->invitationService->getById($invitationId); + $this->invitationService->validate($invitation, $registration->getEmail()); + } catch (DoesNotExistException $e) { + // The invitation was deleted in the meantime, continue without it + } + } + if (class_exists(PhoneNumberUtil::class) && $this->appConfig->getAppValueBool('show_phone')) { if ($phone) { @@ -299,6 +341,15 @@ public function createAccount(Registration $registration, ?string $loginName = n } $userId = $user->getUID(); + // Apply the quota and consume a use of the invitation, if any + if ($invitation !== null) { + $quota = $invitation->getQuota(); + if ($quota !== null && $quota !== '') { + $user->setQuota($quota); + } + $this->invitationService->incrementUses($invitation); + } + // Set user email try { $user->setEMailAddress($registration->getEmail()); @@ -343,8 +394,9 @@ public function createAccount(Registration $registration, ?string $loginName = n $groupId = ''; } - // disable user if this is requested by config - $adminApprovalRequired = $this->appConfig->getAppValueBool('admin_approval_required'); + // disable user if this is requested by config (unless the invitation opts out) + $adminApprovalRequired = $this->appConfig->getAppValueBool('admin_approval_required') + && !($invitation !== null && $invitation->getSkipAdminApproval() === true); if ($adminApprovalRequired) { $user->setEnabled(false); $this->config->setUserValue($userId, Application::APP_ID, 'send_welcome_mail_on_enable', 'yes'); diff --git a/lib/Settings/RegistrationSettings.php b/lib/Settings/RegistrationSettings.php index 183113ca..ae47cf1c 100644 --- a/lib/Settings/RegistrationSettings.php +++ b/lib/Settings/RegistrationSettings.php @@ -43,6 +43,10 @@ public function getForm(): TemplateResponse { 'allowed_domains', $this->config->getAppValueString('allowed_domains') ); + $this->initialState->provideInitialState( + 'allowed_emails', + $this->config->getAppValueString('allowed_emails') + ); $this->initialState->provideInitialState( 'domains_is_blocklist', $this->config->getAppValueBool('domains_is_blocklist') @@ -55,6 +59,10 @@ public function getForm(): TemplateResponse { 'disable_email_verification', $this->config->getAppValueBool('disable_email_verification') ); + $this->initialState->provideInitialState( + 'invitation_code_required', + $this->config->getAppValueBool('invitation_code_required') + ); $this->initialState->provideInitialState( 'email_is_optional', $this->config->getAppValueBool('email_is_optional') diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index f0d69c18..278c2731 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -53,6 +53,14 @@ placeholder="nextcloud.com;*.example.com" @update:modelValue="debounceSavingSlow" /> + + {{ t('registration', 'Disable email verification') }} + + + {{ t('registration', 'Require an invitation code for all registrations') }} + +

{{ t('registration', 'If enabled, users have to enter a valid invitation code. Administrators can create invitation links and codes below.') }}

+ + (null) const adminApproval = ref(loadState('registration', 'admin_approval_required')) const registeredUserGroup = ref(loadState('registration', 'registered_user_group')) const allowedDomains = ref(loadState('registration', 'allowed_domains')) +const allowedEmails = ref(loadState('registration', 'allowed_emails')) const domainsIsBlocklist = ref(loadState('registration', 'domains_is_blocklist')) const showDomains = ref(loadState('registration', 'show_domains')) const emailIsOptional = ref(loadState('registration', 'email_is_optional')) const disableEmailVerification = ref(loadState('registration', 'disable_email_verification')) const emailIsLogin = ref(loadState('registration', 'email_is_login')) +const invitationCodeRequired = ref(loadState('registration', 'invitation_code_required')) const usernamePolicyRegex = ref(loadState('registration', 'username_policy_regex')) const showFullname = ref(loadState('registration', 'show_fullname')) const enforceFullname = ref(loadState('registration', 'enforce_fullname')) @@ -220,6 +242,8 @@ const domainListLabel = computed(() => { return t('registration', 'Allowed email domains') }) +const emailListLabel = computed(() => t('registration', 'Allowed email addresses')) + const showDomainListLabel = computed(() => { if (domainsIsBlocklist.value) { return t('registration', 'Show the blocked email domains to users') @@ -246,11 +270,13 @@ async function saveData() { admin_approval_required: adminApproval.value, registered_user_group: registeredUserGroup.value?.id, allowed_domains: allowedDomains.value, + allowed_emails: allowedEmails.value, domains_is_blocklist: domainsIsBlocklist.value, show_domains: showDomains.value, email_is_optional: emailIsOptional.value, disable_email_verification: emailIsOptional.value || disableEmailVerification.value, email_is_login: !emailIsOptional.value && emailIsLogin.value, + invitation_code_required: invitationCodeRequired.value, username_policy_regex: usernamePolicyRegex.value, show_fullname: showFullname.value, enforce_fullname: enforceFullname.value, diff --git a/src/components/InvitationSettings.vue b/src/components/InvitationSettings.vue new file mode 100644 index 00000000..1dcc4f7c --- /dev/null +++ b/src/components/InvitationSettings.vue @@ -0,0 +1,435 @@ + + + + + + diff --git a/src/components/RegistrationEmail.vue b/src/components/RegistrationEmail.vue index 1d39cdff..c42a49b6 100644 --- a/src/components/RegistrationEmail.vue +++ b/src/components/RegistrationEmail.vue @@ -10,6 +10,19 @@ {{ message }} + + + + ('registration', 'emailIsOptional') const message = loadState('registration', 'message') @@ -59,7 +73,16 @@ const requesttoken = getRequestToken() const disableEmailVerification = loadState('registration', 'disableEmailVerification') const isLoginFlow = loadState('registration', 'isLoginFlow') const loginFormLink = loadState('registration', 'loginFormLink') +const invitationsEnabled = loadState('registration', 'invitationsEnabled') +const invitationCodeRequired = loadState('registration', 'invitationCodeRequired') +const invitationCodeLocked = loadState('registration', 'invitationCodeLocked') +const invitationCode = ref(loadState('registration', 'invitationCode')) +const invitationLabel = computed(() => { + return invitationCodeLocked + ? t('registration', 'Invitation code') + : t('registration', invitationCodeRequired ? 'Invitation code' : 'Invitation code (optional)') +}) const emailLabel = computed(() => { return emailIsOptional ? t('registration', 'Email (optional)') diff --git a/tests/Unit/Controller/RegisterControllerTest.php b/tests/Unit/Controller/RegisterControllerTest.php index 5215fc6e..696d0f3d 100644 --- a/tests/Unit/Controller/RegisterControllerTest.php +++ b/tests/Unit/Controller/RegisterControllerTest.php @@ -11,6 +11,7 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Registration\Controller\RegisterController; use OCA\Registration\Db\Registration; +use OCA\Registration\Service\InvitationService; use OCA\Registration\Service\LoginFlowService; use OCA\Registration\Service\MailService; use OCA\Registration\Service\RegistrationException; @@ -39,6 +40,7 @@ class RegisterControllerTest extends TestCase { private RegistrationService&MockObject $registrationService; private LoginFlowService&MockObject $loginFlowService; private MailService&MockObject $mailService; + private InvitationService&MockObject $invitationService; private IEventDispatcher&MockObject $eventDispatcher; private IInitialState&MockObject $initialState; @@ -51,6 +53,7 @@ public function setUp(): void { $this->registrationService = $this->createMock(RegistrationService::class); $this->loginFlowService = $this->createMock(LoginFlowService::class); $this->mailService = $this->createMock(MailService::class); + $this->invitationService = $this->createMock(InvitationService::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->initialState = $this->createMock(IInitialState::class); @@ -76,6 +79,7 @@ protected function getController(array $methods = []) { $this->registrationService, $this->loginFlowService, $this->mailService, + $this->invitationService, $this->eventDispatcher, $this->initialState ); @@ -92,6 +96,7 @@ protected function getController(array $methods = []) { $this->registrationService, $this->loginFlowService, $this->mailService, + $this->invitationService, $this->eventDispatcher, $this->initialState, ]) @@ -286,8 +291,55 @@ public function testSubmitEmailFormResendPendingRequest(): void { self::assertSame('["registration.register.showVerificationForm",{"secret":"clientSecret"}]', $response->getRedirectURL()); } - public static function dataShowVerificationForm(): array { - return [ + public function testSubmitEmailFormSkipVerificationWithInvitation(): void { + $email = 'nextcloud@example.tld'; + $code = 'INVITE'; + + $this->registrationService + ->method('getRegistrationForEmail') + ->with($email) + ->willThrowException(new DoesNotExistException($email)); + + $invitation = new \OCA\Registration\Db\Invitation(); + $invitation->setCode($code); + $invitation->setSkipEmailVerification(true); + + $this->invitationService + ->expects($this->once()) + ->method('getByCode') + ->with($code) + ->willReturn($invitation); + + $this->registrationService + ->expects($this->once()) + ->method('createRegistration') + ->with($email, '', '', '', $this->anything()) + ->willReturnCallback(function ($email, $username, $password, $displayname, $invitationId) { + return Registration::fromParams([ + 'clientSecret' => 'clientSecret', + 'token' => 'token', + ]); + }); + + $this->mailService + ->expects($this->never()) + ->method('sendTokenByMail'); + + $this->urlGenerator + ->method('linkToRoute') + ->willReturnCallback(function () { + return json_encode(func_get_args()); + }); + + $controller = $this->getController(); + $response = $controller->submitEmailForm($email, $code); + + self::assertInstanceOf(RedirectResponse::class, $response); + /** @var RedirectResponse $response */ + self::assertSame('["registration.register.showUserForm",{"secret":"clientSecret","token":"token"}]', $response->getRedirectURL()); + } + + public static function dataShowVerificationForm(): array { return [ [''], ['The entered verification code is wrong'], ]; diff --git a/tests/Unit/Service/InvitationServiceTest.php b/tests/Unit/Service/InvitationServiceTest.php new file mode 100644 index 00000000..c1f8154b --- /dev/null +++ b/tests/Unit/Service/InvitationServiceTest.php @@ -0,0 +1,192 @@ +get(ISecureRandom::class); + $mapper = new InvitationMapper( + \OC::$server->get(IDBConnection::class), + $random + ); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRouteAbsolute') + ->willReturn('https://example.com/apps/registration/invite/CODE'); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t') + ->willReturnCallback(function ($text, $parameters = []) { + return vsprintf($text, $parameters); + }); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('getTime') + ->willReturn(1000000000); + + $this->service = new InvitationService( + $mapper, + $urlGenerator, + $l10n, + $this->timeFactory, + ); + } + + private function createInvitation(array $overrides = []): Invitation { + $params = array_merge([ + 'code' => 'TESTCODE', + 'email' => '', + 'domain' => '', + 'quota' => '5 GB', + 'max_uses' => '', + 'expires' => '', + 'skip_email_verification' => false, + 'skip_admin_approval' => false, + ], $overrides); + + return $this->service->createInvitation( + $params['code'], + $params['email'], + $params['domain'], + $params['quota'], + $params['max_uses'] !== '' ? (int)$params['max_uses'] : null, + $params['expires'] !== '' ? $params['expires'] : null, + $params['skip_email_verification'], + $params['skip_admin_approval'], + ); + } + + public function testCreateAndGetByCode(): void { + $invitation = $this->createInvitation(); + + $found = $this->service->getByCode('TESTCODE'); + + self::assertSame('TESTCODE', $found->getCode()); + self::assertSame('5 GB', $found->getQuota()); + self::assertSame(0, $found->getUses()); + self::assertNotNull($invitation->getId()); + } + + public function testCreateDuplicateCodeThrows(): void { + $this->createInvitation(); + + $this->expectException(RegistrationException::class); + $this->createInvitation(['quota' => '']); + } + + public function testCreateEmptyCodeThrows(): void { + $this->expectException(RegistrationException::class); + $this->createInvitation(['code' => '']); + } + + public function testGetUnknownCodeThrows(): void { + $this->expectException(DoesNotExistException::class); + $this->service->getByCode('UNKNOWN'); + } + + public function testDeleteById(): void { + $invitation = $this->createInvitation(); + $this->service->deleteById($invitation->getId()); + + $this->expectException(DoesNotExistException::class); + $this->service->getByCode('TESTCODE'); + } + + public function testValidateOk(): void { + $invitation = $this->createInvitation(); + $this->service->validate($invitation, 'user@example.com'); + self::assertTrue(true); + } + + public function testValidateEmailRestriction(): void { + $invitation = $this->createInvitation(['email' => 'foo@example.com']); + $this->service->validate($invitation, 'foo@example.com'); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'bar@example.com'); + } + + public function testValidateDomainRestriction(): void { + $invitation = $this->createInvitation(['domain' => 'example.com']); + $this->service->validate($invitation, 'foo@example.com'); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'foo@example.tld'); + } + + public function testValidateDomainRestrictionInvalidEmail(): void { + $invitation = $this->createInvitation(['domain' => '*.example.com']); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'not-an-email-address'); + } + + public function testValidateExpired(): void { + $invitation = $this->createInvitation(['expires' => '2000-01-01 00:00:00']); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'foo@example.com'); + } + + public function testValidateMaxUsesReached(): void { + $invitation = $this->createInvitation(['max_uses' => '1']); + $this->service->incrementUses($invitation); + + $this->expectException(RegistrationException::class); + $this->service->validate($invitation, 'foo@example.com'); + } + + public function testIncrementUses(): void { + $invitation = $this->createInvitation(['max_uses' => '2']); + $this->service->incrementUses($invitation); + + $found = $this->service->getById($invitation->getId()); + self::assertSame(1, $found->getUses()); + } + + public function testGenerateLink(): void { + $invitation = $this->createInvitation(); + self::assertSame('https://example.com/apps/registration/invite/CODE', $this->service->generateLink($invitation)); + } + + public function testSkipEmailVerificationFlag(): void { + $invitation = $this->createInvitation(['skip_email_verification' => true]); + self::assertTrue($invitation->getSkipEmailVerification()); + + $default = $this->createInvitation(['code' => 'DEFAULT']); + self::assertFalse($default->getSkipEmailVerification()); + } + + public function testSkipAdminApprovalFlag(): void { + $invitation = $this->createInvitation(['skip_admin_approval' => true]); + self::assertTrue($invitation->getSkipAdminApproval()); + + $default = $this->createInvitation(['code' => 'DEFAULT']); + self::assertFalse($default->getSkipAdminApproval()); + } +} diff --git a/tests/Unit/Service/RegistrationServiceTest.php b/tests/Unit/Service/RegistrationServiceTest.php index 680ec037..1cac039d 100644 --- a/tests/Unit/Service/RegistrationServiceTest.php +++ b/tests/Unit/Service/RegistrationServiceTest.php @@ -12,6 +12,7 @@ use OC\Authentication\Token\IProvider; use OCA\Registration\Db\Registration; use OCA\Registration\Db\RegistrationMapper; +use OCA\Registration\Service\InvitationService; use OCA\Registration\Service\MailService; use OCA\Registration\Service\RegistrationException; use OCA\Registration\Service\RegistrationService; @@ -97,7 +98,8 @@ public function setUp(): void { $session, $tokenProvider, $this->crypto, - $this->phoneNumberUtil + $this->phoneNumberUtil, + $this->createMock(InvitationService::class) ); } @@ -125,10 +127,14 @@ public static function dataValidateEmail(): array { */ #[DataProvider('dataValidateEmail')] public function testValidateEmail(string $email, string $allowedDomains, bool $blocked) { - $this->appConfig->expects($this->once()) + $this->appConfig->expects($this->atLeastOnce()) ->method('getAppValueString') - ->with('allowed_domains') - ->willReturn($allowedDomains); + ->willReturnCallback(function ($key) use ($allowedDomains) { + if ($key === 'allowed_domains') { + return $allowedDomains; + } + return ''; + }); $this->appConfig->expects($this->exactly($allowedDomains === '' ? 0 : 2)) ->method('getAppValueBool')