ensureKeyPair(); $header = [ 'alg' => 'RS256', 'typ' => 'JWT', 'kid' => $this->currentKeyId(), ]; $encodedHeader = $this->base64UrlEncode((string) json_encode($header, JSON_UNESCAPED_SLASHES)); $encodedPayload = $this->base64UrlEncode((string) json_encode($payload, JSON_UNESCAPED_SLASHES)); $signingInput = $encodedHeader.'.'.$encodedPayload; $signature = ''; $privateKey = openssl_pkey_get_private($this->readPrivateKey()); if ($privateKey === false) { throw new RuntimeException('OAuth private key 无效。'); } $signed = openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (! $signed) { throw new RuntimeException('OAuth JWT 签名失败。'); } return $signingInput.'.'.$this->base64UrlEncode($signature); } public function verify(string $jwt): ?array { $parts = explode('.', $jwt); if (count($parts) !== 3) { return null; } [$encodedHeader, $encodedPayload, $encodedSignature] = $parts; $header = json_decode($this->base64UrlDecode($encodedHeader), true); $payload = json_decode($this->base64UrlDecode($encodedPayload), true); if (! is_array($header) || ! is_array($payload)) { return null; } if (($header['alg'] ?? '') !== 'RS256') { return null; } $publicKey = openssl_pkey_get_public($this->readPublicKey()); if ($publicKey === false) { return null; } $verifyResult = openssl_verify( $encodedHeader.'.'.$encodedPayload, $this->base64UrlDecode($encodedSignature), $publicKey, OPENSSL_ALGO_SHA256 ); if ($verifyResult !== 1) { return null; } $now = time(); $exp = (int) ($payload['exp'] ?? 0); if ($exp <= 0 || $exp <= $now) { return null; } $nbf = (int) ($payload['nbf'] ?? 0); if ($nbf > 0 && $nbf > $now) { return null; } return $payload; } public function jwks(): array { $this->ensureKeyPair(); $publicKey = openssl_pkey_get_public($this->readPublicKey()); if ($publicKey === false) { throw new RuntimeException('OAuth public key 无效。'); } $details = openssl_pkey_get_details($publicKey); if (! is_array($details) || ! isset($details['rsa']['n'], $details['rsa']['e'])) { throw new RuntimeException('OAuth public key 不是 RSA。'); } $n = $this->base64UrlEncode($details['rsa']['n']); $e = $this->base64UrlEncode($details['rsa']['e']); return [ 'keys' => [[ 'kty' => 'RSA', 'alg' => 'RS256', 'use' => 'sig', 'kid' => $this->currentKeyId($n, $e), 'n' => $n, 'e' => $e, ]], ]; } public function atHash(string $accessToken): string { $digest = hash('sha256', $accessToken, true); return $this->base64UrlEncode(substr($digest, 0, (int) (strlen($digest) / 2))); } private function currentKeyId(?string $n = null, ?string $e = null): string { $configured = trim((string) config('oauth.key_id', '')); if ($configured !== '') { return $configured; } if ($n === null || $e === null) { $jwks = $this->jwks(); $n = (string) ($jwks['keys'][0]['n'] ?? ''); $e = (string) ($jwks['keys'][0]['e'] ?? ''); } $canonical = json_encode(['e' => $e, 'kty' => 'RSA', 'n' => $n], JSON_UNESCAPED_SLASHES); if (! is_string($canonical)) { throw new RuntimeException('OAuth kid 生成失败。'); } return $this->base64UrlEncode(hash('sha256', $canonical, true)); } private function readPrivateKey(): string { $path = (string) config('oauth.private_key_path'); $content = @file_get_contents($path); if (! is_string($content) || trim($content) === '') { throw new RuntimeException('OAuth private key 不存在。'); } return $content; } private function readPublicKey(): string { $path = (string) config('oauth.public_key_path'); $content = @file_get_contents($path); if (! is_string($content) || trim($content) === '') { throw new RuntimeException('OAuth public key 不存在。'); } return $content; } private function ensureKeyPair(): void { $privatePath = (string) config('oauth.private_key_path'); $publicPath = (string) config('oauth.public_key_path'); if (is_file($privatePath) && is_file($publicPath)) { return; } if (! (bool) config('oauth.auto_generate_keys', true)) { throw new RuntimeException('OAuth 密钥未配置,请设置 OAUTH_PRIVATE_KEY_PATH 与 OAUTH_PUBLIC_KEY_PATH。'); } $directory = dirname($privatePath); if (! is_dir($directory)) { @mkdir($directory, 0775, true); } $publicDirectory = dirname($publicPath); if (! is_dir($publicDirectory)) { @mkdir($publicDirectory, 0775, true); } $this->clearOpenSslErrors(); $configPath = $this->resolveOpenSslConfigPath(); $options = [ 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]; if ($configPath !== null) { $options['config'] = $configPath; @putenv('OPENSSL_CONF='.$configPath); } $resource = openssl_pkey_new($options); if ($resource === false) { throw new RuntimeException($this->buildOpenSslFailureMessage('OAuth 密钥生成失败。', $configPath)); } $privateKey = ''; $exported = openssl_pkey_export($resource, $privateKey, null, $configPath ? ['config' => $configPath] : []); $details = openssl_pkey_get_details($resource); $publicKey = is_array($details) ? (string) ($details['key'] ?? '') : ''; if (! $exported || trim($privateKey) === '' || trim($publicKey) === '') { throw new RuntimeException($this->buildOpenSslFailureMessage('OAuth 密钥导出失败。', $configPath)); } $privateWritten = @file_put_contents($privatePath, $privateKey); $publicWritten = @file_put_contents($publicPath, $publicKey); if ($privateWritten === false || $publicWritten === false) { throw new RuntimeException('OAuth 密钥写入失败,请检查目录权限与路径配置。'); } } private function resolveOpenSslConfigPath(): ?string { $configured = trim((string) config('oauth.openssl_config_path', '')); $envConfigured = trim((string) getenv('OPENSSL_CONF')); $phpBinaryDirectory = dirname((string) PHP_BINARY); $candidates = [ $configured, $envConfigured, $phpBinaryDirectory.DIRECTORY_SEPARATOR.'extras'.DIRECTORY_SEPARATOR.'ssl'.DIRECTORY_SEPARATOR.'openssl.cnf', dirname($phpBinaryDirectory).DIRECTORY_SEPARATOR.'ssl'.DIRECTORY_SEPARATOR.'openssl.cnf', 'C:\\Program Files\\Common Files\\SSL\\openssl.cnf', '/etc/ssl/openssl.cnf', '/usr/lib/ssl/openssl.cnf', '/usr/local/ssl/openssl.cnf', ]; foreach (array_values(array_unique(array_filter($candidates))) as $candidate) { $path = trim((string) $candidate); if ($path === '') { continue; } if (! $this->isAbsolutePath($path)) { $path = base_path($path); } if (is_file($path)) { return realpath($path) ?: $path; } } return null; } private function isAbsolutePath(string $path): bool { if ($path === '') { return false; } if (DIRECTORY_SEPARATOR === '\\') { return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 || str_starts_with($path, '\\\\'); } return str_starts_with($path, '/'); } private function buildOpenSslFailureMessage(string $prefix, ?string $configPath): string { $details = $this->collectOpenSslErrors(); $hint = $configPath === null ? '请配置 OAUTH_OPENSSL_CONFIG_PATH 或手动提供密钥文件。' : '请检查 OpenSSL 配置文件是否可读:'.$configPath; if ($details === []) { return $prefix.' '.$hint; } return $prefix.' '.implode(' | ', array_slice($details, 0, 3)).' '.$hint; } /** * @return array */ private function collectOpenSslErrors(): array { $errors = []; while (($error = openssl_error_string()) !== false) { $errors[] = trim((string) $error); } return array_values(array_unique(array_filter($errors))); } private function clearOpenSslErrors(): void { while (openssl_error_string() !== false) { // drain openssl internal error queue } } private function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private function base64UrlDecode(string $value): string { $padding = strlen($value) % 4; if ($padding > 0) { $value .= str_repeat('=', 4 - $padding); } $decoded = base64_decode(strtr($value, '-_', '+/'), true); return $decoded === false ? '' : $decoded; } }