array( 'label' => 'Google', 'authorize' => 'https://accounts.google.com/o/oauth2/v2/auth', 'token' => 'https://oauth2.googleapis.com/token', 'profile' => 'https://openidconnect.googleapis.com/v1/userinfo', 'scope' => 'openid email profile', ), 'facebook' => array( 'label' => 'Facebook', 'authorize' => 'https://www.facebook.com/dialog/oauth', 'token' => 'https://graph.facebook.com/oauth/access_token', 'profile' => 'https://graph.facebook.com/me', 'scope' => 'email,public_profile', ), ); } /** * Whether a provider is switched on and has its keys. * * @param string $provider Provider ID. * @return bool */ public static function enabled( $provider ) { $provider = (string) $provider; return isset( self::providers()[ $provider ] ) && Auth::has_pages() && (bool) Settings::get( 'social_' . $provider, false ) && '' !== self::client_id( $provider ) && '' !== self::client_secret( $provider ); } /** * The buttons for the login and signup pages. * * @return array[] List of id, label and url. */ public static function buttons() { $buttons = array(); foreach ( self::providers() as $id => $provider ) { if ( self::enabled( $id ) ) { $buttons[] = array( 'id' => $id, 'label' => $provider['label'], 'url' => self::start_url( $id ), ); } } return $buttons; } /** * Address that starts a login with a provider. * * @param string $provider Provider ID. * @return string */ public static function start_url( $provider ) { return rest_url( 'spacebay/v1/auth/social/' . $provider ); } /** * Address the provider sends visitors back to. Administrators copy it into * their Google or Facebook app. * * @param string $provider Provider ID. * @return string */ public static function callback_url( $provider ) { return rest_url( 'spacebay/v1/auth/social/' . $provider . '/callback' ); } /** * Client ID of the administrator's app. * * @param string $provider Provider ID. * @return string */ private static function client_id( $provider ) { return trim( (string) Settings::get( 'social_' . $provider . '_id', '' ) ); } /** * Client secret of the administrator's app. * * @param string $provider Provider ID. * @return string */ private static function client_secret( $provider ) { return trim( (string) Settings::get( 'social_' . $provider . '_secret', '' ) ); } /** * Starts a login: remembers it for this browser and returns the provider's address. * * @param string $provider Provider ID. * @param string $redirect Address to open after the login. * @return string */ public static function begin( $provider, $redirect ) { $config = self::providers()[ $provider ]; $state = wp_generate_password( 40, false, false ); $verifier = wp_generate_password( 64, false, false ); set_transient( self::state_key( $state ), array( 'provider' => $provider, 'verifier' => $verifier, 'redirect' => Auth::safe_redirect( $redirect ), ), self::TTL ); self::set_cookie( $state, time() + self::TTL ); $args = array( 'client_id' => self::client_id( $provider ), 'redirect_uri' => self::callback_url( $provider ), 'response_type' => 'code', 'scope' => $config['scope'], 'state' => $state, ); // Google also gets a PKCE challenge, an extra check that the code is used by the same server. if ( 'google' === $provider ) { $args['code_challenge'] = self::challenge( $verifier ); $args['code_challenge_method'] = 'S256'; $args['prompt'] = 'select_account'; } return add_query_arg( array_map( 'rawurlencode', $args ), $config['authorize'] ); } /** * Finishes a login when the provider sends the visitor back. * * @param string $provider Provider ID. * @param array $query Query values of the callback: code, state, error. * @return array|WP_Error The member ("user") and the address to open ("redirect"). */ public static function finish( $provider, array $query ) { $state = (string) ( $query['state'] ?? '' ); $saved = '' !== $state ? get_transient( self::state_key( $state ) ) : false; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Compared with hash_equals() only. $cookie = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ) : ''; // The login is used once, whatever happens next. if ( '' !== $state ) { delete_transient( self::state_key( $state ) ); } self::set_cookie( '', time() - HOUR_IN_SECONDS ); // The provider sends "error" when the visitor pressed Cancel. if ( ! empty( $query['error'] ) ) { return self::error( 'denied' ); } // The login must come back to the browser that started it, for the same provider. if ( ! is_array( $saved ) || '' === $cookie || ! hash_equals( $state, $cookie ) || $saved['provider'] !== $provider ) { return self::error( 'expired' ); } $code = (string) ( $query['code'] ?? '' ); // No code after a valid return means the person did not allow the login (Cancel). if ( '' === $code ) { return self::error( 'denied' ); } $token = self::exchange( $provider, $code, (string) $saved['verifier'] ); if ( is_wp_error( $token ) ) { return $token; } $profile = self::profile( $provider, $token ); if ( is_wp_error( $profile ) ) { return $profile; } $user = self::member_for( $provider, $profile ); if ( is_wp_error( $user ) ) { return $user; } return array( 'user' => $user, 'redirect' => (string) $saved['redirect'], ); } /** * Finds or creates the member for a provider profile. * * @param string $provider Provider ID. * @param array $profile Normalised profile: id, email, verified, name. * @return WP_User|WP_Error */ public static function member_for( $provider, array $profile ) { $id = (string) ( $profile['id'] ?? '' ); if ( '' === $id ) { return self::error( 'failed' ); } // 1. A member who used this provider before. $linked = get_users( array( 'meta_key' => self::META . $provider, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One indexed lookup per login. 'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- One indexed lookup per login. 'number' => 1, 'blog_id' => 0, ) ); if ( $linked ) { return $linked[0]; } $email = sanitize_email( (string) ( $profile['email'] ?? '' ) ); if ( '' === $email || ! is_email( $email ) ) { return self::error( 'no_email' ); } // 2. A member with the same email address: connect the provider, but only // when the provider has checked that the address belongs to this person // and the member has shown it too. Signing up never checks the address, so // without the second check someone could sign up first with another // person's address and a password they know, and keep access to the // account after its real owner logs in with Google or Facebook. $existing = get_user_by( 'email', $email ); if ( $existing ) { if ( empty( $profile['verified'] ) ) { return self::error( 'unverified' ); } // Site and community managers always log in with their password, so a // social account can never take over an account with admin rights. if ( user_can( $existing, 'manage_options' ) || user_can( $existing, Capabilities::MANAGE ) ) { return self::error( 'manager' ); } if ( ! Auth::email_verified( $existing ) ) { return self::error( 'exists' ); } update_user_meta( $existing->ID, self::META . $provider, $id ); return $existing; } // 3. Someone new: a new account, only while signing up is open. if ( ! Auth::signup_open() ) { return self::error( 'closed' ); } $limit = Throttle::hit( 'signup', Throttle::ip(), 5, HOUR_IN_SECONDS ); if ( is_wp_error( $limit ) ) { return $limit; } $name = mb_substr( sanitize_text_field( (string) ( $profile['name'] ?? '' ) ), 0, 60 ); $user = Auth::create_account( array( 'username' => '', 'email' => $email, 'name' => $name, // Nobody knows this password; the member can choose one with "Forgot your password?". 'password' => wp_generate_password( 32, true, true ), 'profile' => array(), ) ); if ( is_wp_error( $user ) ) { return $user; } update_user_meta( $user->ID, self::META . $provider, $id ); // The provider confirmed this address, so the account may connect another provider later. if ( ! empty( $profile['verified'] ) ) { Auth::mark_email_verified( $user->ID ); } return $user; } /** * Exchanges the one-time code for an access token. * * @param string $provider Provider ID. * @param string $code Code from the callback. * @param string $verifier PKCE verifier saved when the login started. * @return string|WP_Error Access token. */ private static function exchange( $provider, $code, $verifier ) { $args = array( 'client_id' => self::client_id( $provider ), 'client_secret' => self::client_secret( $provider ), 'redirect_uri' => self::callback_url( $provider ), 'code' => $code, ); $url = self::providers()[ $provider ]['token']; if ( 'google' === $provider ) { $args['code_verifier'] = $verifier; $args['grant_type'] = 'authorization_code'; $response = wp_safe_remote_post( $url, array( 'timeout' => 15, 'body' => $args, 'headers' => array( 'Accept' => 'application/json' ), ) ); } else { $response = wp_safe_remote_get( add_query_arg( array_map( 'rawurlencode', $args ), $url ), array( 'timeout' => 15 ) ); } $data = self::json( $response ); if ( empty( $data['access_token'] ) || ! is_string( $data['access_token'] ) ) { return self::error( 'failed' ); } return $data['access_token']; } /** * Reads the person's ID, name and email address with the access token. * * @param string $provider Provider ID. * @param string $token Access token. * @return array|WP_Error Normalised profile: id, email, verified, name. */ private static function profile( $provider, $token ) { $url = self::providers()[ $provider ]['profile']; if ( 'facebook' === $provider ) { $url = add_query_arg( array( 'fields' => 'id,name,email', 'access_token' => rawurlencode( $token ), // Proves the request comes from the app's server. 'appsecret_proof' => hash_hmac( 'sha256', $token, self::client_secret( $provider ) ), ), $url ); } $response = wp_safe_remote_get( $url, array( 'timeout' => 15, 'headers' => 'google' === $provider ? array( 'Authorization' => 'Bearer ' . $token ) : array(), ) ); $data = self::json( $response ); if ( 'google' === $provider ) { $profile = array( 'id' => (string) ( $data['sub'] ?? '' ), 'email' => (string) ( $data['email'] ?? '' ), 'verified' => true === ( $data['email_verified'] ?? false ) || 'true' === ( $data['email_verified'] ?? '' ), 'name' => (string) ( $data['name'] ?? '' ), ); } else { // Facebook only returns an email address it has confirmed. $profile = array( 'id' => (string) ( $data['id'] ?? '' ), 'email' => (string) ( $data['email'] ?? '' ), 'verified' => '' !== (string) ( $data['email'] ?? '' ), 'name' => (string) ( $data['name'] ?? '' ), ); } if ( '' === $profile['id'] ) { return self::error( 'failed' ); } /** * Filters the profile read from a provider before SpaceBay looks for the member. * * Set "verified" to false to stop SpaceBay from connecting accounts by * email address for a provider you do not trust to check addresses. * * @since 1.0.0 * * @param array $profile id, email, verified, name. * @param string $provider "google" or "facebook". */ return (array) apply_filters( 'spacebay_social_profile', $profile, $provider ); } /** * Decodes a JSON response. * * @param array|WP_Error $response HTTP response. * @return array */ private static function json( $response ) { if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return array(); } $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); return is_array( $data ) ? $data : array(); } /** * PKCE code challenge for a verifier (S256). * * @param string $verifier Verifier. * @return string */ private static function challenge( $verifier ) { return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url is required by the PKCE standard. } /** * Transient key of a started login. The state itself is not stored as the key. * * @param string $state State value. * @return string */ private static function state_key( $state ) { return 'spacebay_oauth_' . substr( hash( 'sha256', $state ), 0, 32 ); } /** * Sets or clears the browser cookie of a started login. * * @param string $value Cookie value. * @param int $expires Expiry time. * @return void */ private static function set_cookie( $value, $expires ) { if ( headers_sent() ) { return; } setcookie( self::COOKIE, $value, array( 'expires' => $expires, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), 'httponly' => true, // Lax: sent when the provider sends the browser back with a normal link. 'samesite' => 'Lax', ) ); } /** * Address of the login page with a social login error. * * @param string $code Error code without prefix, for example "closed". * @return string */ public static function error_url( $code ) { // Appended as text, so it lands after "#/login" too when the community uses addresses with a #. return Portal::link( 'login' ) . '?social_error=' . rawurlencode( sanitize_key( $code ) ); } /** * A social login error. The login page shows a message for each code. * * @param string $code Short code: denied, expired, failed, no_email, unverified, exists, manager, closed. * @return WP_Error */ private static function error( $code ) { return new WP_Error( 'spacebay_social_' . $code, $code, array( 'status' => 400 ) ); } }