feat(OAuth2): 完成Oauth2 BUG的修复
This commit is contained in:
parent
fd7fc0096d
commit
10dd477365
@ -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
|
||||
|
||||
@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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')),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user