80, 'https' => 443, ); if ( isset( $parts['port'] ) && $default_ports[ $parts['scheme'] ] === (int) $parts['port'] ) { return false; } $path = isset( $parts['path'] ) ? $parts['path'] : ''; if ( '' === $path || preg_match( '#(^|/)\.{1,2}(/|$)#', $path ) || preg_match( '#[^A-Za-z0-9\-._~!$&\'()*+,;=:@/%]#', $path ) ) { return false; } $query = isset( $parts['query'] ) ? '?' . $parts['query'] : ''; $rebuilt = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . $path . $query; return $rebuilt === $url; } /** * Scheme, host and port of a URL. * * @param string $url URL. * @return string */ private static function origin( string $url ): string { $parts = wp_parse_url( $url ); return $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); } /** * Start a flow for the current user: store state and verifier, return the /connect URL. * * @return string */ public static function connect_url(): string { $user_id = get_current_user_id(); $state = Pkce::random_token(); $verifier = Pkce::random_token(); set_transient( self::STATE_PREFIX . $user_id, $state, self::FLOW_TTL ); $pending = self::pending_verifiers(); $pending[ $user_id ] = array( 'verifier' => $verifier, 'expires_at' => time() + self::FLOW_TTL, ); update_option( self::PKCE_OPTION, $pending, false ); $query = array( 'client_id' => self::CLIENT_ID, 'redirect_uri' => self::redirect_uri(), 'state' => $state, 'code_challenge' => Pkce::challenge( $verifier ), 'code_challenge_method' => 'S256', ); return self::server_url() . '/connect?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ); } /** * Handle the browser coming back from /connect. * * @param array $query Query parameters (unslashed). * @return true|WP_Error */ public static function handle_return( array $query ) { if ( ! current_user_can( Menu::CAPABILITY ) ) { return new WP_Error( 'smsportal_forbidden', __( 'You do not have permission to connect this site.', '85pl-android-sms-api' ) ); } $verifier = self::consume_flow( isset( $query['state'] ) ? (string) $query['state'] : '' ); if ( null === $verifier ) { return new WP_Error( 'smsportal_state', __( 'This connection link has expired or is not valid. Please try again.', '85pl-android-sms-api' ) ); } if ( isset( $query['error'] ) ) { $error = sanitize_key( (string) $query['error'] ); if ( 'access_denied' === $error ) { return new WP_Error( 'smsportal_cancelled', __( 'Connection cancelled.', '85pl-android-sms-api' ) ); } /* translators: %s: OAuth error code, e.g. invalid_request. */ ConnectionLog::add( 'auth_error', sprintf( __( 'Connection failed: authorization error (%s).', '85pl-android-sms-api' ), $error ) ); return self::failed(); } $code = isset( $query['code'] ) ? (string) $query['code'] : ''; if ( '' === $code ) { ConnectionLog::add( 'auth_error', __( 'Connection failed: smsportal.app sent no authorization code.', '85pl-android-sms-api' ) ); return self::failed(); } $connection = self::exchange_code( $code, $verifier ); if ( is_wp_error( $connection ) ) { return $connection; } update_option( self::OPTION, $connection, false ); Api::forget_info(); ConnectionLog::add( 'connected', /* translators: 1: masked account e-mail, 2: phone name. */ sprintf( __( 'Connected: account %1$s, phone %2$s.', '85pl-android-sms-api' ), $connection['account_email'], $connection['device_name'] ) ); return true; } /** * Whether the site holds a connection. * * @return bool */ public static function is_connected(): bool { return null !== self::api_key(); } /** * Stored connection (contains secrets — never print it whole). * * @return array|null */ public static function get(): ?array { $data = get_option( self::OPTION ); return is_array( $data ) && ! empty( $data['api_key'] ) ? $data : null; } /** * API key of the connection. * * @return string|null */ public static function api_key(): ?string { $data = self::get(); return null === $data ? null : (string) $data['api_key']; } /** * Webhook signing secret of the connection. * * @return string|null */ public static function webhook_secret(): ?string { $data = self::get(); return null === $data || empty( $data['webhook_secret'] ) ? null : (string) $data['webhook_secret']; } /** * Site address the connection was made on, or null for a connection made before it was stored. * * @return string|null */ public static function stored_home(): ?string { $data = self::get(); return null === $data || empty( $data['home'] ) || ! is_string( $data['home'] ) ? null : $data['home']; } /** * Whether this is a copy of the site the connection was made on (its address changed): a staging copy * or a clone must not send SMS from the owner's account. The raw option is compared, not home_url(), * which translation plugins change per language. * * @return bool */ public static function home_changed(): bool { $stored = self::stored_home(); return null !== $stored && untrailingslashit( $stored ) !== untrailingslashit( self::current_home() ); } /** * The site really moved: take its new address as the connection's own. * * @return void */ public static function keep_home(): void { $data = self::get(); if ( null === $data ) { return; } $old = (string) self::stored_home(); $data['home'] = self::current_home(); update_option( self::OPTION, $data, false ); /* translators: 1: old site address, 2: new site address. */ ConnectionLog::add( 'connected', sprintf( __( 'The site address changed from %1$s to %2$s; the connection was kept for the new address.', '85pl-android-sms-api' ), $old, $data['home'] ) ); } /** * Site address as stored in Settings > General (no filters). * * @return string */ public static function current_home(): string { return (string) get_option( 'home' ); } /** * Remove the connection. For $by = 'user' smsportal is told first (best effort); local data * is removed either way. On a copy of the site (home_changed()) the key belongs to the original * site, so it is never revoked: 'user' then works like 'local'. * * @param string $by 'user' (disconnected in WordPress), 'local' (only this site, the key stays valid) * or 'server' (smsportal no longer accepts the key). * @return bool False only when smsportal did not confirm revoking the key (it may still work there). */ public static function disconnect( string $by ): bool { $api_key = self::api_key(); if ( null === $api_key ) { return true; } if ( 'user' === $by && self::home_changed() ) { $by = 'local'; } $confirmed = true; $message = __( 'Disconnected by smsportal.app (the access key is no longer valid).', '85pl-android-sms-api' ); if ( 'local' === $by ) { $message = __( 'Disconnected only on this site: its address changed, so the connection belongs to the original site and keeps working there.', '85pl-android-sms-api' ); } elseif ( 'user' === $by ) { $confirmed = self::revoke_on_server( $api_key ); $message = $confirmed ? __( 'Disconnected in WordPress.', '85pl-android-sms-api' ) : __( 'Disconnected in WordPress. smsportal.app could not be reached; if this site is still listed under Integrations there, remove it.', '85pl-android-sms-api' ); } delete_option( self::OPTION ); Api::forget_info(); ConnectionLog::add( 'disconnected', $message ); return $confirmed; } /** * Tell smsportal to revoke the key. * * @param string $api_key Key. * @return bool Whether smsportal confirmed (2xx, or 401 = already revoked). */ private static function revoke_on_server( string $api_key ): bool { $response = wp_remote_request( self::server_url() . '/api/v1/integrations/connection', array( 'method' => 'DELETE', 'timeout' => self::REVOKE_TIMEOUT, // The key must never be sent on to another address. 'redirection' => 0, 'headers' => array( 'x-api-key' => $api_key ), ) ); if ( is_wp_error( $response ) ) { return false; } $status = (int) wp_remote_retrieve_response_code( $response ); return ( $status >= 200 && $status < 300 ) || 401 === $status; } /** * Exchange the code for the connection data. * * @param string $code Authorization code. * @param string $verifier PKCE verifier. * @return array|WP_Error */ private static function exchange_code( string $code, string $verifier ) { $response = wp_remote_post( self::server_url() . '/api/v1/integrations/token', array( 'timeout' => self::TOKEN_TIMEOUT, // The code and verifier must never be sent on to another address. 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( array( 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => self::redirect_uri(), 'client_id' => self::CLIENT_ID, 'code_verifier' => $verifier, 'webhook_url' => self::webhook_url(), ) ), ) ); if ( is_wp_error( $response ) ) { /* translators: %s: network error, e.g. "cURL error 28: Operation timed out". */ ConnectionLog::add( 'auth_error', sprintf( __( 'Connection failed: smsportal.app could not be reached (%s).', '85pl-android-sms-api' ), $response->get_error_message() ) ); return self::failed(); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $body = is_array( $body ) ? $body : array(); if ( 200 !== $status ) { $error = isset( $body['error'] ) && is_string( $body['error'] ) ? sanitize_key( $body['error'] ) : 'unknown'; /* translators: 1: HTTP status code, 2: error code from smsportal.app, e.g. invalid_grant. */ ConnectionLog::add( 'auth_error', sprintf( __( 'Connection failed: smsportal.app answered HTTP %1$d (%2$s).', '85pl-android-sms-api' ), $status, $error ) ); return self::failed(); } $connection = array(); foreach ( self::STORED_FIELDS as $field ) { $connection[ $field ] = isset( $body[ $field ] ) && is_scalar( $body[ $field ] ) ? sanitize_text_field( (string) $body[ $field ] ) : ''; } if ( '' === $connection['api_key'] || '' === $connection['webhook_secret'] || '' === $connection['connection_id'] ) { ConnectionLog::add( 'auth_error', __( 'Connection failed: smsportal.app sent an incomplete answer.', '85pl-android-sms-api' ) ); return self::failed(); } $connection['connected_at'] = time(); // A copy of the site (staging, a move) is recognised by a different address: see home_changed(). $connection['home'] = self::current_home(); return $connection; } /** * Check the state against the current user's flow and use the flow up. * * @param string $state State from the return address. * @return string|null Verifier, or null when the state does not match. */ private static function consume_flow( string $state ): ?string { $user_id = get_current_user_id(); $expected = get_transient( self::STATE_PREFIX . $user_id ); if ( ! is_string( $expected ) || '' === $state || ! hash_equals( $expected, $state ) ) { return null; } $pending = self::pending_verifiers(); $flow = isset( $pending[ $user_id ] ) ? $pending[ $user_id ] : null; unset( $pending[ $user_id ] ); update_option( self::PKCE_OPTION, $pending, false ); delete_transient( self::STATE_PREFIX . $user_id ); if ( ! is_array( $flow ) || empty( $flow['verifier'] ) ) { return null; } return (string) $flow['verifier']; } /** * Unexpired pending verifiers, keyed by user ID. * * @return array */ private static function pending_verifiers(): array { $pending = get_option( self::PKCE_OPTION, array() ); if ( ! is_array( $pending ) ) { return array(); } $now = time(); return array_filter( $pending, static function ( $flow ) use ( $now ) { return is_array( $flow ) && isset( $flow['expires_at'] ) && (int) $flow['expires_at'] > $now; } ); } /** * Error shown to the admin when the exchange fails (details are in the connection log). * * @return WP_Error */ private static function failed(): WP_Error { return new WP_Error( 'smsportal_token', __( 'Could not connect. Please try again.', '85pl-android-sms-api' ) ); } }