From ede9dd34edb558a0f5549f5e9568842c9eab7e7a Mon Sep 17 00:00:00 2001 From: fabianmarian8 Date: Sun, 17 May 2026 15:56:57 +0200 Subject: [PATCH] Harden Google ID token validation --- .../modules/v1/controllers/AuthController.php | 7 +- .../modules/v1/controllers/AuthController.php | 7 +- common/components/GoogleIdTokenVerifier.php | 110 ++++++++++++++++++ common/config/main.php | 4 + .../modules/v1/controllers/AuthController.php | 7 +- .../modules/v1/controllers/AuthController.php | 7 +- .../modules/v1/controllers/AuthController.php | 7 +- tests/check-google-id-token-hardening.py | 56 +++++++++ 8 files changed, 180 insertions(+), 25 deletions(-) create mode 100644 common/components/GoogleIdTokenVerifier.php create mode 100644 tests/check-google-id-token-hardening.py diff --git a/admin/modules/v1/controllers/AuthController.php b/admin/modules/v1/controllers/AuthController.php index 55845836..46207390 100644 --- a/admin/modules/v1/controllers/AuthController.php +++ b/admin/modules/v1/controllers/AuthController.php @@ -160,12 +160,9 @@ public function actionLoginByGoogle() { $token = Yii::$app->request->getBodyParam("idToken"); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" . $token); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $response = json_decode(curl_exec($ch)); + $response = Yii::$app->googleIdTokenVerifier->verify($token); - if (empty($response->email)) { + if (!$response) { return [ 'operation' => 'error', "code" => 1, diff --git a/candidate/modules/v1/controllers/AuthController.php b/candidate/modules/v1/controllers/AuthController.php index af3f52c2..83152e7d 100644 --- a/candidate/modules/v1/controllers/AuthController.php +++ b/candidate/modules/v1/controllers/AuthController.php @@ -953,12 +953,9 @@ public function actionLoginByGoogle() { $token = Yii::$app->request->getBodyParam("idToken"); $utm_uuid = Yii::$app->request->getBodyParam("utm_uuid"); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" . $token); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $response = json_decode(curl_exec($ch)); + $response = Yii::$app->googleIdTokenVerifier->verify($token); - if (empty($response->email)) { + if (!$response) { return [ 'operation' => 'error', "code" => 1, diff --git a/common/components/GoogleIdTokenVerifier.php b/common/components/GoogleIdTokenVerifier.php new file mode 100644 index 00000000..8eddbc93 --- /dev/null +++ b/common/components/GoogleIdTokenVerifier.php @@ -0,0 +1,110 @@ +allowedClientIds)) { + $this->allowedClientIds = $this->parseClientIds($this->allowedClientIds); + } + + if (!$this->allowedClientIds) { + $this->allowedClientIds = $this->parseClientIds(getenv('GOOGLE_OAUTH_CLIENT_IDS') ?: ''); + } + } + + public function verify($idToken) + { + $idToken = trim((string)$idToken); + + if ($idToken === '') { + return null; + } + + if (!$this->allowedClientIds) { + Yii::warning('GOOGLE_OAUTH_CLIENT_IDS is not configured; rejecting Google login.', __METHOD__); + return null; + } + + $response = $this->fetchTokenInfo($idToken); + + if (!$response || !$this->hasValidClaims($response)) { + return null; + } + + return $response; + } + + private function fetchTokenInfo($idToken) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $this->tokenInfoEndpoint . '?' . http_build_query(['id_token' => $idToken])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); + + $body = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($body === false || $httpCode !== 200) { + Yii::warning('Google tokeninfo request failed: HTTP ' . $httpCode . ($curlError ? ' - ' . $curlError : ''), __METHOD__); + return null; + } + + $response = json_decode($body); + + if (json_last_error() !== JSON_ERROR_NONE || !is_object($response)) { + Yii::warning('Google tokeninfo response was not valid JSON.', __METHOD__); + return null; + } + + return $response; + } + + private function hasValidClaims($response) + { + if (empty($response->email) || empty($response->aud) || empty($response->iss) || empty($response->exp)) { + return false; + } + + if (!in_array($response->aud, $this->allowedClientIds, true)) { + return false; + } + + if (!in_array($response->iss, ['accounts.google.com', 'https://accounts.google.com'], true)) { + return false; + } + + if ((int)$response->exp <= time()) { + return false; + } + + return $this->isEmailVerified($response->email_verified ?? null); + } + + private function isEmailVerified($value) + { + if (is_bool($value)) { + return $value; + } + + return in_array(strtolower((string)$value), ['1', 'true'], true); + } + + private function parseClientIds($value) + { + return array_values(array_filter(array_map('trim', explode(',', (string)$value)))); + } +} diff --git a/common/config/main.php b/common/config/main.php index 793bdb35..dcb74f32 100644 --- a/common/config/main.php +++ b/common/config/main.php @@ -26,6 +26,10 @@ 'class' => 'common\components\GoogleMap', 'accessKey' => getenv('GOOGLE_MAPS_API_KEY'), ], + 'googleIdTokenVerifier' => [ + 'class' => 'common\components\GoogleIdTokenVerifier', + 'allowedClientIds' => getenv('GOOGLE_OAUTH_CLIENT_IDS') ?: '', + ], 'reCaptcha' => [ 'class' => 'common\components\ReCaptcha', 'secretKey' => "6Lei9R4pAAAAAD5-OIUbCZeMQ00saNLKNuU62b4v" diff --git a/company/modules/v1/controllers/AuthController.php b/company/modules/v1/controllers/AuthController.php index c7ba1843..45ac8e70 100644 --- a/company/modules/v1/controllers/AuthController.php +++ b/company/modules/v1/controllers/AuthController.php @@ -205,12 +205,9 @@ public function actionLoginByGoogle() { $token = Yii::$app->request->getBodyParam("idToken"); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" . $token); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $response = json_decode(curl_exec($ch)); + $response = Yii::$app->googleIdTokenVerifier->verify($token); - if (empty($response->email)) { + if (!$response) { return [ 'operation' => 'error', "code" => 1, diff --git a/manager/modules/v1/controllers/AuthController.php b/manager/modules/v1/controllers/AuthController.php index 41ed48a1..24d0aa4d 100644 --- a/manager/modules/v1/controllers/AuthController.php +++ b/manager/modules/v1/controllers/AuthController.php @@ -155,12 +155,9 @@ public function actionLoginByGoogle() { $token = Yii::$app->request->getBodyParam("idToken"); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" . $token); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $response = json_decode(curl_exec($ch)); + $response = Yii::$app->googleIdTokenVerifier->verify($token); - if (empty($response->email)) { + if (!$response) { return [ 'operation' => 'error', "code" => 1, diff --git a/staff/modules/v1/controllers/AuthController.php b/staff/modules/v1/controllers/AuthController.php index 65fa756a..aeca8e4c 100644 --- a/staff/modules/v1/controllers/AuthController.php +++ b/staff/modules/v1/controllers/AuthController.php @@ -210,12 +210,9 @@ public function actionLoginByGoogle() { $token = Yii::$app->request->getBodyParam("idToken"); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" . $token); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $response = json_decode(curl_exec($ch)); + $response = Yii::$app->googleIdTokenVerifier->verify($token); - if (empty($response->email)) { + if (!$response) { return [ 'operation' => 'error', "code" => 1, diff --git a/tests/check-google-id-token-hardening.py b/tests/check-google-id-token-hardening.py new file mode 100644 index 00000000..cdf7b372 --- /dev/null +++ b/tests/check-google-id-token-hardening.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +CONTROLLERS = [ + "admin/modules/v1/controllers/AuthController.php", + "company/modules/v1/controllers/AuthController.php", + "staff/modules/v1/controllers/AuthController.php", + "manager/modules/v1/controllers/AuthController.php", + "candidate/modules/v1/controllers/AuthController.php", +] + + +def read(path): + return (ROOT / path).read_text() + + +def assert_contains(path, text): + content = read(path) + if text not in content: + raise AssertionError(f"{path} does not contain {text!r}") + + +def assert_not_contains(path, text): + content = read(path) + if text in content: + raise AssertionError(f"{path} still contains {text!r}") + + +def main(): + verifier = "common/components/GoogleIdTokenVerifier.php" + + for expected in [ + "GOOGLE_OAUTH_CLIENT_IDS", + "$response->aud", + "$response->iss", + "$response->exp", + "$response->email", + "email_verified", + "http_build_query(['id_token' => $idToken])", + ]: + assert_contains(verifier, expected) + + assert_contains("common/config/main.php", "'googleIdTokenVerifier'") + assert_contains("common/config/main.php", "GOOGLE_OAUTH_CLIENT_IDS") + + for controller in CONTROLLERS: + assert_contains(controller, "Yii::$app->googleIdTokenVerifier->verify($token)") + assert_not_contains(controller, "tokeninfo?id_token=") + assert_not_contains(controller, "json_decode(curl_exec($ch))") + + +if __name__ == "__main__": + main()