CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m46s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A studio admin can generate a shareable group invite link (e.g. for a newsletter) from the Invites page, choosing a required expiry date. Anyone with the link may register while it is valid, in any registration mode: the form collects their own email, they must confirm it via the usual hashed token, and confirming approves the account immediately — group signups never enter the Pending Students queue. - us_invites grows kind (personal/group) and expires_at; an explicit expiry wins over the personal 14-day window. Group links stay pending (multi-use) until revoked or expired. - RegistrationPage: group signups create the account pending with the us_auto_approve marker and send the confirmation email; no auto-login. - EmailConfirmationHandler: auto-approve accounts are approved on confirmation, emailed the approved notice, and redirected to a new us_confirmed=ready notice with a sign-in link. Closes #77 Co-Authored-By: Claude Fable 5 <[email protected]>
157 lines
5.3 KiB
PHP
157 lines
5.3 KiB
PHP
<?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';
|
|
|
|
/**
|
|
* Set on accounts created via a group invite link: confirming the email
|
|
* approves the account immediately instead of queueing it for admin review.
|
|
*/
|
|
public const META_AUTO_APPROVE = 'us_auto_approve';
|
|
|
|
/**
|
|
* 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.
|
|
* With `$autoApprove` (group invite links) confirming the email approves
|
|
* the account immediately — no admin review step.
|
|
*/
|
|
public static function markPending( int $userId, bool $autoApprove = false ): 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 )
|
|
);
|
|
|
|
if ( $autoApprove ) {
|
|
update_user_meta( $userId, self::META_AUTO_APPROVE, '1' );
|
|
}
|
|
|
|
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 );
|
|
delete_user_meta( $userId, self::META_AUTO_APPROVE );
|
|
}
|
|
|
|
public static function isAwaitingApproval( int $userId ): bool {
|
|
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
|
|
}
|
|
|
|
/**
|
|
* Whether confirming this account's email should approve it immediately
|
|
* (group invite link signups).
|
|
*/
|
|
public static function isAutoApprove( int $userId ): bool {
|
|
return '1' === Val::string( get_user_meta( $userId, self::META_AUTO_APPROVE, 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;
|
|
}
|
|
}
|