register_social_routes(); $text = array( 'type' => 'string', 'required' => true, ); $routes = array( '/auth/login' => array( 'callback' => 'login', 'args' => array( 'login' => $text, 'password' => $text, 'remember' => array( 'type' => 'boolean', 'default' => false, ), 'redirect_to' => array( 'type' => 'string', 'default' => '', ), ), ), '/auth/signup' => array( 'callback' => 'signup', 'args' => array( 'email' => $text, 'password' => $text, ), ), '/auth/forgot' => array( 'callback' => 'forgot', 'args' => array( 'login' => $text ), ), '/auth/reset' => array( 'callback' => 'reset', 'args' => array( 'login' => $text, 'key' => $text, 'password' => $text, ), ), ); foreach ( $routes as $route => $config ) { register_rest_route( $this->namespace, $route, array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, $config['callback'] ), 'permission_callback' => array( $this, 'permission_pages' ), 'args' => $config['args'], ), ) ); } } /** * Registers the social login routes. They are opened by the browser * (GET, no nonce) and answer with redirects. * * @return void */ public function register_social_routes() { $provider = array( 'provider' => array( 'type' => 'string', 'enum' => array_keys( Social::providers() ), ), ); register_rest_route( $this->namespace, '/auth/social/(?P[a-z]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'social_start' ), 'permission_callback' => array( $this, 'permission_social' ), 'args' => $provider + array( 'redirect_to' => array( 'type' => 'string', 'default' => '', ), ), ), ) ); register_rest_route( $this->namespace, '/auth/social/(?P[a-z]+)/callback', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'social_callback' ), 'permission_callback' => array( $this, 'permission_social' ), 'args' => $provider, ), ) ); } /** * Allows a social login route when that provider is switched on. * * @param WP_REST_Request $request Request object. * @return true|WP_Error */ public function permission_social( $request ) { if ( Social::enabled( (string) $request['provider'] ) ) { return true; } return new WP_Error( 'spacebay_social_off', __( 'This login option is not available.', 'spacebay' ), array( 'status' => 404 ) ); } /** * Sends the visitor to the provider. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function social_start( $request ) { $limit = Throttle::hit( 'social', Throttle::ip(), 30, 15 * MINUTE_IN_SECONDS ); if ( is_wp_error( $limit ) ) { return $this->redirect( Social::error_url( 'busy' ) ); } return $this->redirect( Social::begin( (string) $request['provider'], (string) $request['redirect_to'] ), false ); } /** * Finishes a social login when the provider sends the visitor back. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function social_callback( $request ) { $result = Social::finish( (string) $request['provider'], array( 'code' => (string) $request->get_param( 'code' ), 'state' => (string) $request->get_param( 'state' ), 'error' => (string) $request->get_param( 'error' ) . self::raw_query_value( 'error' ), ) ); if ( is_wp_error( $result ) ) { $code = str_replace( 'spacebay_social_', '', $result->get_error_code() ); return $this->redirect( Social::error_url( 'spacebay_too_many_attempts' === $result->get_error_code() ? 'busy' : $code ) ); } /** This filter is documented in includes/Rest/AuthController.php */ $challenge = apply_filters( 'spacebay_login_challenge', null, $result['user'], true, $result['redirect'] ); if ( is_wp_error( $challenge ) ) { return $this->redirect( Social::error_url( 'failed' ) ); } // A second step, such as a two-factor code (SpaceBay Pro): the login page asks for it. if ( is_array( $challenge ) ) { return $this->redirect( ! empty( $challenge['token'] ) ? Portal::link( 'login' ) . '?verify=' . rawurlencode( (string) $challenge['token'] ) : Social::error_url( 'failed' ) ); } Auth::log_in( $result['user'], true ); return $this->redirect( $result['redirect'] ); } /** * Reads a value straight from the query string. WordPress removes "error" * from $_GET on some requests, and that is the name providers use for Cancel. * * @param string $name Query parameter. * @return string */ private static function raw_query_value( $name ) { $query = isset( $_SERVER['QUERY_STRING'] ) ? (string) wp_unslash( $_SERVER['QUERY_STRING'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Parsed and sanitized below. parse_str( $query, $values ); return isset( $values[ $name ] ) && is_string( $values[ $name ] ) ? sanitize_key( $values[ $name ] ) : ''; } /** * A redirect answer for the browser. * * @param string $url Address. * @param bool $safe Whether the address must be on this site. * @return WP_REST_Response */ private function redirect( $url, $safe = true ) { $response = new WP_REST_Response( null, 302 ); $response->header( 'Location', $safe ? wp_validate_redirect( $url, Portal::url() ) : $url ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Allows visitors when the community login pages are switched on. * * @return true|WP_Error */ public function permission_pages() { if ( Auth::has_pages() ) { return true; } return new WP_Error( 'spacebay_auth_pages_off', __( 'Logging in here is switched off. Please use the login page of the site.', 'spacebay' ), array( 'status' => 404 ) ); } /** * Checks a username or email address and a password. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function login( $request ) { $login = trim( (string) $request['login'] ); $password = (string) $request['password']; $redirect = Auth::safe_redirect( $request['redirect_to'] ); $ip = Throttle::ip(); if ( is_user_logged_in() ) { return $this->done( $redirect ); } if ( '' === $login || '' === $password ) { return new WP_Error( 'spacebay_login_empty', __( 'Please enter your email address and password.', 'spacebay' ), array( 'status' => 400 ) ); } // Wrong passwords are counted per account from one address (5) and per // account from anywhere (20), so one busy address never blocks other members. // Wrong passwords per address across all accounts (30) are counted too, but // only refuse after a failed check below: offices and proxies share one // address, and a correct password must always work. foreach ( array( array( 'login', $ip . '|' . $login, 5 ), array( 'login_any', $login, 20 ) ) as $limit ) { $allowed = Throttle::check( $limit[0], $limit[1], $limit[2] ); if ( is_wp_error( $allowed ) ) { return $allowed; } } // This route runs inside a REST request, where WordPress also accepts // application passwords. Those are for API clients only and must never // open a browser session, so they are turned off for this check. add_filter( 'application_password_is_api_request', '__return_false', 99 ); $user = wp_authenticate( $login, $password ); remove_filter( 'application_password_is_api_request', '__return_false', 99 ); if ( ! $user instanceof WP_User ) { Throttle::hit( 'login', $ip . '|' . $login, 5, 15 * MINUTE_IN_SECONDS ); Throttle::hit( 'login_any', $login, 20, 15 * MINUTE_IN_SECONDS ); $address = Throttle::hit( 'login_ip', $ip, 30, 15 * MINUTE_IN_SECONDS ); return is_wp_error( $address ) ? $address : $this->login_error( $user ); } Throttle::clear( 'login', $ip . '|' . $login ); /** * Filters whether a login needs a second step after a correct password, * for example a two-factor code (SpaceBay Pro). Return an array that * describes the step (sent to the login page as is) or a WP_Error; return * null to log the member in. * * @since 1.0.0 * * @param array|WP_Error|null $challenge Second step, or null. * @param WP_User $user Member whose password was correct. * @param bool $remember Whether "Remember me" was ticked. * @param string $redirect Address to open after the login. */ $challenge = apply_filters( 'spacebay_login_challenge', null, $user, (bool) $request['remember'], $redirect ); if ( is_wp_error( $challenge ) || is_array( $challenge ) ) { return is_wp_error( $challenge ) ? $challenge : rest_ensure_response( $challenge ); } Auth::log_in( $user, (bool) $request['remember'] ); return $this->done( $redirect ); } /** * Turns a WordPress login error into a message that does not reveal * whether the account exists. * * @param mixed $error Result of wp_authenticate(). * @return WP_Error */ private function login_error( $error ) { $generic = array( 'invalid_username', 'invalid_email', 'incorrect_password', 'empty_username', 'empty_password', 'invalidcombo' ); if ( is_wp_error( $error ) && ! in_array( $error->get_error_code(), $generic, true ) && '' !== $error->get_error_message() ) { // Another plugin refused the login, for example a security plugin: show its reason. return new WP_Error( 'spacebay_login_refused', wp_strip_all_tags( $error->get_error_message() ), array( 'status' => 403 ) ); } return new WP_Error( 'spacebay_login_failed', __( 'The email address or password is not correct.', 'spacebay' ), array( 'status' => 401 ) ); } /** * The response of a finished login. * * @param string $redirect Address to open. * @return WP_REST_Response */ public function done( $redirect ) { return rest_ensure_response( array( 'logged_in' => true, 'redirect' => $redirect, ) ); } /** * Creates an account from the signup form and logs the new member in. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function signup( $request ) { if ( ! Auth::signup_open() ) { return new WP_Error( 'spacebay_signup_closed', __( 'This community is not open for new members right now.', 'spacebay' ), array( 'status' => 403 ) ); } if ( is_user_logged_in() ) { return $this->done( Portal::url() ); } $input = (array) $request->get_json_params(); $input = $input ? $input : (array) $request->get_body_params(); // A field people cannot see: only automated signups fill it in. if ( '' !== trim( (string) ( $input['contact_time'] ?? '' ) ) ) { return new WP_Error( 'spacebay_signup_invalid', __( 'Something went wrong. Please reload the page and try again.', 'spacebay' ), array( 'status' => 400 ) ); } // A generous limit before the form is checked, so the "already exists" answers cannot be used to test long lists of addresses. $limit = Throttle::hit( 'signup_try', Throttle::ip(), 30, 15 * MINUTE_IN_SECONDS ); if ( is_wp_error( $limit ) ) { return $limit; } $clean = SignupFields::validate( $input ); if ( is_wp_error( $clean ) ) { return $clean; } // Counted after the form is valid, so typing mistakes do not use up attempts. $limit = Throttle::hit( 'signup', Throttle::ip(), 5, HOUR_IN_SECONDS ); if ( is_wp_error( $limit ) ) { return $limit; } $user = Auth::create_account( $clean ); if ( is_wp_error( $user ) ) { return $user; } Auth::log_in( $user, false ); return $this->done( Portal::url() ); } /** * Emails a link to choose a new password. Always answers the same way, * so nobody can find out which email addresses have an account. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function forgot( $request ) { $login = trim( (string) $request['login'] ); $limit = Throttle::hit( 'forgot', Throttle::ip(), 5, 15 * MINUTE_IN_SECONDS ); if ( is_wp_error( $limit ) ) { return $limit; } if ( '' === $login ) { return new WP_Error( 'spacebay_login_empty', __( 'Please enter your email address.', 'spacebay' ), array( 'status' => 400 ) ); } $user = is_email( $login ) ? get_user_by( 'email', $login ) : get_user_by( 'login', $login ); if ( $user && true === Throttle::hit( 'forgot_user', 'user:' . $user->ID, 3, HOUR_IN_SECONDS ) ) { $key = get_password_reset_key( $user ); if ( ! is_wp_error( $key ) ) { Mail::send( $user->ID, array( 'subject' => __( 'Choose a new password', 'spacebay' ), 'text' => __( 'Choose a new password', 'spacebay' ), 'excerpt' => __( 'Someone asked to reset the password of your account. If that was you, use the button below within 24 hours. If not, you can ignore this email and nothing will change.', 'spacebay' ), 'url' => Portal::link( 'login/reset' ) . '?key=' . rawurlencode( $key ) . '&login=' . rawurlencode( $user->user_login ), 'button' => __( 'Choose a new password', 'spacebay' ), ) ); } } return rest_ensure_response( array( 'sent' => true, 'message' => __( 'If an account exists for that address, we sent an email with a link to choose a new password. Check your spam folder if it does not arrive.', 'spacebay' ), ) ); } /** * Saves a new password with the key from the reset email. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function reset( $request ) { $limit = Throttle::hit( 'reset', Throttle::ip(), 10, 15 * MINUTE_IN_SECONDS ); if ( is_wp_error( $limit ) ) { return $limit; } $user = check_password_reset_key( (string) $request['key'], (string) $request['login'] ); if ( ! $user instanceof WP_User ) { return new WP_Error( 'spacebay_reset_expired', __( 'This link has expired or was already used. Please ask for a new one.', 'spacebay' ), array( 'status' => 400 ) ); } $password = (string) $request['password']; $check = SignupFields::check_password( $password, $user->user_email ); if ( is_wp_error( $check ) ) { return $check; } reset_password( $user, $password ); return rest_ensure_response( array( 'reset' => true, 'message' => __( 'Your password is changed. You can log in with it now.', 'spacebay' ), ) ); } }