319 lines
9.8 KiB
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;
|
|
}
|
|
}
|