." */ public static function timestamp_token() { $ts = time(); return $ts . '.' . wp_hash( 'accessnow_slms_reg|' . $ts ); } /** * Check the slms_reg_ts value of a submitted registration form. * * @param mixed $token Submitted value. * @return string 'ok', 'invalid' (missing or not signed by this site), 'too_fast' or 'expired'. */ public static function check_timestamp_token( $token ) { if ( ! is_string( $token ) || ! preg_match( '/^(\d{9,12})\.([a-f0-9]{32,64})$/', $token, $m ) ) { return 'invalid'; } if ( ! hash_equals( wp_hash( 'accessnow_slms_reg|' . $m[1] ), $m[2] ) ) { return 'invalid'; } $age = time() - (int) $m[1]; if ( $age < self::MIN_FORM_SECONDS ) { return 'too_fast'; // Includes a timestamp in the future. } return $age > self::MAX_FORM_SECONDS ? 'expired' : 'ok'; } /** * The message for a failed timestamp check (a stale page gets a useful one, bots the vague one). * * @param string $status Result of check_timestamp_token(). * @return string */ public static function timestamp_message( $status ) { return 'expired' === $status ? __( 'This form has expired. Please reload the page and try again.', 'accessnow-lms' ) : __( 'Registration failed. Please try again.', 'accessnow-lms' ); } public static function render_honeypot_fields() { // Signed timestamp echo ''; // Honeypot: not displayed, so people (and screen readers, and the Tab key) never reach it; bots fill it. echo ''; } /** * One of the anti-spam fields this plugin adds to core's registration form, sanitised. * * Core's registration form (wp-login.php?action=register) has no nonce, and it is * filled in by visitors who are not signed in, so no nonce can apply here. These two * fields are only read to spot bots; the registration itself is core's. * * @param string $key Field name. * @return string The value, or '' when it was not sent. */ private static function posted( $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- core's registration form has no nonce (see above). return isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; } /** * Validate honeypot, timing and rate limit on the default WP registration form. * * @param \WP_Error $errors Registration errors. * @param string $sanitized_user_login User login. * @param string $user_email User email. * @return \WP_Error */ public static function validate_registration( $errors, $sanitized_user_login, $user_email ) { // Honeypot if ( '' !== self::posted( 'website_url' ) ) { $errors->add( 'spam_detected', __( 'Error: Registration failed. Please try again.', 'accessnow-lms' ) ); return $errors; } // Signed time check: required, so a bot can't skip it by leaving the field out. $status = self::check_timestamp_token( self::posted( 'slms_reg_ts' ) ); if ( 'ok' !== $status ) { $errors->add( 'spam_detected', '' . esc_html__( 'Error:', 'accessnow-lms' ) . ' ' . esc_html( self::timestamp_message( $status ) ) ); return $errors; } if ( self::registration_limit_reached() ) { $errors->add( 'too_many_registrations', self::limit_message() ); } return $errors; } /** * Rate limit the multisite sign-up form. * * @param array $result Sign-up validation result. * @return array */ public static function validate_multisite_signup( $result ) { if ( is_array( $result ) && isset( $result['errors'] ) && is_wp_error( $result['errors'] ) && self::registration_limit_reached() ) { $result['errors']->add( 'too_many_registrations', self::limit_message() ); } return $result; } /* ─── IP Rate Limiting (self-registration only) ─────────── */ public static function limit_message() { return __( 'Too many registration attempts. Please try again later.', 'accessnow-lms' ); } private static function client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } private static function transient_key( $ip ) { return 'accessnow_slms_reg_' . md5( $ip ); } /** * Has this visitor's IP used up its self-registrations for the hour? * Never true for users who may create users (admins), or without an IP (CLI). * * @return bool */ public static function registration_limit_reached() { if ( current_user_can( 'create_users' ) ) { return false; } $ip = self::client_ip(); if ( '' === $ip ) { return false; } return (int) get_transient( self::transient_key( $ip ) ) >= self::MAX_REGISTRATIONS_PER_HOUR; } /** * Count one successful self-registration against the visitor's IP. */ public static function record_registration() { if ( current_user_can( 'create_users' ) ) { return; } $ip = self::client_ip(); if ( '' === $ip ) { return; } $key = self::transient_key( $ip ); set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS ); } /* ─── Auto-assign student role ─────────────────────────── */ /** * A visitor registered through the core form (register_new_user() only runs * for self-registration): count it and make them an AccessNow LMS student. * * @param int $user_id New user ID. */ public static function on_core_self_registration( $user_id ) { self::record_registration(); self::make_student( $user_id ); } /** * Give a self-registered user the student role, but only if they still have * just the site's default role (so a role set by another plugin is kept). * * @param int $user_id User ID. */ public static function make_student( $user_id ) { $user = get_userdata( $user_id ); if ( ! $user || ! get_role( self::STUDENT_ROLE ) ) { return; } $roles = array_values( (array) $user->roles ); if ( array() === $roles || array( get_option( 'default_role' ) ) === $roles ) { $user->set_role( self::STUDENT_ROLE ); } } }