BastionSSO/app/Services/OAuth/OAuthJwtService.php

319 lines
9.8 KiB
PHP

<?php
namespace App\Services\OAuth;
use RuntimeException;
class OAuthJwtService
{
public function sign(array $payload): string
{
$this->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<int, string>
*/
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;
}
}