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

305 lines
11 KiB
PHP

<?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;
}
}