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', 'If enabled, users have to enter a valid invitation code. Administrators can create invitation links and codes below.') }}
+{{ t('registration', 'If enabled, the email address does not need to be verified and the user can create the account right after entering their email address.') }}
+ +{{ t('registration', 'If enabled, the account is enabled immediately even when administrator approval is required.') }}
+ +| {{ t('registration', 'Code') }} | +{{ t('registration', 'Restriction') }} | +{{ t('registration', 'Quota') }} | +{{ t('registration', 'Uses') }} | +{{ t('registration', 'Expires') }} | +{{ t('registration', 'Link') }} | ++ |
|---|---|---|---|---|---|---|
{{ invitation.code }} |
+ {{ restrictionLabel(invitation) }} | +{{ invitation.quota || '—' }} | +{{ invitation.uses }}{{ invitation.max_uses ? ` / ${invitation.max_uses}` : '' }} | +{{ invitation.expires ? formatDate(invitation.expires) : '—' }} | +
+ |
+
+ |
+
+ {{ t('registration', 'Share the link or the code below with the person you want to invite.') }} +
+