323 lines
12 KiB
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,
|
|
],
|
|
]);
|
|
}
|
|
}
|