Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
];
109 changes: 109 additions & 0 deletions lib/Controller/InvitationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Registration\Controller;

use OCA\Registration\Db\Invitation;
use OCA\Registration\Service\InvitationService;
use OCA\Registration\Service\RegistrationException;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IL10N;
use OCP\IRequest;

class InvitationController extends Controller {

public function __construct(
string $appName,
IRequest $request,
private InvitationService $invitationService,
private IL10N $l10n,
) {
parent::__construct($appName, $request);
}

#[AdminRequired]

Check failure on line 32 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

UndefinedAttributeClass

lib/Controller/InvitationController.php:32:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)

Check failure on line 32 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

UndefinedAttributeClass

lib/Controller/InvitationController.php:32:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)

Check failure on line 32 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

UndefinedAttributeClass

lib/Controller/InvitationController.php:32:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)
public function index(): DataResponse {
$invitations = array_map(
fn(Invitation $invitation) => $this->serialize($invitation),
$this->invitationService->getAll()
);

return new DataResponse($invitations);
}

#[AdminRequired]

Check failure on line 42 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

UndefinedAttributeClass

lib/Controller/InvitationController.php:42:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)

Check failure on line 42 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

UndefinedAttributeClass

lib/Controller/InvitationController.php:42:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)

Check failure on line 42 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

UndefinedAttributeClass

lib/Controller/InvitationController.php:42:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)
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]

Check failure on line 72 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable34

UndefinedAttributeClass

lib/Controller/InvitationController.php:72:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)

Check failure on line 72 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

UndefinedAttributeClass

lib/Controller/InvitationController.php:72:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)

Check failure on line 72 in lib/Controller/InvitationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

UndefinedAttributeClass

lib/Controller/InvitationController.php:72:4: UndefinedAttributeClass: Attribute class OCP\AppFramework\Http\Attribute\AdminRequired does not exist (see https://psalm.dev/241)
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;
}
}
114 changes: 104 additions & 10 deletions lib/Controller/RegisterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
) {
Expand All @@ -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')) {
Expand All @@ -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(
Expand All @@ -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()));
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
}
14 changes: 13 additions & 1 deletion lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -58,14 +61,22 @@ 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');
} else {
$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');
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading