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