Compare commits

...

2 Commits

32 changed files with 3701 additions and 3 deletions

View File

@ -72,3 +72,17 @@ BASTION_TOKEN_POLL_INTERVAL_MS=500
BASTION_TOKEN_TASK_TTL_SECONDS=1800
BASTION_TOKEN_SERVICE=https://myapp.cdu.edu.cn/index.html
BASTION_TOKEN_VERIFY_SSL=false
OAUTH_ISSUER=${APP_URL}
OAUTH_FRONTEND_LOGIN_URL="http://localhost:5173/#/login"
OAUTH_FRONTEND_CONSENT_URL="http://localhost:5173/#/oauth-consent"
OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE=/api
OAUTH_KEY_ID=
OAUTH_PRIVATE_KEY_PATH=storage/oauth/private.pem
OAUTH_PUBLIC_KEY_PATH=storage/oauth/public.pem
OAUTH_OPENSSL_CONFIG_PATH=
OAUTH_AUTO_GENERATE_KEYS=true
OAUTH_AUTHORIZATION_CODE_TTL_SECONDS=300
OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600
OAUTH_REFRESH_TOKEN_TTL_SECONDS=1209600
OAUTH_REDIRECT_URI_POLICY=same_domain

1
.gitignore vendored
View File

@ -4,6 +4,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/oauth
/vendor
.env
.env.backup

View File

@ -130,6 +130,10 @@ class UserManageCommand extends Command
'platform.accounts.manage',
'platform.logs.view',
'platform.logs.manage',
'platform.oauth_clients.view',
'platform.oauth_clients.manage',
'platform.oauth_scopes.view',
'platform.oauth_scopes.manage',
'resource.servers.use',
];

View File

@ -0,0 +1,34 @@
<?php
namespace App\Exceptions;
use Exception;
class OAuthProtocolException extends Exception
{
public function __construct(
public string $oauthError,
public string $oauthDescription = '',
public int $httpStatus = 400,
public ?string $oauthErrorUri = null
) {
parent::__construct($oauthDescription);
}
/**
* @return array<string, string>
*/
public function toResponsePayload(): array
{
$payload = [
'error' => $this->oauthError,
'error_description' => $this->oauthDescription,
];
if ($this->oauthErrorUri !== null && $this->oauthErrorUri !== '') {
$payload['error_uri'] = $this->oauthErrorUri;
}
return $payload;
}
}

View File

