feat(OAuth2): 完成Oauth2 BUG的修复

This commit is contained in:
Boen_Shi 2026-05-21 16:43:40 +08:00
parent fd7fc0096d
commit 10dd477365
5 changed files with 75 additions and 83 deletions

View File

@ -76,6 +76,7 @@ BASTION_TOKEN_VERIFY_SSL=false
OAUTH_ISSUER=${APP_URL}
OAUTH_FRONTEND_LOGIN_URL="http://localhost:5173/#/login"
OAUTH_FRONTEND_CONSENT_URL="http://localhost:5173/#/oauth-consent"
OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE=/api
OAUTH_KEY_ID=
OAUTH_PRIVATE_KEY_PATH=storage/oauth/private.pem
OAUTH_PUBLIC_KEY_PATH=storage/oauth/public.pem

View File

@ -3,9 +3,9 @@
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\OauthScope;
use App\Models\User;
use App\Services\OAuth\OAuthClientAuthService;
use App\Services\OAuth\OAuthTokenService;
@ -31,7 +31,6 @@ class OauthAuthorizationController extends Controller
'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'],
@ -61,21 +60,14 @@ class OauthAuthorizationController extends Controller
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.');
}
$serverScopes = $this->clientAuthService->allowedScopeNames($client);
$resourceOwner = $this->resolveResourceOwner($request);
if (! $resourceOwner) {
return $this->redirectToFrontendLogin($request);
return $this->redirectToFrontendConsent($request, $client);
}
$scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes);
$scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes);
$consent = OauthConsent::query()
->where('user_id', $resourceOwner->id)
->where('client_id', $client->id)
@ -85,17 +77,19 @@ class OauthAuthorizationController extends Controller
$consentRequired = $consent === null || $prompt === 'consent';
if ($consentRequired) {
return $this->redirectToFrontendConsent($request, $client, $requestedScopes);
return $this->redirectToFrontendConsent($request, $client);
}
$code = $this->tokenService->issueAuthorizationCode(
user: $resourceOwner,
client: $client,
scopes: $requestedScopes,
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', ''));
}
@ -106,7 +100,6 @@ class OauthAuthorizationController extends Controller
'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'],
]);
@ -142,18 +135,11 @@ class OauthAuthorizationController extends Controller
], 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);
}
$serverScopes = $this->clientAuthService->allowedScopeNames($client);
if (! $request->boolean('approve')) {
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false);
return $this->authorizationError(
$redirectUri,
(string) $request->input('state', ''),
@ -162,7 +148,7 @@ class OauthAuthorizationController extends Controller
);
}
$scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes);
$scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes);
OauthConsent::query()->updateOrCreate(
[
'user_id' => $resourceOwner->id,
@ -170,7 +156,7 @@ class OauthAuthorizationController extends Controller
'scope_fingerprint' => $scopeFingerprint,
],
[
'scopes' => $requestedScopes,
'scopes' => $serverScopes,
'granted_at' => now(),
]
);
@ -178,52 +164,62 @@ class OauthAuthorizationController extends Controller
$code = $this->tokenService->issueAuthorizationCode(
user: $resourceOwner,
client: $client,
scopes: $requestedScopes,
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', ''));
}
/**
* @param array<int, string> $scopes
*/
private function redirectToFrontendConsent(Request $request, OauthClient $client, array $scopes): RedirectResponse
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' => $request->fullUrl(),
'return_to' => $this->buildFrontendAuthorizeReturnTo($request),
'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', ''),
'userinfo_fields' => base64_encode((string) json_encode($userinfoFields, JSON_UNESCAPED_UNICODE)),
'userinfo_remap' => base64_encode((string) json_encode($remapRules, JSON_UNESCAPED_UNICODE)),
];
$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
private function buildFrontendAuthorizeReturnTo(Request $request): string
{
$frontendLoginUrl = (string) config('oauth.frontend_login_url');
$query = ['return_to' => $request->fullUrl()];
$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 redirect()->away($this->appendQueryToFrontendUrl($frontendLoginUrl, $query));
return $queryString === '' ? $target : $target.'?'.$queryString;
}
private function successRedirect(string $redirectUri, string $code, string $state): RedirectResponse
@ -301,4 +297,26 @@ class OauthAuthorizationController extends Controller
return $user instanceof User ? $user : null;
}
private function writeOAuthAuthorizationLog(User $user, Request $request, OauthClient $client, bool $autoApproved): void
{
AccessLog::query()->create([
'user_id' => $user->id,
'server_resource_id' => null,
'bastion_account_id' => null,
'protocol' => 'oauth2',
'action' => 'oauth_authorization',
'requested_at' => now(),
'metadata' => [
'path' => $request->path(),
'method' => $request->method(),
'client_ip' => $request->ip(),
'oauth_client_id' => $client->id,
'oauth_client_name' => $client->name,
'oauth_client_identifier' => $client->client_id,
'redirect_uri' => (string) $request->input('redirect_uri', $request->query('redirect_uri', '')),
'auto_approved' => $autoApproved,
],
]);
}
}

View File

@ -3,8 +3,6 @@
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;
@ -15,8 +13,7 @@ use Illuminate\Support\Collection;
class OauthUserInfoController extends Controller
{
public function __construct(
private readonly OAuthTokenService $tokenService,
private readonly OAuthClientAuthService $clientAuthService
private readonly OAuthTokenService $tokenService
) {}
#[Apidoc\Title('UserInfo'), Apidoc\Method('GET'), Apidoc\Url('/oauth/userinfo')]
@ -32,36 +29,12 @@ class OauthUserInfoController extends Controller
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,
@ -71,7 +44,7 @@ class OauthUserInfoController extends Controller
];
$payload = ['sub' => $claims['sub']];
foreach ($finalFields as $fieldName) {
foreach ($clientFields as $fieldName) {
$field = (string) $fieldName;
if ($field === 'sub') {
continue;

View File

@ -4,6 +4,7 @@ return [
'issuer' => env('OAUTH_ISSUER', rtrim((string) env('APP_URL', 'http://localhost'), '/')),
'frontend_login_url' => env('OAUTH_FRONTEND_LOGIN_URL', rtrim((string) env('APP_URL', 'http://localhost'), '/').'/#/login'),
'frontend_consent_url' => env('OAUTH_FRONTEND_CONSENT_URL', rtrim((string) env('APP_URL', 'http://localhost'), '/').'/#/oauth-consent'),
'frontend_authorize_proxy_base' => env('OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE', '/api'),
'key_id' => env('OAUTH_KEY_ID', ''),
'private_key_path' => env('OAUTH_PRIVATE_KEY_PATH', storage_path('oauth/private.pem')),

View File

@ -111,7 +111,7 @@ class OauthProtocolTest extends TestCase
->assertJsonPath('keys.0.alg', 'RS256');
}
public function test_invalid_scope_is_returned_as_standard_oauth_redirect_error(): void
public function test_requested_scope_is_ignored_and_authorization_continues_with_server_config(): void
{
$resourceOwner = User::factory()->create();
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
@ -137,9 +137,8 @@ class OauthProtocolTest extends TestCase
$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'] ?? ''));
$this->assertStringContainsString('/#/oauth-consent', $location);
$this->assertStringContainsString('state=scope_state', $location);
}
public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void