diff --git a/.env.example b/.env.example index 3b645f7..42bf5bc 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,16 @@ BASTION_TOKEN_POLL_INTERVAL_MS=500 BASTION_TOKEN_TASK_TTL_SECONDS=1800 BASTION_TOKEN_SERVICE=https://myapp.cdu.edu.cn/index.html 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_KEY_ID= +OAUTH_PRIVATE_KEY_PATH=storage/oauth/private.pem +OAUTH_PUBLIC_KEY_PATH=storage/oauth/public.pem +OAUTH_OPENSSL_CONFIG_PATH= +OAUTH_AUTO_GENERATE_KEYS=true +OAUTH_AUTHORIZATION_CODE_TTL_SECONDS=300 +OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600 +OAUTH_REFRESH_TOKEN_TTL_SECONDS=1209600 +OAUTH_REDIRECT_URI_POLICY=same_domain diff --git a/.gitignore b/.gitignore index 3d491bc..163e85a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /public/hot /public/storage /storage/*.key +/storage/oauth /vendor .env .env.backup diff --git a/app/Console/Commands/UserManageCommand.php b/app/Console/Commands/UserManageCommand.php index 0051a61..d72c9a3 100644 --- a/app/Console/Commands/UserManageCommand.php +++ b/app/Console/Commands/UserManageCommand.php @@ -130,6 +130,10 @@ class UserManageCommand extends Command 'platform.accounts.manage', 'platform.logs.view', 'platform.logs.manage', + 'platform.oauth_clients.view', + 'platform.oauth_clients.manage', + 'platform.oauth_scopes.view', + 'platform.oauth_scopes.manage', 'resource.servers.use', ]; diff --git a/app/Exceptions/OAuthProtocolException.php b/app/Exceptions/OAuthProtocolException.php new file mode 100644 index 0000000..2e39052 --- /dev/null +++ b/app/Exceptions/OAuthProtocolException.php @@ -0,0 +1,34 @@ + + */ + public function toResponsePayload(): array + { + $payload = [ + 'error' => $this->oauthError, + 'error_description' => $this->oauthDescription, + ]; + + if ($this->oauthErrorUri !== null && $this->oauthErrorUri !== '') { + $payload['error_uri'] = $this->oauthErrorUri; + } + + return $payload; + } +} diff --git a/app/Http/Controllers/Api/OauthAuthorizationController.php b/app/Http/Controllers/Api/OauthAuthorizationController.php new file mode 100644 index 0000000..c7d191b --- /dev/null +++ b/app/Http/Controllers/Api/OauthAuthorizationController.php @@ -0,0 +1,304 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/OauthClientController.php b/app/Http/Controllers/Api/OauthClientController.php new file mode 100644 index 0000000..a739a32 --- /dev/null +++ b/app/Http/Controllers/Api/OauthClientController.php @@ -0,0 +1,204 @@ +middleware('auth:api'); + $this->middleware('permission:platform.oauth_clients.view,api')->only(['index', 'show']); + $this->middleware('permission:platform.oauth_clients.manage,api')->only(['store', 'update', 'destroy', 'resetSecret']); + } + + #[Apidoc\Title('OAuth 客户端列表'), Apidoc\Method('GET'), Apidoc\Url('/oauth/clients')] + public function index(Request $request): JsonResponse + { + $validated = $request->validate([ + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + ]); + $perPage = (int) ($validated['per_page'] ?? 20); + $paginator = OauthClient::query() + ->with('scopes') + ->latest() + ->paginate($perPage); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $paginator]); + } + + #[Apidoc\Title('创建 OAuth 客户端'), Apidoc\Method('POST'), Apidoc\Url('/oauth/clients')] + public function store(StoreOauthClientRequest $request): JsonResponse + { + $validated = $request->validated(); + $plainSecret = $this->generateRandomToken(40); + + $client = OauthClient::query()->create([ + 'name' => trim((string) $validated['name']), + 'logo_url' => $validated['logo_url'] ?? null, + 'client_id' => $this->generateClientId(), + 'client_secret_hash' => Hash::make($plainSecret), + 'redirect_uris' => $this->normalizeStringArray($validated['redirect_uris'] ?? []), + 'allowed_userinfo_fields' => $this->normalizeStringArray($validated['allowed_userinfo_fields'] ?? []), + 'userinfo_claim_remap' => $this->normalizeRemap($validated['userinfo_claim_remap'] ?? []), + 'is_confidential' => true, + 'is_active' => (bool) ($validated['is_active'] ?? true), + ]); + $client->scopes()->sync($this->resolveScopeIds($validated)); + + $this->auditLog($request, 'oauth_client_create', ['metadata' => ['oauth_client_id' => $client->id]]); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => [ + 'client' => $client->fresh('scopes'), + 'client_secret' => $plainSecret, + ], + ], 201); + } + + #[Apidoc\Title('OAuth 客户端详情'), Apidoc\Method('GET'), Apidoc\Url('/oauth/clients/{id}')] + public function show(int $id): JsonResponse + { + $client = OauthClient::query()->with('scopes')->findOrFail($id); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $client]); + } + + #[Apidoc\Title('更新 OAuth 客户端'), Apidoc\Method('PUT'), Apidoc\Url('/oauth/clients/{id}')] + public function update(UpdateOauthClientRequest $request, int $id): JsonResponse + { + $client = OauthClient::query()->findOrFail($id); + $validated = $request->validated(); + + $client->update([ + 'name' => trim((string) $validated['name']), + 'logo_url' => $validated['logo_url'] ?? null, + 'redirect_uris' => $this->normalizeStringArray($validated['redirect_uris'] ?? []), + 'allowed_userinfo_fields' => $this->normalizeStringArray($validated['allowed_userinfo_fields'] ?? []), + 'userinfo_claim_remap' => $this->normalizeRemap($validated['userinfo_claim_remap'] ?? []), + 'is_confidential' => true, + 'is_active' => (bool) ($validated['is_active'] ?? true), + ]); + $client->scopes()->sync($this->resolveScopeIds($validated, $client)); + + $this->auditLog($request, 'oauth_client_update', ['metadata' => ['oauth_client_id' => $client->id]]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $client->fresh('scopes')]); + } + + #[Apidoc\Title('删除 OAuth 客户端'), Apidoc\Method('DELETE'), Apidoc\Url('/oauth/clients/{id}')] + public function destroy(Request $request, int $id): JsonResponse + { + $client = OauthClient::query()->findOrFail($id); + $this->auditLog($request, 'oauth_client_delete', ['metadata' => ['oauth_client_id' => $client->id]]); + $client->delete(); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]); + } + + #[Apidoc\Title('重置 OAuth 客户端密钥'), Apidoc\Method('POST'), Apidoc\Url('/oauth/clients/{id}/reset-secret')] + public function resetSecret(Request $request, int $id): JsonResponse + { + $client = OauthClient::query()->findOrFail($id); + $plainSecret = $this->generateRandomToken(40); + $client->update([ + 'client_secret_hash' => Hash::make($plainSecret), + 'is_confidential' => true, + ]); + + $this->auditLog($request, 'oauth_client_reset_secret', ['metadata' => ['oauth_client_id' => $client->id]]); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => [ + 'client_id' => $client->client_id, + 'client_secret' => $plainSecret, + ], + ]); + } + + /** + * @param array $values + * @return array + */ + private function normalizeStringArray(array $values): array + { + return collect($values) + ->map(fn ($value): string => trim((string) $value)) + ->filter() + ->unique() + ->values() + ->all(); + } + + /** + * @param array $remap + * @return array + */ + private function normalizeRemap(array $remap): array + { + $result = []; + foreach ($remap as $source => $target) { + $from = trim((string) $source); + $to = trim((string) $target); + if ($from === '' || $to === '' || $from === 'sub') { + continue; + } + + $result[$from] = $to; + } + + return $result; + } + + private function generateClientId(): string + { + do { + $clientId = 'cli_'.strtolower($this->generateRandomToken(18)); + } while (OauthClient::query()->where('client_id', $clientId)->exists()); + + return $clientId; + } + + private function generateRandomToken(int $bytes): string + { + return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); + } + + /** + * @param array $validated + * @return array + */ + private function resolveScopeIds(array $validated, ?OauthClient $client = null): array + { + if (array_key_exists('scope_ids', $validated)) { + return collect($validated['scope_ids']) + ->map(fn ($scopeId): int => (int) $scopeId) + ->values() + ->all(); + } + + if ($client !== null) { + return $client->scopes()->pluck('oauth_scopes.id')->map(fn ($scopeId): int => (int) $scopeId)->all(); + } + + return OauthScope::query() + ->where('is_active', true) + ->pluck('id') + ->map(fn ($scopeId): int => (int) $scopeId) + ->all(); + } +} diff --git a/app/Http/Controllers/Api/OauthMetadataController.php b/app/Http/Controllers/Api/OauthMetadataController.php new file mode 100644 index 0000000..0e75d5f --- /dev/null +++ b/app/Http/Controllers/Api/OauthMetadataController.php @@ -0,0 +1,50 @@ +where('is_active', true) + ->orderBy('name') + ->pluck('name') + ->values() + ->all(); + + return response()->json([ + 'issuer' => (string) config('oauth.issuer'), + 'authorization_endpoint' => URL::to('/oauth/authorize'), + 'token_endpoint' => URL::to('/oauth/token'), + 'userinfo_endpoint' => URL::to('/oauth/userinfo'), + 'jwks_uri' => URL::to('/oauth/jwks'), + 'response_types_supported' => ['code'], + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'scopes_supported' => $scopes, + 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'claims_supported' => ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash', 'nickname', 'email', 'phone'], + ]); + } + + #[Apidoc\Title('JWKS'), Apidoc\Method('GET'), Apidoc\Url('/oauth/jwks')] + public function jwks(): JsonResponse + { + return response()->json($this->jwtService->jwks()); + } +} diff --git a/app/Http/Controllers/Api/OauthTokenController.php b/app/Http/Controllers/Api/OauthTokenController.php new file mode 100644 index 0000000..9982af7 --- /dev/null +++ b/app/Http/Controllers/Api/OauthTokenController.php @@ -0,0 +1,122 @@ +clientAuthService->authenticateConfidentialClient($request); + + $grantType = trim((string) $request->input('grant_type', '')); + $responseData = match ($grantType) { + 'authorization_code' => $this->handleAuthorizationCodeGrant($request, $client), + 'refresh_token' => $this->handleRefreshTokenGrant($request, $client), + default => throw new OAuthProtocolException('unsupported_grant_type', 'Unsupported grant_type.'), + }; + + return response() + ->json($responseData) + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + } catch (OAuthProtocolException $exception) { + $response = response() + ->json($exception->toResponsePayload(), $exception->httpStatus) + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + + if ($exception->oauthError === 'invalid_client') { + $response->header('WWW-Authenticate', 'Basic realm="OAuth"'); + } + + return $response; + } + } + + #[Apidoc\Title('OAuth Revoke'), Apidoc\Method('POST'), Apidoc\Url('/oauth/revoke')] + public function revoke(Request $request): JsonResponse + { + try { + $client = $this->clientAuthService->authenticateConfidentialClient($request); + $validator = Validator::make($request->all(), [ + 'token' => ['required', 'string'], + 'token_type_hint' => ['nullable', 'string'], + ]); + if ($validator->fails()) { + throw new OAuthProtocolException('invalid_request', (string) $validator->errors()->first()); + } + + $this->tokenService->revokeTokenForClient($client, trim((string) $request->input('token'))); + + return response()->json([], 200); + } catch (OAuthProtocolException $exception) { + $response = response()->json($exception->toResponsePayload(), $exception->httpStatus); + if ($exception->oauthError === 'invalid_client') { + $response->header('WWW-Authenticate', 'Basic realm="OAuth"'); + } + + return $response; + } + } + + /** + * @return array + */ + private function handleAuthorizationCodeGrant(Request $request, OauthClient $client): array + { + $validator = Validator::make($request->all(), [ + 'code' => ['required', 'string'], + 'redirect_uri' => ['required', 'url:http,https'], + ]); + if ($validator->fails()) { + throw new OAuthProtocolException('invalid_request', (string) $validator->errors()->first()); + } + + $redirectUri = (string) $request->input('redirect_uri'); + if (! $this->clientAuthService->isRedirectUriAllowed($client, $redirectUri)) { + throw new OAuthProtocolException('invalid_grant', 'redirect_uri mismatch.'); + } + + return $this->tokenService->exchangeAuthorizationCode( + client: $client, + code: trim((string) $request->input('code')), + redirectUri: $redirectUri + ); + } + + /** + * @return array + */ + private function handleRefreshTokenGrant(Request $request, OauthClient $client): array + { + $validator = Validator::make($request->all(), [ + 'refresh_token' => ['required', 'string'], + ]); + if ($validator->fails()) { + throw new OAuthProtocolException('invalid_request', (string) $validator->errors()->first()); + } + + return $this->tokenService->exchangeRefreshToken( + client: $client, + refreshToken: trim((string) $request->input('refresh_token')) + ); + } +} diff --git a/app/Http/Controllers/Api/OauthUserInfoController.php b/app/Http/Controllers/Api/OauthUserInfoController.php new file mode 100644 index 0000000..b9c0721 --- /dev/null +++ b/app/Http/Controllers/Api/OauthUserInfoController.php @@ -0,0 +1,124 @@ +bearerToken() ?: $request->query('access_token', ''))); + if ($rawToken === '') { + return $this->invalidTokenResponse('Missing access token.'); + } + + $accessToken = $this->tokenService->validateAccessToken($rawToken); + if (! $accessToken || ! $accessToken->user || ! $accessToken->client) { + 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, + 'nickname' => (string) ($user->nickname ?? ''), + 'email' => (string) ($user->email ?? ''), + 'phone' => (string) ($user->phone ?? ''), + ]; + + $payload = ['sub' => $claims['sub']]; + foreach ($finalFields as $fieldName) { + $field = (string) $fieldName; + if ($field === 'sub') { + continue; + } + if (array_key_exists($field, $claims) && $claims[$field] !== '') { + $payload[$field] = $claims[$field]; + } + } + + $payload = $this->applyClientClaimRemap($payload, collect($accessToken->client->userinfo_claim_remap ?? [])); + + return response() + ->json($payload) + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + } + + /** + * @param array $payload + * @param Collection $remapRules + * @return array + */ + private function applyClientClaimRemap(array $payload, Collection $remapRules): array + { + foreach ($remapRules as $source => $target) { + $from = trim((string) $source); + $to = trim((string) $target); + if ($from === '' || $to === '' || $from === 'sub' || ! array_key_exists($from, $payload)) { + continue; + } + + $payload[$to] = $payload[$from]; + unset($payload[$from]); + } + + return $payload; + } + + private function invalidTokenResponse(string $description): JsonResponse + { + return response() + ->json([ + 'error' => 'invalid_token', + 'error_description' => $description, + ], 401) + ->header('WWW-Authenticate', 'Bearer error="invalid_token"') + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + } +} diff --git a/app/Http/Controllers/Api/PermissionController.php b/app/Http/Controllers/Api/PermissionController.php index d062746..c3ce5a7 100644 --- a/app/Http/Controllers/Api/PermissionController.php +++ b/app/Http/Controllers/Api/PermissionController.php @@ -139,6 +139,10 @@ class PermissionController extends Controller 'platform.accounts.manage' => ['category' => '堡垒机账号', 'description' => '维护堡垒机授权账号与刷新令牌'], 'platform.logs.view' => ['category' => '日志审计', 'description' => '查看访问与操作日志'], 'platform.logs.manage' => ['category' => '日志审计', 'description' => '新增或维护日志数据'], + 'platform.oauth_clients.view' => ['category' => 'OAuth 管理', 'description' => '查看 OAuth 客户端配置'], + 'platform.oauth_clients.manage' => ['category' => 'OAuth 管理', 'description' => '维护 OAuth 客户端配置与密钥'], + 'platform.oauth_scopes.view' => ['category' => 'OAuth 管理', 'description' => '查看 OAuth Scope 配置'], + 'platform.oauth_scopes.manage' => ['category' => 'OAuth 管理', 'description' => '维护 OAuth Scope 配置'], 'resource.servers.use' => ['category' => '资源使用', 'description' => '发起服务器资源访问与连接操作'], ]; diff --git a/app/Http/Requests/StoreOauthClientRequest.php b/app/Http/Requests/StoreOauthClientRequest.php new file mode 100644 index 0000000..03d2e2a --- /dev/null +++ b/app/Http/Requests/StoreOauthClientRequest.php @@ -0,0 +1,30 @@ + ['required', 'string', 'max:255'], + 'logo_url' => ['nullable', 'url:http,https', 'max:255'], + 'redirect_uris' => ['required', 'array', 'min:1'], + 'redirect_uris.*' => ['required', 'url:http,https', 'max:500'], + 'scope_ids' => ['sometimes', 'array', 'min:1'], + 'scope_ids.*' => ['integer', 'exists:oauth_scopes,id'], + 'allowed_userinfo_fields' => ['required', 'array', 'min:1'], + 'allowed_userinfo_fields.*' => ['string', 'max:60'], + 'userinfo_claim_remap' => ['nullable', 'array'], + 'userinfo_claim_remap.*' => ['string', 'max:60'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateOauthClientRequest.php b/app/Http/Requests/UpdateOauthClientRequest.php new file mode 100644 index 0000000..7041011 --- /dev/null +++ b/app/Http/Requests/UpdateOauthClientRequest.php @@ -0,0 +1,30 @@ + ['required', 'string', 'max:255'], + 'logo_url' => ['nullable', 'url:http,https', 'max:255'], + 'redirect_uris' => ['required', 'array', 'min:1'], + 'redirect_uris.*' => ['required', 'url:http,https', 'max:500'], + 'scope_ids' => ['sometimes', 'array', 'min:1'], + 'scope_ids.*' => ['integer', 'exists:oauth_scopes,id'], + 'allowed_userinfo_fields' => ['required', 'array', 'min:1'], + 'allowed_userinfo_fields.*' => ['string', 'max:60'], + 'userinfo_claim_remap' => ['nullable', 'array'], + 'userinfo_claim_remap.*' => ['string', 'max:60'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Models/OauthAccessToken.php b/app/Models/OauthAccessToken.php new file mode 100644 index 0000000..70809e6 --- /dev/null +++ b/app/Models/OauthAccessToken.php @@ -0,0 +1,42 @@ +belongsTo(OauthAuthorization::class, 'authorization_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'client_id'); + } + + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } +} diff --git a/app/Models/OauthAuthorization.php b/app/Models/OauthAuthorization.php new file mode 100644 index 0000000..790c379 --- /dev/null +++ b/app/Models/OauthAuthorization.php @@ -0,0 +1,50 @@ +belongsTo(User::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'client_id'); + } + + public function codes(): HasMany + { + return $this->hasMany(OauthAuthorizationCode::class, 'authorization_id'); + } + + public function accessTokens(): HasMany + { + return $this->hasMany(OauthAccessToken::class, 'authorization_id'); + } + + public function refreshTokens(): HasMany + { + return $this->hasMany(OauthRefreshToken::class, 'authorization_id'); + } + + protected function casts(): array + { + return [ + 'revoked_at' => 'datetime', + ]; + } +} diff --git a/app/Models/OauthAuthorizationCode.php b/app/Models/OauthAuthorizationCode.php new file mode 100644 index 0000000..d1e6a58 --- /dev/null +++ b/app/Models/OauthAuthorizationCode.php @@ -0,0 +1,46 @@ +belongsTo(OauthAuthorization::class, 'authorization_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'client_id'); + } + + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + 'consumed_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 0000000..96edc7f --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'logo_url', + 'client_id', + 'client_secret_hash', + 'redirect_uris', + 'allowed_userinfo_fields', + 'userinfo_claim_remap', + 'is_confidential', + 'is_active', + ]; + + public function scopes(): BelongsToMany + { + return $this->belongsToMany(OauthScope::class, 'oauth_client_scope', 'client_id', 'scope_id') + ->withTimestamps(); + } + + public function consents(): HasMany + { + return $this->hasMany(OauthConsent::class, 'client_id'); + } + + public function authorizations(): HasMany + { + return $this->hasMany(OauthAuthorization::class, 'client_id'); + } + + protected function casts(): array + { + return [ + 'redirect_uris' => 'array', + 'allowed_userinfo_fields' => 'array', + 'userinfo_claim_remap' => 'array', + 'is_confidential' => 'boolean', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Models/OauthConsent.php b/app/Models/OauthConsent.php new file mode 100644 index 0000000..98a7618 --- /dev/null +++ b/app/Models/OauthConsent.php @@ -0,0 +1,35 @@ +belongsTo(User::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'client_id'); + } + + protected function casts(): array + { + return [ + 'scopes' => 'array', + 'granted_at' => 'datetime', + ]; + } +} diff --git a/app/Models/OauthRefreshToken.php b/app/Models/OauthRefreshToken.php new file mode 100644 index 0000000..c10cfe7 --- /dev/null +++ b/app/Models/OauthRefreshToken.php @@ -0,0 +1,58 @@ +belongsTo(OauthAuthorization::class, 'authorization_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'client_id'); + } + + public function previousToken(): BelongsTo + { + return $this->belongsTo(OauthRefreshToken::class, 'previous_refresh_token_id'); + } + + public function replacedByToken(): BelongsTo + { + return $this->belongsTo(OauthRefreshToken::class, 'replaced_by_refresh_token_id'); + } + + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + 'used_at' => 'datetime', + 'replayed_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } +} diff --git a/app/Models/OauthScope.php b/app/Models/OauthScope.php new file mode 100644 index 0000000..43d09f6 --- /dev/null +++ b/app/Models/OauthScope.php @@ -0,0 +1,36 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'display_name', + 'description', + 'claims', + 'is_active', + ]; + + public function clients(): BelongsToMany + { + return $this->belongsToMany(OauthClient::class, 'oauth_client_scope', 'scope_id', 'client_id') + ->withTimestamps(); + } + + protected function casts(): array + { + return [ + 'claims' => 'array', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Services/OAuth/OAuthClientAuthService.php b/app/Services/OAuth/OAuthClientAuthService.php new file mode 100644 index 0000000..468a248 --- /dev/null +++ b/app/Services/OAuth/OAuthClientAuthService.php @@ -0,0 +1,173 @@ +extractClientCredentials($request); + if ($clientId === '') { + throw new OAuthProtocolException('invalid_client', 'Missing client_id.', 401); + } + if ($clientSecret === '') { + throw new OAuthProtocolException('invalid_client', 'Missing client_secret.', 401); + } + + $client = OauthClient::query() + ->with(['scopes' => function ($query) { + $query->where('is_active', true); + }]) + ->where('client_id', $clientId) + ->first(); + if (! $client || ! $client->is_active) { + throw new OAuthProtocolException('invalid_client', 'Client authentication failed.', 401); + } + + if (! $client->is_confidential) { + throw new OAuthProtocolException('unauthorized_client', 'Only confidential clients are supported.', 400); + } + + $secretHash = (string) ($client->client_secret_hash ?? ''); + if ($secretHash === '' || ! Hash::check($clientSecret, $secretHash)) { + throw new OAuthProtocolException('invalid_client', 'Client authentication failed.', 401); + } + + return $client; + } + + /** + * @return array{0:string,1:string} + */ + public function extractClientCredentials(Request $request): array + { + $header = (string) $request->header('Authorization', ''); + if (str_starts_with(strtolower($header), 'basic ')) { + $encoded = trim(substr($header, 6)); + $decoded = base64_decode($encoded, true); + if ($decoded !== false && str_contains($decoded, ':')) { + [$id, $secret] = explode(':', $decoded, 2); + + return [trim($id), $secret]; + } + } + + return [ + trim((string) $request->input('client_id', '')), + (string) $request->input('client_secret', ''), + ]; + } + + public function findActiveClientById(string $clientId): ?OauthClient + { + if ($clientId === '') { + return null; + } + + return OauthClient::query() + ->with(['scopes' => function ($query) { + $query->where('is_active', true); + }]) + ->where('client_id', $clientId) + ->where('is_active', true) + ->first(); + } + + /** + * @return array + */ + public function allowedScopeNames(OauthClient $client): array + { + return $client->scopes + ->pluck('name') + ->map(fn ($scope): string => trim((string) $scope)) + ->filter() + ->values() + ->all(); + } + + /** + * @param array $requestedScopes + * @param array $allowedScopes + */ + public function isScopeSubset(array $requestedScopes, array $allowedScopes): bool + { + $allowed = collect($allowedScopes)->map(fn (string $scope): string => trim($scope))->filter()->all(); + foreach ($requestedScopes as $scope) { + if (! in_array($scope, $allowed, true)) { + return false; + } + } + + return true; + } + + public function isRedirectUriAllowed(OauthClient $client, string $redirectUri): bool + { + if ($redirectUri === '') { + return false; + } + + $requested = parse_url($redirectUri); + $requestedHost = strtolower((string) ($requested['host'] ?? '')); + if ($requestedHost === '') { + return false; + } + + $registeredUris = collect($client->redirect_uris ?? []) + ->map(fn ($uri): string => trim((string) $uri)) + ->filter() + ->values(); + if ($registeredUris->isEmpty()) { + return false; + } + + $policy = (string) config('oauth.redirect_uri_policy', 'same_domain'); + if ($policy === 'same_domain') { + return $registeredUris->contains(function (string $uri) use ($requestedHost): bool { + $parsed = parse_url($uri); + $host = strtolower((string) ($parsed['host'] ?? '')); + + return $host !== '' && $host === $requestedHost; + }); + } + + return $registeredUris->contains($redirectUri); + } + + /** + * @param array $scopes + */ + public function buildScopeString(array $scopes): string + { + return collect($scopes) + ->map(fn (string $scope): string => trim($scope)) + ->filter() + ->unique() + ->values() + ->implode(' '); + } + + /** + * @return array + */ + public function parseScopes(string $scopeValue): array + { + if (trim($scopeValue) === '') { + return []; + } + + return Collection::make(preg_split('/\s+/', trim($scopeValue)) ?: []) + ->map(fn ($scope): string => trim((string) $scope)) + ->filter() + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Services/OAuth/OAuthJwtService.php b/app/Services/OAuth/OAuthJwtService.php new file mode 100644 index 0000000..0c83d87 --- /dev/null +++ b/app/Services/OAuth/OAuthJwtService.php @@ -0,0 +1,318 @@ +ensureKeyPair(); + + $header = [ + 'alg' => 'RS256', + 'typ' => 'JWT', + 'kid' => $this->currentKeyId(), + ]; + + $encodedHeader = $this->base64UrlEncode((string) json_encode($header, JSON_UNESCAPED_SLASHES)); + $encodedPayload = $this->base64UrlEncode((string) json_encode($payload, JSON_UNESCAPED_SLASHES)); + $signingInput = $encodedHeader.'.'.$encodedPayload; + + $signature = ''; + $privateKey = openssl_pkey_get_private($this->readPrivateKey()); + if ($privateKey === false) { + throw new RuntimeException('OAuth private key 无效。'); + } + + $signed = openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (! $signed) { + throw new RuntimeException('OAuth JWT 签名失败。'); + } + + return $signingInput.'.'.$this->base64UrlEncode($signature); + } + + public function verify(string $jwt): ?array + { + $parts = explode('.', $jwt); + if (count($parts) !== 3) { + return null; + } + + [$encodedHeader, $encodedPayload, $encodedSignature] = $parts; + $header = json_decode($this->base64UrlDecode($encodedHeader), true); + $payload = json_decode($this->base64UrlDecode($encodedPayload), true); + + if (! is_array($header) || ! is_array($payload)) { + return null; + } + + if (($header['alg'] ?? '') !== 'RS256') { + return null; + } + + $publicKey = openssl_pkey_get_public($this->readPublicKey()); + if ($publicKey === false) { + return null; + } + + $verifyResult = openssl_verify( + $encodedHeader.'.'.$encodedPayload, + $this->base64UrlDecode($encodedSignature), + $publicKey, + OPENSSL_ALGO_SHA256 + ); + + if ($verifyResult !== 1) { + return null; + } + + $now = time(); + $exp = (int) ($payload['exp'] ?? 0); + if ($exp <= 0 || $exp <= $now) { + return null; + } + + $nbf = (int) ($payload['nbf'] ?? 0); + if ($nbf > 0 && $nbf > $now) { + return null; + } + + return $payload; + } + + public function jwks(): array + { + $this->ensureKeyPair(); + $publicKey = openssl_pkey_get_public($this->readPublicKey()); + if ($publicKey === false) { + throw new RuntimeException('OAuth public key 无效。'); + } + + $details = openssl_pkey_get_details($publicKey); + if (! is_array($details) || ! isset($details['rsa']['n'], $details['rsa']['e'])) { + throw new RuntimeException('OAuth public key 不是 RSA。'); + } + + $n = $this->base64UrlEncode($details['rsa']['n']); + $e = $this->base64UrlEncode($details['rsa']['e']); + + return [ + 'keys' => [[ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'use' => 'sig', + 'kid' => $this->currentKeyId($n, $e), + 'n' => $n, + 'e' => $e, + ]], + ]; + } + + public function atHash(string $accessToken): string + { + $digest = hash('sha256', $accessToken, true); + + return $this->base64UrlEncode(substr($digest, 0, (int) (strlen($digest) / 2))); + } + + private function currentKeyId(?string $n = null, ?string $e = null): string + { + $configured = trim((string) config('oauth.key_id', '')); + if ($configured !== '') { + return $configured; + } + + if ($n === null || $e === null) { + $jwks = $this->jwks(); + $n = (string) ($jwks['keys'][0]['n'] ?? ''); + $e = (string) ($jwks['keys'][0]['e'] ?? ''); + } + + $canonical = json_encode(['e' => $e, 'kty' => 'RSA', 'n' => $n], JSON_UNESCAPED_SLASHES); + if (! is_string($canonical)) { + throw new RuntimeException('OAuth kid 生成失败。'); + } + + return $this->base64UrlEncode(hash('sha256', $canonical, true)); + } + + private function readPrivateKey(): string + { + $path = (string) config('oauth.private_key_path'); + $content = @file_get_contents($path); + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OAuth private key 不存在。'); + } + + return $content; + } + + private function readPublicKey(): string + { + $path = (string) config('oauth.public_key_path'); + $content = @file_get_contents($path); + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OAuth public key 不存在。'); + } + + return $content; + } + + private function ensureKeyPair(): void + { + $privatePath = (string) config('oauth.private_key_path'); + $publicPath = (string) config('oauth.public_key_path'); + if (is_file($privatePath) && is_file($publicPath)) { + return; + } + + if (! (bool) config('oauth.auto_generate_keys', true)) { + throw new RuntimeException('OAuth 密钥未配置,请设置 OAUTH_PRIVATE_KEY_PATH 与 OAUTH_PUBLIC_KEY_PATH。'); + } + + $directory = dirname($privatePath); + if (! is_dir($directory)) { + @mkdir($directory, 0775, true); + } + + $publicDirectory = dirname($publicPath); + if (! is_dir($publicDirectory)) { + @mkdir($publicDirectory, 0775, true); + } + + $this->clearOpenSslErrors(); + $configPath = $this->resolveOpenSslConfigPath(); + $options = [ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + if ($configPath !== null) { + $options['config'] = $configPath; + @putenv('OPENSSL_CONF='.$configPath); + } + + $resource = openssl_pkey_new($options); + + if ($resource === false) { + throw new RuntimeException($this->buildOpenSslFailureMessage('OAuth 密钥生成失败。', $configPath)); + } + + $privateKey = ''; + $exported = openssl_pkey_export($resource, $privateKey, null, $configPath ? ['config' => $configPath] : []); + $details = openssl_pkey_get_details($resource); + $publicKey = is_array($details) ? (string) ($details['key'] ?? '') : ''; + + if (! $exported || trim($privateKey) === '' || trim($publicKey) === '') { + throw new RuntimeException($this->buildOpenSslFailureMessage('OAuth 密钥导出失败。', $configPath)); + } + + $privateWritten = @file_put_contents($privatePath, $privateKey); + $publicWritten = @file_put_contents($publicPath, $publicKey); + + if ($privateWritten === false || $publicWritten === false) { + throw new RuntimeException('OAuth 密钥写入失败,请检查目录权限与路径配置。'); + } + } + + private function resolveOpenSslConfigPath(): ?string + { + $configured = trim((string) config('oauth.openssl_config_path', '')); + $envConfigured = trim((string) getenv('OPENSSL_CONF')); + $phpBinaryDirectory = dirname((string) PHP_BINARY); + + $candidates = [ + $configured, + $envConfigured, + $phpBinaryDirectory.DIRECTORY_SEPARATOR.'extras'.DIRECTORY_SEPARATOR.'ssl'.DIRECTORY_SEPARATOR.'openssl.cnf', + dirname($phpBinaryDirectory).DIRECTORY_SEPARATOR.'ssl'.DIRECTORY_SEPARATOR.'openssl.cnf', + 'C:\\Program Files\\Common Files\\SSL\\openssl.cnf', + '/etc/ssl/openssl.cnf', + '/usr/lib/ssl/openssl.cnf', + '/usr/local/ssl/openssl.cnf', + ]; + + foreach (array_values(array_unique(array_filter($candidates))) as $candidate) { + $path = trim((string) $candidate); + if ($path === '') { + continue; + } + + if (! $this->isAbsolutePath($path)) { + $path = base_path($path); + } + + if (is_file($path)) { + return realpath($path) ?: $path; + } + } + + return null; + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if (DIRECTORY_SEPARATOR === '\\') { + return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 || str_starts_with($path, '\\\\'); + } + + return str_starts_with($path, '/'); + } + + private function buildOpenSslFailureMessage(string $prefix, ?string $configPath): string + { + $details = $this->collectOpenSslErrors(); + $hint = $configPath === null + ? '请配置 OAUTH_OPENSSL_CONFIG_PATH 或手动提供密钥文件。' + : '请检查 OpenSSL 配置文件是否可读:'.$configPath; + + if ($details === []) { + return $prefix.' '.$hint; + } + + return $prefix.' '.implode(' | ', array_slice($details, 0, 3)).' '.$hint; + } + + /** + * @return array + */ + private function collectOpenSslErrors(): array + { + $errors = []; + while (($error = openssl_error_string()) !== false) { + $errors[] = trim((string) $error); + } + + return array_values(array_unique(array_filter($errors))); + } + + private function clearOpenSslErrors(): void + { + while (openssl_error_string() !== false) { + // drain openssl internal error queue + } + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): string + { + $padding = strlen($value) % 4; + if ($padding > 0) { + $value .= str_repeat('=', 4 - $padding); + } + + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + + return $decoded === false ? '' : $decoded; + } +} diff --git a/app/Services/OAuth/OAuthTokenService.php b/app/Services/OAuth/OAuthTokenService.php new file mode 100644 index 0000000..a61e659 --- /dev/null +++ b/app/Services/OAuth/OAuthTokenService.php @@ -0,0 +1,360 @@ + $scopes + */ + public function issueAuthorizationCode( + User $user, + OauthClient $client, + array $scopes, + string $redirectUri, + ?string $nonce = null + ): string { + $scope = $this->clientAuthService->buildScopeString($scopes); + $scopeFingerprint = $this->scopeFingerprint($scopes); + + $authorization = OauthAuthorization::query()->create([ + 'user_id' => $user->id, + 'client_id' => $client->id, + 'scope' => $scope, + 'scope_fingerprint' => $scopeFingerprint, + 'revoked_at' => null, + ]); + + $rawCode = $this->randomToken(48); + OauthAuthorizationCode::query()->create([ + 'code_hash' => hash('sha256', $rawCode), + 'authorization_id' => $authorization->id, + 'user_id' => $user->id, + 'client_id' => $client->id, + 'redirect_uri' => $redirectUri, + 'scope' => $scope, + 'nonce' => $nonce, + 'expires_at' => now()->addSeconds((int) config('oauth.authorization_code_ttl_seconds', 300)), + 'consumed_at' => null, + 'revoked_at' => null, + ]); + + return $rawCode; + } + + /** + * @return array + */ + public function exchangeAuthorizationCode(OauthClient $client, string $code, string $redirectUri): array + { + return DB::transaction(function () use ($client, $code, $redirectUri): array { + $codeRecord = OauthAuthorizationCode::query() + ->where('code_hash', hash('sha256', $code)) + ->lockForUpdate() + ->first(); + + if (! $codeRecord) { + throw new OAuthProtocolException('invalid_grant', 'Invalid authorization code.'); + } + + if ((int) $codeRecord->client_id !== (int) $client->id) { + throw new OAuthProtocolException('invalid_grant', 'Authorization code does not belong to client.'); + } + + if ((string) $codeRecord->redirect_uri !== $redirectUri) { + throw new OAuthProtocolException('invalid_grant', 'redirect_uri mismatch.'); + } + + if ($codeRecord->revoked_at || $codeRecord->consumed_at || $codeRecord->expires_at->isPast()) { + throw new OAuthProtocolException('invalid_grant', 'Authorization code has expired or been used.'); + } + + $authorization = OauthAuthorization::query() + ->lockForUpdate() + ->find($codeRecord->authorization_id); + if (! $authorization || $authorization->revoked_at) { + throw new OAuthProtocolException('invalid_grant', 'Authorization is no longer valid.'); + } + + $codeRecord->consumed_at = now(); + $codeRecord->save(); + + return $this->issueTokenSet( + $authorization, + $client, + (string) $codeRecord->scope, + $codeRecord->nonce, + createRefreshToken: true + ); + }); + } + + /** + * @return array + */ + public function exchangeRefreshToken(OauthClient $client, string $refreshToken): array + { + return DB::transaction(function () use ($client, $refreshToken): array { + $tokenHash = hash('sha256', $refreshToken); + $refreshRecord = OauthRefreshToken::query() + ->where('token_hash', $tokenHash) + ->where('client_id', $client->id) + ->lockForUpdate() + ->first(); + + if (! $refreshRecord) { + throw new OAuthProtocolException('invalid_grant', 'Invalid refresh token.'); + } + + $authorization = OauthAuthorization::query() + ->lockForUpdate() + ->find($refreshRecord->authorization_id); + if (! $authorization || $authorization->revoked_at) { + throw new OAuthProtocolException('invalid_grant', 'Authorization is no longer valid.'); + } + + if ($refreshRecord->revoked_at || $refreshRecord->expires_at->isPast()) { + throw new OAuthProtocolException('invalid_grant', 'Refresh token expired or revoked.'); + } + + if ($refreshRecord->used_at !== null || $refreshRecord->replaced_by_refresh_token_id !== null) { + $refreshRecord->replayed_at = $refreshRecord->replayed_at ?? now(); + $refreshRecord->save(); + $this->revokeAuthorizationChain((int) $authorization->id); + + throw new OAuthProtocolException('invalid_grant', 'Refresh token reuse detected.'); + } + + $refreshRecord->used_at = now(); + $refreshRecord->save(); + + $tokenSet = $this->issueTokenSet( + $authorization, + $client, + (string) $authorization->scope, + nonce: null, + createRefreshToken: true, + previousRefreshTokenId: (int) $refreshRecord->id + ); + + if (! isset($tokenSet['_refresh_record_id'])) { + throw new OAuthProtocolException('server_error', 'Failed to rotate refresh token.', 500); + } + + $refreshRecord->replaced_by_refresh_token_id = (int) $tokenSet['_refresh_record_id']; + $refreshRecord->save(); + unset($tokenSet['_refresh_record_id']); + + return $tokenSet; + }); + } + + public function revokeTokenForClient(OauthClient $client, string $token): void + { + if ($token === '') { + return; + } + + $tokenHash = hash('sha256', $token); + $refreshRecord = OauthRefreshToken::query() + ->where('token_hash', $tokenHash) + ->where('client_id', $client->id) + ->first(); + if ($refreshRecord) { + $this->revokeAuthorizationChain((int) $refreshRecord->authorization_id); + + return; + } + + $payload = $this->jwtService->verify($token); + $jti = is_array($payload) ? (string) ($payload['jti'] ?? '') : ''; + if ($jti === '') { + return; + } + + $accessToken = OauthAccessToken::query() + ->where('jti', $jti) + ->where('client_id', $client->id) + ->first(); + if ($accessToken && ! $accessToken->revoked_at) { + $accessToken->revoked_at = now(); + $accessToken->save(); + } + } + + public function validateAccessToken(string $token): ?OauthAccessToken + { + $payload = $this->jwtService->verify($token); + if (! is_array($payload)) { + return null; + } + + $jti = (string) ($payload['jti'] ?? ''); + if ($jti === '') { + return null; + } + + $accessToken = OauthAccessToken::query() + ->with(['authorization', 'user', 'client']) + ->where('jti', $jti) + ->first(); + if (! $accessToken || $accessToken->revoked_at || $accessToken->expires_at->isPast()) { + return null; + } + + if (! $accessToken->authorization || $accessToken->authorization->revoked_at) { + return null; + } + + return $accessToken; + } + + /** + * @param array $scopes + */ + public function scopeFingerprint(array $scopes): string + { + $normalized = collect($scopes) + ->map(fn (string $scope): string => trim($scope)) + ->filter() + ->unique() + ->sort() + ->values() + ->implode('|'); + + return hash('sha256', $normalized); + } + + public function revokeAuthorizationChain(int $authorizationId): void + { + OauthAuthorization::query() + ->where('id', $authorizationId) + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + + OauthAuthorizationCode::query() + ->where('authorization_id', $authorizationId) + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + + OauthAccessToken::query() + ->where('authorization_id', $authorizationId) + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + + OauthRefreshToken::query() + ->where('authorization_id', $authorizationId) + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + } + + /** + * @return array + */ + private function issueTokenSet( + OauthAuthorization $authorization, + OauthClient $client, + string $scope, + ?string $nonce, + bool $createRefreshToken = true, + ?int $previousRefreshTokenId = null + ): array { + $now = now(); + $accessTokenTtl = (int) config('oauth.access_token_ttl_seconds', 3600); + $refreshTokenTtl = (int) config('oauth.refresh_token_ttl_seconds', 1209600); + $scopeList = $this->clientAuthService->parseScopes($scope); + $scopeString = $this->clientAuthService->buildScopeString($scopeList); + $subject = (string) $authorization->user_id; + + $accessJti = (string) Str::uuid(); + $accessPayload = [ + 'iss' => (string) config('oauth.issuer'), + 'sub' => $subject, + 'aud' => $client->client_id, + 'exp' => $now->copy()->addSeconds($accessTokenTtl)->timestamp, + 'iat' => $now->timestamp, + 'jti' => $accessJti, + 'scope' => $scopeString, + 'client_id' => $client->client_id, + ]; + $accessToken = $this->jwtService->sign($accessPayload); + + OauthAccessToken::query()->create([ + 'jti' => $accessJti, + 'authorization_id' => $authorization->id, + 'user_id' => $authorization->user_id, + 'client_id' => $authorization->client_id, + 'scope' => $scopeString, + 'expires_at' => Carbon::createFromTimestamp($accessPayload['exp']), + 'revoked_at' => null, + ]); + + $result = [ + 'access_token' => $accessToken, + 'token_type' => 'Bearer', + 'expires_in' => $accessTokenTtl, + 'scope' => $scopeString, + ]; + + if ($createRefreshToken) { + $rawRefreshToken = $this->randomToken(64); + $refreshJti = (string) Str::uuid(); + $refreshRecord = OauthRefreshToken::query()->create([ + 'jti' => $refreshJti, + 'token_hash' => hash('sha256', $rawRefreshToken), + 'authorization_id' => $authorization->id, + 'user_id' => $authorization->user_id, + 'client_id' => $authorization->client_id, + 'previous_refresh_token_id' => $previousRefreshTokenId, + 'replaced_by_refresh_token_id' => null, + 'expires_at' => now()->addSeconds($refreshTokenTtl), + 'used_at' => null, + 'replayed_at' => null, + 'revoked_at' => null, + ]); + + $result['refresh_token'] = $rawRefreshToken; + $result['_refresh_record_id'] = $refreshRecord->id; + } + + if (in_array('openid', $scopeList, true)) { + $idTokenPayload = [ + 'iss' => (string) config('oauth.issuer'), + 'sub' => $subject, + 'aud' => $client->client_id, + 'exp' => $now->copy()->addSeconds($accessTokenTtl)->timestamp, + 'iat' => $now->timestamp, + 'auth_time' => $authorization->created_at?->timestamp ?? $now->timestamp, + ]; + + if ($nonce !== null && trim($nonce) !== '') { + $idTokenPayload['nonce'] = trim($nonce); + } + $idTokenPayload['at_hash'] = $this->jwtService->atHash($accessToken); + $result['id_token'] = $this->jwtService->sign($idTokenPayload); + } + + return $result; + } + + private function randomToken(int $bytes = 48): string + { + return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6320ef2..c6f97eb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ PermissionMiddleware::class, 'role_or_permission' => RoleOrPermissionMiddleware::class, ]); + + $middleware->validateCsrfTokens(except: [ + 'oauth/token', + 'oauth/revoke', + 'oauth/authorize/decision', + ]); }) ->withExceptions(function (Exceptions $exceptions) { - $exceptions->render(function (ValidationException $exception, Request $request) { + $isOauthRequest = function (Request $request): bool { + return $request->is('oauth') || $request->is('oauth/*') || $request->path() === '.well-known/openid-configuration'; + }; + + $exceptions->render(function (OAuthProtocolException $exception, Request $request) { + return response()->json($exception->toResponsePayload(), $exception->httpStatus); + }); + + $exceptions->render(function (ValidationException $exception, Request $request) use ($isOauthRequest) { + if ($isOauthRequest($request)) { + $firstError = collect($exception->errors()) + ->flatten() + ->first(); + + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => (string) ($firstError ?: 'Invalid request parameters.'), + ], 400); + } + if (! $request->expectsJson()) { return null; } @@ -122,7 +148,14 @@ return Application::configure(basePath: dirname(__DIR__)) ], 422); }); - $exceptions->render(function (UnauthorizedException $exception, Request $request) { + $exceptions->render(function (UnauthorizedException $exception, Request $request) use ($isOauthRequest) { + if ($isOauthRequest($request)) { + return response()->json([ + 'error' => 'access_denied', + 'error_description' => 'The request is not allowed.', + ], 403); + } + if ($request->expectsJson()) { return response()->json([ 'code' => 403, @@ -132,7 +165,14 @@ return Application::configure(basePath: dirname(__DIR__)) } }); - $exceptions->render(function (AuthenticationException $exception, Request $request) { + $exceptions->render(function (AuthenticationException $exception, Request $request) use ($isOauthRequest) { + if ($isOauthRequest($request)) { + return response()->json([ + 'error' => 'invalid_token', + 'error_description' => 'Authentication is required.', + ], 401); + } + if ($request->expectsJson()) { return response()->json([ 'code' => 401, diff --git a/config/oauth.php b/config/oauth.php new file mode 100644 index 0000000..d4271d8 --- /dev/null +++ b/config/oauth.php @@ -0,0 +1,21 @@ + 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'), + + 'key_id' => env('OAUTH_KEY_ID', ''), + 'private_key_path' => env('OAUTH_PRIVATE_KEY_PATH', storage_path('oauth/private.pem')), + 'public_key_path' => env('OAUTH_PUBLIC_KEY_PATH', storage_path('oauth/public.pem')), + 'openssl_config_path' => env('OAUTH_OPENSSL_CONFIG_PATH', ''), + 'auto_generate_keys' => (bool) env('OAUTH_AUTO_GENERATE_KEYS', true), + + 'authorization_code_ttl_seconds' => (int) env('OAUTH_AUTHORIZATION_CODE_TTL_SECONDS', 300), + 'access_token_ttl_seconds' => (int) env('OAUTH_ACCESS_TOKEN_TTL_SECONDS', 3600), + 'refresh_token_ttl_seconds' => (int) env('OAUTH_REFRESH_TOKEN_TTL_SECONDS', 1209600), + + 'redirect_uri_policy' => env('OAUTH_REDIRECT_URI_POLICY', 'same_domain'), + 'default_scopes' => ['openid', 'profile', 'email', 'phone'], + 'userinfo_fields' => ['sub', 'nickname', 'email', 'phone'], +]; diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 0000000..a6ac880 --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,27 @@ + + */ +class OauthClientFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => $this->faker->company(), + 'logo_url' => $this->faker->optional()->imageUrl(), + 'client_id' => 'client_'.$this->faker->unique()->regexify('[A-Za-z0-9]{24}'), + 'client_secret_hash' => null, + 'redirect_uris' => ['https://example.com/callback'], + 'allowed_userinfo_fields' => ['sub', 'nickname', 'email'], + 'userinfo_claim_remap' => [], + 'is_confidential' => true, + 'is_active' => true, + ]; + } +} diff --git a/database/factories/OauthScopeFactory.php b/database/factories/OauthScopeFactory.php new file mode 100644 index 0000000..8b99a03 --- /dev/null +++ b/database/factories/OauthScopeFactory.php @@ -0,0 +1,23 @@ + + */ +class OauthScopeFactory extends Factory +{ + public function definition(): array + { + return [ + 'name' => $this->faker->unique()->slug(2), + 'display_name' => $this->faker->words(2, true), + 'description' => $this->faker->sentence(), + 'claims' => [], + 'is_active' => true, + ]; + } +} diff --git a/database/migrations/2026_05_20_041122_create_oauth_tables.php b/database/migrations/2026_05_20_041122_create_oauth_tables.php new file mode 100644 index 0000000..71ca571 --- /dev/null +++ b/database/migrations/2026_05_20_041122_create_oauth_tables.php @@ -0,0 +1,175 @@ +id(); + $table->string('name', 100)->unique(); + $table->string('display_name', 120); + $table->string('description', 255)->nullable(); + $table->json('claims')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['is_active', 'name']); + }); + + Schema::create('oauth_clients', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('logo_url')->nullable(); + $table->string('client_id', 80)->unique(); + $table->string('client_secret_hash')->nullable(); + $table->json('redirect_uris')->nullable(); + $table->json('allowed_userinfo_fields')->nullable(); + $table->json('userinfo_claim_remap')->nullable(); + $table->boolean('is_confidential')->default(true); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['is_active', 'client_id']); + }); + + Schema::create('oauth_client_scope', function (Blueprint $table) { + $table->id(); + $table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->foreignId('scope_id')->constrained('oauth_scopes')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['client_id', 'scope_id'], 'uq_oauth_client_scope'); + }); + + Schema::create('oauth_consents', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->string('scope_fingerprint', 64); + $table->json('scopes'); + $table->timestamp('granted_at'); + $table->timestamps(); + + $table->unique(['user_id', 'client_id', 'scope_fingerprint'], 'uq_oauth_consents_fingerprint'); + $table->index(['user_id', 'client_id']); + }); + + Schema::create('oauth_authorizations', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->string('scope', 1000); + $table->string('scope_fingerprint', 64); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'client_id']); + }); + + Schema::create('oauth_authorization_codes', function (Blueprint $table) { + $table->id(); + $table->string('code_hash', 64)->unique(); + $table->foreignId('authorization_id')->constrained('oauth_authorizations')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->text('redirect_uri'); + $table->string('scope', 1000); + $table->string('nonce')->nullable(); + $table->timestamp('expires_at'); + $table->timestamp('consumed_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index(['client_id', 'expires_at']); + }); + + Schema::create('oauth_access_tokens', function (Blueprint $table) { + $table->id(); + $table->string('jti', 64)->unique(); + $table->foreignId('authorization_id')->constrained('oauth_authorizations')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->string('scope', 1000); + $table->timestamp('expires_at'); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index(['client_id', 'user_id']); + $table->index(['expires_at', 'revoked_at']); + }); + + Schema::create('oauth_refresh_tokens', function (Blueprint $table) { + $table->id(); + $table->string('jti', 64)->unique(); + $table->string('token_hash', 64)->unique(); + $table->foreignId('authorization_id')->constrained('oauth_authorizations')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->foreignId('previous_refresh_token_id')->nullable()->constrained('oauth_refresh_tokens')->nullOnDelete(); + $table->foreignId('replaced_by_refresh_token_id')->nullable()->constrained('oauth_refresh_tokens')->nullOnDelete(); + $table->timestamp('expires_at'); + $table->timestamp('used_at')->nullable(); + $table->timestamp('replayed_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->index(['authorization_id', 'expires_at']); + }); + + DB::table('oauth_scopes')->insert([ + [ + 'name' => 'openid', + 'display_name' => 'OpenID', + 'description' => '启用 OIDC 身份声明', + 'claims' => json_encode([], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'profile', + 'display_name' => 'Profile', + 'description' => '基础用户资料', + 'claims' => json_encode(['nickname'], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'email', + 'display_name' => 'Email', + 'description' => '邮箱信息', + 'claims' => json_encode(['email'], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'phone', + 'display_name' => 'Phone', + 'description' => '手机号信息', + 'claims' => json_encode(['phone'], JSON_UNESCAPED_UNICODE), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + Schema::dropIfExists('oauth_access_tokens'); + Schema::dropIfExists('oauth_authorization_codes'); + Schema::dropIfExists('oauth_authorizations'); + Schema::dropIfExists('oauth_consents'); + Schema::dropIfExists('oauth_client_scope'); + Schema::dropIfExists('oauth_clients'); + Schema::dropIfExists('oauth_scopes'); + } +}; diff --git a/oauth_test/README.md b/oauth_test/README.md new file mode 100644 index 0000000..61e7518 --- /dev/null +++ b/oauth_test/README.md @@ -0,0 +1,81 @@ +# oauth_test - OAuth2/OIDC 独立联调客户端 + +这是一个**完全独立**的静态测试页,不依赖现有系统前端工程,用于联调你们平台的 OAuth2/OIDC 端点。 + +## 文件说明 + +- `index.html`:单页联调客户端(内联 JS/CSS) +- `README.md`:使用说明 + +## 启动方式 + +任选其一: + +1. 直接双击打开 `index.html` +2. 使用任意静态服务启动(推荐,避免部分浏览器本地文件策略影响) + +示例(PowerShell): + +```powershell +cd D:\Projects\PHP\BastionSSO\oauth_test +python -m http.server 8800 +``` + +然后访问:`http://localhost:8800/index.html` + +## 建议默认配置(按当前项目) + +假设你的服务端是 `http://localhost`: + +- `issuer`: `http://localhost` +- `discovery_endpoint`: `http://localhost/.well-known/openid-configuration` +- `authorize_endpoint`: `http://localhost/oauth/authorize` +- `token_endpoint`: `http://localhost/oauth/token` +- `userinfo_endpoint`: `http://localhost/oauth/userinfo` +- `revoke_endpoint`: `http://localhost/oauth/revoke` + +## 支持流程 + +- Discovery 自动发现并回填端点 +- Authorization Code 授权跳转 +- 解析回调 `code/error/state` +- 换取 Token(默认 Basic 优先,可切换 post) +- 拉取 UserInfo +- Refresh Token +- Revoke Token(access/refresh) +- PKCE 开关(S256) + +## 使用步骤(推荐) + +1. 填写 `issuer`,点击“按 issuer 自动填充端点” +2. 填写 `client_id`、`client_secret`、`redirect_uri`、`scope` +3. 点击“发起授权”,在 OAuth 页面登录并同意 +4. 回跳后点击“解析回调” +5. 点击“换取 Token” +6. 点击“拉取 UserInfo”验证用户信息返回 +7. 可继续测试“刷新 Token”和“撤销 Token” + +## 常见问题 + +1. `invalid_client` +- 检查 `client_id/client_secret` 是否正确 +- 检查客户端认证方式(Basic / post)是否与服务端一致 + +2. `invalid_grant` +- `code` 可能已过期或已使用 +- `redirect_uri` 与授权时不一致 + +3. `redirect_uri mismatch` +- 确保客户端配置中的回调地址与请求参数完全一致(协议/域名/端口/路径) + +4. 浏览器报 `Failed to fetch` +- 多数是 CORS 或网络策略问题 +- 需要服务端放行当前 Origin,以及 `Authorization` / `Content-Type` 请求头 + +5. `scope` 相关错误 +- 请求 scope 必须是客户端允许 scope 的子集 + +## 备注 + +- 页面配置和 token 状态会保存在浏览器 `localStorage`。 +- 本目录不会接入现有系统菜单、路由或打包流程。 diff --git a/oauth_test/index.html b/oauth_test/index.html new file mode 100644 index 0000000..b726e45 --- /dev/null +++ b/oauth_test/index.html @@ -0,0 +1,947 @@ + + + + + + OAuth2/OIDC 联调客户端 + + + +
+
+
+

