Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.91% covered (warning)
46.91%
38 / 81
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenVerifier
46.91% covered (warning)
46.91%
38 / 81
53.85% covered (warning)
53.85%
7 / 13
229.89
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_supported_algorithm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 verify_token
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 base64_encode_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 base64_decode_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 current_user
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_public_key
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
8.01
 is_valid_jwt
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 is_valid_signature
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 valid_data
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_max_age
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
4.07
 set_transient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_transient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
11declare(strict_types=1);
12
13namespace RtCamp\GoogleLogin\Utils;
14
15use Requests_Utility_CaseInsensitiveDictionary;
16use Exception;
17use RtCamp\GoogleLogin\Modules\Settings;
18use stdClass;
19
20/**
21 * Class TokenVerifier
22 *
23 * @package RtCamp\GoogleLogin\Utils
24 */
25class 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}