Add open student registration with email confirmation and approval
CI / Tests (PHP 8.1) (pull_request) Successful in 1m18s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m18s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 3m20s
CI / Coding Standards (pull_request) Successful in 3m25s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m33s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m18s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m18s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 3m20s
CI / Coding Standards (pull_request) Successful in 3m25s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m33s
CI / Build Plugin Zip (pull_request) Skipped
Students could previously join by invite only. Add an optional self-approval mode, toggled from Studio Settings → Registration: anyone may sign up on the existing [us_student_register] page, confirm their email via a tokenised link, and then be approved by a studio admin before the account is usable. - Enabling the toggle mirrors WordPress's own membership settings (users_can_register + default_role = us_student) and snapshots their previous values so disabling restores them. - WordPress's native registration form is blocked while open registration is on (login_init redirect + registration_errors fail-safe + register_url) so it cannot bypass signup policy acceptance. - Pending accounts: unconfirmed email cannot log in; confirmed but unapproved can log in but the booking capability is withheld and the booking page shows an "awaiting approval" screen. - Approve/reject from Students → Pending Students; reject hard-deletes the account so the email is freed to re-apply. - Invite registration is unchanged; both modes coexist. Account lifecycle lives in user meta (RegistrationStatus); no new tables. Closes #63 Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The account lifecycle for a self-signup student, expressed entirely as user
|
||||
* meta so it lives alongside the WordPress user and needs no extra table.
|
||||
*
|
||||
* States (see {@see docs/features/account-registration.md}):
|
||||
* - Email unconfirmed — `us_awaiting_approval='1'`, no `us_email_confirmed`, a
|
||||
* hashed confirmation token + expiry set. Login is blocked.
|
||||
* - Confirmed, awaiting approval — `us_awaiting_approval='1'`,
|
||||
* `us_email_confirmed='1'`, token/expiry cleared. Login allowed but the
|
||||
* booking capability is withheld.
|
||||
* - Approved / active — `us_awaiting_approval` deleted; a normal student.
|
||||
*
|
||||
* Invite- and admin-created students carry none of these metas, so they behave
|
||||
* exactly as before.
|
||||
*/
|
||||
class RegistrationStatus {
|
||||
|
||||
public const META_AWAITING_APPROVAL = 'us_awaiting_approval';
|
||||
public const META_EMAIL_CONFIRMED = 'us_email_confirmed';
|
||||
public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
|
||||
public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires';
|
||||
|
||||
/**
|
||||
* Hours a self-signup email-confirmation link stays valid after the account
|
||||
* is created. Limits the window in which a leaked link can be redeemed.
|
||||
*/
|
||||
public const EMAIL_CONFIRM_EXPIRY_HOURS = 48;
|
||||
|
||||
/**
|
||||
* Hash a raw confirmation token for storage and lookup. Only the hash is
|
||||
* persisted (mirrors {@see Invite::hashToken()}), so a database leak cannot
|
||||
* be used to confirm an account — the raw token exists only in the email.
|
||||
*/
|
||||
public static function hashToken( string $rawToken ): string {
|
||||
return hash( 'sha256', $rawToken );
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a freshly created user into the pending state and issue an email
|
||||
* confirmation token. Returns the raw token to embed in the emailed link.
|
||||
*/
|
||||
public static function markPending( int $userId ): string {
|
||||
$rawToken = wp_generate_password( 32, false );
|
||||
|
||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
||||
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
|
||||
update_user_meta(
|
||||
$userId,
|
||||
self::META_CONFIRM_EXPIRES,
|
||||
gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 )
|
||||
);
|
||||
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the account's email confirmed and discard the (now spent) token. The
|
||||
* account stays awaiting approval.
|
||||
*/
|
||||
public static function confirmEmail( int $userId ): void {
|
||||
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the account: clear the pending flag and any leftover token so the
|
||||
* student becomes a normal, active student.
|
||||
*/
|
||||
public static function approve( int $userId ): void {
|
||||
delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
|
||||
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
|
||||
}
|
||||
|
||||
public static function isAwaitingApproval( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
|
||||
}
|
||||
|
||||
public static function emailConfirmed( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user awaiting confirmation whose stored hash matches the supplied
|
||||
* raw token, or null when none matches.
|
||||
*/
|
||||
public static function userIdForToken( string $rawToken ): ?int {
|
||||
if ( '' === $rawToken ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$users = get_users(
|
||||
[
|
||||
'meta_key' => self::META_CONFIRM_TOKEN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_value' => self::hashToken( $rawToken ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
'number' => 1,
|
||||
'fields' => 'ID',
|
||||
]
|
||||
);
|
||||
|
||||
if ( [] === $users ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Val::int( $users[0] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the confirmation token for a user has passed its expiry, measured
|
||||
* against the supplied `Y-m-d H:i:s` (UTC) timestamp. A user with no stored
|
||||
* expiry is treated as expired (there is nothing valid to confirm).
|
||||
*/
|
||||
public static function isTokenExpired( int $userId, string $now ): bool {
|
||||
$expires = Val::string( get_user_meta( $userId, self::META_CONFIRM_EXPIRES, true ) );
|
||||
if ( '' === $expires ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$expiresTs = strtotime( $expires );
|
||||
$nowTs = strtotime( $now );
|
||||
if ( false === $expiresTs || false === $nowTs ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $nowTs > $expiresTs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user