Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
46.91% |
38 / 81 |
|
53.85% |
7 / 13 |
CRAP | |
0.00% |
0 / 1 |
| TokenVerifier | |
46.91% |
38 / 81 |
|
53.85% |
7 / 13 |
229.89 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_supported_algorithm | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| verify_token | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| base64_encode_url | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| base64_decode_url | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| current_user | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_public_key | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
8.01 | |||
| is_valid_jwt | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| is_valid_signature | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
| valid_data | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
| get_max_age | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
4.07 | |||
| set_transient | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_transient | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * JWT Token Verifier. |
| 4 | * |
| 5 | * This will verify the token based on asymmetric encryption. |
| 6 | * |
| 7 | * @package RtCamp\GoogleLogin |
| 8 | * @since 1.0.16 |
| 9 | */ |
| 10 | |
| 11 | declare(strict_types=1); |
| 12 | |
| 13 | namespace RtCamp\GoogleLogin\Utils; |
| 14 | |
| 15 | use Requests_Utility_CaseInsensitiveDictionary; |
| 16 | use Exception; |
| 17 | use RtCamp\GoogleLogin\Modules\Settings; |
| 18 | use stdClass; |
| 19 | |
| 20 | /** |
| 21 | * Class TokenVerifier |
| 22 | * |
| 23 | * @package RtCamp\GoogleLogin\Utils |
| 24 | */ |
| 25 | class TokenVerifier { |
| 26 | /** |
| 27 | * Get list of public keys to verify signature. |
| 28 | */ |
| 29 | const CERTS_URL = 'https://www.googleapis.com/oauth2/v1/certs'; |
| 30 | |
| 31 | /** |
| 32 | * List of supported algorithms. |
| 33 | */ |
| 34 | const SUPPORTED_ALGORITHMS = [ |
| 35 | 'RS256' => OPENSSL_ALGO_SHA256, |
| 36 | 'RS384' => OPENSSL_ALGO_SHA384, |
| 37 | 'RS512' => OPENSSL_ALGO_SHA512, |
| 38 | 'ES384' => OPENSSL_ALGO_SHA384, |
| 39 | 'ES256' => OPENSSL_ALGO_SHA512, |
| 40 | ]; |
| 41 | |
| 42 | /** |
| 43 | * ID Token Sent via Google. |
| 44 | * |
| 45 | * @var string |
| 46 | */ |
| 47 | private $token = ''; |
| 48 | |
| 49 | /** |
| 50 | * User who needs to be authenticated. |
| 51 | * |
| 52 | * @var stdClass |
| 53 | */ |
| 54 | private $current_user; |
| 55 | |
| 56 | /** |
| 57 | * Settings instance. |
| 58 | * |
| 59 | * @var Settings |
| 60 | */ |
| 61 | private $settings; |
| 62 | |
| 63 | /** |
| 64 | * TokenVerifier constructor. |
| 65 | * |
| 66 | * @param Settings $settings Settings instance. |
| 67 | */ |
| 68 | public function __construct( Settings $settings ) { |
| 69 | $this->settings = $settings; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Get supported algorithms value. |
| 74 | * |
| 75 | * @param string $algo Algorithm. |
| 76 | */ |
| 77 | public static function get_supported_algorithm( string $algo = '' ) { |
| 78 | $find_algo = array_key_exists( $algo, self::SUPPORTED_ALGORITHMS ); |
| 79 | |
| 80 | if ( ! $find_algo ) { |
| 81 | return apply_filters( 'rtcamp.default_algorithm', OPENSSL_ALGO_SHA256, $algo ); |
| 82 | } |
| 83 | |
| 84 | return self::SUPPORTED_ALGORITHMS[ $algo ]; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Verify if a token is valid or not. |
| 89 | * |
| 90 | * @param string $token Received ID token from Google. |
| 91 | * |
| 92 | * @return bool |
| 93 | * @throws Exception Token verification failure exception. |
| 94 | */ |
| 95 | public function verify_token( string $token ): bool { |
| 96 | $this->token = $token; |
| 97 | |
| 98 | try { |
| 99 | $this->is_valid_jwt(); |
| 100 | $this->is_valid_signature(); |
| 101 | $this->valid_data(); |
| 102 | |
| 103 | return true; |
| 104 | } catch ( Exception $e ) { |
| 105 | |
| 106 | do_action( 'rtcamp.login_with_google_exception', $e ); |
| 107 | |
| 108 | throw $e; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Base64 URL Encode a string. |
| 114 | * |
| 115 | * @param string $string Input string to encode. |
| 116 | * |
| 117 | * @return array|string|string[] |
| 118 | */ |
| 119 | public function base64_encode_url( $string ) { |
| 120 | return str_replace( [ '+', '/', '=' ], [ '-', '_', '' ], base64_encode( $string ) ); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Base64 URL Encode a string. |
| 125 | * |
| 126 | * @param string $string Input string to decode. |
| 127 | * |
| 128 | * @return false|string |
| 129 | */ |
| 130 | public function base64_decode_url( string $string ) { |
| 131 | return base64_decode( str_replace( [ '-', '_' ], [ '+', '/' ], $string ) ); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Retrieve current user's data. |
| 136 | * |
| 137 | * Current user is Google user, not WP user. |
| 138 | * |
| 139 | * @return stdClass|null |
| 140 | */ |
| 141 | public function current_user(): ?stdClass { |
| 142 | |
| 143 | return $this->current_user; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Get public key based on key ID. |
| 148 | * |
| 149 | * @param string|null $key_id Key ID. |
| 150 | * |
| 151 | * @return string|null |
| 152 | */ |
| 153 | public function get_public_key( $key_id = null ): ?string { |
| 154 | if ( ! $key_id ) { |
| 155 | return null; |
| 156 | } |
| 157 | |
| 158 | $transient_key = 'lwg_pk_' . $key_id; |
| 159 | $cached_pk = $this->get_transient( $transient_key ); |
| 160 | |
| 161 | if ( ! empty( $cached_pk ) ) { |
| 162 | return (string) $cached_pk; |
| 163 | } |
| 164 | |
| 165 | //phpcs:disable WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get |
| 166 | $certs = wp_remote_get( self::CERTS_URL ); |
| 167 | |
| 168 | if ( 200 !== wp_remote_retrieve_response_code( $certs ) ) { |
| 169 | return null; |
| 170 | } |
| 171 | |
| 172 | $headers = wp_remote_retrieve_headers( $certs ); |
| 173 | $keys = wp_remote_retrieve_body( $certs ); |
| 174 | $keys = json_decode( $keys ); |
| 175 | |
| 176 | if ( property_exists( $keys, $key_id ) ) { |
| 177 | $max_age = is_object( $headers ) && is_a( $headers, Requests_Utility_CaseInsensitiveDictionary::class ) ? $this->get_max_age( $headers ) : 0; |
| 178 | |
| 179 | /** |
| 180 | * Cache public key in transient. |
| 181 | * |
| 182 | * We will cache it for 5 mins less than the actual expiration time, |
| 183 | * so that it should be cleared on time. |
| 184 | */ |
| 185 | if ( $max_age ) { |
| 186 | $max_age = $max_age - 300; |
| 187 | $this->set_transient( $transient_key, $keys->{$key_id}, max( 5, $max_age ) ); |
| 188 | } |
| 189 | |
| 190 | return $keys->{$key_id}; |
| 191 | } |
| 192 | |
| 193 | return null; |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * Checks whether received token is valid JWT token or not. |
| 198 | * |
| 199 | * @return array|null Decoded informational array with Header|Payload|Signature form. |
| 200 | * @throws Exception ID token invalid. |
| 201 | */ |
| 202 | private function is_valid_jwt(): ?array { |
| 203 | $parts = explode( '.', $this->token ); |
| 204 | |
| 205 | if ( ! is_array( $parts ) || 3 !== count( $parts ) ) { |
| 206 | throw new Exception( __( 'ID token is invalid', 'login-with-google' ) ); |
| 207 | } |
| 208 | |
| 209 | list( $header, $payload, $obtained_signature ) = $parts; |
| 210 | $header = $this->base64_decode_url( $header ); |
| 211 | $payload = $this->base64_decode_url( $payload ); |
| 212 | |
| 213 | if ( ! $header || ! $payload ) { |
| 214 | throw new Exception( __( 'ID token is invalid', 'login-with-google' ) ); |
| 215 | } |
| 216 | |
| 217 | return [ |
| 218 | $header, |
| 219 | $payload, |
| 220 | $obtained_signature, |
| 221 | ]; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Verifies the signature in token. |
| 226 | * |
| 227 | * @return void |
| 228 | * @throws Exception Failed signature verification. |
| 229 | */ |
| 230 | private function is_valid_signature(): void { |
| 231 | list( $header, $payload, $obtained_signature ) = $this->is_valid_jwt(); |
| 232 | $parsed_header = json_decode( $header ); |
| 233 | $parsed_header = wp_parse_args( |
| 234 | (array) $parsed_header, |
| 235 | [ |
| 236 | 'kid' => null, |
| 237 | 'alg' => null, |
| 238 | 'typ' => 'JWT', |
| 239 | ] |
| 240 | ); |
| 241 | |
| 242 | if ( ! $parsed_header['kid'] || ! $parsed_header['alg'] ) { |
| 243 | throw new Exception( __( 'Cannot verify the ID token signature. Please try again.', 'login-with-google' ) ); |
| 244 | } |
| 245 | |
| 246 | $pubkey_pem = $this->get_public_key( $parsed_header['kid'] ); |
| 247 | $decryption_key = openssl_pkey_get_public( $pubkey_pem ); |
| 248 | $data = $this->base64_encode_url( $header ) . '.' . $this->base64_encode_url( $payload ); |
| 249 | $calculated_signature = openssl_verify( $data, $this->base64_decode_url( $obtained_signature ), $decryption_key, self::get_supported_algorithm( $parsed_header['alg'] ) ); |
| 250 | |
| 251 | if ( 1 === (int) $calculated_signature ) { |
| 252 | $this->current_user = json_decode( $payload ); |
| 253 | |
| 254 | return; |
| 255 | } |
| 256 | |
| 257 | throw new Exception( __( 'Cannot verify the ID token signature. Please try again.', 'login-with-google' ) ); |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Check the validity of data. |
| 262 | * |
| 263 | * @throws Exception If user is not set. |
| 264 | */ |
| 265 | private function valid_data(): void { |
| 266 | if ( is_null( $this->current_user ) ) { |
| 267 | throw new Exception( __( 'No user present to validate', 'login-with-google' ) ); |
| 268 | } |
| 269 | |
| 270 | if ( $this->settings->client_id !== $this->current_user->aud ) { |
| 271 | throw new Exception( __( 'Invalid data found for authentication', 'login-with-google' ) ); |
| 272 | } |
| 273 | |
| 274 | if ( ! in_array( $this->current_user->iss, [ 'accounts.google.com', 'https://accounts.google.com' ], true ) ) { |
| 275 | throw new Exception( __( 'Invalid source found for authentication', 'login-with-google' ) ); |
| 276 | } |
| 277 | |
| 278 | if ( $this->current_user->exp < strtotime( 'now' ) ) { |
| 279 | throw new Exception( __( 'User data is stale! Please try again.', 'login-with-google' ) ); |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Get max age to cache the response from Cache-Control header. |
| 285 | * |
| 286 | * @param Requests_Utility_CaseInsensitiveDictionary $headers List of response headers. |
| 287 | * |
| 288 | * @return int |
| 289 | */ |
| 290 | private function get_max_age( Requests_Utility_CaseInsensitiveDictionary $headers ): int { |
| 291 | if ( ! $headers->offsetExists( 'cache-control' ) ) { |
| 292 | return 0; |
| 293 | } |
| 294 | |
| 295 | $cache_control = $headers->offsetGet( 'cache-control' ); |
| 296 | $cache_control = explode( ',', $cache_control ); |
| 297 | $cache_control = array_map( 'trim', $cache_control ); |
| 298 | $cache_control = preg_grep( '/max-age=(\d+)?/', $cache_control ); |
| 299 | |
| 300 | if ( is_array( $cache_control ) && 1 === count( $cache_control ) ) { |
| 301 | $max_age = array_pop( $cache_control ); |
| 302 | $max_age = explode( '=', $max_age ); |
| 303 | $max_age = $max_age[1]; |
| 304 | |
| 305 | return intval( $max_age ); |
| 306 | } |
| 307 | |
| 308 | return 0; |
| 309 | } |
| 310 | |
| 311 | /** |
| 312 | * Set the public key in transient. |
| 313 | * |
| 314 | * @param string $key Transient key. |
| 315 | * @param string $value Transient value. |
| 316 | * @param int $expire Transient expiration time in seconds. |
| 317 | * |
| 318 | * @return void |
| 319 | */ |
| 320 | private function set_transient( string $key, string $value, int $expire = 0 ): void { |
| 321 | set_transient( $key, $value, $expire ); |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Retrieve the transient. |
| 326 | * |
| 327 | * @param string $key Transient key. |
| 328 | * |
| 329 | * @return mixed |
| 330 | */ |
| 331 | private function get_transient( string $key ) { |
| 332 | return get_transient( $key ); |
| 333 | } |
| 334 | } |