$parsed['type'], 'identifier' => $parsed['value'], 'exists' => $hide ? null : (bool) $user, 'methods' => $methods, 'channels' => count( $channels ) > 1 ? $channels : array(), ); } /** * Sends a one-time code. * * @param string $raw Raw identifier. * @param string $preferred Optional channel id the visitor chose. Only that channel is used. * @return array|WP_Error */ public static function send_code( $raw, $preferred = '' ) { $parsed = Identifier::parse( $raw ); if ( is_wp_error( $parsed ) ) { return $parsed; } if ( 'username' === $parsed['type'] || ! Options::get( 'forms.allow_otp' ) ) { return new WP_Error( 'aslansheed_otp_unavailable', __( 'Codes are not available for this sign-in method.', 'aslansheed-login' ) ); } $access = Access_Control::check( $parsed ); if ( is_wp_error( $access ) ) { return $access; } $identifier = $parsed['value']; $ip = Request::ip(); $allowed = Rate_Limiter::check( Rate_Limiter::ACTION_SEND, $identifier, $ip ); if ( is_wp_error( $allowed ) ) { Audit_Log::log( 'rate_limited', array( 'identifier' => self::label( $parsed ), 'context' => array( 'action' => 'send' ), ) ); return $allowed; } $response = array( 'sent' => true, 'resend_in' => (int) Options::get( 'otp.resend_delay' ), 'expires_in' => (int) Options::get( 'otp.expiry' ), 'length' => (int) Options::get( 'otp.length' ), ); if ( ! User_Resolver::find( $parsed ) && ! Options::get( 'forms.allow_registration' ) ) { // With hidden accounts the answer must look the same as a real send. if ( Options::get( 'security.hide_account_existence' ) ) { Rate_Limiter::record( Rate_Limiter::ACTION_SEND, $identifier, $ip, true ); return $response; } return new WP_Error( 'aslansheed_no_account', __( 'No account was found. Registration is closed.', 'aslansheed-login' ) ); } $issued = Otp_Manager::issue( $identifier, self::PURPOSE, '' ); if ( is_wp_error( $issued ) ) { return $issued; } $sent = Dispatcher::send_code( $identifier, $parsed['type'], $issued['code'], self::PURPOSE, $preferred, '' !== $preferred ); Rate_Limiter::record( Rate_Limiter::ACTION_SEND, $identifier, $ip, $sent['success'] ); if ( ! $sent['success'] ) { Otp_Manager::discard( $identifier, self::PURPOSE ); return new WP_Error( 'aslansheed_delivery_failed', __( 'We could not send the code. Please try again in a moment or use another method.', 'aslansheed-login' ) ); } Audit_Log::log( 'otp_sent', array( 'identifier' => self::label( $parsed ), 'context' => array( 'channel' => $sent['channel'] ), ) ); $response['channel'] = $sent['channel']; return $response; } /** * Checks a code, then logs in, registers or asks for the missing details. * * @param string $raw Raw identifier. * @param string $code Code typed by the visitor. * @param string $redirect_to Optional requested redirect. * @return array|WP_Error */ public static function verify_code( $raw, $code, $redirect_to = '' ) { $parsed = Identifier::parse( $raw ); if ( is_wp_error( $parsed ) ) { return $parsed; } if ( 'username' === $parsed['type'] ) { return new WP_Error( 'aslansheed_otp_unavailable', __( 'Codes are not available for this sign-in method.', 'aslansheed-login' ) ); } $identifier = $parsed['value']; $ip = Request::ip(); $allowed = Rate_Limiter::check( Rate_Limiter::ACTION_VERIFY, $identifier, $ip ); if ( is_wp_error( $allowed ) ) { return $allowed; } $result = Otp_Manager::verify( $identifier, self::PURPOSE, $code ); if ( is_wp_error( $result ) ) { Rate_Limiter::record( Rate_Limiter::ACTION_VERIFY, $identifier, $ip, false ); Audit_Log::log( 'otp_failed', array( 'identifier' => self::label( $parsed ), 'context' => array( 'reason' => $result->get_error_code() ), ) ); return $result; } Rate_Limiter::record( Rate_Limiter::ACTION_VERIFY, $identifier, $ip, true ); Rate_Limiter::reset_failures( $identifier ); $user = User_Resolver::find( $parsed ); if ( $user ) { // A phone found through another plugin's data is linked now that the code is verified. if ( 'phone' === $parsed['type'] && null === Identity_Store::find( 'phone', $identifier ) && array() === Identity_Store::find_for_user( (int) $user->ID, 'phone' ) ) { Identity_Store::save( (int) $user->ID, 'phone', $identifier ); /** This action is documented in includes/auth/class-account-phone.php. */ do_action( 'aslansheed_otp_phone_linked', (int) $user->ID, $identifier ); } return self::login( $user, 'otp', $redirect_to ); } if ( ! Options::get( 'forms.allow_registration' ) ) { return new WP_Error( 'aslansheed_no_account', __( 'No account was found. Registration is closed.', 'aslansheed-login' ) ); } if ( ! self::needs_details( $parsed['type'] ) ) { return self::register_user( $parsed, array(), 'otp', $redirect_to ); } $token = wp_generate_password( 32, false ); set_transient( self::REGISTER_PREFIX . hash( 'sha256', $token ), array( 'type' => $parsed['type'], 'value' => $parsed['value'], ), self::REGISTER_TTL ); return array( 'status' => 'register', 'token' => $token, 'fields' => self::register_fields( $parsed['type'] ), 'custom' => Custom_Fields::for_client( $parsed['type'] ), 'calendar' => (string) Options::get( 'forms.date_calendar' ), 'min_password' => (int) Options::get( 'forms.password_min_length' ), ); } /** * Finishes a registration after the code was verified. * * @param string $token Token issued by verify_code(). * @param array $data Submitted details: name, password, terms, custom. * @param string $redirect_to Optional requested redirect. * @return array|WP_Error */ public static function register( $token, array $data, $redirect_to = '' ) { $key = self::REGISTER_PREFIX . hash( 'sha256', (string) $token ); $stored = get_transient( $key ); if ( ! is_array( $stored ) || empty( $stored['type'] ) || empty( $stored['value'] ) ) { return new WP_Error( 'aslansheed_register_expired', __( 'Your verification expired. Please start again.', 'aslansheed-login' ) ); } $clean = Register_Fields::validate( (string) $stored['type'], $data ); if ( is_wp_error( $clean ) ) { return $clean; } $parsed = array( 'type' => (string) $stored['type'], 'value' => (string) $stored['value'], ); // A phone sign-up that gave an email proves it first when the admin asks for that. if ( 'phone' === $parsed['type'] && ! empty( $clean['email'] ) && 'off' !== Options::get( 'forms.email_verify' ) ) { $stored['pending'] = Crypto::encrypt( (string) wp_json_encode( $clean ) ); $stored['email'] = $clean['email']; $sent = Code_Service::send( $clean['email'], 'email', self::EMAIL_PURPOSE ); if ( is_wp_error( $sent ) ) { return $sent; } set_transient( $key, $stored, self::REGISTER_TTL ); return array( 'status' => 'verify_email', 'skippable' => 'optional' === Options::get( 'forms.email_verify' ), 'email' => $clean['email'], 'resend_in' => $sent['resend_in'], 'length' => $sent['length'], ); } delete_transient( $key ); return self::register_user( $parsed, $clean, 'register', $redirect_to ); } /** * Sends the email code again. * * @param string $token Token issued by verify_code(). * @return array|WP_Error */ public static function resend_email_code( $token ) { $stored = get_transient( self::REGISTER_PREFIX . hash( 'sha256', (string) $token ) ); if ( ! is_array( $stored ) || empty( $stored['pending'] ) || empty( $stored['email'] ) ) { return new WP_Error( 'aslansheed_register_expired', __( 'Your verification expired. Please start again.', 'aslansheed-login' ) ); } return Code_Service::send( (string) $stored['email'], 'email', self::EMAIL_PURPOSE ); } /** * Checks the email code and creates the account. * * @param string $token Token issued by verify_code(). * @param string $code Code from the email. * @param string $redirect_to Optional requested redirect. * @return array|WP_Error */ public static function verify_email_code( $token, $code, $redirect_to = '' ) { $key = self::REGISTER_PREFIX . hash( 'sha256', (string) $token ); $stored = get_transient( $key ); if ( ! is_array( $stored ) || empty( $stored['pending'] ) || empty( $stored['email'] ) ) { return new WP_Error( 'aslansheed_register_expired', __( 'Your verification expired. Please start again.', 'aslansheed-login' ) ); } $checked = Code_Service::check( (string) $stored['email'], self::EMAIL_PURPOSE, $code ); if ( is_wp_error( $checked ) ) { return $checked; } $json = Crypto::decrypt( (string) $stored['pending'] ); $clean = is_string( $json ) ? json_decode( $json, true ) : null; if ( ! is_array( $clean ) ) { return new WP_Error( 'aslansheed_register_expired', __( 'Your verification expired. Please start again.', 'aslansheed-login' ) ); } delete_transient( $key ); // The address could have been taken while the visitor was reading the email. if ( email_exists( (string) $stored['email'] ) ) { return new WP_Error( 'aslansheed_email_taken', __( 'This email address is already used by another account.', 'aslansheed-login' ) ); } $clean['email_verified'] = true; return self::register_user( array( 'type' => (string) $stored['type'], 'value' => (string) $stored['value'], ), $clean, 'register', $redirect_to ); } /** * Creates the account without confirming the email address, when the admin made that step optional. * * @param string $token Token issued by verify_code(). * @param string $redirect_to Optional requested redirect. * @return array|WP_Error */ public static function skip_email_code( $token, $redirect_to = '' ) { $key = self::REGISTER_PREFIX . hash( 'sha256', (string) $token ); $stored = get_transient( $key ); if ( ! is_array( $stored ) || empty( $stored['pending'] ) || empty( $stored['email'] ) ) { return new WP_Error( 'aslansheed_register_expired', __( 'Your verification expired. Please start again.', 'aslansheed-login' ) ); } if ( 'optional' !== Options::get( 'forms.email_verify' ) ) { return new WP_Error( 'aslansheed_email_required', __( 'Please confirm your email address to continue.', 'aslansheed-login' ), array( 'status' => 403 ) ); } $json = Crypto::decrypt( (string) $stored['pending'] ); $clean = is_string( $json ) ? json_decode( $json, true ) : null; if ( ! is_array( $clean ) ) { return new WP_Error( 'aslansheed_register_expired', __( 'Your verification expired. Please start again.', 'aslansheed-login' ) ); } delete_transient( $key ); if ( email_exists( (string) $stored['email'] ) ) { return new WP_Error( 'aslansheed_email_taken', __( 'This email address is already used by another account.', 'aslansheed-login' ) ); } return self::register_user( array( 'type' => (string) $stored['type'], 'value' => (string) $stored['value'], ), $clean, 'register', $redirect_to ); } /** * Signs in with a password. * * @param string $raw Raw identifier. * @param string $password Password typed by the visitor. * @param string $redirect_to Optional requested redirect. * @return array|WP_Error */ public static function password_login( $raw, $password, $redirect_to = '' ) { if ( ! Options::get( 'forms.allow_password' ) ) { return new WP_Error( 'aslansheed_password_disabled', __( 'Password sign-in is not enabled.', 'aslansheed-login' ) ); } $parsed = Identifier::parse( $raw ); if ( is_wp_error( $parsed ) ) { return $parsed; } $access = Access_Control::check( $parsed ); if ( is_wp_error( $access ) ) { return $access; } $identifier = $parsed['value']; $ip = Request::ip(); $allowed = Rate_Limiter::check( Rate_Limiter::ACTION_VERIFY, $identifier, $ip ); if ( is_wp_error( $allowed ) ) { return $allowed; } $user = User_Resolver::find( $parsed ); if ( ! $user || '' === (string) $password || ! wp_check_password( (string) $password, $user->user_pass, $user->ID ) ) { Rate_Limiter::record( Rate_Limiter::ACTION_VERIFY, $identifier, $ip, false ); Audit_Log::log( 'password_failed', array( 'identifier' => self::label( $parsed ), ) ); return new WP_Error( 'aslansheed_bad_credentials', __( 'The details you entered are not correct.', 'aslansheed-login' ) ); } Rate_Limiter::record( Rate_Limiter::ACTION_VERIFY, $identifier, $ip, true ); Rate_Limiter::reset_failures( $identifier ); return self::login( $user, 'password', $redirect_to ); } /** * Logs a user in and returns the redirect address. * * @param \WP_User $user User to sign in. * @param string $method Method slug for the audit log. * @param string $redirect_to Optional requested redirect. * @return array|WP_Error */ public static function login( $user, $method, $redirect_to = '' ) { /** * Lets other code block a sign-in, for example for suspended accounts. * * @param true|WP_Error $allowed Return a WP_Error to refuse. * @param \WP_User $user User about to sign in. * @param string $method Method slug. */ $allowed = apply_filters( 'aslansheed_otp_can_login', true, $user, $method ); if ( is_wp_error( $allowed ) ) { return $allowed; } // The second step, when the site uses one, finishes the sign-in through finish_login(). $second_step = Two_Factor::gate( $user, $method, $redirect_to ); if ( null !== $second_step ) { return $second_step; } return self::finish_login( $user, $method, $redirect_to ); } /** * Sets the sign-in cookie and returns the redirect address. * * @param \WP_User $user User to sign in. * @param string $method Method slug for the audit log. * @param string $redirect_to Optional requested redirect. * @return array */ public static function finish_login( $user, $method, $redirect_to = '' ) { wp_set_current_user( $user->ID ); wp_set_auth_cookie( $user->ID, true ); /** This action is documented in wp-includes/user.php. */ do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook. Audit_Log::log( 'login_success', array( 'user_id' => (int) $user->ID, 'context' => array( 'method' => $method ), ) ); /** * Fires after the plugin signed a user in. * * @param \WP_User $user Signed-in user. * @param string $method Method slug. */ do_action( 'aslansheed_otp_logged_in', $user, $method ); return array( 'status' => 'logged_in', 'redirect' => self::redirect_url( $user, $redirect_to ), ); } /** * Works out where a signed-in user should go. * * @param \WP_User $user Signed-in user. * @param string $redirect_to Requested address, may be empty. * @return string */ public static function redirect_url( $user, $redirect_to = '' ) { $requested = '' !== (string) $redirect_to ? wp_validate_redirect( (string) $redirect_to, '' ) : ''; $target = $requested; if ( '' === $target ) { $target = (string) Options::get( 'general.login_redirect' ); } if ( '' === $target ) { $target = home_url( '/' ); } /** This filter is documented in wp-login.php. */ return (string) apply_filters( 'login_redirect', $target, $requested, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter. } /** * Tells which extra fields the registration step shows. * * @param string $type Identifier type, phone or email. * @return array */ public static function register_fields( $type = 'phone' ) { return Register_Fields::for_type( $type ); } /** * Tells whether registration needs an extra step. * * @param string $type Identifier type. * @return bool */ public static function needs_details( $type = 'phone' ) { return array() !== self::register_fields( $type ) || array() !== Custom_Fields::for_type( $type ); } /** * Creates the user and signs it in. * * @param array $parsed Parsed identifier. * @param array $data Name and password. * @param string $method Method slug. * @param string $redirect_to Requested redirect. * @return array|WP_Error */ private static function register_user( array $parsed, array $data, $method, $redirect_to ) { $user = User_Resolver::create( $parsed, $data ); if ( is_wp_error( $user ) ) { return $user; } Register_Fields::save_meta( (int) $user->ID, $data ); if ( 'email' === $parsed['type'] || ! empty( $data['email_verified'] ) ) { update_user_meta( (int) $user->ID, Register_Fields::META_EMAIL_VERIFIED, time() ); } /** * Fires after a new user and their registration details were saved. * * @param \WP_User $user New user. * @param array $parsed Parsed identifier. * @param array $data Clean registration details. */ do_action( 'aslansheed_otp_registration_saved', $user, $parsed, $data ); Audit_Log::log( 'user_registered', array( 'user_id' => (int) $user->ID, 'identifier' => self::label( $parsed ), ) ); return self::login( $user, $method, $redirect_to ); } /** * Returns a log-safe label for an identifier. * * @param array $parsed Parsed identifier. * @return string */ private static function label( array $parsed ) { return 'phone' === $parsed['type'] ? Phone_Normalizer::mask( $parsed['value'] ) : $parsed['value']; } }