From b65ecc1b24a79e88c59a3469c7b8d76ad8d1c7d3 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 16 Jan 2026 17:42:59 +0900 Subject: [PATCH] =?UTF-8?q?qr=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 3 + backend/internal/domain/auth_models.go | 6 + backend/internal/handler/auth_handler.go | 99 +++++++++- .../lib/core/services/auth_proxy_service.dart | 45 +++++ .../auth/presentation/approve_qr_screen.dart | 117 ++++++++++++ .../auth/presentation/login_screen.dart | 178 +++++++++++++++++- frontend/lib/main.dart | 11 +- frontend/pubspec.yaml | 1 + 8 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 frontend/lib/features/auth/presentation/approve_qr_screen.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index bbdedced..8951cb40 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -132,6 +132,9 @@ func main() { auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/sms", authHandler.SendSms) auth.Post("/verify-sms", authHandler.VerifySms) + auth.Post("/qr/init", authHandler.InitQRLogin) + auth.Post("/qr/poll", authHandler.PollQRLogin) + auth.Post("/qr/approve", authHandler.ScanQRLogin) // Admin Routes admin := api.Group("/admin") diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 9da4b79c..89992130 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -24,4 +24,10 @@ type EnchantedLinkPollResponse struct { type MagicLinkVerifyRequest struct { Token string `json:"token"` +} + +type QRInitResponse struct { + QRCode string `json:"qrCode"` // Base64 or URL + PendingRef string `json:"pendingRef"` + ExpiresIn int `json:"expiresIn"` } \ No newline at end of file diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 29bc67b4..ca220a1e 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -132,7 +132,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { loginID := strings.ReplaceAll(req.LoginID, "-", "") loginID = strings.ReplaceAll(loginID, " ", "") - + // Generate secure tokens token := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3) @@ -150,7 +150,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } link := fmt.Sprintf("%s/verify/%s", frontendURL, token) content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link) - + log.Printf("[Enchanted] Sending SMS to %s via Naver Cloud", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { @@ -230,8 +230,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { if strings.HasPrefix(searchPhone, "010") { searchPhone = "+82" + searchPhone[1:] } else if strings.HasPrefix(searchPhone, "82") { - searchPhone = "+" + searchPhone - } + searchPhone = "+" + searchPhone + } } log.Printf("[Verify] Searching for user with phone: %s", searchPhone) @@ -239,10 +239,10 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { Phones: []string{searchPhone}, Limit: 1, } - + var targetLoginID string users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions) - + if errSearch == nil && len(users) > 0 { if len(users[0].LoginIDs) > 0 { targetLoginID = users[0].LoginIDs[0] @@ -264,7 +264,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { if err != nil { if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") { log.Printf("[Verify] User %s not found. Creating...", targetLoginID) - + // Create User with Explicit Phone Attribute userObj := &descope.UserRequest{} if strings.Contains(targetLoginID, "@") { @@ -278,7 +278,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { log.Printf("[Verify] Failed to create user: %v", errCreate) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"}) } - + embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) if err != nil { log.Printf("[Verify] Failed to generate token after creation: %v", err) @@ -304,12 +304,93 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { "jwt": sessionToken, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) - + return c.JSON(fiber.Map{ "token": sessionToken, "message": "Login successful", }) } + +// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. +func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { + pendingRef := GenerateSecureToken(16) + + // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. + frontendURL := os.Getenv("FRONTEND_URL") + if frontendURL == "" { + frontendURL = "https://ssologin.hmac.kr" + } + qrPayload := fmt.Sprintf("%s/approve?ref=%s", frontendURL, pendingRef) + + log.Printf("[QR] Init: PendingRef=%s, URL=%s", pendingRef, qrPayload) + + // Redis에 초기 상태 저장 (5분 만료) + h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute) + + return c.JSON(fiber.Map{ + "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 + "pendingRef": pendingRef, + "expiresIn": 300, + }) +} + +// PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다. +func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { + var req struct { + PendingRef string `json:"pendingRef"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) + } + + val, err := h.RedisService.Get(prefixSession + req.PendingRef) + if err != nil || val == "" { + return c.JSON(fiber.Map{"status": "expired"}) + } + + var data map[string]string + json.Unmarshal([]byte(val), &data) + + if data["status"] == statusSuccess { + return c.JSON(fiber.Map{ + "status": "ok", + "sessionJwt": data["jwt"], + }) + } + + return c.JSON(fiber.Map{"status": statusPending}) +} + +// ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다. +// (이미 로그인된 세션이 필요함) +func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { + var req struct { + PendingRef string `json:"pendingRef"` + Token string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용) + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) + } + + log.Printf("[QR] Scan & Approve: PendingRef=%s", req.PendingRef) + + // 1. Redis에서 세션 확인 + val, err := h.RedisService.Get(prefixSession + req.PendingRef) + if err != nil || val == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) + } + + // 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달 + + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": req.Token, + }) + h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute) + + return c.JSON(fiber.Map{"message": "QR Login Approved"}) +} + // ProxyToDescope (Placeholder) func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error { return c.Status(501).SendString("Descope Proxy Disabled") diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart index 0fa208d8..c2833446 100644 --- a/frontend/lib/core/services/auth_proxy_service.dart +++ b/frontend/lib/core/services/auth_proxy_service.dart @@ -99,6 +99,51 @@ class AuthProxyService { } } + static Future> initQrLogin() async { + final url = Uri.parse('$_baseUrl/api/v1/auth/qr/init'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to init QR login: ${response.body}'); + } + } + + static Future> pollQrStatus(String pendingRef) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/qr/poll'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'pendingRef': pendingRef}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('QR Polling failed: ${response.body}'); + } + } + + static Future approveQrLogin(String pendingRef, String token) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'pendingRef': pendingRef, + 'token': token, + }), + ); + + if (response.statusCode != 200) { + throw Exception('QR Approval failed: ${response.body}'); + } + } + static Future checkAdminAuth(String adminPassword) async { final url = Uri.parse('$_baseUrl/api/v1/admin/check'); try { diff --git a/frontend/lib/features/auth/presentation/approve_qr_screen.dart b/frontend/lib/features/auth/presentation/approve_qr_screen.dart new file mode 100644 index 00000000..eda1ab12 --- /dev/null +++ b/frontend/lib/features/auth/presentation/approve_qr_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:descope/descope.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/services/auth_proxy_service.dart'; + +class ApproveQrScreen extends StatefulWidget { + final String? pendingRef; + const ApproveQrScreen({super.key, this.pendingRef}); + + @override + State createState() => _ApproveQrScreenState(); +} + +class _ApproveQrScreenState extends State { + bool _isLoading = false; + String? _message; + bool _success = false; + + Future _handleApprove() async { + if (widget.pendingRef == null) return; + + final session = Descope.sessionManager.session; + if (session == null || session.refreshToken.isExpired) { + setState(() => _message = "Please log in on your phone first."); + context.go('/'); // Redirect to login + return; + } + + setState(() { + _isLoading = true; + _message = null; + }); + // jwt 유효성 확인 + try { + await AuthProxyService.approveQrLogin( + widget.pendingRef!, + session.sessionToken.jwt, + ); + setState(() { + _success = true; + _message = "Login Approved! Your browser should now be logged in."; + }); + } catch (e) { + setState(() => _message = "Error: $e"); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false; + + return Scaffold( + appBar: AppBar(title: const Text("QR Login Approval")), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue), + const SizedBox(height: 24), + const Text( + "Web Login Request", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Text( + "A computer is trying to log in using this QR code.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + const SizedBox(height: 40), + + if (_message != null) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + _message!, + style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + + if (!_success) + FilledButton.icon( + onPressed: _isLoading || !isLoggedIn ? null : _handleApprove, + icon: const Icon(Icons.check_circle), + label: const Text("Approve Login"), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(60), + backgroundColor: Colors.blue, + ), + ), + + if (!isLoggedIn && !_success) + Padding( + padding: const EdgeInsets.only(top: 16), + child: TextButton( + onPressed: () => context.go('/'), + child: const Text("Login on this device first"), + ), + ), + + if (_success) + FilledButton( + onPressed: () => context.go('/dashboard'), + child: const Text("Go to My Dashboard"), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 710e6698..5fb49e47 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -5,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import '../../../core/services/audit_service.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; @@ -27,10 +29,19 @@ class _LoginScreenState extends ConsumerState bool _smsSent = false; String? _redirectUrl; + // QR Login Variables + String? _qrImageBase64; + String? _qrPendingRef; + bool _isQrLoading = false; + Timer? _qrPollingTimer; + int _qrRemainingSeconds = 0; + Timer? _qrCountdownTimer; + @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this, initialIndex: 1); + _tabController = TabController(length: 3, vsync: this, initialIndex: 1); + _tabController.addListener(_handleTabSelection); // Check for tokens (Path Parameter or Legacy Query Parameter) WidgetsBinding.instance.addPostFrameCallback((_) { @@ -50,6 +61,89 @@ class _LoginScreenState extends ConsumerState }); } + void _handleTabSelection() { + if (_tabController.index == 2 && _qrPendingRef == null) { + _startQrFlow(); + } else if (_tabController.index != 2) { + _stopQrPolling(); + } + } + + Future _startQrFlow() async { + if (_isQrLoading) return; + setState(() { + _isQrLoading = true; + _qrImageBase64 = null; + _qrRemainingSeconds = 0; + }); + + try { + final res = await AuthProxyService.initQrLogin(); + if (mounted) { + setState(() { + _qrImageBase64 = res['qrCode']; + _qrPendingRef = res['pendingRef']; + _qrRemainingSeconds = res['expiresIn'] ?? 300; + _isQrLoading = false; + }); + _startQrPolling(); + _startCountdown(); + } + } catch (e) { + _showError("Failed to init QR: $e"); + if (mounted) setState(() => _isQrLoading = false); + } + } + + void _startCountdown() { + _qrCountdownTimer?.cancel(); + _qrCountdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted || _qrRemainingSeconds <= 0) { + timer.cancel(); + if (_qrRemainingSeconds <= 0) _stopQrPolling(); + return; + } + setState(() { + _qrRemainingSeconds--; + }); + }); + } + + void _startQrPolling() { + _qrPollingTimer?.cancel(); + _qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async { + if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) { + timer.cancel(); + return; + } + + try { + final res = await AuthProxyService.pollQrStatus(_qrPendingRef!); + if (res['status'] == 'ok' && res['sessionJwt'] != null) { + timer.cancel(); + _qrCountdownTimer?.cancel(); + _onLoginSuccess(res['sessionJwt']); + } + } catch (e) { + debugPrint("[QR] Polling error: $e"); + } + }); + } + + void _stopQrPolling() { + _qrPollingTimer?.cancel(); + _qrPollingTimer = null; + _qrCountdownTimer?.cancel(); + _qrCountdownTimer = null; + _qrPendingRef = null; + } + + String _formatTime(int seconds) { + final m = seconds ~/ 60; + final s = seconds % 60; + return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}"; + } + Future _verifyToken(String token) async { debugPrint("[Auth] Starting verification for token: $token"); try { @@ -81,6 +175,7 @@ class _LoginScreenState extends ConsumerState @override void dispose() { + _stopQrPolling(); _tabController.dispose(); _emailController.dispose(); _passwordController.dispose(); @@ -192,6 +287,32 @@ class _LoginScreenState extends ConsumerState final jwt = result['sessionJwt']; if (jwt != null) { debugPrint("[Auth] Polling SUCCESS. Token received."); + + // Descope SDK 세션 강제 주입 + // Note: DescopeUser in 0.9.11 requires 18 positional arguments. + final dummyUser = DescopeUser( + 'unknown', // userId + [], // loginIds + 0, // createdAt + 'User', // name + null, // picture (Uri?) + '', // email + false, // isVerifiedEmail + '', // phone + false, // isVerifiedPhone + {}, // customAttributes + '', // givenName + '', // middleName + '', // familyName + false, // hasPassword + 'enabled', // status + [], // roleNames + [], // ssoAppIds + [], // oauthProviders (List) + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + if (mounted) { Navigator.of(context).pop(); // Close Polling Dialog _onLoginSuccess(jwt); @@ -287,11 +408,12 @@ class _LoginScreenState extends ConsumerState if (WebAuthIntegration.isPopup()) { WebAuthIntegration.sendLoginSuccess(token); _showError("Login Successful! You can close this window."); - } else if (_redirectUrl != null) { + } else if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { final target = "$_redirectUrl?token=$token"; launchUrlString(target, webOnlyWindowName: '_self'); } else { - _showError("Login Successful (Token Received)"); + // Standalone mode: Go to dashboard to act as an auth platform + if (mounted) context.go('/dashboard'); } } @@ -333,12 +455,13 @@ class _LoginScreenState extends ConsumerState tabs: const [ Tab(text: "Email"), Tab(text: "Phone (SMS)"), + Tab(text: "QR"), ], ), const SizedBox(height: 24), SizedBox( - height: 300, + height: 350, child: TabBarView( controller: _tabController, children: [ @@ -402,6 +525,53 @@ class _LoginScreenState extends ConsumerState ), ], ), + + // QR Login View + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isQrLoading) + const CircularProgressIndicator() + else if (_qrImageBase64 != null) + Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: QrImageView( + data: _qrImageBase64!, + version: QrVersions.auto, + size: 200.0, + ), + ), + const SizedBox(height: 12), + Text( + _qrRemainingSeconds > 0 + ? "Remaining Time: ${_formatTime(_qrRemainingSeconds)}" + : "QR Code Expired", + style: TextStyle( + color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + "Scan with your mobile app", + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + TextButton( + onPressed: _startQrFlow, + child: const Text("Refresh QR") + ), + ], + ) + else + const Text("Failed to load QR code."), + ], + ), ], ), ), diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 89ece320..81e37ab6 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'features/auth/presentation/login_screen.dart'; +import 'features/auth/presentation/approve_qr_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'core/services/auth_proxy_service.dart'; @@ -79,6 +80,14 @@ final _router = GoRouter( return LoginScreen(verificationToken: token); }, ), + GoRoute( + path: '/approve', + builder: (context, state) { + final ref = state.uri.queryParameters['ref']; + _routerLogger.info("Navigating to /approve with ref: $ref"); + return ApproveQrScreen(pendingRef: ref); + }, + ), GoRoute( path: '/dashboard', builder: (context, state) { @@ -98,7 +107,7 @@ final _router = GoRouter( final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false; final path = state.uri.path; - final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/'); + final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/') || path == '/approve'; _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 5dedbd77..1f13a129 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: url_launcher: ^6.3.2 logging: ^1.2.0 logger: ^2.0.0 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: