From 3a3ea4879ef473cc24874c9f2ea065c7e6b32110 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 2 Feb 2026 14:45:04 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EB=8F=99=EC=9D=98=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20OIDC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/core/services/auth_proxy_service.dart | 50 ++++++- .../auth/presentation/consent_screen.dart | 123 ++++++++++++++++++ .../auth/presentation/login_screen.dart | 54 ++++---- userfront/lib/main.dart | 30 ++++- 4 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 userfront/lib/features/auth/presentation/consent_screen.dart diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 8389815f..4d9886ec 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'http_client.dart'; +import 'dart:html' as html; class AuthProxyService { static String _envOrDefault(String key, String fallback) { @@ -196,23 +197,60 @@ class AuthProxyService { } } - static Future> loginWithPassword(String loginId, String password) async { + static Future> loginWithPassword(String loginId, String password, {String? loginChallenge}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); + final payload = { + 'loginId': loginId, + 'password': password, + if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge, + }; + final response = await http.post( url, headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'loginId': loginId, - 'password': password, - }), + body: jsonEncode(payload), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) { + html.window.location.href = data['redirectTo']; + } + return data; + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to login'); + } + } + static Future> getConsentInfo(String consentChallenge) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge}); + final response = await http.get( + url, + headers: {'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to login'); + throw Exception(errorBody['error'] ?? 'Failed to get consent info'); + } + } + + static Future> acceptConsent(String consentChallenge) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'consent_challenge': consentChallenge}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to accept consent'); } } diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart new file mode 100644 index 00000000..a078e968 --- /dev/null +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -0,0 +1,123 @@ +import 'dart:html' as html; + +import 'package:flutter/material.dart'; +import 'package:userfront/core/services/auth_proxy_service.dart'; + +class ConsentScreen extends StatefulWidget { + final String consentChallenge; + + const ConsentScreen({super.key, required this.consentChallenge}); + + @override + State createState() => _ConsentScreenState(); +} + +class _ConsentScreenState extends State { + Map? _consentInfo; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchConsentInfo(); + } + + Future _fetchConsentInfo() async { + try { + final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); + setState(() { + _consentInfo = info; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Failed to load consent information: $e'; + _isLoading = false; + }); + } + } + + Future _acceptConsent() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final result = + await AuthProxyService.acceptConsent(widget.consentChallenge); + if (result['redirectTo'] != null) { + html.window.location.href = result['redirectTo']; + } else { + setState(() { + _error = 'Consent accepted, but no redirect URL received.'; + _isLoading = false; + }); + } + } catch (e) { + setState(() { + _error = 'Failed to accept consent: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Grant Access')), + body: Center( + child: _isLoading + ? const CircularProgressIndicator() + : _error != null + ? Text(_error!, style: const TextStyle(color: Colors.red)) + : _consentInfo != null + ? Card( + elevation: 4, + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_consentInfo!['client']?['client_name'] ?? 'An application'} wants to access your account', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + const Text('This will allow the application to:'), + const SizedBox(height: 16), + if (_consentInfo!['requested_scope'] != null) + ...(_consentInfo!['requested_scope'] as List) + .map((scope) => ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text(scope.toString()), + )) + .toList(), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () { + // TODO: Implement reject consent + html.window.alert('Consent rejected. You can close this window.'); + }, + child: const Text('Deny'), + ), + ElevatedButton( + onPressed: _acceptConsent, + child: const Text('Allow'), + ), + ], + ) + ], + ), + ), + ) + : const Text('No consent information available.'), + ), + ); + } +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8bc03e11..7e73ac01 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -10,10 +10,13 @@ import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; +import 'dart:html' as html; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; - const LoginScreen({super.key, this.verificationToken}); + final String? loginChallenge; + + const LoginScreen({super.key, this.verificationToken, this.loginChallenge}); @override ConsumerState createState() => _LoginScreenState(); @@ -26,6 +29,7 @@ class _LoginScreenState extends ConsumerState final TextEditingController _passwordLoginIdController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); String? _redirectUrl; + String? _loginChallenge; // QR Login Variables String? _qrImageBase64; @@ -58,14 +62,13 @@ class _LoginScreenState extends ConsumerState @override void initState() { super.initState(); - // 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크") _tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController.addListener(_handleTabSelection); _drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; - - // Check for tokens (Path Parameter or Legacy Query Parameter) + WidgetsBinding.instance.addPostFrameCallback((_) { final uri = Uri.base; + _loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge']; final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; final pendingRefParam = uri.queryParameters['pendingRef']; @@ -190,7 +193,6 @@ class _LoginScreenState extends ConsumerState }); } - // JWT를 디코딩해 표시용 로그인 아이디 추출 String _getLoginIdFromJwt(String jwt) { try { final parts = jwt.split('.'); @@ -198,7 +200,6 @@ class _LoginScreenState extends ConsumerState final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); final data = json.decode(payload); - // 일반적으로 name/email/sub 필드를 사용 return data['name'] ?? data['email'] ?? data['sub'] ?? 'User'; } catch (e) { debugPrint("[JWT] Decode error: $e"); @@ -207,7 +208,6 @@ class _LoginScreenState extends ConsumerState } void _handleTabSelection() { - // QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작 if (_tabController.index == 2 && _qrPendingRef == null) { _startQrFlow(); } else if (_tabController.index != 2) { @@ -626,7 +626,6 @@ class _LoginScreenState extends ConsumerState super.dispose(); } - // 이메일/비밀번호 로그인 처리 Future _handlePasswordLogin() async { final input = _passwordLoginIdController.text.trim(); final password = _passwordController.text.trim(); @@ -637,14 +636,12 @@ class _LoginScreenState extends ConsumerState String loginId = input; if (!input.contains('@')) { - // Format phone number if it's not an email loginId = input.replaceAll(RegExp(r'[-\s]'), ''); if (loginId.startsWith('010')) { loginId = '+82${loginId.substring(1)}'; } } - // 로딩 인디케이터 표시 showDialog( context: context, barrierDismissible: false, @@ -652,15 +649,23 @@ class _LoginScreenState extends ConsumerState ); try { - final res = await AuthProxyService.loginWithPassword(loginId, password); + final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge); final jwt = res['sessionJwt']; final provider = res['provider'] as String?; - if (jwt != null && mounted) { - Navigator.of(context).pop(); // 로딩 닫기 + final redirectTo = res['redirectTo'] as String?; + + if (mounted) Navigator.of(context).pop(); + + if (redirectTo != null && redirectTo.isNotEmpty) { + html.window.location.href = redirectTo; + return; + } + + if (jwt != null) { _onLoginSuccess(jwt, provider: provider); } } catch (e) { - if (mounted) Navigator.of(context).pop(); // 로딩 닫기 + if (mounted) Navigator.of(context).pop(); if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { @@ -669,14 +674,12 @@ class _LoginScreenState extends ConsumerState } } - // 로그인 링크 전송 처리 Future _handleLinkLogin() async { final input = _linkIdController.text.trim(); if (input.isEmpty) return; String loginId = input; if (!input.contains('@')) { - // Format phone number if it's not an email loginId = input.replaceAll(RegExp(r'[-\s]'), ''); if (loginId.startsWith('010')) { loginId = '+82${loginId.substring(1)}'; @@ -685,7 +688,6 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Initiating Enchanted Link for: $loginId"); - // 링크 전송 전 사용자 존재 여부 체크 (백엔드에서 이미 처리하지만 에러 핸들링을 위해) try { await _startEnchantedFlow(loginId, isEmail: input.contains('@')); } catch (e) { @@ -707,7 +709,6 @@ class _LoginScreenState extends ConsumerState ); } - // 1. Init via Backend API final initResponse = await AuthProxyService.initEnchantedLink( loginId, codeOnly: codeOnly, @@ -727,13 +728,12 @@ class _LoginScreenState extends ConsumerState _lastLinkLoginId = loginId; _lastLinkIsEmail = isEmail; }); - Navigator.of(context).pop(); // Close Loading + Navigator.of(context).pop(); _showInfo(isEmail ? "입력하신 이메일로 로그인 링크를 보냈습니다." : "입력하신 번호로 로그인 링크를 보냈습니다."); - // 2. Poll Backend manually final initialInterval = (interval is int && interval > 0) ? Duration(seconds: interval) : const Duration(seconds: 2); @@ -761,7 +761,7 @@ class _LoginScreenState extends ConsumerState Future _pollForSession(String pendingRef, {Duration? initialInterval}) async { int attempts = 0; - const maxAttempts = 60; // 2 minutes + const maxAttempts = 60; var pollInterval = initialInterval ?? const Duration(seconds: 2); debugPrint("[Auth] Starting poll for ref: $pendingRef"); @@ -789,7 +789,7 @@ class _LoginScreenState extends ConsumerState } if (result['error'] == 'expired_token') { if (mounted) { - Navigator.of(context).pop(); // Close Polling Dialog + Navigator.of(context).pop(); _showError("Login timed out."); } return; @@ -820,7 +820,7 @@ class _LoginScreenState extends ConsumerState if (mounted) { debugPrint("[Auth] Polling timed out for ref: $pendingRef"); - Navigator.of(context).pop(); // Close Polling Dialog + Navigator.of(context).pop(); _showError("Login timed out."); } } @@ -879,19 +879,16 @@ class _LoginScreenState extends ConsumerState AuthTokenStore.setToken(token, provider: providerName); AuthTokenStore.clearPendingProvider(); - // 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트 try { await ref.read(profileProvider.notifier).loadProfile(); } catch (e) { debugPrint("[Auth] Failed to pre-fetch profile: $e"); } - // 1. Handle Popup Flow if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); WebAuthIntegration.sendLoginSuccess(token); } else { - // 2. Handle Redirect Flow if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl"); final target = "$_redirectUrl?token=$token"; @@ -900,7 +897,6 @@ class _LoginScreenState extends ConsumerState } } - // 3. Standalone mode / Fallback debugPrint("[Auth] Login success. Navigating to root."); AuthNotifier.instance.notify(); if (mounted) { @@ -908,7 +904,6 @@ class _LoginScreenState extends ConsumerState } } - // [New] 미등록 회원 안내 팝업 void _showUnregisteredDialog() { showDialog( context: context, @@ -1010,7 +1005,6 @@ class _LoginScreenState extends ConsumerState child: TabBarView( controller: _tabController, children: [ - // 1. 이메일/비밀번호 로그인 폼 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -1047,7 +1041,6 @@ class _LoginScreenState extends ConsumerState ), ), - // 2. 로그인 링크 전송 -> 전송 후 코드 입력으로 전환 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -1188,7 +1181,6 @@ class _LoginScreenState extends ConsumerState ), ), - // 3. QR 로그인 뷰 Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index c4f9ce7b..d83d7479 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -20,6 +20,7 @@ import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; import 'package:logging/logging.dart'; +import 'features/auth/presentation/consent_screen.dart'; final _log = Logger('Main'); @@ -91,9 +92,10 @@ final _router = GoRouter( GoRoute( path: '/signin', builder: (context, state) { - _routerLogger.info("Navigating to /signin"); - return LoginScreen(key: state.pageKey); - } + final loginChallenge = state.uri.queryParameters['login_challenge']; + _routerLogger.info("Navigating to /signin with login_challenge: $loginChallenge"); + return LoginScreen(key: state.pageKey, loginChallenge: loginChallenge); + }, ), GoRoute( path: '/login', @@ -102,6 +104,18 @@ final _router = GoRouter( return LoginScreen(key: state.pageKey); }, ), + GoRoute( + path: '/consent', + builder: (BuildContext context, GoRouterState state) { + final consentChallenge = state.uri.queryParameters['consent_challenge']; + if (consentChallenge == null) { + _routerLogger.warning("Consent screen loaded without a challenge."); + return const Scaffold(body: Center(child: Text('Error: Consent challenge is missing.'))); + } + _routerLogger.info("Navigating to /consent with challenge."); + return ConsentScreen(consentChallenge: consentChallenge); + }, + ), GoRoute( path: '/signup', builder: (context, state) { @@ -243,7 +257,8 @@ final _router = GoRouter( path == '/recovery' || path == '/reset-password' || path == '/error' || - path == '/settings'; + path == '/settings' || + path == '/consent'; // Consent page is public _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); @@ -254,7 +269,12 @@ final _router = GoRouter( // If not logged in and trying to access a protected page, redirect to /signin if (!isLoggedIn) { - _routerLogger.info("Not logged in, redirecting to /signin"); + _routerLogger.info("Not logged in, redirecting to /signin"); + // Preserve OIDC challenge if present + final loginChallenge = state.uri.queryParameters['login_challenge']; + if (loginChallenge != null) { + return '/signin?login_challenge=$loginChallenge'; + } return '/signin'; }