From 939d8ee911b0722cfb4e342564e5dc88b9123bdc Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 23 Jan 2026 15:59:08 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC/=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 + backend/internal/handler/auth_handler.go | 36 ++++ .../lib/core/services/auth_proxy_service.dart | 20 ++ .../auth/presentation/login_screen.dart | 172 +++++++++++------- 4 files changed, 162 insertions(+), 67 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fc09f2a7..ab39a986 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -228,6 +228,7 @@ func main() { auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) + auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/sms", authHandler.SendSms) auth.Post("/verify-sms", authHandler.VerifySms) auth.Post("/qr/init", authHandler.InitQRLogin) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 18f61d7c..5a431c2a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -667,6 +667,42 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { }) } + +// PasswordLogin - Authenticate a user with login ID and password. +func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { + var req struct { + LoginID string `json:"loginId"` + Password string `json:"password"` + } + + if err := c.BodyParser(&req); err != nil { + slog.Error("[PasswordLogin] Body parse error", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + slog.Info("[PasswordLogin] Attempting to login", "loginID", req.LoginID) + + if h.DescopeClient == nil { + slog.Error("[PasswordLogin] Descope Client is nil!") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) + } + + // Sign in using Descope + authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil) + if err != nil { + slog.Warn("[PasswordLogin] Descope sign-in failed", "loginID", req.LoginID, "error", err) + // It's good practice to return a generic error message for security. + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) + } + + slog.Info("[PasswordLogin] Success", "loginID", req.LoginID) + return c.JSON(fiber.Map{ + "sessionJwt": authInfo.SessionToken.JWT, + "status": "ok", + }) +} + + // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart index 0d03eb84..09810966 100644 --- a/frontend/lib/core/services/auth_proxy_service.dart +++ b/frontend/lib/core/services/auth_proxy_service.dart @@ -66,6 +66,26 @@ class AuthProxyService { } } + static Future> loginWithPassword(String loginId, String password) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'loginId': loginId, + 'password': password, + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to login'); + } + } + static Future sendSms(String phoneNumber) async { final url = Uri.parse('$_baseUrl/api/v1/auth/sms'); diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 9e9eebf2..189510b1 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -24,9 +24,9 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; - final TextEditingController _idController = TextEditingController(); - final TextEditingController _smsCodeController = TextEditingController(); // Keep if needed for verification inputs later? Actually not used in link flow. - bool _smsSent = false; + final TextEditingController _linkIdController = TextEditingController(); + final TextEditingController _passwordLoginIdController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); String? _redirectUrl; // QR Login Variables @@ -40,7 +40,8 @@ class _LoginScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + // 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크") + _tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController.addListener(_handleTabSelection); // Check for tokens (Path Parameter or Legacy Query Parameter) @@ -92,9 +93,10 @@ class _LoginScreenState extends ConsumerState } void _handleTabSelection() { - if (_tabController.index == 1 && _qrPendingRef == null) { + // QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작 + if (_tabController.index == 2 && _qrPendingRef == null) { _startQrFlow(); - } else if (_tabController.index != 1) { + } else if (_tabController.index != 2) { _stopQrPolling(); } } @@ -230,28 +232,56 @@ class _LoginScreenState extends ConsumerState } } - void _showSuccessDialog() { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const AlertDialog( - title: Text("Authentication Successful"), - content: Text("You can close this tab and return to the application."), - ), - ); - } - @override void dispose() { _stopQrPolling(); _tabController.dispose(); - _idController.dispose(); - _smsCodeController.dispose(); + _linkIdController.dispose(); + _passwordLoginIdController.dispose(); + _passwordController.dispose(); super.dispose(); } - Future _handleLogin() async { - final input = _idController.text.trim(); + // 이메일/비밀번호 로그인 처리 + Future _handlePasswordLogin() async { + final loginId = _passwordLoginIdController.text.trim(); + final password = _passwordController.text.trim(); + if (loginId.isEmpty || password.isEmpty) { + _showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요."); + return; + } + + // 로딩 인디케이터 표시 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + + try { + final res = await AuthProxyService.loginWithPassword(loginId, password); + final jwt = res['sessionJwt']; + if (jwt != null && mounted) { + Navigator.of(context).pop(); // 로딩 닫기 + + final displayName = _getLoginIdFromJwt(jwt); + final dummyUser = DescopeUser( + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + + _onLoginSuccess(jwt); + } + } catch (e) { + if (mounted) Navigator.of(context).pop(); // 로딩 닫기 + _showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); + } + } + + // 로그인 링크 전송 처리 + Future _handleLinkLogin() async { + final input = _linkIdController.text.trim(); if (input.isEmpty) return; String loginId = input; @@ -340,26 +370,8 @@ class _LoginScreenState extends ConsumerState final displayName = _getLoginIdFromJwt(jwt); // Descope SDK 세션 강제 주입 - // Note: DescopeUser in 0.9.11 requires 18 positional arguments. final dummyUser = DescopeUser( - 'unknown', // userId - [], // loginIds - 0, // createdAt - displayName, // name - null, // picture (Uri?) - '', // email - false, // isVerifiedEmail - '', // phone - false, // isVerifiedPhone - {}, // customAttributes - '', // givenName - '', // middleName - '', // familyName - false, // hasPassword - 'enabled', // status - [], // roleNames - [], // ssoAppIds - [], // oauthProviders (List) + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], ); final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); Descope.sessionManager.manageSession(session); @@ -397,38 +409,25 @@ class _LoginScreenState extends ConsumerState void _logTokenDetails(String jwt) { try { - // JWT는 세 부분(Header, Payload, Signature)이 '.'으로 구분된 문자열입니다. 이를 분리합니다. final parts = jwt.split('.'); - // 세 부분으로 정확히 나뉘지 않았다면 유효한 JWT가 아니므로 중단합니다. if (parts.length != 3) return; - // JWT의 두 번째 부분(Payload)은 Base64Url로 인코딩된 JSON 데이터입니다. - // 1. Base64Url 문자열을 디코딩하여 바이트 배열로 변환합니다. - // normalize()는 Base64 패딩(=) 문제를 처리해줍니다. final decodedPayload = base64Url.decode(base64Url.normalize(parts[1])); - // 2. 바이트 배열을 UTF-8 형식의 일반 문자열(JSON)로 변환합니다. final payloadJson = utf8.decode(decodedPayload); - // 3. JSON 문자열을 Dart에서 사용할 수 있는 Map 객체로 변환합니다. final data = json.decode(payloadJson) as Map; - // [FIX] 'exp'는 int 또는 double일 수 있으므로, 안전하게 num으로 처리합니다. final accessExpValue = data['exp'] as num?; - // 'exp' (Expiration Time) 필드는 Access Token의 만료 시간을 나타냅니다. Unix 타임스탬프(초 단위) 값입니다. - // 이 값을 Dart의 DateTime 객체로 변환합니다. (1000을 곱해 밀리초 단위로 만듦) final accessExp = accessExpValue != null ? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000) : 'N/A'; - // 'rexp' (Refresh Expiration) 필드는 Descope가 사용하는 커스텀 필드로, Refresh Token의 만료 시간을 ISO 8601 형식의 문자열로 나타냅니다. final refreshExp = data['rexp'] ?? 'N/A'; - // 확인된 만료 시간 정보들을 디버그 콘솔에 출력합니다. debugPrint(""" [Auth] Session Token Details --- - Access Token Expires: $accessExp - Refresh Token Expires: $refreshExp """); } catch (e) { - // JWT를 해석하는 과정에서 오류가 발생하면 콘솔에 에러를 출력합니다. debugPrint("[Auth] Failed to decode or log token details: $e"); } } @@ -438,7 +437,6 @@ class _LoginScreenState extends ConsumerState _logTokenDetails(token); - // [FIX] 감사 로그에 실제 사용자 ID를 전송하기 위해 토큰에서 ID를 추출합니다. final userId = _getUserIdFromJwt(token); // Record Audit Log @@ -449,16 +447,12 @@ class _LoginScreenState extends ConsumerState details: "User logged in via Baron SSO", ); - // 1. Handle Popup Flow (Highest Priority for child windows) - // If opened as a popup (has opener), we notify and try to close. + // 1. Handle Popup Flow if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); WebAuthIntegration.sendLoginSuccess(token); - - // We don't 'return' here to allow a fallback if window.close() is blocked, - // but in most cases WebAuthIntegration.sendLoginSuccess will close the window. } else { - // 2. Handle Redirect Flow (Only if NOT a popup) + // 2. Handle Redirect Flow if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl"); final target = "$_redirectUrl?token=$token"; @@ -468,7 +462,6 @@ class _LoginScreenState extends ConsumerState } // 3. Standalone mode / Fallback - // If it's a standard login, or if a popup's window.close() was blocked by the browser. debugPrint("[Auth] Login success. Navigating to root."); AuthNotifier.instance.notify(); if (mounted) { @@ -505,7 +498,8 @@ class _LoginScreenState extends ConsumerState TabBar( controller: _tabController, tabs: const [ - Tab(text: "로그인"), + Tab(text: "이메일/전화번호"), + Tab(text: "로그인 링크"), Tab(text: "QR 코드"), ], ), @@ -516,24 +510,68 @@ class _LoginScreenState extends ConsumerState child: TabBarView( controller: _tabController, children: [ - // Unified Login Form + // 1. 이메일/비밀번호 로그인 폼 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( children: [ TextField( - controller: _idController, + controller: _passwordLoginIdController, + decoration: const InputDecoration( + labelText: "이메일 또는 휴대폰 번호", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + onSubmitted: (_) => _handlePasswordLogin(), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: "비밀번호", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock_outline), + ), + onSubmitted: (_) => _handlePasswordLogin(), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _handlePasswordLogin, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: const Text("로그인"), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + _showError("비밀번호 재설정은 아직 구현되지 않았습니다."); + }, + child: const Text("비밀번호를 잊으셨나요?"), + ) + ], + ), + ), + + // 2. 로그인 링크 전송 폼 + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + children: [ + TextField( + controller: _linkIdController, decoration: const InputDecoration( labelText: "이메일 또는 휴대폰 번호", hintText: "", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person_outline), ), - onSubmitted: (_) => _handleLogin(), + onSubmitted: (_) => _handleLinkLogin(), ), const SizedBox(height: 24), FilledButton( - onPressed: _handleLogin, + onPressed: _handleLinkLogin, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(50), ), @@ -560,7 +598,7 @@ class _LoginScreenState extends ConsumerState ), ), - // QR Login View + // 3. QR 로그인 뷰 Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,