OAuth2/OIDC 联调客户端

+

独立静态页,不依赖现有系统前端。用于授权码流程联调、排错和回归验证。

+
+
未执行
+
+ +
+
+

服务器配置

+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+

客户端配置

+
+
+
+
+
+
+
+
+
+ +
+

高级选项

+
+
+ + +
+
+ + +
+
+ + +
+
+

说明:PKCE 开启后会自动生成 code_verifier/code_challenge,并在授权与换 token 时自动带上。

+
+ +
+

操作

+
+ + + + + + + + +
+
+ +
+

结果与状态

+
+
+
授权 code
-
+
state 校验
-
+
access_token
-
+
refresh_token
-
+
id_token
-
+
+
+ + +
+ +
+
+
+
+ +
+

日志

+
+ + +
+
+
+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..6f17eaf --- /dev/null +++ b/public/404.html @@ -0,0 +1,7 @@ + +404 Not Found + +

404 Not Found

+
nginx
+ + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..79060af --- /dev/null +++ b/public/index.html @@ -0,0 +1,62 @@ + + + + 恭喜,站点创建成功! + + + + + +
+ +
+

本页面在FTP根目录下的index.html

+

您可以修改、删除或覆盖本页面

+

FTP相关信息,请到“面板系统后台 > FTP” 查看

