BastionSSO/app/Http/Controllers/Api/OauthAuthorizationController.php

323 lines
12 KiB
PHP

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