@ -0,0 +1,322 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AccessLog;
use App\Models\OauthClient;
use App\Models\OauthConsent;
use App\Models\User;
use App\Services\OAuth\OAuthClientAuthService;
use App\Services\OAuth\OAuthTokenService;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Tymon\JWTAuth\Facades\JWTAuth;
#[Apidoc\Title('OAuth 协议授权端点')]
class OauthAuthorizationController extends Controller
{
public function __construct(
private readonly OAuthClientAuthService $clientAuthService,
private readonly OAuthTokenService $tokenService
) {}
#[Apidoc\Title('OAuth 授权端点'), Apidoc\Method('GET'), Apidoc\Url('/oauth/authorize')]
public function authorizeEndpoint(Request $request): RedirectResponse|JsonResponse
{
$validator = Validator::make($request->query(), [
'response_type' => ['required', 'in:code'],
'client_id' => ['required', 'string', 'max:80'],
'redirect_uri' => ['required', 'url:http,https'],
'state' => ['nullable', 'string', 'max:1024'],
'nonce' => ['nullable', 'string', 'max:255'],
'prompt' => ['nullable', 'string', 'max:100'],
]);
if ($validator->fails()) {
return $this->authorizationError(
(string) $request->query('redirect_uri', ''),
(string) $request->query('state', ''),
'invalid_request',
(string) $validator->errors()->first()
);
}
$client = $this->clientAuthService->findActiveClientById((string) $request->query('client_id'));
if (! $client) {
return $this->authorizationError(
(string) $request->query('redirect_uri', ''),
(string) $request->query('state', ''),
'invalid_client',
'Client not found or disabled.'
);
}
$redirectUri = (string) $request->query('redirect_uri');
if (! $this->clientAuthService->isRedirectUriAllowed($client, $redirectUri)) {
return $this->authorizationError($redirectUri, (string) $request->query('state', ''), 'invalid_request', 'Invalid redirect_uri.');
}
$serverScopes = $this->clientAuthService->allowedScopeNames($client);
$resourceOwner = $this->resolveResourceOwner($request);
if (! $resourceOwner) {
return $this->redirectToFrontendConsent($request, $client);
}
$scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes);
$consent = OauthConsent::query()
->where('user_id', $resourceOwner->id)
->where('client_id', $client->id)
->where('scope_fingerprint', $scopeFingerprint)
->first();
$prompt = trim((string) $request->query('prompt', ''));
$consentRequired = $consent === null || $prompt === 'consent';
if ($consentRequired) {
return $this->redirectToFrontendConsent($request, $client);
}
$code = $this->tokenService->issueAuthorizationCode(
user: $resourceOwner,
client: $client,
scopes: $serverScopes,
redirectUri: $redirectUri,
nonce: $request->query('nonce') ? (string) $request->query('nonce') : null
);
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, true);
return $this->successRedirect($redirectUri, $code, (string) $request->query('state', ''));
}
#[Apidoc\Title('OAuth 授权确认'), Apidoc\Method('POST'), Apidoc\Url('/oauth/authorize/decision')]
public function decision(Request $request): RedirectResponse|JsonResponse
{
$validator = Validator::make($request->all(), [
'approve' => ['required', 'boolean'],
'client_id' => ['required', 'string', 'max:80'],
'redirect_uri' => ['required', 'url:http,https'],
'state' => ['nullable', 'string', 'max:1024'],
'nonce' => ['nullable', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json([
'error' => 'invalid_request',
'error_description' => (string) $validator->errors()->first(),
], 400);
}
$client = $this->clientAuthService->findActiveClientById((string) $request->input('client_id'));
if (! $client) {
return response()->json([
'error' => 'invalid_client',
'error_description' => 'Client not found or disabled.',
], 400);
}
$redirectUri = (string) $request->input('redirect_uri');
if (! $this->clientAuthService->isRedirectUriAllowed($client, $redirectUri)) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'Invalid redirect_uri.',
], 400);
}
$resourceOwner = $this->resolveResourceOwner($request);
if (! $resourceOwner) {
return response()->json([
'error' => 'access_denied',
'error_description' => 'User is not authenticated.',
], 401);
}
$serverScopes = $this->clientAuthService->allowedScopeNames($client);
if (! $request->boolean('approve')) {
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false);
return $this->authorizationError(
$redirectUri,
(string) $request->input('state', ''),
'access_denied',
'Resource owner denied the request.'
);
}
$scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes);
OauthConsent::query()->updateOrCreate(
[
'user_id' => $resourceOwner->id,
'client_id' => $client->id,
'scope_fingerprint' => $scopeFingerprint,
],
[
'scopes' => $serverScopes,
'granted_at' => now(),
]
);
$code = $this->tokenService->issueAuthorizationCode(
user: $resourceOwner,
client: $client,
scopes: $serverScopes,
redirectUri: $redirectUri,
nonce: $request->input('nonce') ? (string) $request->input('nonce') : null
);
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false);
return $this->successRedirect($redirectUri, $code, (string) $request->input('state', ''));
}
private function redirectToFrontendConsent(Request $request, OauthClient $client): RedirectResponse
{
$frontendConsentUrl = (string) config('oauth.frontend_consent_url');
$userinfoFields = collect($client->allowed_userinfo_fields ?? config('oauth.userinfo_fields', []))
->map(fn ($field): string => trim((string) $field))
->filter()
->unique()
->values()
->all();
$remapRules = collect($client->userinfo_claim_remap ?? [])
->mapWithKeys(function ($target, $source): array {
$from = trim((string) $source);
$to = trim((string) $target);
if ($from === '' || $to === '') {
return [];
}
return [$from => $to];
})
->all();
$query = [
'return_to' => $this->buildFrontendAuthorizeReturnTo($request),
'client_id' => $client->client_id,
'client_name' => $client->name,
'client_logo' => $client->logo_url,
'state' => (string) $request->query('state', ''),
'nonce' => (string) $request->query('nonce', ''),
'redirect_uri' => (string) $request->query('redirect_uri', ''),
'userinfo_fields' => base64_encode((string) json_encode($userinfoFields, JSON_UNESCAPED_UNICODE)),
'userinfo_remap' => base64_encode((string) json_encode($remapRules, JSON_UNESCAPED_UNICODE)),
];
return redirect()->away($this->appendQueryToFrontendUrl($frontendConsentUrl, $query));
}
private function buildFrontendAuthorizeReturnTo(Request $request): string
{
$base = trim((string) config('oauth.frontend_authorize_proxy_base', '/api'));
$base = $base === '' ? '/api' : $base;
$base = rtrim($base, '/');
$queryString = (string) ($request->getQueryString() ?? '');
$target = $base.'/oauth/authorize';
return $queryString === '' ? $target : $target.'?'.$queryString;
}
private function successRedirect(string $redirectUri, string $code, string $state): RedirectResponse
{
$query = ['code' => $code];
if ($state !== '') {
$query['state'] = $state;
}
return redirect()->away($redirectUri.(str_contains($redirectUri, '?') ? '&' : '?').http_build_query($query));
}
/**
* @param array<string, mixed> $query
*/
private function appendQueryToFrontendUrl(string $url, array $query): string
{
$queryString = http_build_query($query);
if (! str_contains($url, '#')) {
return $url.(str_contains($url, '?') ? '&' : '?').$queryString;
}
[$base, $fragment] = explode('#', $url, 2);
$separator = str_contains($fragment, '?') ? '&' : '?';
return $base.'#'.$fragment.$separator.$queryString;
}
private function authorizationError(string $redirectUri, string $state, string $error, string $description): RedirectResponse|JsonResponse
{
if ($this->isValidAbsoluteUrl($redirectUri)) {
$query = ['error' => $error, 'error_description' => $description];
if ($state !== '') {
$query['state'] = $state;
}
return redirect()->away($redirectUri.(str_contains($redirectUri, '?') ? '&' : '?').http_build_query($query));
}
return response()->json([
'error' => $error,
'error_description' => $description,
], 400);
}
private function isValidAbsoluteUrl(string $url): bool
{
if ($url === '') {
return false;
}
$parsed = parse_url($url);
if (! is_array($parsed)) {
return false;
}
return isset($parsed['scheme'], $parsed['host']);
}
private function resolveResourceOwner(Request $request): ?User
{
$accessToken = trim((string) (
$request->bearerToken()
?: $request->input('access_token', $request->query('access_token', ''))
));
if ($accessToken === '') {
return null;
}
try {
$user = JWTAuth::setToken($accessToken)->authenticate();
} catch (\Throwable) {
return null;
}
return $user instanceof User ? $user : null;
}
private function writeOAuthAuthorizationLog(User $user, Request $request, OauthClient $client, bool $autoApproved): void
{
AccessLog::query()->create([
'user_id' => $user->id,
'server_resource_id' => null,
'bastion_account_id' => null,
'protocol' => 'oauth2',
'action' => 'oauth_authorization',
'requested_at' => now(),
'metadata' => [
'path' => $request->path(),
'method' => $request->method(),
'client_ip' => $request->ip(),
'oauth_client_id' => $client->id,
'oauth_client_name' => $client->name,
'oauth_client_identifier' => $client->client_id,
'redirect_uri' => (string) $request->input('redirect_uri', $request->query('redirect_uri', '')),
'auto_approved' => $autoApproved,
],
]);
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreOauthClientRequest;
use App\Http\Requests\UpdateOauthClientRequest;
use App\Models\OauthClient;
use App\Models\OauthScope;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
#[Apidoc\Title('OAuth 客户端管理')]
class OauthClientController extends Controller
{
public function __construct()
{
$this->middleware('auth:api');
$this->middleware('permission:platform.oauth_clients.view,api')->only(['index', 'show']);
$this->middleware('permission:platform.oauth_clients.manage,api')->only(['store', 'update', 'destroy', 'resetSecret']);
}
#[Apidoc\Title('OAuth 客户端列表'), Apidoc\Method('GET'), Apidoc\Url('/oauth/clients')]
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
]);
$perPage = (int) ($validated['per_page'] ?? 20);
$paginator = OauthClient::query()
->with('scopes')
->latest()
->paginate($perPage);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $paginator]);
}
#[Apidoc\Title('创建 OAuth 客户端'), Apidoc\Method('POST'), Apidoc\Url('/oauth/clients')]
public function store(StoreOauthClientRequest $request): JsonResponse
{
$validated = $request->validated();
$plainSecret = $this->generateRandomToken(40);
$client = OauthClient::query()->create([
'name' => trim((string) $validated['name']),
'logo_url' => $validated['logo_url'] ?? null,
'client_id' => $this->generateClientId(),
'client_secret_hash' => Hash::make($plainSecret),
'redirect_uris' => $this->normalizeStringArray($validated['redirect_uris'] ?? []),
'allowed_userinfo_fields' => $this->normalizeStringArray($validated['allowed_userinfo_fields'] ?? []),
'userinfo_claim_remap' => $this->normalizeRemap($validated['userinfo_claim_remap'] ?? []),
'is_confidential' => true,
'is_active' => (bool) ($validated['is_active'] ?? true),
]);
$client->scopes()->sync($this->resolveScopeIds($validated));
$this->auditLog($request, 'oauth_client_create', ['metadata' => ['oauth_client_id' => $client->id]]);
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'client' => $client->fresh('scopes'),
'client_secret' => $plainSecret,
],
], 201);
}
#[Apidoc\Title('OAuth 客户端详情'), Apidoc\Method('GET'), Apidoc\Url('/oauth/clients/{id}')]
public function show(int $id): JsonResponse
{
$client = OauthClient::query()->with('scopes')->findOrFail($id);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $client]);
}
#[Apidoc\Title('更新 OAuth 客户端'), Apidoc\Method('PUT'), Apidoc\Url('/oauth/clients/{id}')]
public function update(UpdateOauthClientRequest $request, int $id): JsonResponse
{
$client = OauthClient::query()->findOrFail($id);
$validated = $request->validated();
$client->update([
'name' => trim((string) $validated['name']),
'logo_url' => $validated['logo_url'] ?? null,
'redirect_uris' => $this->normalizeStringArray($validated['redirect_uris'] ?? []),
'allowed_userinfo_fields' => $this->normalizeStringArray($validated['allowed_userinfo_fields'] ?? []),
'userinfo_claim_remap' => $this->normalizeRemap($validated['userinfo_claim_remap'] ?? []),
'is_confidential' => true,
'is_active' => (bool) ($validated['is_active'] ?? true),
]);
$client->scopes()->sync($this->resolveScopeIds($validated, $client));
$this->auditLog($request, 'oauth_client_update', ['metadata' => ['oauth_client_id' => $client->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $client->fresh('scopes')]);
}
#[Apidoc\Title('删除 OAuth 客户端'), Apidoc\Method('DELETE'), Apidoc\Url('/oauth/clients/{id}')]
public function destroy(Request $request, int $id): JsonResponse
{
$client = OauthClient::query()->findOrFail($id);
$this->auditLog($request, 'oauth_client_delete', ['metadata' => ['oauth_client_id' => $client->id]]);
$client->delete();
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
}
#[Apidoc\Title('重置 OAuth 客户端密钥'), Apidoc\Method('POST'), Apidoc\Url('/oauth/clients/{id}/reset-secret')]
public function resetSecret(Request $request, int $id): JsonResponse
{
$client = OauthClient::query()->findOrFail($id);
$plainSecret = $this->generateRandomToken(40);
$client->update([
'client_secret_hash' => Hash::make($plainSecret),
'is_confidential' => true,
]);
$this->auditLog($request, 'oauth_client_reset_secret', ['metadata' => ['oauth_client_id' => $client->id]]);
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'client_id' => $client->client_id,
'client_secret' => $plainSecret,
],
]);
}
/**
* @param array<int, mixed> $values
* @return array<int, string>
*/
private function normalizeStringArray(array $values): array
{
return collect($values)
->map(fn ($value): string => trim((string) $value))
->filter()
->unique()
->values()
->all();
}
/**
* @param array<string, mixed> $remap
* @return array<string, string>
*/
private function normalizeRemap(array $remap): array
{
$result = [];
foreach ($remap as $source => $target) {
$from = trim((string) $source);
$to = trim((string) $target);
if ($from === '' || $to === '' || $from === 'sub') {
continue;
}
$result[$from] = $to;
}
return $result;
}
private function generateClientId(): string
{
do {
$clientId = 'cli_'.strtolower($this->generateRandomToken(18));
} while (OauthClient::query()->where('client_id', $clientId)->exists());
return $clientId;
}
private function generateRandomToken(int $bytes): string
{
return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
}
/**
* @param array<string, mixed> $validated
* @return array<int, int>
*/
private function resolveScopeIds(array $validated, ?OauthClient $client = null): array
{
if (array_key_exists('scope_ids', $validated)) {
return collect($validated['scope_ids'])
->map(fn ($scopeId): int => (int) $scopeId)
->values()
->all();
}
if ($client !== null) {
return $client->scopes()->pluck('oauth_scopes.id')->map(fn ($scopeId): int => (int) $scopeId)->all();
}
return OauthScope::query()
->where('is_active', true)
->pluck('id')
->map(fn ($scopeId): int => (int) $scopeId)
->all();
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OauthScope;
use App\Services\OAuth\OAuthJwtService;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\URL;
#[Apidoc\Title('OAuth 协议元数据端点')]
class OauthMetadataController extends Controller
{
public function __construct(
private readonly OAuthJwtService $jwtService
) {}
#[Apidoc\Title('OIDC Discovery'), Apidoc\Method('GET'), Apidoc\Url('/.well-known/openid-configuration')]
public function openidConfiguration(): JsonResponse
{
$scopes = OauthScope::query()
->where('is_active', true)
->orderBy('name')
->pluck('name')
->values()
->all();
return response()->json([
'issuer' => (string) config('oauth.issuer'),
'authorization_endpoint' => URL::to('/oauth/authorize'),
'token_endpoint' => URL::to('/oauth/token'),
'userinfo_endpoint' => URL::to('/oauth/userinfo'),
'jwks_uri' => URL::to('/oauth/jwks'),
'response_types_supported' => ['code'],
'subject_types_supported' => ['public'],
'id_token_signing_alg_values_supported' => ['RS256'],
'scopes_supported' => $scopes,
'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post'],
'grant_types_supported' => ['authorization_code', 'refresh_token'],
'claims_supported' => ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash', 'nickname', 'email', 'phone'],
]);
}
#[Apidoc\Title('JWKS'), Apidoc\Method('GET'), Apidoc\Url('/oauth/jwks')]
public function jwks(): JsonResponse
{
return response()->json($this->jwtService->jwks());
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\Api;
use App\Exceptions\OAuthProtocolException;
use App\Http\Controllers\Controller;
use App\Models\OauthClient;
use App\Services\OAuth\OAuthClientAuthService;
use App\Services\OAuth\OAuthTokenService;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
#[Apidoc\Title('OAuth 协议令牌端点')]
class OauthTokenController extends Controller
{
public function __construct(
private readonly OAuthClientAuthService $clientAuthService,
private readonly OAuthTokenService $tokenService
) {}
#[Apidoc\Title('OAuth Token'), Apidoc\Method('POST'), Apidoc\Url('/oauth/token')]
public function token(Request $request): JsonResponse
{
try {
$client = $this->clientAuthService->authenticateConfidentialClient($request);
$grantType = trim((string) $request->input('grant_type', ''));
$responseData = match ($grantType) {
'authorization_code' => $this->handleAuthorizationCodeGrant($request, $client),
'refresh_token' => $this->handleRefreshTokenGrant($request, $client),
default => throw new OAuthProtocolException('unsupported_grant_type', 'Unsupported grant_type.'),
};
return response()
->json($responseData)
->header('Cache-Control', 'no-store')
->header('Pragma', 'no-cache');
} catch (OAuthProtocolException $exception) {
$response = response()
->json($exception->toResponsePayload(), $exception->httpStatus)
->header('Cache-Control', 'no-store')
->header('Pragma', 'no-cache');
if ($exception->oauthError === 'invalid_client') {
$response->header('WWW-Authenticate', 'Basic realm="OAuth"');
}
return $response;
}
}
#[Apidoc\Title('OAuth Revoke'), Apidoc\Method('POST'), Apidoc\Url('/oauth/revoke')]
public function revoke(Request $request): JsonResponse
{
try {
$client = $this->clientAuthService->authenticateConfidentialClient($request);
$validator = Validator::make($request->all(), [
'token' => ['required', 'string'],
'token_type_hint' => ['nullable', 'string'],
]);
if ($validator->fails()) {
throw new OAuthProtocolException('invalid_request', (string) $validator->errors()->first());
}
$this->tokenService->revokeTokenForClient($client, trim((string) $request->input('token')));
return response()->json([], 200);
} catch (OAuthProtocolException $exception) {
$response = response()->json($exception->toResponsePayload(), $exception->httpStatus);
if ($exception->oauthError === 'invalid_client') {
$response->header('WWW-Authenticate', 'Basic realm="OAuth"');
}
return $response;
}
}
/**
* @return array<string, mixed>
*/
private function handleAuthorizationCodeGrant(Request $request, OauthClient $client): array
{
$validator = Validator::make($request->all(), [
'code' => ['required', 'string'],
'redirect_uri' => ['required', 'url:http,https'],
]);
if ($validator->fails()) {
throw new OAuthProtocolException('invalid_request', (string) $validator->errors()->first());
}
$redirectUri = (string) $request->input('redirect_uri');
if (! $this->clientAuthService->isRedirectUriAllowed($client, $redirectUri)) {
throw new OAuthProtocolException('invalid_grant', 'redirect_uri mismatch.');
}
return $this->tokenService->exchangeAuthorizationCode(
client: $client,
code: trim((string) $request->input('code')),
redirectUri: $redirectUri
);
}
/**
* @return array<string, mixed>
*/
private function handleRefreshTokenGrant(Request $request, OauthClient $client): array
{
$validator = Validator::make($request->all(), [
'refresh_token' => ['required', 'string'],
]);
if ($validator->fails()) {
throw new OAuthProtocolException('invalid_request', (string) $validator->errors()->first());
}
return $this->tokenService->exchangeRefreshToken(
client: $client,
refreshToken: trim((string) $request->input('refresh_token'))
);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\OAuth\OAuthTokenService;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
#[Apidoc\Title('OAuth 协议 UserInfo 端点')]
class OauthUserInfoController extends Controller
{
public function __construct(
private readonly OAuthTokenService $tokenService
) {}
#[Apidoc\Title('UserInfo'), Apidoc\Method('GET'), Apidoc\Url('/oauth/userinfo')]
public function userinfo(Request $request): JsonResponse
{
$rawToken = trim((string) ($request->bearerToken() ?: $request->query('access_token', '')));
if ($rawToken === '') {
return $this->invalidTokenResponse('Missing access token.');
}
$accessToken = $this->tokenService->validateAccessToken($rawToken);
if (! $accessToken || ! $accessToken->user || ! $accessToken->client) {
return $this->invalidTokenResponse('Invalid access token.');
}
$clientFields = collect($accessToken->client->allowed_userinfo_fields ?? config('oauth.userinfo_fields', []))
->map(fn ($field): string => trim((string) $field))
->filter()
->unique()
->values();
$user = $accessToken->user;
$claims = [
'sub' => (string) $user->id,
'nickname' => (string) ($user->nickname ?? ''),
'email' => (string) ($user->email ?? ''),
'phone' => (string) ($user->phone ?? ''),
];
$payload = ['sub' => $claims['sub']];
foreach ($clientFields as $fieldName) {
$field = (string) $fieldName;
if ($field === 'sub') {
continue;
}
if (array_key_exists($field, $claims) && $claims[$field] !== '') {
$payload[$field] = $claims[$field];
}
}
$payload = $this->applyClientClaimRemap($payload, collect($accessToken->client->userinfo_claim_remap ?? []));
return response()
->json($payload)
->header('Cache-Control', 'no-store')
->header('Pragma', 'no-cache');
}
/**
* @param array<string, mixed> $payload
* @param Collection<int|string, mixed> $remapRules
* @return array<string, mixed>
*/
private function applyClientClaimRemap(array $payload, Collection $remapRules): array
{
foreach ($remapRules as $source => $target) {
$from = trim((string) $source);
$to = trim((string) $target);
if ($from === '' || $to === '' || $from === 'sub' || ! array_key_exists($from, $payload)) {
continue;
}
$payload[$to] = $payload[$from];
unset($payload[$from]);
}
return $payload;
}
private function invalidTokenResponse(string $description): JsonResponse
{
return response()
->json([
'error' => 'invalid_token',
'error_description' => $description,
], 401)
->header('WWW-Authenticate', 'Bearer error="invalid_token"')
->header('Cache-Control', 'no-store')
->header('Pragma', 'no-cache');
}
}

View File

@ -139,6 +139,10 @@ class PermissionController extends Controller
'platform.accounts.manage' => ['category' => '堡垒机账号', 'description' => '维护堡垒机授权账号与刷新令牌'],
'platform.logs.view' => ['category' => '日志审计', 'description' => '查看访问与操作日志'],
'platform.logs.manage' => ['category' => '日志审计', 'description' => '新增或维护日志数据'],
'platform.oauth_clients.view' => ['category' => 'OAuth 管理', 'description' => '查看 OAuth 客户端配置'],
'platform.oauth_clients.manage' => ['category' => 'OAuth 管理', 'description' => '维护 OAuth 客户端配置与密钥'],
'platform.oauth_scopes.view' => ['category' => 'OAuth 管理', 'description' => '查看 OAuth Scope 配置'],
'platform.oauth_scopes.manage' => ['category' => 'OAuth 管理', 'description' => '维护 OAuth Scope 配置'],
'resource.servers.use' => ['category' => '资源使用', 'description' => '发起服务器资源访问与连接操作'],
];

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreOauthClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'logo_url' => ['nullable', 'url:http,https', 'max:255'],
'redirect_uris' => ['required', 'array', 'min:1'],
'redirect_uris.*' => ['required', 'url:http,https', 'max:500'],
'scope_ids' => ['sometimes', 'array', 'min:1'],
'scope_ids.*' => ['integer', 'exists:oauth_scopes,id'],
'allowed_userinfo_fields' => ['required', 'array', 'min:1'],
'allowed_userinfo_fields.*' => ['string', 'max:60'],
'userinfo_claim_remap' => ['nullable', 'array'],
'userinfo_claim_remap.*' => ['string', 'max:60'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOauthClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'logo_url' => ['nullable', 'url:http,https', 'max:255'],
'redirect_uris' => ['required', 'array', 'min:1'],
'redirect_uris.*' => ['required', 'url:http,https', 'max:500'],
'scope_ids' => ['sometimes', 'array', 'min:1'],
'scope_ids.*' => ['integer', 'exists:oauth_scopes,id'],
'allowed_userinfo_fields' => ['required', 'array', 'min:1'],
'allowed_userinfo_fields.*' => ['string', 'max:60'],
'userinfo_claim_remap' => ['nullable', 'array'],
'userinfo_claim_remap.*' => ['string', 'max:60'],
'is_active' => ['sometimes', 'boolean'],
];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OauthAccessToken extends Model
{
protected $fillable = [
'jti',
'authorization_id',
'user_id',
'client_id',
'scope',
'expires_at',
'revoked_at',
];
public function authorization(): BelongsTo
{
return $this->belongsTo(OauthAuthorization::class, 'authorization_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(OauthClient::class, 'client_id');
}
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class OauthAuthorization extends Model
{
protected $fillable = [
'user_id',
'client_id',
'scope',
'scope_fingerprint',
'revoked_at',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(OauthClient::class, 'client_id');
}
public function codes(): HasMany
{
return $this->hasMany(OauthAuthorizationCode::class, 'authorization_id');
}
public function accessTokens(): HasMany
{
return $this->hasMany(OauthAccessToken::class, 'authorization_id');
}
public function refreshTokens(): HasMany
{
return $this->hasMany(OauthRefreshToken::class, 'authorization_id');
}
protected function casts(): array
{
return [
'revoked_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OauthAuthorizationCode extends Model
{
protected $fillable = [
'code_hash',
'authorization_id',
'user_id',
'client_id',
'redirect_uri',
'scope',
'nonce',
'expires_at',
'consumed_at',
'revoked_at',
];
public function authorization(): BelongsTo
{
return $this->belongsTo(OauthAuthorization::class, 'authorization_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(OauthClient::class, 'client_id');
}
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'consumed_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Database\Factories\OauthClientFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class OauthClient extends Model
{
/** @use HasFactory<OauthClientFactory> */
use HasFactory;
protected $fillable = [
'name',
'logo_url',
'client_id',
'client_secret_hash',
'redirect_uris',
'allowed_userinfo_fields',
'userinfo_claim_remap',
'is_confidential',
'is_active',
];
public function scopes(): BelongsToMany
{
return $this->belongsToMany(OauthScope::class, 'oauth_client_scope', 'client_id', 'scope_id')
->withTimestamps();
}
public function consents(): HasMany
{
return $this->hasMany(OauthConsent::class, 'client_id');
}
public function authorizations(): HasMany
{
return $this->hasMany(OauthAuthorization::class, 'client_id');
}
protected function casts(): array
{
return [
'redirect_uris' => 'array',
'allowed_userinfo_fields' => 'array',
'userinfo_claim_remap' => 'array',
'is_confidential' => 'boolean',
'is_active' => 'boolean',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OauthConsent extends Model
{
protected $fillable = [
'user_id',
'client_id',
'scope_fingerprint',
'scopes',
'granted_at',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(OauthClient::class, 'client_id');
}
protected function casts(): array
{
return [
'scopes' => 'array',
'granted_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OauthRefreshToken extends Model
{
protected $fillable = [
'jti',
'token_hash',
'authorization_id',
'user_id',
'client_id',
'previous_refresh_token_id',
'replaced_by_refresh_token_id',
'expires_at',
'used_at',
'replayed_at',
'revoked_at',
];
public function authorization(): BelongsTo
{
return $this->belongsTo(OauthAuthorization::class, 'authorization_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(OauthClient::class, 'client_id');
}
public function previousToken(): BelongsTo
{
return $this->belongsTo(OauthRefreshToken::class, 'previous_refresh_token_id');
}
public function replacedByToken(): BelongsTo
{
return $this->belongsTo(OauthRefreshToken::class, 'replaced_by_refresh_token_id');
}
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'used_at' => 'datetime',
'replayed_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
}

36
app/Models/OauthScope.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Database\Factories\OauthScopeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class OauthScope extends Model
{
/** @use HasFactory<OauthScopeFactory> */
use HasFactory;
protected $fillable = [
'name',
'display_name',
'description',
'claims',
'is_active',
];
public function clients(): BelongsToMany
{
return $this->belongsToMany(OauthClient::class, 'oauth_client_scope', 'scope_id', 'client_id')
->withTimestamps();
}
protected function casts(): array
{
return [
'claims' => 'array',
'is_active' => 'boolean',
];
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace App\Services\OAuth;
use App\Exceptions\OAuthProtocolException;
use App\Models\OauthClient;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
class OAuthClientAuthService
{
public function authenticateConfidentialClient(Request $request): OauthClient
{
[$clientId, $clientSecret] = $this->extractClientCredentials($request);
if ($clientId === '') {
throw new OAuthProtocolException('invalid_client', 'Missing client_id.', 401);
}
if ($clientSecret === '') {
throw new OAuthProtocolException('invalid_client', 'Missing client_secret.', 401);
}
$client = OauthClient::query()
->with(['scopes' => function ($query) {
$query->where('is_active', true);
}])
->where('client_id', $clientId)
->first();
if (! $client || ! $client->is_active) {
throw new OAuthProtocolException('invalid_client', 'Client authentication failed.', 401);
}
if (! $client->is_confidential) {
throw new OAuthProtocolException('unauthorized_client', 'Only confidential clients are supported.', 400);
}
$secretHash = (string) ($client->client_secret_hash ?? '');
if ($secretHash === '' || ! Hash::check($clientSecret, $secretHash)) {
throw new OAuthProtocolException('invalid_client', 'Client authentication failed.', 401);
}
return $client;
}
/**
* @return array{0:string,1:string}
*/
public function extractClientCredentials(Request $request): array
{
$header = (string) $request->header('Authorization', '');
if (str_starts_with(strtolower($header), 'basic ')) {
$encoded = trim(substr($header, 6));
$decoded = base64_decode($encoded, true);
if ($decoded !== false && str_contains($decoded, ':')) {
[$id, $secret] = explode(':', $decoded, 2);
return [trim($id), $secret];
}
}
return [
trim((string) $request->input('client_id', '')),
(string) $request->input('client_secret', ''),
];
}
public function findActiveClientById(string $clientId): ?OauthClient
{
if ($clientId === '') {
return null;
}
return OauthClient::query()
->with(['scopes' => function ($query) {
$query->where('is_active', true);
}])
->where('client_id', $clientId)
->where('is_active', true)
->first();
}
/**
* @return array<int, string>
*/
public function allowedScopeNames(OauthClient $client): array
{
return $client->scopes
->pluck('name')
->map(fn ($scope): string => trim((string) $scope))
->filter()
->values()
->all();
}
/**
* @param array<int, string> $requestedScopes
* @param array<int, string> $allowedScopes
*/
public function isScopeSubset(array $requestedScopes, array $allowedScopes): bool
{
$allowed = collect($allowedScopes)->map(fn (string $scope): string => trim($scope))->filter()->all();
foreach ($requestedScopes as $scope) {
if (! in_array($scope, $allowed, true)) {
return false;
}
}
return true;
}
public function isRedirectUriAllowed(OauthClient $client, string $redirectUri): bool
{
if ($redirectUri === '') {
return false;
}
$requested = parse_url($redirectUri);
$requestedHost = strtolower((string) ($requested['host'] ?? ''));
if ($requestedHost === '') {
return false;
}
$registeredUris = collect($client->redirect_uris ?? [])
->map(fn ($uri): string => trim((string) $uri))
->filter()
->values();
if ($registeredUris->isEmpty()) {
return false;
}
$policy = (string) config('oauth.redirect_uri_policy', 'same_domain');
if ($policy === 'same_domain') {
return $registeredUris->contains(function (string $uri) use ($requestedHost): bool {
$parsed = parse_url($uri);
$host = strtolower((string) ($parsed['host'] ?? ''));
return $host !== '' && $host === $requestedHost;
});
}
return $registeredUris->contains($redirectUri);
}
/**
* @param array<int, string> $scopes
*/
public function buildScopeString(array $scopes): string
{
return collect($scopes)
->map(fn (string $scope): string => trim($scope))
->filter()
->unique()
->values()
->implode(' ');
}
/**
* @return array<int, string>
*/
public function parseScopes(string $scopeValue): array
{
if (trim($scopeValue) === '') {
return [];
}
return Collection::make(preg_split('/\s+/', trim($scopeValue)) ?: [])
->map(fn ($scope): string => trim((string) $scope))
->filter()
->unique()
->values()
->all();
}
}

View File

@ -0,0 +1,318 @@
<?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;
}
}

View File

@ -0,0 +1,360 @@
<?php
namespace App\Services\OAuth;
use App\Exceptions\OAuthProtocolException;
use App\Models\OauthAccessToken;
use App\Models\OauthAuthorization;
use App\Models\OauthAuthorizationCode;
use App\Models\OauthClient;
use App\Models\OauthRefreshToken;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class OAuthTokenService
{
public function __construct(
private readonly OAuthJwtService $jwtService,
private readonly OAuthClientAuthService $clientAuthService
) {}
/**
* @param array<int, string> $scopes
*/
public function issueAuthorizationCode(
User $user,
OauthClient $client,
array $scopes,
string $redirectUri,
?string $nonce = null
): string {
$scope = $this->clientAuthService->buildScopeString($scopes);
$scopeFingerprint = $this->scopeFingerprint($scopes);
$authorization = OauthAuthorization::query()->create([
'user_id' => $user->id,
'client_id' => $client->id,
'scope' => $scope,
'scope_fingerprint' => $scopeFingerprint,
'revoked_at' => null,
]);
$rawCode = $this->randomToken(48);
OauthAuthorizationCode::query()->create([
'code_hash' => hash('sha256', $rawCode),
'authorization_id' => $authorization->id,
'user_id' => $user->id,
'client_id' => $client->id,
'redirect_uri' => $redirectUri,
'scope' => $scope,
'nonce' => $nonce,
'expires_at' => now()->addSeconds((int) config('oauth.authorization_code_ttl_seconds', 300)),
'consumed_at' => null,
'revoked_at' => null,
]);
return $rawCode;
}
/**
* @return array<string, mixed>
*/
public function exchangeAuthorizationCode(OauthClient $client, string $code, string $redirectUri): array
{
return DB::transaction(function () use ($client, $code, $redirectUri): array {
$codeRecord = OauthAuthorizationCode::query()
->where('code_hash', hash('sha256', $code))
->lockForUpdate()
->first();
if (! $codeRecord) {
throw new OAuthProtocolException('invalid_grant', 'Invalid authorization code.');
}
if ((int) $codeRecord->client_id !== (int) $client->id) {
throw new OAuthProtocolException('invalid_grant', 'Authorization code does not belong to client.');
}
if ((string) $codeRecord->redirect_uri !== $redirectUri) {
throw new OAuthProtocolException('invalid_grant', 'redirect_uri mismatch.');
}
if ($codeRecord->revoked_at || $codeRecord->consumed_at || $codeRecord->expires_at->isPast()) {
throw new OAuthProtocolException('invalid_grant', 'Authorization code has expired or been used.');
}
$authorization = OauthAuthorization::query()
->lockForUpdate()
->find($codeRecord->authorization_id);
if (! $authorization || $authorization->revoked_at) {
throw new OAuthProtocolException('invalid_grant', 'Authorization is no longer valid.');
}
$codeRecord->consumed_at = now();
$codeRecord->save();
return $this->issueTokenSet(
$authorization,
$client,
(string) $codeRecord->scope,
$codeRecord->nonce,
createRefreshToken: true
);
});
}
/**
* @return array<string, mixed>
*/
public function exchangeRefreshToken(OauthClient $client, string $refreshToken): array
{
return DB::transaction(function () use ($client, $refreshToken): array {
$tokenHash = hash('sha256', $refreshToken);
$refreshRecord = OauthRefreshToken::query()
->where('token_hash', $tokenHash)
->where('client_id', $client->id)
->lockForUpdate()
->first();
if (! $refreshRecord) {
throw new OAuthProtocolException('invalid_grant', 'Invalid refresh token.');
}
$authorization = OauthAuthorization::query()
->lockForUpdate()
->find($refreshRecord->authorization_id);
if (! $authorization || $authorization->revoked_at) {
throw new OAuthProtocolException('invalid_grant', 'Authorization is no longer valid.');
}
if ($refreshRecord->revoked_at || $refreshRecord->expires_at->isPast()) {
throw new OAuthProtocolException('invalid_grant', 'Refresh token expired or revoked.');
}
if ($refreshRecord->used_at !== null || $refreshRecord->replaced_by_refresh_token_id !== null) {
$refreshRecord->replayed_at = $refreshRecord->replayed_at ?? now();
$refreshRecord->save();
$this->revokeAuthorizationChain((int) $authorization->id);
throw new OAuthProtocolException('invalid_grant', 'Refresh token reuse detected.');
}
$refreshRecord->used_at = now();
$refreshRecord->save();
$tokenSet = $this->issueTokenSet(
$authorization,
$client,
(string) $authorization->scope,
nonce: null,
createRefreshToken: true,
previousRefreshTokenId: (int) $refreshRecord->id
);
if (! isset($tokenSet['_refresh_record_id'])) {
throw new OAuthProtocolException('server_error', 'Failed to rotate refresh token.', 500);
}
$refreshRecord->replaced_by_refresh_token_id = (int) $tokenSet['_refresh_record_id'];
$refreshRecord->save();
unset($tokenSet['_refresh_record_id']);
return $tokenSet;
});
}
public function revokeTokenForClient(OauthClient $client, string $token): void
{
if ($token === '') {
return;
}
$tokenHash = hash('sha256', $token);
$refreshRecord = OauthRefreshToken::query()
->where('token_hash', $tokenHash)
->where('client_id', $client->id)
->first();
if ($refreshRecord) {
$this->revokeAuthorizationChain((int) $refreshRecord->authorization_id);
return;
}
$payload = $this->jwtService->verify($token);
$jti = is_array($payload) ? (string) ($payload['jti'] ?? '') : '';
if ($jti === '') {
return;
}
$accessToken = OauthAccessToken::query()
->where('jti', $jti)
->where('client_id', $client->id)
->first();
if ($accessToken && ! $accessToken->revoked_at) {
$accessToken->revoked_at = now();
$accessToken->save();
}
}
public function validateAccessToken(string $token): ?OauthAccessToken
{
$payload = $this->jwtService->verify($token);
if (! is_array($payload)) {
return null;
}
$jti = (string) ($payload['jti'] ?? '');
if ($jti === '') {
return null;
}
$accessToken = OauthAccessToken::query()
->with(['authorization', 'user', 'client'])
->where('jti', $jti)
->first();
if (! $accessToken || $accessToken->revoked_at || $accessToken->expires_at->isPast()) {
return null;
}
if (! $accessToken->authorization || $accessToken->authorization->revoked_at) {
return null;
}
return $accessToken;
}
/**
* @param array<int, string> $scopes
*/
public function scopeFingerprint(array $scopes): string
{
$normalized = collect($scopes)
->map(fn (string $scope): string => trim($scope))
->filter()
->unique()
->sort()
->values()
->implode('|');
return hash('sha256', $normalized);
}
public function revokeAuthorizationChain(int $authorizationId): void
{
OauthAuthorization::query()
->where('id', $authorizationId)
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
OauthAuthorizationCode::query()
->where('authorization_id', $authorizationId)
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
OauthAccessToken::query()
->where('authorization_id', $authorizationId)
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
OauthRefreshToken::query()
->where('authorization_id', $authorizationId)
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
}
/**
* @return array<string, mixed>
*/
private function issueTokenSet(
OauthAuthorization $authorization,
OauthClient $client,
string $scope,
?string $nonce,
bool $createRefreshToken = true,
?int $previousRefreshTokenId = null
): array {
$now = now();
$accessTokenTtl = (int) config('oauth.access_token_ttl_seconds', 3600);
$refreshTokenTtl = (int) config('oauth.refresh_token_ttl_seconds', 1209600);
$scopeList = $this->clientAuthService->parseScopes($scope);
$scopeString = $this->clientAuthService->buildScopeString($scopeList);
$subject = (string) $authorization->user_id;
$accessJti = (string) Str::uuid();
$accessPayload = [
'iss' => (string) config('oauth.issuer'),
'sub' => $subject,
'aud' => $client->client_id,
'exp' => $now->copy()->addSeconds($accessTokenTtl)->timestamp,
'iat' => $now->timestamp,
'jti' => $accessJti,
'scope' => $scopeString,
'client_id' => $client->client_id,
];
$accessToken = $this->jwtService->sign($accessPayload);
OauthAccessToken::query()->create([
'jti' => $accessJti,
'authorization_id' => $authorization->id,
'user_id' => $authorization->user_id,
'client_id' => $authorization->client_id,
'scope' => $scopeString,
'expires_at' => Carbon::createFromTimestamp($accessPayload['exp']),
'revoked_at' => null,
]);
$result = [
'access_token' => $accessToken,
'token_type' => 'Bearer',
'expires_in' => $accessTokenTtl,
'scope' => $scopeString,
];
if ($createRefreshToken) {
$rawRefreshToken = $this->randomToken(64);
$refreshJti = (string) Str::uuid();
$refreshRecord = OauthRefreshToken::query()->create([
'jti' => $refreshJti,
'token_hash' => hash('sha256', $rawRefreshToken),
'authorization_id' => $authorization->id,
'user_id' => $authorization->user_id,
'client_id' => $authorization->client_id,
'previous_refresh_token_id' => $previousRefreshTokenId,
'replaced_by_refresh_token_id' => null,
'expires_at' => now()->addSeconds($refreshTokenTtl),
'used_at' => null,
'replayed_at' => null,
'revoked_at' => null,
]);
$result['refresh_token'] = $rawRefreshToken;
$result['_refresh_record_id'] = $refreshRecord->id;
}
if (in_array('openid', $scopeList, true)) {
$idTokenPayload = [
'iss' => (string) config('oauth.issuer'),
'sub' => $subject,
'aud' => $client->client_id,
'exp' => $now->copy()->addSeconds($accessTokenTtl)->timestamp,
'iat' => $now->timestamp,
'auth_time' => $authorization->created_at?->timestamp ?? $now->timestamp,
];
if ($nonce !== null && trim($nonce) !== '') {
$idTokenPayload['nonce'] = trim($nonce);
}
$idTokenPayload['at_hash'] = $this->jwtService->atHash($accessToken);
$result['id_token'] = $this->jwtService->sign($idTokenPayload);
}
return $result;
}
private function randomToken(int $bytes = 48): string
{
return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Exceptions\OAuthProtocolException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@ -23,9 +24,34 @@ return Application::configure(basePath: dirname(__DIR__))
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
]);
$middleware->validateCsrfTokens(except: [
'oauth/token',
'oauth/revoke',
'oauth/authorize/decision',
]);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ValidationException $exception, Request $request) {
$isOauthRequest = function (Request $request): bool {
return $request->is('oauth') || $request->is('oauth/*') || $request->path() === '.well-known/openid-configuration';
};
$exceptions->render(function (OAuthProtocolException $exception, Request $request) {
return response()->json($exception->toResponsePayload(), $exception->httpStatus);
});
$exceptions->render(function (ValidationException $exception, Request $request) use ($isOauthRequest) {
if ($isOauthRequest($request)) {
$firstError = collect($exception->errors())
->flatten()
->first();
return response()->json([
'error' => 'invalid_request',
'error_description' => (string) ($firstError ?: 'Invalid request parameters.'),
], 400);
}
if (! $request->expectsJson()) {
return null;
}
@ -122,7 +148,14 @@ return Application::configure(basePath: dirname(__DIR__))
], 422);
});
$exceptions->render(function (UnauthorizedException $exception, Request $request) {
$exceptions->render(function (UnauthorizedException $exception, Request $request) use ($isOauthRequest) {
if ($isOauthRequest($request)) {
return response()->json([
'error' => 'access_denied',
'error_description' => 'The request is not allowed.',
], 403);
}
if ($request->expectsJson()) {
return response()->json([
'code' => 403,
@ -132,7 +165,14 @@ return Application::configure(basePath: dirname(__DIR__))
}
});
$exceptions->render(function (AuthenticationException $exception, Request $request) {
$exceptions->render(function (AuthenticationException $exception, Request $request) use ($isOauthRequest) {
if ($isOauthRequest($request)) {
return response()->json([
'error' => 'invalid_token',
'error_description' => 'Authentication is required.',
], 401);
}
if ($request->expectsJson()) {
return response()->json([
'code' => 401,

22
config/oauth.php Normal file
View File

@ -0,0 +1,22 @@
<?php
return [
'issuer' => env('OAUTH_ISSUER', rtrim((string) env('APP_URL', 'http://localhost'), '/')),
'frontend_login_url' => env('OAUTH_FRONTEND_LOGIN_URL', rtrim((string) env('APP_URL', 'http://localhost'), '/').'/#/login'),
'frontend_consent_url' => env('OAUTH_FRONTEND_CONSENT_URL', rtrim((string) env('APP_URL', 'http://localhost'), '/').'/#/oauth-consent'),
'frontend_authorize_proxy_base' => env('OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE', '/api'),
'key_id' => env('OAUTH_KEY_ID', ''),
'private_key_path' => env('OAUTH_PRIVATE_KEY_PATH', storage_path('oauth/private.pem')),
'public_key_path' => env('OAUTH_PUBLIC_KEY_PATH', storage_path('oauth/public.pem')),
'openssl_config_path' => env('OAUTH_OPENSSL_CONFIG_PATH', ''),
'auto_generate_keys' => (bool) env('OAUTH_AUTO_GENERATE_KEYS', true),
'authorization_code_ttl_seconds' => (int) env('OAUTH_AUTHORIZATION_CODE_TTL_SECONDS', 300),
'access_token_ttl_seconds' => (int) env('OAUTH_ACCESS_TOKEN_TTL_SECONDS', 3600),
'refresh_token_ttl_seconds' => (int) env('OAUTH_REFRESH_TOKEN_TTL_SECONDS', 1209600),
'redirect_uri_policy' => env('OAUTH_REDIRECT_URI_POLICY', 'same_domain'),
'default_scopes' => ['openid', 'profile', 'email', 'phone'],
'userinfo_fields' => ['sub', 'nickname', 'email', 'phone'],
];

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\OauthClient;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OauthClient>
*/
class OauthClientFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->company(),
'logo_url' => $this->faker->optional()->imageUrl(),
'client_id' => 'client_'.$this->faker->unique()->regexify('[A-Za-z0-9]{24}'),
'client_secret_hash' => null,
'redirect_uris' => ['https://example.com/callback'],
'allowed_userinfo_fields' => ['sub', 'nickname', 'email'],
'userinfo_claim_remap' => [],
'is_confidential' => true,
'is_active' => true,
];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use App\Models\OauthScope;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OauthScope>
*/
class OauthScopeFactory extends Factory
{
public function definition(): array
{
return [
'name' => $this->faker->unique()->slug(2),
'display_name' => $this->faker->words(2, true),
'description' => $this->faker->sentence(),
'claims' => [],
'is_active' => true,
];
}
}

View File

@ -0,0 +1,175 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('oauth_scopes', function (Blueprint $table) {
$table->id();
$table->string('name', 100)->unique();
$table->string('display_name', 120);
$table->string('description', 255)->nullable();
$table->json('claims')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['is_active', 'name']);
});
Schema::create('oauth_clients', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('logo_url')->nullable();
$table->string('client_id', 80)->unique();
$table->string('client_secret_hash')->nullable();
$table->json('redirect_uris')->nullable();
$table->json('allowed_userinfo_fields')->nullable();
$table->json('userinfo_claim_remap')->nullable();
$table->boolean('is_confidential')->default(true);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['is_active', 'client_id']);
});
Schema::create('oauth_client_scope', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->foreignId('scope_id')->constrained('oauth_scopes')->cascadeOnDelete();
$table->timestamps();
$table->unique(['client_id', 'scope_id'], 'uq_oauth_client_scope');
});
Schema::create('oauth_consents', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->string('scope_fingerprint', 64);
$table->json('scopes');
$table->timestamp('granted_at');
$table->timestamps();
$table->unique(['user_id', 'client_id', 'scope_fingerprint'], 'uq_oauth_consents_fingerprint');
$table->index(['user_id', 'client_id']);
});
Schema::create('oauth_authorizations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->string('scope', 1000);
$table->string('scope_fingerprint', 64);
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'client_id']);
});
Schema::create('oauth_authorization_codes', function (Blueprint $table) {
$table->id();
$table->string('code_hash', 64)->unique();
$table->foreignId('authorization_id')->constrained('oauth_authorizations')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->text('redirect_uri');
$table->string('scope', 1000);
$table->string('nonce')->nullable();
$table->timestamp('expires_at');
$table->timestamp('consumed_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['client_id', 'expires_at']);
});
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->id();
$table->string('jti', 64)->unique();
$table->foreignId('authorization_id')->constrained('oauth_authorizations')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->string('scope', 1000);
$table->timestamp('expires_at');
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['client_id', 'user_id']);
$table->index(['expires_at', 'revoked_at']);
});
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->id();
$table->string('jti', 64)->unique();
$table->string('token_hash', 64)->unique();
$table->foreignId('authorization_id')->constrained('oauth_authorizations')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete();
$table->foreignId('previous_refresh_token_id')->nullable()->constrained('oauth_refresh_tokens')->nullOnDelete();
$table->foreignId('replaced_by_refresh_token_id')->nullable()->constrained('oauth_refresh_tokens')->nullOnDelete();
$table->timestamp('expires_at');
$table->timestamp('used_at')->nullable();
$table->timestamp('replayed_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['authorization_id', 'expires_at']);
});
DB::table('oauth_scopes')->insert([
[
'name' => 'openid',
'display_name' => 'OpenID',
'description' => '启用 OIDC 身份声明',
'claims' => json_encode([], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'profile',
'display_name' => 'Profile',
'description' => '基础用户资料',
'claims' => json_encode(['nickname'], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'email',
'display_name' => 'Email',
'description' => '邮箱信息',
'claims' => json_encode(['email'], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'phone',
'display_name' => 'Phone',
'description' => '手机号信息',
'claims' => json_encode(['phone'], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
Schema::dropIfExists('oauth_access_tokens');
Schema::dropIfExists('oauth_authorization_codes');
Schema::dropIfExists('oauth_authorizations');
Schema::dropIfExists('oauth_consents');
Schema::dropIfExists('oauth_client_scope');
Schema::dropIfExists('oauth_clients');
Schema::dropIfExists('oauth_scopes');
}
};

81
oauth_test/README.md Normal file
View File

@ -0,0 +1,81 @@
# oauth_test - OAuth2/OIDC 独立联调客户端
这是一个**完全独立**的静态测试页,不依赖现有系统前端工程,用于联调你们平台的 OAuth2/OIDC 端点。
## 文件说明
- `index.html`:单页联调客户端(内联 JS/CSS
- `README.md`:使用说明
## 启动方式
任选其一:
1. 直接双击打开 `index.html`
2. 使用任意静态服务启动(推荐,避免部分浏览器本地文件策略影响)
示例PowerShell
```powershell
cd D:\Projects\PHP\BastionSSO\oauth_test
python -m http.server 8800
```
然后访问:`http://localhost:8800/index.html`
## 建议默认配置(按当前项目)
假设你的服务端是 `http://localhost`
- `issuer`: `http://localhost`
- `discovery_endpoint`: `http://localhost/.well-known/openid-configuration`
- `authorize_endpoint`: `http://localhost/oauth/authorize`
- `token_endpoint`: `http://localhost/oauth/token`
- `userinfo_endpoint`: `http://localhost/oauth/userinfo`
- `revoke_endpoint`: `http://localhost/oauth/revoke`
## 支持流程
- Discovery 自动发现并回填端点
- Authorization Code 授权跳转
- 解析回调 `code/error/state`
- 换取 Token默认 Basic 优先,可切换 post
- 拉取 UserInfo
- Refresh Token
- Revoke Tokenaccess/refresh
- PKCE 开关S256
## 使用步骤(推荐)
1. 填写 `issuer`,点击“按 issuer 自动填充端点”
2. 填写 `client_id``client_secret``redirect_uri``scope`
3. 点击“发起授权”,在 OAuth 页面登录并同意
4. 回跳后点击“解析回调”
5. 点击“换取 Token”
6. 点击“拉取 UserInfo”验证用户信息返回
7. 可继续测试“刷新 Token”和“撤销 Token”
## 常见问题
1. `invalid_client`
- 检查 `client_id/client_secret` 是否正确
- 检查客户端认证方式Basic / post是否与服务端一致
2. `invalid_grant`
- `code` 可能已过期或已使用
- `redirect_uri` 与授权时不一致
3. `redirect_uri mismatch`
- 确保客户端配置中的回调地址与请求参数完全一致(协议/域名/端口/路径)
4. 浏览器报 `Failed to fetch`
- 多数是 CORS 或网络策略问题
- 需要服务端放行当前 Origin以及 `Authorization` / `Content-Type` 请求头
5. `scope` 相关错误
- 请求 scope 必须是客户端允许 scope 的子集
## 备注
- 页面配置和 token 状态会保存在浏览器 `localStorage`
- 本目录不会接入现有系统菜单、路由或打包流程。

947
oauth_test/index.html Normal file
View File

@ -0,0 +1,947 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OAuth2/OIDC 联调客户端</title>
<style>
:root {
--bg: #f4f7fb;
--card: #ffffff;
--text: #0f172a;
--muted: #475569;
--line: #dbe2ea;
--brand: #0f766e;
--brand-soft: #e6f4f2;
--danger: #b91c1c;
--warn: #92400e;
--ok: #166534;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: radial-gradient(circle at top right, #d9efe8 0%, var(--bg) 35%);
color: var(--text);
}
.container {
max-width: 1200px;
margin: 24px auto;
padding: 0 16px 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: 24px;
}
.subtitle {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 14px;
}
.card {
grid-column: span 12;
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
}
.card h2 {
margin: 0 0 10px;
font-size: 16px;
}
.card .hint {
margin-top: -4px;
margin-bottom: 10px;
color: var(--muted);
font-size: 12px;
}
.fields {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 10px;
}
.field {
grid-column: span 12;
display: flex;
flex-direction: column;
gap: 6px;
}
.field.s6 { grid-column: span 6; }
.field.s4 { grid-column: span 4; }
.field.s3 { grid-column: span 3; }
label {
font-size: 12px;
color: var(--muted);
font-weight: 600;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px 10px;
font-size: 14px;
outline: none;
background: #fff;
color: var(--text);
}
input:focus, select:focus, textarea:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
textarea {
min-height: 120px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
line-height: 1.5;
}
.btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
button {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
button:hover { background: #f8fafc; }
button.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
button.primary:hover { filter: brightness(0.95); }
button.soft {
border-color: #bfe4dd;
background: var(--brand-soft);
color: #0f5f58;
}
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid var(--line);
background: #fff;
}
.status.ok { color: var(--ok); border-color: #bbf7d0; background: #f0fdf4; }
.status.warn { color: var(--warn); border-color: #fde68a; background: #fffbeb; }
.status.err { color: var(--danger); border-color: #fecaca; background: #fef2f2; }
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.kv {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px;
align-items: center;
font-size: 13px;
margin-bottom: 6px;
}
.kv .key { color: var(--muted); }
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow-wrap: anywhere;
}
.logs {
min-height: 180px;
max-height: 340px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #0b1220;
color: #d2deff;
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
@media (max-width: 980px) {
.split { grid-template-columns: 1fr; }
.field.s6, .field.s4, .field.s3 { grid-column: span 12; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>OAuth2/OIDC 联调客户端</h1>
<p class="subtitle">独立静态页,不依赖现有系统前端。用于授权码流程联调、排错和回归验证。</p>
</div>
<div id="status" class="status">未执行</div>
</div>
<div class="grid">
<section class="card">
<h2>服务器配置</h2>
<div class="fields">
<div class="field s6"><label for="issuer">issuer</label><input id="issuer" placeholder="http://localhost" /></div>
<div class="field s6"><label for="discovery_endpoint">discovery_endpoint</label><input id="discovery_endpoint" placeholder="http://localhost/.well-known/openid-configuration" /></div>
<div class="field s6"><label for="authorize_endpoint">authorize_endpoint</label><input id="authorize_endpoint" placeholder="http://localhost/oauth/authorize" /></div>
<div class="field s6"><label for="token_endpoint">token_endpoint</label><input id="token_endpoint" placeholder="http://localhost/oauth/token" /></div>
<div class="field s6"><label for="userinfo_endpoint">userinfo_endpoint</label><input id="userinfo_endpoint" placeholder="http://localhost/oauth/userinfo" /></div>
<div class="field s6"><label for="revoke_endpoint">revoke_endpoint</label><input id="revoke_endpoint" placeholder="http://localhost/oauth/revoke" /></div>
</div>
<div class="btns" style="margin-top: 10px;">
<button class="soft" id="btn-discovery">发现配置</button>
<button id="btn-fill-defaults">按 issuer 自动填充端点</button>
</div>
</section>
<section class="card">
<h2>客户端配置</h2>
<div class="fields">
<div class="field s4"><label for="client_id">client_id</label><input id="client_id" /></div>
<div class="field s4"><label for="client_secret">client_secret</label><input id="client_secret" type="password" /></div>
<div class="field s4"><label for="redirect_uri">redirect_uri</label><input id="redirect_uri" /></div>
<div class="field s4"><label for="scope">scope</label><input id="scope" placeholder="openid profile email phone" /></div>
<div class="field s4"><label for="state">state</label><input id="state" /></div>
<div class="field s4"><label for="nonce">nonce</label><input id="nonce" /></div>
</div>
</section>
<section class="card">
<h2>高级选项</h2>
<div class="fields">
<div class="field s4">
<label for="auth_mode">token/revoke 客户端认证</label>
<select id="auth_mode">
<option value="basic_first">Basic 优先(失败可切 post</option>
<option value="basic_only">仅 Basic</option>
<option value="post_only">仅 client_secret_post</option>
</select>
</div>
<div class="field s4">
<label for="pkce_enabled">PKCE</label>
<select id="pkce_enabled">
<option value="false">关闭</option>
<option value="true">开启S256</option>
</select>
</div>
<div class="field s4">
<label for="token_type_hint">revoke token_type_hint</label>
<select id="token_type_hint">
<option value="">不传</option>
<option value="access_token">access_token</option>
<option value="refresh_token">refresh_token</option>
</select>
</div>
</div>
<p class="hint">说明PKCE 开启后会自动生成 code_verifier/code_challenge并在授权与换 token 时自动带上。</p>
</section>
<section class="card">
<h2>操作</h2>
<div class="btns">
<button class="primary" id="btn-authorize">发起授权</button>
<button id="btn-parse-callback">解析回调</button>
<button id="btn-exchange-code">换取 Token</button>
<button id="btn-userinfo">拉取 UserInfo</button>
<button id="btn-refresh">刷新 Token</button>
<button id="btn-revoke-access">撤销 Access Token</button>
<button id="btn-revoke-refresh">撤销 Refresh Token</button>
<button id="btn-clear-state">清空状态</button>
</div>
</section>
<section class="card">
<h2>结果与状态</h2>
<div class="split">
<div>
<div class="kv"><div class="key">授权 code</div><div id="out-code" class="mono">-</div></div>
<div class="kv"><div class="key">state 校验</div><div id="out-state-check" class="mono">-</div></div>
<div class="kv"><div class="key">access_token</div><div id="out-access" class="mono">-</div></div>
<div class="kv"><div class="key">refresh_token</div><div id="out-refresh" class="mono">-</div></div>
<div class="kv"><div class="key">id_token</div><div id="out-idtoken" class="mono">-</div></div>
</div>
<div>
<label for="response_json">最近响应 JSON</label>
<textarea id="response_json" readonly></textarea>
<div class="btns" style="margin-top: 8px;">
<button id="btn-copy-response">复制响应 JSON</button>
</div>
</div>
</div>
</section>
<section class="card">
<h2>日志</h2>
<div class="btns" style="margin-bottom: 8px;">
<button id="btn-copy-log">复制日志</button>
<button id="btn-clear-log">清空日志</button>
</div>
<div id="logs" class="logs"></div>
</section>
</div>
</div>
<script>
(() => {
const STORAGE_KEY = 'oauth_test_config_v1';
const TOKEN_KEY = 'oauth_test_token_state_v1';
const fields = {
issuer: document.getElementById('issuer'),
discovery_endpoint: document.getElementById('discovery_endpoint'),
authorize_endpoint: document.getElementById('authorize_endpoint'),
token_endpoint: document.getElementById('token_endpoint'),
userinfo_endpoint: document.getElementById('userinfo_endpoint'),
revoke_endpoint: document.getElementById('revoke_endpoint'),
client_id: document.getElementById('client_id'),
client_secret: document.getElementById('client_secret'),
redirect_uri: document.getElementById('redirect_uri'),
scope: document.getElementById('scope'),
state: document.getElementById('state'),
nonce: document.getElementById('nonce'),
auth_mode: document.getElementById('auth_mode'),
pkce_enabled: document.getElementById('pkce_enabled'),
token_type_hint: document.getElementById('token_type_hint'),
response_json: document.getElementById('response_json')
};
const outputs = {
status: document.getElementById('status'),
code: document.getElementById('out-code'),
stateCheck: document.getElementById('out-state-check'),
access: document.getElementById('out-access'),
refresh: document.getElementById('out-refresh'),
idtoken: document.getElementById('out-idtoken'),
logs: document.getElementById('logs')
};
const tokenState = {
code: '',
stateFromCallback: '',
access_token: '',
refresh_token: '',
id_token: '',
token_type: 'Bearer',
expires_in: 0,
code_verifier: ''
};
function now() {
return new Date().toLocaleString();
}
function log(message, level = 'info') {
const prefix = level === 'error' ? '[ERR]' : level === 'warn' ? '[WRN]' : '[INF]';
outputs.logs.textContent += `${now()} ${prefix} ${message}\n`;
outputs.logs.scrollTop = outputs.logs.scrollHeight;
}
function setStatus(text, kind = '') {
outputs.status.textContent = text;
outputs.status.className = `status${kind ? ` ${kind}` : ''}`;
}
function randomString(len = 24) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const array = new Uint8Array(len);
crypto.getRandomValues(array);
for (let i = 0; i < len; i += 1) {
result += chars[array[i] % chars.length];
}
return result;
}
function base64UrlEncode(bytes) {
const bin = String.fromCharCode(...bytes);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function sha256Base64Url(str) {
const encoded = new TextEncoder().encode(str);
const digest = await crypto.subtle.digest('SHA-256', encoded);
return base64UrlEncode(new Uint8Array(digest));
}
function getConfig() {
return {
issuer: fields.issuer.value.trim(),
discovery_endpoint: fields.discovery_endpoint.value.trim(),
authorize_endpoint: fields.authorize_endpoint.value.trim(),
token_endpoint: fields.token_endpoint.value.trim(),
userinfo_endpoint: fields.userinfo_endpoint.value.trim(),
revoke_endpoint: fields.revoke_endpoint.value.trim(),
client_id: fields.client_id.value.trim(),
client_secret: fields.client_secret.value,
redirect_uri: fields.redirect_uri.value.trim(),
scope: fields.scope.value.trim(),
state: fields.state.value.trim(),
nonce: fields.nonce.value.trim(),
auth_mode: fields.auth_mode.value,
pkce_enabled: fields.pkce_enabled.value === 'true',
token_type_hint: fields.token_type_hint.value
};
}
function saveConfig() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(getConfig()));
}
function saveTokenState() {
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenState));
}
function loadConfig() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
const origin = location.origin.startsWith('http') ? location.origin : 'http://localhost';
fields.issuer.value = origin;
fillEndpointsByIssuer();
fields.redirect_uri.value = `${origin}${location.pathname}`;
fields.scope.value = 'openid profile email phone';
fields.state.value = randomString(16);
fields.nonce.value = randomString(16);
fields.auth_mode.value = 'basic_first';
fields.pkce_enabled.value = 'false';
fields.token_type_hint.value = '';
return;
}
try {
const cfg = JSON.parse(raw);
Object.keys(fields).forEach((k) => {
if (cfg[k] !== undefined && fields[k]) {
fields[k].value = String(cfg[k]);
}
});
} catch (e) {
log(`读取本地配置失败: ${String(e)}`, 'warn');
}
}
function loadTokenState() {
const raw = localStorage.getItem(TOKEN_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
Object.assign(tokenState, parsed || {});
renderState();
} catch (e) {
log(`读取 token 状态失败: ${String(e)}`, 'warn');
}
}
function fillEndpointsByIssuer() {
const issuer = fields.issuer.value.trim().replace(/\/$/, '');
if (!issuer) {
throw new Error('请先填写 issuer');
}
fields.discovery_endpoint.value = `${issuer}/.well-known/openid-configuration`;
fields.authorize_endpoint.value = `${issuer}/oauth/authorize`;
fields.token_endpoint.value = `${issuer}/oauth/token`;
fields.userinfo_endpoint.value = `${issuer}/oauth/userinfo`;
fields.revoke_endpoint.value = `${issuer}/oauth/revoke`;
log('已按 issuer 自动填充端点。');
saveConfig();
}
async function parseJsonResponse(resp) {
const text = await resp.text();
let json = null;
if (text) {
try {
json = JSON.parse(text);
} catch (_e) {
json = { raw: text };
}
}
return { status: resp.status, ok: resp.ok, json };
}
function summarizeRequest(method, url, headers, body) {
const sanitizedHeaders = { ...headers };
if (sanitizedHeaders.Authorization) {
sanitizedHeaders.Authorization = '[REDACTED]';
}
return {
method,
url,
headers: sanitizedHeaders,
body
};
}
function showResponse(data) {
fields.response_json.value = JSON.stringify(data, null, 2);
}
function renderState() {
outputs.code.textContent = tokenState.code || '-';
outputs.access.textContent = tokenState.access_token || '-';
outputs.refresh.textContent = tokenState.refresh_token || '-';
outputs.idtoken.textContent = tokenState.id_token || '-';
}
function ensure(v, msg) {
if (!v) throw new Error(msg);
}
async function discover() {
const cfg = getConfig();
ensure(cfg.discovery_endpoint, '请填写 discovery_endpoint');
const url = cfg.discovery_endpoint;
log(`发起 discovery 请求: ${url}`);
setStatus('正在发现配置...', 'warn');
const reqSummary = summarizeRequest('GET', url, { Accept: 'application/json' }, null);
try {
const resp = await fetch(url, { headers: { Accept: 'application/json' } });
const parsed = await parseJsonResponse(resp);
const result = { request: reqSummary, response: parsed };
showResponse(result);
if (!parsed.ok) {
setStatus(`Discovery 失败 (${parsed.status})`, 'err');
log(`Discovery 失败: HTTP ${parsed.status}`, 'error');
return;
}
const d = parsed.json || {};
fields.issuer.value = d.issuer || fields.issuer.value;
fields.authorize_endpoint.value = d.authorization_endpoint || fields.authorize_endpoint.value;
fields.token_endpoint.value = d.token_endpoint || fields.token_endpoint.value;
fields.userinfo_endpoint.value = d.userinfo_endpoint || fields.userinfo_endpoint.value;
fields.revoke_endpoint.value = d.revocation_endpoint || fields.revoke_endpoint.value;
saveConfig();
setStatus('Discovery 成功', 'ok');
log('Discovery 成功并已回填端点。');
} catch (e) {
handleFetchError(e, 'Discovery');
}
}
async function beginAuthorize() {
const cfg = getConfig();
ensure(cfg.authorize_endpoint, '请填写 authorize_endpoint');
ensure(cfg.client_id, '请填写 client_id');
ensure(cfg.redirect_uri, '请填写 redirect_uri');
if (!cfg.state) {
cfg.state = randomString(16);
fields.state.value = cfg.state;
}
if (!cfg.nonce) {
cfg.nonce = randomString(16);
fields.nonce.value = cfg.nonce;
}
const params = new URLSearchParams({
response_type: 'code',
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
scope: cfg.scope || '',
state: cfg.state,
nonce: cfg.nonce
});
if (cfg.pkce_enabled) {
tokenState.code_verifier = randomString(64);
const challenge = await sha256Base64Url(tokenState.code_verifier);
params.set('code_challenge', challenge);
params.set('code_challenge_method', 'S256');
log('PKCE 已开启,已生成 code_verifier/code_challenge。');
} else {
tokenState.code_verifier = '';
}
saveConfig();
saveTokenState();
const url = `${cfg.authorize_endpoint}${cfg.authorize_endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
log(`即将跳转授权: ${url}`);
setStatus('跳转授权中...', 'warn');
location.href = url;
}
function parseCallback() {
const query = new URLSearchParams(location.search || '');
const hashIndex = location.hash.indexOf('?');
if (hashIndex >= 0) {
const hashQuery = new URLSearchParams(location.hash.slice(hashIndex + 1));
hashQuery.forEach((v, k) => query.set(k, v));
}
const code = query.get('code') || '';
const error = query.get('error') || '';
const errorDescription = query.get('error_description') || '';
const state = query.get('state') || '';
tokenState.code = code;
tokenState.stateFromCallback = state;
const expectedState = fields.state.value.trim();
if (state && expectedState) {
outputs.stateCheck.textContent = state === expectedState ? '通过' : `失败expected=${expectedState}, actual=${state}`;
} else {
outputs.stateCheck.textContent = '-';
}
saveTokenState();
renderState();
const result = {
callback_url: location.href,
code,
state,
error,
error_description: errorDescription
};
showResponse(result);
if (error) {
setStatus(`回调错误: ${error}`, 'err');
log(`授权回调错误: ${error} ${errorDescription}`, 'error');
} else if (code) {
setStatus('已获取授权码', 'ok');
log('授权回调成功,已获取 code。');
} else {
setStatus('未发现授权参数', 'warn');
log('当前 URL 未包含 code/error 参数。', 'warn');
}
}
function buildBasicAuth(clientId, clientSecret) {
return btoa(`${clientId}:${clientSecret}`);
}
async function tokenRequest(form, endpoint, authMode, clientId, clientSecret) {
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
const body = new URLSearchParams(form);
const tryBasic = authMode === 'basic_first' || authMode === 'basic_only';
const allowPostFallback = authMode === 'basic_first';
const postOnly = authMode === 'post_only';
if (postOnly) {
body.set('client_id', clientId);
body.set('client_secret', clientSecret);
const reqSummary = summarizeRequest('POST', endpoint, headers, Object.fromEntries(body.entries()));
const resp = await fetch(endpoint, { method: 'POST', headers, body: body.toString() });
return { parsed: await parseJsonResponse(resp), reqSummary };
}
if (tryBasic) {
const basicHeaders = { ...headers, Authorization: `Basic ${buildBasicAuth(clientId, clientSecret)}` };
const reqSummary = summarizeRequest('POST', endpoint, basicHeaders, Object.fromEntries(body.entries()));
const resp = await fetch(endpoint, { method: 'POST', headers: basicHeaders, body: body.toString() });
const parsed = await parseJsonResponse(resp);
if (parsed.ok || !allowPostFallback) {
return { parsed, reqSummary };
}
log(`Basic 方式失败(HTTP ${parsed.status}),尝试 client_secret_post...`, 'warn');
body.set('client_id', clientId);
body.set('client_secret', clientSecret);
const fallbackHeaders = { ...headers };
const fallbackSummary = summarizeRequest('POST', endpoint, fallbackHeaders, Object.fromEntries(body.entries()));
const fallbackResp = await fetch(endpoint, { method: 'POST', headers: fallbackHeaders, body: body.toString() });
return { parsed: await parseJsonResponse(fallbackResp), reqSummary: fallbackSummary };
}
throw new Error('未知认证模式');
}
async function exchangeCode() {
const cfg = getConfig();
ensure(cfg.token_endpoint, '请填写 token_endpoint');
ensure(cfg.client_id, '请填写 client_id');
ensure(cfg.client_secret, '请填写 client_secret');
ensure(cfg.redirect_uri, '请填写 redirect_uri');
ensure(tokenState.code, '未检测到授权 code请先点击“解析回调”');
const form = {
grant_type: 'authorization_code',
code: tokenState.code,
redirect_uri: cfg.redirect_uri
};
if (cfg.pkce_enabled) {
ensure(tokenState.code_verifier, 'PKCE 已开启但缺少 code_verifier请重新发起授权');
form.code_verifier = tokenState.code_verifier;
}
setStatus('换取 Token 中...', 'warn');
log(`请求 token endpoint: ${cfg.token_endpoint}`);
try {
const { parsed, reqSummary } = await tokenRequest(form, cfg.token_endpoint, cfg.auth_mode, cfg.client_id, cfg.client_secret);
showResponse({ request: reqSummary, response: parsed });
if (!parsed.ok) {
setStatus(`换 token 失败 (${parsed.status})`, 'err');
log(`换 token 失败: HTTP ${parsed.status}`, 'error');
return;
}
const payload = parsed.json || {};
tokenState.access_token = payload.access_token || '';
tokenState.refresh_token = payload.refresh_token || '';
tokenState.id_token = payload.id_token || '';
tokenState.token_type = payload.token_type || 'Bearer';
tokenState.expires_in = Number(payload.expires_in || 0);
saveTokenState();
renderState();
setStatus('换 token 成功', 'ok');
log('换 token 成功。');
} catch (e) {
handleFetchError(e, '换 token');
}
}
async function fetchUserinfo() {
const cfg = getConfig();
ensure(cfg.userinfo_endpoint, '请填写 userinfo_endpoint');
ensure(tokenState.access_token, '请先获取 access_token');
const headers = {
Authorization: `${tokenState.token_type || 'Bearer'} ${tokenState.access_token}`,
Accept: 'application/json'
};
const reqSummary = summarizeRequest('GET', cfg.userinfo_endpoint, headers, null);
setStatus('请求 UserInfo 中...', 'warn');
log(`请求 userinfo endpoint: ${cfg.userinfo_endpoint}`);
try {
const resp = await fetch(cfg.userinfo_endpoint, { headers });
const parsed = await parseJsonResponse(resp);
showResponse({ request: reqSummary, response: parsed });
if (!parsed.ok) {
setStatus(`UserInfo 失败 (${parsed.status})`, 'err');
log(`UserInfo 失败: HTTP ${parsed.status}`, 'error');
return;
}
setStatus('UserInfo 成功', 'ok');
log('UserInfo 获取成功。');
} catch (e) {
handleFetchError(e, 'UserInfo');
}
}
async function refreshToken() {
const cfg = getConfig();
ensure(cfg.token_endpoint, '请填写 token_endpoint');
ensure(cfg.client_id, '请填写 client_id');
ensure(cfg.client_secret, '请填写 client_secret');
ensure(tokenState.refresh_token, '请先获取 refresh_token');
const form = {
grant_type: 'refresh_token',
refresh_token: tokenState.refresh_token
};
setStatus('刷新 Token 中...', 'warn');
log(`请求 refresh token: ${cfg.token_endpoint}`);
try {
const { parsed, reqSummary } = await tokenRequest(form, cfg.token_endpoint, cfg.auth_mode, cfg.client_id, cfg.client_secret);
showResponse({ request: reqSummary, response: parsed });
if (!parsed.ok) {
setStatus(`刷新失败 (${parsed.status})`, 'err');
log(`刷新 token 失败: HTTP ${parsed.status}`, 'error');
return;
}
const payload = parsed.json || {};
tokenState.access_token = payload.access_token || tokenState.access_token;
tokenState.refresh_token = payload.refresh_token || tokenState.refresh_token;
tokenState.id_token = payload.id_token || tokenState.id_token;
tokenState.token_type = payload.token_type || tokenState.token_type || 'Bearer';
tokenState.expires_in = Number(payload.expires_in || tokenState.expires_in || 0);
saveTokenState();
renderState();
setStatus('刷新成功', 'ok');
log('刷新 token 成功。');
} catch (e) {
handleFetchError(e, '刷新 token');
}
}
async function revokeToken(token, hint) {
const cfg = getConfig();
ensure(cfg.revoke_endpoint, '请填写 revoke_endpoint');
ensure(cfg.client_id, '请填写 client_id');
ensure(cfg.client_secret, '请填写 client_secret');
ensure(token, '没有可撤销的 token');
const form = { token };
if (hint) form.token_type_hint = hint;
setStatus('撤销 Token 中...', 'warn');
log(`请求 revoke endpoint: ${cfg.revoke_endpoint}`);
try {
const { parsed, reqSummary } = await tokenRequest(form, cfg.revoke_endpoint, cfg.auth_mode, cfg.client_id, cfg.client_secret);
showResponse({ request: reqSummary, response: parsed });
if (!parsed.ok) {
setStatus(`撤销失败 (${parsed.status})`, 'err');
log(`撤销失败: HTTP ${parsed.status}`, 'error');
return;
}
if (hint === 'refresh_token') {
tokenState.refresh_token = '';
} else if (hint === 'access_token') {
tokenState.access_token = '';
}
saveTokenState();
renderState();
setStatus('撤销成功', 'ok');
log('撤销成功。');
} catch (e) {
handleFetchError(e, '撤销 token');
}
}
function clearState() {
tokenState.code = '';
tokenState.stateFromCallback = '';
tokenState.access_token = '';
tokenState.refresh_token = '';
tokenState.id_token = '';
tokenState.token_type = 'Bearer';
tokenState.expires_in = 0;
tokenState.code_verifier = '';
fields.response_json.value = '';
outputs.stateCheck.textContent = '-';
saveTokenState();
renderState();
setStatus('已清空状态', 'warn');
log('已清空 OAuth 会话状态。');
}
function handleFetchError(err, actionName) {
const msg = String(err && err.message ? err.message : err);
const isCors = /Failed to fetch|NetworkError|Load failed/i.test(msg);
setStatus(`${actionName}异常`, 'err');
log(`${actionName}异常: ${msg}`, 'error');
if (isCors) {
log('疑似 CORS 或网络策略问题:请检查服务端是否放行当前 Origin 及 Authorization/Content-Type 头。', 'warn');
}
showResponse({ error: msg, action: actionName });
}
function bind() {
document.getElementById('btn-fill-defaults').addEventListener('click', () => {
try {
fillEndpointsByIssuer();
} catch (e) {
handleFetchError(e, '自动填充端点');
}
});
document.getElementById('btn-discovery').addEventListener('click', discover);
document.getElementById('btn-authorize').addEventListener('click', () => {
beginAuthorize().catch((e) => handleFetchError(e, '发起授权'));
});
document.getElementById('btn-parse-callback').addEventListener('click', parseCallback);
document.getElementById('btn-exchange-code').addEventListener('click', exchangeCode);
document.getElementById('btn-userinfo').addEventListener('click', fetchUserinfo);
document.getElementById('btn-refresh').addEventListener('click', refreshToken);
document.getElementById('btn-revoke-access').addEventListener('click', () => revokeToken(tokenState.access_token, fields.token_type_hint.value || 'access_token'));
document.getElementById('btn-revoke-refresh').addEventListener('click', () => revokeToken(tokenState.refresh_token, fields.token_type_hint.value || 'refresh_token'));
document.getElementById('btn-clear-state').addEventListener('click', clearState);
document.getElementById('btn-copy-response').addEventListener('click', async () => {
await navigator.clipboard.writeText(fields.response_json.value || '');
log('已复制响应 JSON。');
});
document.getElementById('btn-copy-log').addEventListener('click', async () => {
await navigator.clipboard.writeText(outputs.logs.textContent || '');
log('已复制日志。');
});
document.getElementById('btn-clear-log').addEventListener('click', () => {
outputs.logs.textContent = '';
log('日志已清空。');
});
Object.values(fields).forEach((el) => {
el.addEventListener('change', saveConfig);
el.addEventListener('blur', saveConfig);
});
}
function init() {
loadConfig();
loadTokenState();
bind();
parseCallback();
setStatus('就绪', 'ok');
log('测试客户端已就绪。');
}
init();
})();
</script>
</body>
</html>

7
public/404.html Normal file
View File

@ -0,0 +1,7 @@
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

62
public/index.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>恭喜,站点创建成功!</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="robots" content="noindex" />
<style type="text/css"><!--
body {
color: #444444;
background-color: #EEEEEE;
font-family: 'Trebuchet MS', sans-serif;
font-size: 80%;
}
h1 {}
h2 { font-size: 1.2em; }
#page{
background-color: #FFFFFF;
width: 60%;
margin: 24px auto;
padding: 12px;
}
#header {
padding: 6px ;
text-align: center;
}
.status3xx { background-color: #475076; color: #FFFFFF; }
.status4xx { background-color: #C55042; color: #FFFFFF; }
.status5xx { background-color: #F2E81A; color: #000000; }
#content {
padding: 4px 0 24px 0;
}
#footer {
color: #666666;
background: #f9f9f9;
padding: 10px 20px;
border-top: 5px #efefef solid;
font-size: 0.8em;
text-align: center;
}
#footer a {
color: #999999;
}
--></style>
</head>
<body>
<div id="page">
<div id="header" class="status5xx">
<h1>站点已创建成功!</h1>
<h3>这是默认index.html本页面由系统自动生成</h3>
</div>
<div id="content">
<p>本页面在FTP根目录下的index.html</p>
<p>您可以修改、删除或覆盖本页面</p>
<p>FTP相关信息请到“面板系统后台 > FTP” 查看</p>
</div>
<div id="footer">
<p>Powered by <a href="http://www.bt5.me/?a1" rel="nofollow">BT5-Linux免费面板</a></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,230 @@
<?php
namespace Tests\Feature;
use App\Models\OauthClient;
use App\Models\OauthRefreshToken;
use App\Models\OauthScope;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
use Tymon\JWTAuth\Facades\JWTAuth;
class OauthProtocolTest extends TestCase
{
use RefreshDatabase;
public function test_authorization_code_flow_userinfo_remap_and_discovery(): void
{
config()->set('oauth.frontend_login_url', 'http://frontend.local/#/login');
config()->set('oauth.frontend_consent_url', 'http://frontend.local/#/oauth-consent');
config()->set('oauth.issuer', 'http://localhost');
$resourceOwner = User::factory()->create([
'nickname' => 'alice',
'email' => 'alice@example.com',
'phone' => '13800000000',
]);
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
$plainSecret = 'secret_123456789';
$client = OauthClient::factory()->create([
'client_id' => 'cli_test_client',
'client_secret_hash' => Hash::make($plainSecret),
'redirect_uris' => ['https://client.example.com/callback'],
'allowed_userinfo_fields' => ['sub', 'nickname', 'email'],
'userinfo_claim_remap' => ['nickname' => 'username'],
]);
$scopeIds = OauthScope::query()
->whereIn('name', ['openid', 'profile', 'email'])
->pluck('id')
->all();
$client->scopes()->sync($scopeIds);
$authorizeResponse = $this->get('/oauth/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $client->client_id,
'redirect_uri' => 'https://client.example.com/callback',
'scope' => 'openid profile email',
'state' => 'state_001',
'nonce' => 'nonce_001',
'access_token' => $accessTokenForAuthorize,
]));
$authorizeResponse->assertRedirectContains('/#/oauth-consent');
$decisionResponse = $this->post('/oauth/authorize/decision', [
'approve' => true,
'client_id' => $client->client_id,
'redirect_uri' => 'https://client.example.com/callback',
'scope' => 'openid profile email',
'state' => 'state_001',
'nonce' => 'nonce_001',
'access_token' => $accessTokenForAuthorize,
]);
$decisionResponse->assertStatus(302);
$callbackUrl = (string) $decisionResponse->headers->get('Location');
$queryValues = $this->queryValuesFromUrl($callbackUrl);
$authorizationCode = (string) ($queryValues['code'] ?? '');
$this->assertNotSame('', $authorizationCode);
$this->assertSame('state_001', (string) ($queryValues['state'] ?? ''));
$tokenResponse = $this->post('/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'redirect_uri' => 'https://client.example.com/callback',
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
]);
$tokenResponse
->assertOk()
->assertJsonStructure([
'access_token',
'token_type',
'expires_in',
'refresh_token',
'scope',
'id_token',
]);
$userInfoResponse = $this->get('/oauth/userinfo?access_token='.(string) $tokenResponse->json('access_token'));
$userInfoResponse
->assertOk()
->assertJsonPath('sub', (string) $resourceOwner->id)
->assertJsonPath('username', 'alice')
->assertJsonPath('email', 'alice@example.com')
->assertJsonMissingPath('nickname');
$discoveryResponse = $this->get('/.well-known/openid-configuration');
$discoveryResponse
->assertOk()
->assertJsonPath('issuer', 'http://localhost')
->assertJsonPath('token_endpoint_auth_methods_supported.0', 'client_secret_basic');
$jwksResponse = $this->get('/oauth/jwks');
$jwksResponse
->assertOk()
->assertJsonPath('keys.0.kty', 'RSA')
->assertJsonPath('keys.0.alg', 'RS256');
}
public function test_requested_scope_is_ignored_and_authorization_continues_with_server_config(): void
{
$resourceOwner = User::factory()->create();
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
$client = OauthClient::factory()->create([
'client_id' => 'cli_scope_subset',
'client_secret_hash' => Hash::make('secret'),
'redirect_uris' => ['https://subset.example.com/callback'],
'allowed_userinfo_fields' => ['sub'],
'userinfo_claim_remap' => [],
]);
$scopeIds = OauthScope::query()->whereIn('name', ['profile'])->pluck('id')->all();
$client->scopes()->sync($scopeIds);
$response = $this->get('/oauth/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $client->client_id,
'redirect_uri' => 'https://subset.example.com/callback',
'scope' => 'profile openid',
'state' => 'scope_state',
'access_token' => $accessTokenForAuthorize,
]));
$response->assertStatus(302);
$location = (string) $response->headers->get('Location');
$this->assertStringContainsString('/#/oauth-consent', $location);
$this->assertStringContainsString('state=scope_state', $location);
}
public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void
{
$resourceOwner = User::factory()->create();
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
$plainSecret = 'secret_refresh_001';
$client = OauthClient::factory()->create([
'client_id' => 'cli_refresh_client',
'client_secret_hash' => Hash::make($plainSecret),
'redirect_uris' => ['https://refresh.example.com/callback'],
'allowed_userinfo_fields' => ['sub'],
'userinfo_claim_remap' => [],
]);
$scopeIds = OauthScope::query()->whereIn('name', ['openid'])->pluck('id')->all();
$client->scopes()->sync($scopeIds);
$decisionResponse = $this->post('/oauth/authorize/decision', [
'approve' => true,
'client_id' => $client->client_id,
'redirect_uri' => 'https://refresh.example.com/callback',
'scope' => 'openid',
'state' => 'refresh_state',
'nonce' => 'refresh_nonce',
'access_token' => $accessTokenForAuthorize,
]);
$authorizationCode = (string) ($this->queryValuesFromUrl((string) $decisionResponse->headers->get('Location'))['code'] ?? '');
$this->assertNotSame('', $authorizationCode);
$tokenResponse = $this->post('/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $authorizationCode,
'redirect_uri' => 'https://refresh.example.com/callback',
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
])->assertOk();
$firstRefreshToken = (string) $tokenResponse->json('refresh_token');
$this->assertNotSame('', $firstRefreshToken);
$refreshResponse = $this->post('/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $firstRefreshToken,
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
]);
$refreshResponse->assertOk();
$secondRefreshToken = (string) $refreshResponse->json('refresh_token');
$this->assertNotSame('', $secondRefreshToken);
$this->assertNotSame($firstRefreshToken, $secondRefreshToken);
$replayResponse = $this->post('/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $firstRefreshToken,
], [
'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret),
'Accept' => 'application/json',
]);
$replayResponse
->assertStatus(400)
->assertJsonPath('error', 'invalid_grant');
$latestRefreshRecord = OauthRefreshToken::query()
->where('token_hash', hash('sha256', $secondRefreshToken))
->first();
$this->assertNotNull($latestRefreshRecord);
$this->assertNotNull($latestRefreshRecord->revoked_at);
}
/**
* @return array<string, string>
*/
private function queryValuesFromUrl(string $url): array
{
$query = parse_url($url, PHP_URL_QUERY);
if (! is_string($query)) {
return [];
}
parse_str($query, $values);
return collect($values)->map(fn ($value): string => (string) $value)->all();
}
}