+
+ +
+ + diff --git a/tests/Feature/OauthProtocolTest.php b/tests/Feature/OauthProtocolTest.php new file mode 100644 index 0000000..69c7f06 --- /dev/null +++ b/tests/Feature/OauthProtocolTest.php @@ -0,0 +1,231 @@ +set('oauth.frontend_login_url', 'http://frontend.local/#/login'); + config()->set('oauth.frontend_consent_url', 'http://frontend.local/#/oauth-consent'); + config()->set('oauth.issuer', 'http://localhost'); + + $resourceOwner = User::factory()->create([ + 'nickname' => 'alice', + 'email' => 'alice@example.com', + 'phone' => '13800000000', + ]); + $accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner); + + $plainSecret = 'secret_123456789'; + $client = OauthClient::factory()->create([ + 'client_id' => 'cli_test_client', + 'client_secret_hash' => Hash::make($plainSecret), + 'redirect_uris' => ['https://client.example.com/callback'], + 'allowed_userinfo_fields' => ['sub', 'nickname', 'email'], + 'userinfo_claim_remap' => ['nickname' => 'username'], + ]); + $scopeIds = OauthScope::query() + ->whereIn('name', ['openid', 'profile', 'email']) + ->pluck('id') + ->all(); + $client->scopes()->sync($scopeIds); + + $authorizeResponse = $this->get('/oauth/authorize?'.http_build_query([ + 'response_type' => 'code', + 'client_id' => $client->client_id, + 'redirect_uri' => 'https://client.example.com/callback', + 'scope' => 'openid profile email', + 'state' => 'state_001', + 'nonce' => 'nonce_001', + 'access_token' => $accessTokenForAuthorize, + ])); + $authorizeResponse->assertRedirectContains('/#/oauth-consent'); + + $decisionResponse = $this->post('/oauth/authorize/decision', [ + 'approve' => true, + 'client_id' => $client->client_id, + 'redirect_uri' => 'https://client.example.com/callback', + 'scope' => 'openid profile email', + 'state' => 'state_001', + 'nonce' => 'nonce_001', + 'access_token' => $accessTokenForAuthorize, + ]); + $decisionResponse->assertStatus(302); + + $callbackUrl = (string) $decisionResponse->headers->get('Location'); + $queryValues = $this->queryValuesFromUrl($callbackUrl); + $authorizationCode = (string) ($queryValues['code'] ?? ''); + $this->assertNotSame('', $authorizationCode); + $this->assertSame('state_001', (string) ($queryValues['state'] ?? '')); + + $tokenResponse = $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authorizationCode, + 'redirect_uri' => 'https://client.example.com/callback', + ], [ + 'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret), + 'Accept' => 'application/json', + ]); + + $tokenResponse + ->assertOk() + ->assertJsonStructure([ + 'access_token', + 'token_type', + 'expires_in', + 'refresh_token', + 'scope', + 'id_token', + ]); + + $userInfoResponse = $this->get('/oauth/userinfo?access_token='.(string) $tokenResponse->json('access_token')); + $userInfoResponse + ->assertOk() + ->assertJsonPath('sub', (string) $resourceOwner->id) + ->assertJsonPath('username', 'alice') + ->assertJsonPath('email', 'alice@example.com') + ->assertJsonMissingPath('nickname'); + + $discoveryResponse = $this->get('/.well-known/openid-configuration'); + $discoveryResponse + ->assertOk() + ->assertJsonPath('issuer', 'http://localhost') + ->assertJsonPath('token_endpoint_auth_methods_supported.0', 'client_secret_basic'); + + $jwksResponse = $this->get('/oauth/jwks'); + $jwksResponse + ->assertOk() + ->assertJsonPath('keys.0.kty', 'RSA') + ->assertJsonPath('keys.0.alg', 'RS256'); + } + + public function test_invalid_scope_is_returned_as_standard_oauth_redirect_error(): void + { + $resourceOwner = User::factory()->create(); + $accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner); + + $client = OauthClient::factory()->create([ + 'client_id' => 'cli_scope_subset', + 'client_secret_hash' => Hash::make('secret'), + 'redirect_uris' => ['https://subset.example.com/callback'], + 'allowed_userinfo_fields' => ['sub'], + 'userinfo_claim_remap' => [], + ]); + $scopeIds = OauthScope::query()->whereIn('name', ['profile'])->pluck('id')->all(); + $client->scopes()->sync($scopeIds); + + $response = $this->get('/oauth/authorize?'.http_build_query([ + 'response_type' => 'code', + 'client_id' => $client->client_id, + 'redirect_uri' => 'https://subset.example.com/callback', + 'scope' => 'profile openid', + 'state' => 'scope_state', + 'access_token' => $accessTokenForAuthorize, + ])); + $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'] ?? '')); + } + + public function test_refresh_rotation_and_replay_revokes_authorization_chain(): void + { + $resourceOwner = User::factory()->create(); + $accessTokenForAuthorize = JWTAuth::fromUser($resourceOwner); + $plainSecret = 'secret_refresh_001'; + + $client = OauthClient::factory()->create([ + 'client_id' => 'cli_refresh_client', + 'client_secret_hash' => Hash::make($plainSecret), + 'redirect_uris' => ['https://refresh.example.com/callback'], + 'allowed_userinfo_fields' => ['sub'], + 'userinfo_claim_remap' => [], + ]); + $scopeIds = OauthScope::query()->whereIn('name', ['openid'])->pluck('id')->all(); + $client->scopes()->sync($scopeIds); + + $decisionResponse = $this->post('/oauth/authorize/decision', [ + 'approve' => true, + 'client_id' => $client->client_id, + 'redirect_uri' => 'https://refresh.example.com/callback', + 'scope' => 'openid', + 'state' => 'refresh_state', + 'nonce' => 'refresh_nonce', + 'access_token' => $accessTokenForAuthorize, + ]); + + $authorizationCode = (string) ($this->queryValuesFromUrl((string) $decisionResponse->headers->get('Location'))['code'] ?? ''); + $this->assertNotSame('', $authorizationCode); + + $tokenResponse = $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authorizationCode, + 'redirect_uri' => 'https://refresh.example.com/callback', + ], [ + 'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret), + 'Accept' => 'application/json', + ])->assertOk(); + + $firstRefreshToken = (string) $tokenResponse->json('refresh_token'); + $this->assertNotSame('', $firstRefreshToken); + + $refreshResponse = $this->post('/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $firstRefreshToken, + ], [ + 'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret), + 'Accept' => 'application/json', + ]); + $refreshResponse->assertOk(); + + $secondRefreshToken = (string) $refreshResponse->json('refresh_token'); + $this->assertNotSame('', $secondRefreshToken); + $this->assertNotSame($firstRefreshToken, $secondRefreshToken); + + $replayResponse = $this->post('/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $firstRefreshToken, + ], [ + 'Authorization' => 'Basic '.base64_encode($client->client_id.':'.$plainSecret), + 'Accept' => 'application/json', + ]); + $replayResponse + ->assertStatus(400) + ->assertJsonPath('error', 'invalid_grant'); + + $latestRefreshRecord = OauthRefreshToken::query() + ->where('token_hash', hash('sha256', $secondRefreshToken)) + ->first(); + $this->assertNotNull($latestRefreshRecord); + $this->assertNotNull($latestRefreshRecord->revoked_at); + } + + /** + * @return array + */ + private function queryValuesFromUrl(string $url): array + { + $query = parse_url($url, PHP_URL_QUERY); + if (! is_string($query)) { + return []; + } + + parse_str($query, $values); + + return collect($values)->map(fn ($value): string => (string) $value)->all(); + } +}