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_ISSUER=${APP_URL}
|
||||||
OAUTH_FRONTEND_LOGIN_URL="http://localhost:5173/#/login"
|
OAUTH_FRONTEND_LOGIN_URL="http://localhost:5173/#/login"
|
||||||
OAUTH_FRONTEND_CONSENT_URL="http://localhost:5173/#/oauth-consent"
|
OAUTH_FRONTEND_CONSENT_URL="http://localhost:5173/#/oauth-consent"
|
||||||
|
OAUTH_FRONTEND_AUTHORIZE_PROXY_BASE=/api
|
||||||
OAUTH_KEY_ID=
|
OAUTH_KEY_ID=
|
||||||
OAUTH_PRIVATE_KEY_PATH=storage/oauth/private.pem
|
OAUTH_PRIVATE_KEY_PATH=storage/oauth/private.pem
|
||||||
OAUTH_PUBLIC_KEY_PATH=storage/oauth/public.pem
|
OAUTH_PUBLIC_KEY_PATH=storage/oauth/public.pem
|
||||||
|
|||||||
@ -3,9 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AccessLog;
|
||||||
use App\Models\OauthClient;
|
use App\Models\OauthClient;
|
||||||
use App\Models\OauthConsent;
|
use App\Models\OauthConsent;
|
||||||
use App\Models\OauthScope;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OAuth\OAuthClientAuthService;
|
use App\Services\OAuth\OAuthClientAuthService;
|
||||||
use App\Services\OAuth\OAuthTokenService;
|
use App\Services\OAuth\OAuthTokenService;
|
||||||
@ -31,7 +31,6 @@ class OauthAuthorizationController extends Controller
|
|||||||
'response_type' => ['required', 'in:code'],
|
'response_type' => ['required', 'in:code'],
|
||||||
'client_id' => ['required', 'string', 'max:80'],
|
'client_id' => ['required', 'string', 'max:80'],
|
||||||
'redirect_uri' => ['required', 'url:http,https'],
|
'redirect_uri' => ['required', 'url:http,https'],
|
||||||
'scope' => ['nullable', 'string', 'max:1000'],
|
|
||||||
'state' => ['nullable', 'string', 'max:1024'],
|
'state' => ['nullable', 'string', 'max:1024'],
|
||||||
'nonce' => ['nullable', 'string', 'max:255'],
|
'nonce' => ['nullable', 'string', 'max:255'],
|
||||||
'prompt' => ['nullable', 'string', 'max:100'],
|
'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.');
|
return $this->authorizationError($redirectUri, (string) $request->query('state', ''), 'invalid_request', 'Invalid redirect_uri.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedScopes = $this->clientAuthService->allowedScopeNames($client);
|
$serverScopes = $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);
|
$resourceOwner = $this->resolveResourceOwner($request);
|
||||||
if (! $resourceOwner) {
|
if (! $resourceOwner) {
|
||||||
return $this->redirectToFrontendLogin($request);
|
return $this->redirectToFrontendConsent($request, $client);
|
||||||
}
|
}
|
||||||
|
|
||||||
$scopeFingerprint = $this->tokenService->scopeFingerprint($requestedScopes);
|
$scopeFingerprint = $this->tokenService->scopeFingerprint($serverScopes);
|
||||||
$consent = OauthConsent::query()
|
$consent = OauthConsent::query()
|
||||||
->where('user_id', $resourceOwner->id)
|
->where('user_id', $resourceOwner->id)
|
||||||
->where('client_id', $client->id)
|
->where('client_id', $client->id)
|
||||||
@ -85,17 +77,19 @@ class OauthAuthorizationController extends Controller
|
|||||||
$consentRequired = $consent === null || $prompt === 'consent';
|
$consentRequired = $consent === null || $prompt === 'consent';
|
||||||
|
|
||||||
if ($consentRequired) {
|
if ($consentRequired) {
|
||||||
return $this->redirectToFrontendConsent($request, $client, $requestedScopes);
|
return $this->redirectToFrontendConsent($request, $client);
|
||||||
}
|
}
|
||||||
|
|
||||||
$code = $this->tokenService->issueAuthorizationCode(
|
$code = $this->tokenService->issueAuthorizationCode(
|
||||||
user: $resourceOwner,
|
user: $resourceOwner,
|
||||||
client: $client,
|
client: $client,
|
||||||
scopes: $requestedScopes,
|
scopes: $serverScopes,
|
||||||
redirectUri: $redirectUri,
|
redirectUri: $redirectUri,
|
||||||
nonce: $request->query('nonce') ? (string) $request->query('nonce') : null
|
nonce: $request->query('nonce') ? (string) $request->query('nonce') : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, true);
|
||||||
|
|
||||||
return $this->successRedirect($redirectUri, $code, (string) $request->query('state', ''));
|
return $this->successRedirect($redirectUri, $code, (string) $request->query('state', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +100,6 @@ class OauthAuthorizationController extends Controller
|
|||||||
'approve' => ['required', 'boolean'],
|
'approve' => ['required', 'boolean'],
|
||||||
'client_id' => ['required', 'string', 'max:80'],
|
'client_id' => ['required', 'string', 'max:80'],
|
||||||
'redirect_uri' => ['required', 'url:http,https'],
|
'redirect_uri' => ['required', 'url:http,https'],
|
||||||
'scope' => ['nullable', 'string', 'max:1000'],
|
|
||||||
'state' => ['nullable', 'string', 'max:1024'],
|
'state' => ['nullable', 'string', 'max:1024'],
|
||||||
'nonce' => ['nullable', 'string', 'max:255'],
|
'nonce' => ['nullable', 'string', 'max:255'],
|
||||||
]);
|
]);
|
||||||
@ -142,18 +135,11 @@ class OauthAuthorizationController extends Controller
|
|||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedScopes = $this->clientAuthService->allowedScopeNames($client);
|
$serverScopes = $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')) {
|
if (! $request->boolean('approve')) {
|
||||||
|
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false);
|
||||||
|
|
||||||
return $this->authorizationError(
|
return $this->authorizationError(
|
||||||
$redirectUri,
|
$redirectUri,
|
||||||
(string) $request->input('state', ''),
|
(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(
|
OauthConsent::query()->updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => $resourceOwner->id,
|
'user_id' => $resourceOwner->id,
|
||||||
@ -170,7 +156,7 @@ class OauthAuthorizationController extends Controller
|
|||||||
'scope_fingerprint' => $scopeFingerprint,
|
'scope_fingerprint' => $scopeFingerprint,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'scopes' => $requestedScopes,
|
'scopes' => $serverScopes,
|
||||||
'granted_at' => now(),
|
'granted_at' => now(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@ -178,52 +164,62 @@ class OauthAuthorizationController extends Controller
|
|||||||
$code = $this->tokenService->issueAuthorizationCode(
|
$code = $this->tokenService->issueAuthorizationCode(
|
||||||
user: $resourceOwner,
|
user: $resourceOwner,
|
||||||
client: $client,
|
client: $client,
|
||||||
scopes: $requestedScopes,
|
scopes: $serverScopes,
|
||||||
redirectUri: $redirectUri,
|
redirectUri: $redirectUri,
|
||||||
nonce: $request->input('nonce') ? (string) $request->input('nonce') : null
|
nonce: $request->input('nonce') ? (string) $request->input('nonce') : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->writeOAuthAuthorizationLog($resourceOwner, $request, $client, false);
|
||||||
|
|
||||||
return $this->successRedirect($redirectUri, $code, (string) $request->input('state', ''));
|
return $this->successRedirect($redirectUri, $code, (string) $request->input('state', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function redirectToFrontendConsent(Request $request, OauthClient $client): RedirectResponse
|
||||||
* @param array<int, string> $scopes
|
|
||||||
*/
|
|
||||||
private function redirectToFrontendConsent(Request $request, OauthClient $client, array $scopes): RedirectResponse
|
|
||||||
{
|
{
|
||||||
$frontendConsentUrl = (string) config('oauth.frontend_consent_url');
|
$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 = [
|
$query = [
|
||||||
'return_to' => $request->fullUrl(),
|
'return_to' => $this->buildFrontendAuthorizeReturnTo($request),
|
||||||
'client_id' => $client->client_id,
|
'client_id' => $client->client_id,
|
||||||
'client_name' => $client->name,
|
'client_name' => $client->name,
|
||||||
'client_logo' => $client->logo_url,
|
'client_logo' => $client->logo_url,
|
||||||
'scope' => $this->clientAuthService->buildScopeString($scopes),
|
|
||||||
'state' => (string) $request->query('state', ''),
|
'state' => (string) $request->query('state', ''),
|
||||||
'nonce' => (string) $request->query('nonce', ''),
|
'nonce' => (string) $request->query('nonce', ''),
|
||||||
'redirect_uri' => (string) $request->query('redirect_uri', ''),
|
'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));
|
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');
|
$base = trim((string) config('oauth.frontend_authorize_proxy_base', '/api'));
|
||||||
$query = ['return_to' => $request->fullUrl()];
|
$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
|
private function successRedirect(string $redirectUri, string $code, string $state): RedirectResponse
|
||||||
@ -301,4 +297,26 @@ class OauthAuthorizationController extends Controller
|
|||||||
|
|
||||||
return $user instanceof User ? $user : 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,6 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\OauthScope;
|
|
||||||
use App\Services\OAuth\OAuthClientAuthService;
|
|
||||||
use App\Services\OAuth\OAuthTokenService;
|
use App\Services\OAuth\OAuthTokenService;
|
||||||
use hg\apidoc\annotation as Apidoc;
|
use hg\apidoc\annotation as Apidoc;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@ -15,8 +13,7 @@ use Illuminate\Support\Collection;
|
|||||||
class OauthUserInfoController extends Controller
|
class OauthUserInfoController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OAuthTokenService $tokenService,
|
private readonly OAuthTokenService $tokenService
|
||||||
private readonly OAuthClientAuthService $clientAuthService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Apidoc\Title('UserInfo'), Apidoc\Method('GET'), Apidoc\Url('/oauth/userinfo')]
|
#[Apidoc\Title('UserInfo'), Apidoc\Method('GET'), Apidoc\Url('/oauth/userinfo')]
|
||||||
@ -32,36 +29,12 @@ class OauthUserInfoController extends Controller
|
|||||||
return $this->invalidTokenResponse('Invalid access token.');
|
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', []))
|
$clientFields = collect($accessToken->client->allowed_userinfo_fields ?? config('oauth.userinfo_fields', []))
|
||||||
->map(fn ($field): string => trim((string) $field))
|
->map(fn ($field): string => trim((string) $field))
|
||||||
->filter()
|
->filter()
|
||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$finalFields = $scopeFields
|
|
||||||
->intersect($clientFields)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$user = $accessToken->user;
|
$user = $accessToken->user;
|
||||||
$claims = [
|
$claims = [
|
||||||
'sub' => (string) $user->id,
|
'sub' => (string) $user->id,
|
||||||
@ -71,7 +44,7 @@ class OauthUserInfoController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
$payload = ['sub' => $claims['sub']];
|
$payload = ['sub' => $claims['sub']];
|
||||||
foreach ($finalFields as $fieldName) {
|
foreach ($clientFields as $fieldName) {
|
||||||
$field = (string) $fieldName;
|
$field = (string) $fieldName;
|
||||||
if ($field === 'sub') {
|
if ($field === 'sub') {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ return [
|
|||||||
'issuer' => env('OAUTH_ISSUER', rtrim((string) env('APP_URL', 'http://localhost'), '/')),
|
'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_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_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', ''),
|
'key_id' => env('OAUTH_KEY_ID', ''),
|
||||||
'private_key_path' => env('OAUTH_PRIVATE_KEY_PATH', storage_path('oauth/private.pem')),
|
'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');
|
->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();
|
$resourceOwner = User::factory()->create();
|
||||||
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
|
$accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner);
|
||||||
@ -137,9 +137,8 @@ class OauthProtocolTest extends TestCase
|
|||||||
$response->assertStatus(302);
|
$response->assertStatus(302);
|
||||||
|
|
||||||
$location = (string) $response->headers->get('Location');
|
$location = (string) $response->headers->get('Location');
|
||||||
$queryValues = $this->queryValuesFromUrl($location);
|
$this->assertStringContainsString('/#/oauth-consent', $location);
|
||||||
$this->assertSame('invalid_scope', (string) ($queryValues['error'] ?? ''));
|
$this->assertStringContainsString('state=scope_state', $location);
|
||||||
$this->assertSame('scope_state', (string) ($queryValues['state'] ?? ''));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void
|
public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user