Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
42.22% |
38 / 90 |
|
53.85% |
7 / 13 |
CRAP | |
0.00% |
0 / 1 |
TokenVerifier | |
42.22% |
38 / 90 |
|
53.85% |
7 / 13 |
285.97 | |
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 / 13 |
|
0.00% |
0 / 1 |
30 | |||
is_valid_signature | |
0.00% |
0 / 20 |
|
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 | } |