Lock the registration email to the invite only when the invite is redeemable #82

Merged
thatguygriff merged 1 commits from fix/invite-email-lock into main 2026-07-22 13:31:17 +00:00
4 changed files with 54 additions and 5 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
## Flow (invite mode) ## Flow (invite mode)
1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite. 1. Studio admin opens **Invites** (`manage_students`) and invites an email; an invite row is created storing the token's SHA-256 hash, and the registration link (with the raw token) is shown **once** in a notice. To re-send a lost link, revoke and re-invite.
2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash. 2. The invitee opens `[us_student_register]` with the token (`?us_invite=<token>`); the lookup hashes the submitted token and matches it against the stored hash.
3. The form pre-fills the email and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. 3. The form shows the invited email **pre-filled and read-only** (the server always uses the invite's address on submit, so a tampered value is ignored) and collects a display name and password, and renders the signup-scoped published policies, each with a required acceptance checkbox. A token that is no longer redeemable (expired / accepted / revoked) renders the normal editable email field instead when open registration is on.
4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in. 4. On submit, the token is re-validated (hashed lookup); a `us_student` user is created, the policy acceptances are recorded (`account` type), the invite is marked `accepted`, and the user is logged in.
## Flow (self-approval mode) ## Flow (self-approval mode)
+7 -1
View File
@@ -45,6 +45,12 @@ class RegistrationPage {
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null; $invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
$open = $this->settings->openRegistrationEnabled(); $open = $this->settings->openRegistrationEnabled();
// Only a redeemable invite fixes the form's email to the invited address.
// A stale token (expired / accepted / revoked) with open registration on
// must fall back to the normal editable email field, not show — and then
// fail to submit — the stale invite's address.
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
$error = ''; $error = '';
$successType = ''; $successType = '';
@@ -65,7 +71,7 @@ class RegistrationPage {
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) ); $loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
$policyForms = $this->signupPolicies(); $policyForms = $this->signupPolicies();
$canRegister = $open || ( null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ) ); $canRegister = $open || $inviteValid;
ob_start(); ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
+2 -1
View File
@@ -7,6 +7,7 @@ if (! defined('ABSPATH')) {
/** /**
* @var \Unsupervised\Schedular\Auth\Invite|null $invite * @var \Unsupervised\Schedular\Auth\Invite|null $invite
* @var bool $inviteValid Whether $invite can still be redeemed — only then is the email fixed.
* @var string $token Raw invite token from the request (only its hash is stored). * @var string $token Raw invite token from the request (only its hash is stored).
* @var bool $canRegister * @var bool $canRegister
* @var bool $open Whether open (self-approval) registration is enabled. * @var bool $open Whether open (self-approval) registration is enabled.
@@ -43,7 +44,7 @@ if (! defined('ABSPATH')) {
<p> <p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label> <label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($invite !== null) : ?> <?php if ($inviteValid && $invite !== null) : ?>
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly> <input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
<?php else : ?> <?php else : ?>
<input type="email" name="email" id="us-reg-email" autocomplete="email" required> <input type="email" name="email" id="us-reg-email" autocomplete="email" required>
+44 -2
View File
@@ -56,8 +56,9 @@ class RegistrationPageTest extends TestCase
protected function tearDown(): void protected function tearDown(): void
{ {
$_POST = []; $_POST = [];
$_GET = []; $_GET = [];
$_REQUEST = [];
parent::tearDown(); parent::tearDown();
} }
@@ -176,6 +177,47 @@ class RegistrationPageTest extends TestCase
self::assertStringContainsString('<form', $html); self::assertStringContainsString('<form', $html);
} }
public function testValidInviteRendersEmailPrefilledAndLocked(): void
{
$_REQUEST = ['us_invite' => 'raw-token'];
$this->stubRenderContext();
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
$this->ctx['invites']->shouldReceive('findByToken')
->once()
->with(Invite::hashToken('raw-token'))
->andReturn($invite);
$html = $this->ctx['page']->render([]);
// The invited address is shown read-only; there is no editable email input.
self::assertStringContainsString('value="[email protected]" readonly', $html);
self::assertStringNotContainsString('name="email"', $html);
}
public function testStaleInviteWithOpenRegistrationShowsEditableEmail(): void
{
$_REQUEST = ['us_invite' => 'raw-token'];
$this->stubRenderContext();
// Already-redeemed invite: not acceptable, so the open-registration form
// must collect an email rather than showing the stale locked address.
$invite = new Invite(
email: '[email protected]',
token: 'hash',
status: Invite::STATUS_ACCEPTED,
createdAt: '2024-01-01 00:00:00',
id: 9
);
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
$html = $this->ctx['page']->render([]);
self::assertStringContainsString('name="email"', $html);
self::assertStringNotContainsString('[email protected]', $html);
self::assertStringNotContainsString('readonly', $html);
}
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{ {
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ]; $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];