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,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Handles the self-signup email-confirmation link, and keeps WordPress's own
|
||||
* registration form from being used to bypass the studio's policy-accepting
|
||||
* registration page while open registration is enabled.
|
||||
*/
|
||||
class EmailConfirmationHandler {
|
||||
|
||||
public function __construct(
|
||||
private StudioSettings $settings,
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
add_action( 'template_redirect', [ $this, 'maybeConfirm' ] );
|
||||
add_filter( 'register_url', [ $this, 'registerUrl' ] );
|
||||
// login_init fires at the top of wp-login.php for every request (GET form
|
||||
// display AND a direct POST) before any registration processing, so it is
|
||||
// the reliable choke point; registration_errors is a fail-safe in case a
|
||||
// POST ever reaches register_new_user().
|
||||
add_action( 'login_init', [ $this, 'blockNativeRegistration' ] );
|
||||
add_filter( 'registration_errors', [ $this, 'blockRegistrationErrors' ], 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a self-signup's email when the emailed `?us_confirm=<token>` link
|
||||
* is opened, then redirect back to the registration page with a result flag.
|
||||
*/
|
||||
public function maybeConfirm(): void {
|
||||
if ( is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is itself the capability-bearing secret (like a password-reset key); nonces do not apply to an emailed link.
|
||||
$rawToken = sanitize_text_field( Val::string( wp_unslash( $_GET['us_confirm'] ?? '' ) ) );
|
||||
if ( '' === $rawToken ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$base = $this->registrationPageUrl();
|
||||
$userId = RegistrationStatus::userIdForToken( $rawToken );
|
||||
|
||||
if ( null === $userId || RegistrationStatus::isTokenExpired( $userId, gmdate( 'Y-m-d H:i:s' ) ) ) {
|
||||
wp_safe_redirect( add_query_arg( 'us_confirmed', 'expired', $base ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
RegistrationStatus::confirmEmail( $userId );
|
||||
|
||||
$user = get_user_by( 'id', $userId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->notifyAdminsPending( $user );
|
||||
}
|
||||
|
||||
wp_safe_redirect( add_query_arg( 'us_confirmed', '1', $base ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point WordPress's own "Register" links at the studio registration page
|
||||
* while open registration is on and a page is configured.
|
||||
*/
|
||||
public function registerUrl( string $url ): string {
|
||||
if ( ! $this->settings->openRegistrationEnabled() ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
||||
|
||||
return $pageId > 0 ? (string) get_permalink( $pageId ) : $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect any `wp-login.php?action=register` request (GET or POST) to the
|
||||
* studio registration page, so the bare native form — which cannot collect
|
||||
* required policy acceptances — is never used.
|
||||
*/
|
||||
public function blockNativeRegistration(): void {
|
||||
if ( ! $this->settings->openRegistrationEnabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing decision; no state is changed here.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_REQUEST['action'] ?? '' ) ) );
|
||||
if ( 'register' !== $action ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
||||
if ( $pageId <= 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_safe_redirect( (string) get_permalink( $pageId ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-safe: reject any native registration attempt while open registration
|
||||
* is on, so `register_new_user()` can never create a policy-less account.
|
||||
*
|
||||
* @param \WP_Error $errors Accumulated registration errors.
|
||||
* @return \WP_Error
|
||||
*/
|
||||
public function blockRegistrationErrors( \WP_Error $errors ): \WP_Error {
|
||||
if ( $this->settings->openRegistrationEnabled() ) {
|
||||
$errors->add(
|
||||
'us_registration_redirect',
|
||||
esc_html__( 'Please register on the studio registration page.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function registrationPageUrl(): string {
|
||||
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
||||
|
||||
return $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Admin page (Students → Pending Students) for reviewing self-signup accounts:
|
||||
* approve a confirmed applicant into a full student, or reject (delete) them.
|
||||
* Only relevant while open registration is enabled.
|
||||
*/
|
||||
class RegistrationApprovalController {
|
||||
|
||||
public const PAGE_SLUG = 'us-pending-students';
|
||||
public const NONCE_ACTION = 'usc_registration_approval';
|
||||
|
||||
public function __construct( private RegistrationMailer $mailer ) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to manage student registrations.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( self::NONCE_ACTION ) ) {
|
||||
$this->handleAction();
|
||||
}
|
||||
|
||||
$awaitingApproval = [];
|
||||
$awaitingConfirmation = [];
|
||||
foreach ( $this->pendingUsers() as $user ) {
|
||||
if ( RegistrationStatus::emailConfirmed( (int) $user->ID ) ) {
|
||||
$awaitingApproval[] = $user;
|
||||
} else {
|
||||
$awaitingConfirmation[] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/registrations.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve or reject the posted user. Approval clears the pending flags and
|
||||
* emails the student; rejection emails them, then hard-deletes the account so
|
||||
* the email is freed to re-apply.
|
||||
*/
|
||||
private function handleAction(): void {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
$userId = absint( Val::int( $_POST['user_id'] ?? 0 ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
if ( $userId <= 0 || ! RegistrationStatus::isAwaitingApproval( $userId ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'approve' === $action ) {
|
||||
RegistrationStatus::approve( $userId );
|
||||
$user = get_user_by( 'id', $userId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->sendApproved( $user );
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'reject' === $action ) {
|
||||
$user = get_user_by( 'id', $userId );
|
||||
$email = $user instanceof \WP_User ? (string) $user->user_email : '';
|
||||
if ( '' !== $email ) {
|
||||
$this->mailer->sendRejected( $email );
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wp_delete_user' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
}
|
||||
wp_delete_user( $userId );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account still awaiting approval (confirmed or not).
|
||||
*
|
||||
* @return list<\WP_User>
|
||||
*/
|
||||
private function pendingUsers(): array {
|
||||
return array_values(
|
||||
array_filter(
|
||||
get_users(
|
||||
[
|
||||
'meta_key' => RegistrationStatus::META_AWAITING_APPROVAL, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
|
||||
'number' => 500,
|
||||
'orderby' => 'user_registered',
|
||||
'order' => 'ASC',
|
||||
]
|
||||
),
|
||||
static fn( mixed $user ): bool => $user instanceof \WP_User
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Enforces the pending state of self-signup accounts:
|
||||
* - an account whose email is not yet confirmed cannot log in at all;
|
||||
* - a confirmed-but-unapproved account may log in, but its booking capability
|
||||
* is withheld so it only reaches the "awaiting approval" screen.
|
||||
*
|
||||
* Both checks key solely off the pending user meta, so invite- and
|
||||
* admin-created students (which carry none of it) are unaffected.
|
||||
*/
|
||||
class RegistrationLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Block authentication for a self-signup that has not yet confirmed its
|
||||
* email. Runs after password verification.
|
||||
*
|
||||
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
public function blockUnconfirmed( $user ) {
|
||||
if (
|
||||
$user instanceof \WP_User
|
||||
&& RegistrationStatus::isAwaitingApproval( (int) $user->ID )
|
||||
&& ! RegistrationStatus::emailConfirmed( (int) $user->ID )
|
||||
) {
|
||||
return new \WP_Error(
|
||||
'us_email_unconfirmed',
|
||||
esc_html__( 'Please confirm your email address before logging in — check your inbox for the confirmation link.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the booking capability from any account still awaiting approval, so a
|
||||
* confirmed-but-unapproved student cannot book until a studio admin approves.
|
||||
*
|
||||
* @param array<string, bool> $allcaps All capabilities currently held.
|
||||
* @param array<int, string> $caps Required capabilities (unused).
|
||||
* @param array<int, mixed> $args Callback args (unused).
|
||||
* @param mixed $user The user being checked (a WP_User in practice).
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function withholdBookingWhilePending( array $allcaps, array $caps, array $args, mixed $user ): array {
|
||||
if ( $user instanceof \WP_User && RegistrationStatus::isAwaitingApproval( (int) $user->ID ) ) {
|
||||
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
|
||||
}
|
||||
|
||||
return $allcaps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Transactional emails for the self-approval registration flow: the email
|
||||
* confirmation link, the studio-admin heads-up that someone is ready to
|
||||
* approve, and the approval / rejection notices to the student.
|
||||
*/
|
||||
class RegistrationMailer {
|
||||
|
||||
/**
|
||||
* Email the new student a link to confirm their address. Returns false when
|
||||
* there is no recipient.
|
||||
*/
|
||||
public function sendConfirmation( \WP_User $user, string $confirmUrl ): bool {
|
||||
if ( '' === (string) $user->user_email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: site name */
|
||||
__( 'Confirm your email for %s', 'unsupervised-schedular' ),
|
||||
$this->siteName()
|
||||
);
|
||||
|
||||
$body = sprintf(
|
||||
/* translators: 1: site name, 2: confirmation URL */
|
||||
__( "Thanks for signing up at %1\$s.\n\nPlease confirm your email address by opening this link:\n%2\$s\n\nOnce confirmed, a studio admin will review and approve your account. You'll get another email when it's ready.", 'unsupervised-schedular' ),
|
||||
$this->siteName(),
|
||||
$confirmUrl
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $user->user_email, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the studio admins a self-signup has confirmed their email and is
|
||||
* waiting for approval. Sent to the site admin email.
|
||||
*/
|
||||
public function notifyAdminsPending( \WP_User $user ): bool {
|
||||
$adminEmail = Val::string( get_option( 'admin_email', '' ) );
|
||||
if ( '' === $adminEmail ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = __( 'A new student is awaiting approval', 'unsupervised-schedular' );
|
||||
$body = sprintf(
|
||||
/* translators: 1: student name, 2: student email */
|
||||
__( "%1\$s (%2\$s) has confirmed their email and is awaiting approval.\n\nReview them under Students → Pending Students in wp-admin.", 'unsupervised-schedular' ),
|
||||
(string) $user->display_name,
|
||||
(string) $user->user_email
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $adminEmail, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the student their account has been approved. Returns false when there
|
||||
* is no recipient.
|
||||
*/
|
||||
public function sendApproved( \WP_User $user ): bool {
|
||||
if ( '' === (string) $user->user_email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: site name */
|
||||
__( 'Your %s account is approved', 'unsupervised-schedular' ),
|
||||
$this->siteName()
|
||||
);
|
||||
$body = sprintf(
|
||||
/* translators: 1: site name, 2: login URL */
|
||||
__( "Good news — your account at %1\$s has been approved. You can now log in and book:\n%2\$s", 'unsupervised-schedular' ),
|
||||
$this->siteName(),
|
||||
wp_login_url()
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $user->user_email, $subject, $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell an applicant their registration was declined. Takes the email address
|
||||
* directly, since the account is deleted as part of rejection.
|
||||
*/
|
||||
public function sendRejected( string $email ): bool {
|
||||
if ( '' === $email ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subject = sprintf(
|
||||
/* translators: %s: site name */
|
||||
__( 'Your %s registration', 'unsupervised-schedular' ),
|
||||
$this->siteName()
|
||||
);
|
||||
$body = sprintf(
|
||||
/* translators: %s: site name */
|
||||
__( 'Thank you for your interest in %s. We are unable to approve your registration at this time. Please contact the studio if you have any questions.', 'unsupervised-schedular' ),
|
||||
$this->siteName()
|
||||
);
|
||||
|
||||
return (bool) wp_mail( $email, $subject, $body );
|
||||
}
|
||||
|
||||
private function siteName(): string {
|
||||
$name = (string) get_bloginfo( 'name' );
|
||||
|
||||
return '' !== $name ? $name : __( 'the studio', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||
@@ -12,11 +13,19 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
class RegistrationPage {
|
||||
|
||||
/** Success signal: an invited student was created and logged in. */
|
||||
private const RESULT_INVITE = 'invite';
|
||||
|
||||
/** Success signal: a self-signup was created and must confirm their email. */
|
||||
private const RESULT_CONFIRM = 'confirm';
|
||||
|
||||
public function __construct(
|
||||
private InviteRepository $invites,
|
||||
private PolicyRepository $policies,
|
||||
private PolicyVersionRepository $versions,
|
||||
private AcceptanceRepository $acceptances,
|
||||
private StudioSettings $settings,
|
||||
private RegistrationMailer $mailer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -33,21 +42,26 @@ class RegistrationPage {
|
||||
$token = sanitize_text_field( Val::string( wp_unslash( $_REQUEST['us_invite'] ?? '' ) ) );
|
||||
// Only the token's hash is stored, so hash the submitted token for lookup.
|
||||
$invite = '' !== $token ? $this->invites->findByToken( Invite::hashToken( $token ) ) : null;
|
||||
$open = $this->settings->openRegistrationEnabled();
|
||||
|
||||
$error = '';
|
||||
$success = false;
|
||||
$error = '';
|
||||
$successType = '';
|
||||
|
||||
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
|
||||
$result = $this->handleSubmit( $invite );
|
||||
if ( true === $result ) {
|
||||
$success = true;
|
||||
$result = $this->handleSubmit( $invite, $open );
|
||||
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM ], true ) ) {
|
||||
$successType = $result;
|
||||
} else {
|
||||
$error = $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Result of an email-confirmation link (set by EmailConfirmationHandler's redirect).
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, not a state change.
|
||||
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
$canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||
$canRegister = $open || ( null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ) );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/register-page.php';
|
||||
@@ -80,11 +94,17 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the submitted registration. Returns true on success or an error
|
||||
* message string on failure.
|
||||
* Process the submitted registration. Returns a success signal
|
||||
* ({@see RESULT_INVITE} or {@see RESULT_CONFIRM}) or an error message string
|
||||
* on failure.
|
||||
*
|
||||
* The invite branch is tried first, so an invited student always completes
|
||||
* signup regardless of whether open registration is enabled.
|
||||
*/
|
||||
private function handleSubmit( ?Invite $invite ): string|bool {
|
||||
if ( null === $invite || ! $invite->isAcceptable( current_time( 'mysql' ) ) ) {
|
||||
private function handleSubmit( ?Invite $invite, bool $open ): string {
|
||||
$inviteValid = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) );
|
||||
|
||||
if ( ! $inviteValid && ! $open ) {
|
||||
return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
@@ -98,6 +118,16 @@ class RegistrationPage {
|
||||
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
// The email is fixed by the invite when there is one; self-signups supply it.
|
||||
if ( $inviteValid ) {
|
||||
$email = $invite->email;
|
||||
} else {
|
||||
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
|
||||
if ( ! is_email( $email ) ) {
|
||||
return esc_html__( 'Please enter a valid email address.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
|
||||
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
|
||||
@@ -109,17 +139,17 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
if ( email_exists( $invite->email ) ) {
|
||||
if ( email_exists( $email ) ) {
|
||||
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$userId = wp_insert_user(
|
||||
[
|
||||
'user_login' => $invite->email,
|
||||
'user_email' => $invite->email,
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'user_pass' => $password,
|
||||
'display_name' => '' !== $displayName ? $displayName : $invite->email,
|
||||
'role' => $invite->role,
|
||||
'display_name' => '' !== $displayName ? $displayName : $email,
|
||||
'role' => $inviteValid ? $invite->role : RoleManager::STUDENT,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -128,12 +158,36 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
|
||||
wp_set_current_user( (int) $userId );
|
||||
wp_set_auth_cookie( (int) $userId );
|
||||
if ( $inviteValid ) {
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
|
||||
return true;
|
||||
wp_set_current_user( (int) $userId );
|
||||
wp_set_auth_cookie( (int) $userId );
|
||||
|
||||
return self::RESULT_INVITE;
|
||||
}
|
||||
|
||||
// Self-approval: hold the account pending, email a confirmation link, and
|
||||
// do NOT log the user in — they must confirm and be approved first.
|
||||
$rawToken = RegistrationStatus::markPending( (int) $userId );
|
||||
$user = get_user_by( 'id', (int) $userId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
|
||||
}
|
||||
|
||||
return self::RESULT_CONFIRM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the email-confirmation URL for a raw token: the configured
|
||||
* registration page (falling back to the home page) with `?us_confirm=`.
|
||||
*/
|
||||
private function confirmUrl( string $rawToken ): string {
|
||||
$pageId = Val::int( get_option( RegistrationController::OPTION_PAGE, 0 ) );
|
||||
$base = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
|
||||
|
||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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