feat(OAuth2): 完成Oauth2服务器及测试客户端的基本编写
This commit is contained in:
parent
10a9ae4553
commit
fd7fc0096d
13
.env.example
13
.env.example
@ -72,3 +72,16 @@ BASTION_TOKEN_POLL_INTERVAL_MS=500
|
|||||||
BASTION_TOKEN_TASK_TTL_SECONDS=1800
|
BASTION_TOKEN_TASK_TTL_SECONDS=1800
|
||||||
BASTION_TOKEN_SERVICE=https://myapp.cdu.edu.cn/index.html
|
BASTION_TOKEN_SERVICE=https://myapp.cdu.edu.cn/index.html
|
||||||
BASTION_TOKEN_VERIFY_SSL=false
|
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_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
1
.gitignore
vendored
@ -4,6 +4,7 @@
|
|||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
|
/storage/oauth
|
||||||
/vendor
|
/vendor
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
|
|||||||
@ -130,6 +130,10 @@ class UserManageCommand extends Command
|
|||||||
'platform.accounts.manage',
|
'platform.accounts.manage',
|
||||||
'platform.logs.view',
|
'platform.logs.view',
|
||||||
'platform.logs.manage',
|
'platform.logs.manage',
|
||||||
|
'platform.oauth_clients.view',
|
||||||
|
'platform.oauth_clients.manage',
|
||||||
|
'platform.oauth_scopes.view',
|
||||||
|
'platform.oauth_scopes.manage',
|
||||||
'resource.servers.use',
|
'resource.servers.use',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
34
app/Exceptions/OAuthProtocolException.php
Normal file
34
app/Exceptions/OAuthProtocolException.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
304
app/Http/Controllers/Api/OauthAuthorizationController.php
Normal file
304
app/Http/Controllers/Api/OauthAuthorizationController.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\OauthClient;
|
||||||
|
use App\Models\OauthConsent;
|
||||||
|
use App\Models\OauthScope;
|
||||||
|
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'],
|
||||||
|
'scope' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedScopes = $this->clientAuthService->allowedScopeNames($client);
|
||||||
|
$requestedScopeRaw = (string) $request->query('scope', '');
|
||||||
|
$requestedScopes = $requestedScopeRaw === ''
|
||||||
|
? $allowedScopes
|
||||||
|
: $this->clientAuthService->parseScopes($requestedScopeRaw);
|
||||||
|
if (! $this->clientAuthService->isScopeSubset($requestedScopes, $allowedScopes)) {
|
||||||
|
return $this->authorizationError($redirectUri, (string) $request->query('state', ''), 'invalid_scope', 'Requested scope is not allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$resourceOwner = $this->resolveResourceOwner($request);
|
||||||
|
if (! $resourceOwner) {
|
||||||
|
return $this->redirectToFrontendLogin($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes);
|
||||||
|
$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, $requestedScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $this->tokenService->issueAuthorizationCode(
|
||||||
|
user: $resourceOwner,
|
||||||
|
client: $client,
|
||||||
|
scopes: $requestedScopes,
|
||||||
|
redirectUri: $redirectUri,
|
||||||
|
nonce: $request->query('nonce') ? (string) $request->query('nonce') : null
|
||||||
|
);
|
||||||
|
|
||||||
|
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'],
|
||||||
|
'scope' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedScopes = $this->clientAuthService->allowedScopeNames($client);
|
||||||
|
$requestedScopes = $request->filled('scope')
|
||||||
|
? $this->clientAuthService->parseScopes((string) $request->input('scope'))
|
||||||
|
: $allowedScopes;
|
||||||
|
if (! $this->clientAuthService->isScopeSubset($requestedScopes, $allowedScopes)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'invalid_scope',
|
||||||
|
'error_description' => 'Requested scope is not allowed.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->boolean('approve')) {
|
||||||
|
return $this->authorizationError(
|
||||||
|
$redirectUri,
|
||||||
|
(string) $request->input('state', ''),
|
||||||
|
'access_denied',
|
||||||
|
'Resource owner denied the request.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes);
|
||||||
|
OauthConsent::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $resourceOwner->id,
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'scope_fingerprint' => $scopeFingerprint,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'scopes' => $requestedScopes,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$code = $this->tokenService->issueAuthorizationCode(
|
||||||
|
user: $resourceOwner,
|
||||||
|
client: $client,
|
||||||
|
scopes: $requestedScopes,
|
||||||
|
redirectUri: $redirectUri,
|
||||||
|
nonce: $request->input('nonce') ? (string) $request->input('nonce') : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->successRedirect($redirectUri, $code, (string) $request->input('state', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $scopes
|
||||||
|
*/
|
||||||
|
private function redirectToFrontendConsent(Request $request, OauthClient $client, array $scopes): RedirectResponse
|
||||||
|
{
|
||||||
|
$frontendConsentUrl = (string) config('oauth.frontend_consent_url');
|
||||||
|
$query = [
|
||||||
|
'return_to' => $request->fullUrl(),
|
||||||
|
'client_id' => $client->client_id,
|
||||||
|
'client_name' => $client->name,
|
||||||
|
'client_logo' => $client->logo_url,
|
||||||
|
'scope' => $this->clientAuthService->buildScopeString($scopes),
|
||||||
|
'state' => (string) $request->query('state', ''),
|
||||||
|
'nonce' => (string) $request->query('nonce', ''),
|
||||||
|
'redirect_uri' => (string) $request->query('redirect_uri', ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$scopesMap = OauthScope::query()
|
||||||
|
->whereIn('name', $scopes)
|
||||||
|
->get(['name', 'display_name', 'description'])
|
||||||
|
->map(fn (OauthScope $scope): array => [
|
||||||
|
'name' => $scope->name,
|
||||||
|
'display_name' => $scope->display_name,
|
||||||
|
'description' => $scope->description,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$query['scope_meta'] = base64_encode((string) json_encode($scopesMap, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
return redirect()->away($this->appendQueryToFrontendUrl($frontendConsentUrl, $query));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redirectToFrontendLogin(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$frontendLoginUrl = (string) config('oauth.frontend_login_url');
|
||||||
|
$query = ['return_to' => $request->fullUrl()];
|
||||||
|
|
||||||
|
return redirect()->away($this->appendQueryToFrontendUrl($frontendLoginUrl, $query));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/Http/Controllers/Api/OauthClientController.php
Normal file
204
app/Http/Controllers/Api/OauthClientController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Controllers/Api/OauthMetadataController.php
Normal file
50
app/Http/Controllers/Api/OauthMetadataController.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Http/Controllers/Api/OauthTokenController.php
Normal file
122
app/Http/Controllers/Api/OauthTokenController.php
Normal 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'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/Http/Controllers/Api/OauthUserInfoController.php
Normal file
124
app/Http/Controllers/Api/OauthUserInfoController.php
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\OauthScope;
|
||||||
|
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\Collection;
|
||||||
|
|
||||||
|
#[Apidoc\Title('OAuth 协议 UserInfo 端点')]
|
||||||
|
class OauthUserInfoController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OAuthTokenService $tokenService,
|
||||||
|
private readonly OAuthClientAuthService $clientAuthService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeNames = $this->clientAuthService->parseScopes((string) $accessToken->scope);
|
||||||
|
$scopes = OauthScope::query()
|
||||||
|
->whereIn('name', $scopeNames)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$scopeFields = $scopes
|
||||||
|
->flatMap(function (OauthScope $scope): array {
|
||||||
|
$claims = $scope->claims;
|
||||||
|
if (! is_array($claims)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($claims);
|
||||||
|
})
|
||||||
|
->map(fn ($field): string => trim((string) $field))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$clientFields = collect($accessToken->client->allowed_userinfo_fields ?? config('oauth.userinfo_fields', []))
|
||||||
|
->map(fn ($field): string => trim((string) $field))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$finalFields = $scopeFields
|
||||||
|
->intersect($clientFields)
|
||||||
|
->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 ($finalFields 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -139,6 +139,10 @@ class PermissionController extends Controller
|
|||||||
'platform.accounts.manage' => ['category' => '堡垒机账号', 'description' => '维护堡垒机授权账号与刷新令牌'],
|
'platform.accounts.manage' => ['category' => '堡垒机账号', 'description' => '维护堡垒机授权账号与刷新令牌'],
|
||||||
'platform.logs.view' => ['category' => '日志审计', 'description' => '查看访问与操作日志'],
|
'platform.logs.view' => ['category' => '日志审计', 'description' => '查看访问与操作日志'],
|
||||||
'platform.logs.manage' => ['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' => '发起服务器资源访问与连接操作'],
|
'resource.servers.use' => ['category' => '资源使用', 'description' => '发起服务器资源访问与连接操作'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
30
app/Http/Requests/StoreOauthClientRequest.php
Normal file
30
app/Http/Requests/StoreOauthClientRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/UpdateOauthClientRequest.php
Normal file
30
app/Http/Requests/UpdateOauthClientRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Models/OauthAccessToken.php
Normal file
42
app/Models/OauthAccessToken.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Models/OauthAuthorization.php
Normal file
50
app/Models/OauthAuthorization.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/OauthAuthorizationCode.php
Normal file
46
app/Models/OauthAuthorizationCode.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Models/OauthClient.php
Normal file
54
app/Models/OauthClient.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/OauthConsent.php
Normal file
35
app/Models/OauthConsent.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Models/OauthRefreshToken.php
Normal file
58
app/Models/OauthRefreshToken.php
Normal 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
36
app/Models/OauthScope.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/Services/OAuth/OAuthClientAuthService.php
Normal file
173
app/Services/OAuth/OAuthClientAuthService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
318
app/Services/OAuth/OAuthJwtService.php
Normal file
318
app/Services/OAuth/OAuthJwtService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
360
app/Services/OAuth/OAuthTokenService.php
Normal file
360
app/Services/OAuth/OAuthTokenService.php
Normal 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)), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Exceptions\OAuthProtocolException;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
@ -23,9 +24,34 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'permission' => PermissionMiddleware::class,
|
'permission' => PermissionMiddleware::class,
|
||||||
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'oauth/token',
|
||||||
|
'oauth/revoke',
|
||||||
|
'oauth/authorize/decision',
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->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()) {
|
if (! $request->expectsJson()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -122,7 +148,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
], 422);
|
], 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()) {
|
if ($request->expectsJson()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 403,
|
'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()) {
|
if ($request->expectsJson()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
|
|||||||
21
config/oauth.php
Normal file
21
config/oauth.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?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'),
|
||||||
|
|
||||||
|
'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'],
|
||||||
|
];
|
||||||
27
database/factories/OauthClientFactory.php
Normal file
27
database/factories/OauthClientFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
database/factories/OauthScopeFactory.php
Normal file
23
database/factories/OauthScopeFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
175
database/migrations/2026_05_20_041122_create_oauth_tables.php
Normal file
175
database/migrations/2026_05_20_041122_create_oauth_tables.php
Normal 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
81
oauth_test/README.md
Normal 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 Token(access/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
947
oauth_test/index.html
Normal 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
7
public/404.html
Normal 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
62
public/index.html
Normal 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>
|
||||||
231
tests/Feature/OauthProtocolTest.php
Normal file
231
tests/Feature/OauthProtocolTest.php
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
<?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_invalid_scope_is_returned_as_standard_oauth_redirect_error(): 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');
|
||||||
|
$queryValues = $this->queryValuesFromUrl($location);
|
||||||
|
$this->assertSame('invalid_scope', (string) ($queryValues['error'] ?? ''));
|
||||||
|
$this->assertSame('scope_state', (string) ($queryValues['state'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user