From 8faa08e3770e526de8799746b2fb412155264434 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 09:40:01 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 59 +++++++++++++++---- .../auth/presentation/login_screen.dart | 56 +++++++++++++++++- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 37825b27..553ef9b6 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -28,16 +28,16 @@ import ( const ( // Redis Key Prefixes - prefixSession = "enchanted_session:" - prefixToken = "enchanted_token:" - prefixLoginCode = "login_code_flow:" - prefixLoginCodePending = "login_code_pending:" + prefixSession = "enchanted_session:" + prefixToken = "enchanted_token:" + prefixLoginCode = "login_code_flow:" + prefixLoginCodePending = "login_code_pending:" prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" - prefixLoginCodeShort = "login_code_short:" - prefixPollMeta = "poll_meta:" - prefixSignupEmail = "signup:email:" - prefixSignupPhone = "signup:phone:" + prefixLoginCodeShort = "login_code_short:" + prefixPollMeta = "poll_meta:" + prefixSignupEmail = "signup:email:" + prefixSignupPhone = "signup:phone:" // Session Statuses statusPending = "pending" @@ -54,6 +54,7 @@ const ( pwdResetExpiration = 15 * time.Minute minPollInterval = 2 * time.Second loginCodeExpiration = 10 * time.Minute + linkResendCooldown = 60 * time.Second ) type AuthHandler struct { @@ -651,6 +652,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { "provider": h.IdpProvider.Name(), "expiresIn": expiresIn, "interval": int(minPollInterval.Seconds()), + "resendAfter": int(linkResendCooldown.Seconds()), }) } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) @@ -716,6 +718,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { "maskedEmail": loginID, "expiresIn": int(defaultExpiration.Seconds()), "interval": int(minPollInterval.Seconds()), + "resendAfter": int(linkResendCooldown.Seconds()), "userCode": userCode, }) } @@ -1490,6 +1493,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { if h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } + if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" { + subject = shortSubject + body = shortBody + } if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"}) @@ -1602,16 +1609,44 @@ type shortLoginCodePayload struct { } func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { - if req == nil || loginID == "" { + _, link, ok := h.prepareKratosShortLogin(req, loginID) + if !ok { return "" } + return fmt.Sprintf("[Baron 통합로그인] %s", link) +} + +func (h *AuthHandler) buildKratosShortEmailBody(req *kratosCourierRequest, loginID string) (string, string) { + shortCode, link, ok := h.prepareKratosShortLogin(req, loginID) + if !ok { + return "", "" + } + subject := "[Baron 통합로그인] 로그인 링크" + body := fmt.Sprintf(` +
+

Baron SSO 로그인

+

아래 버튼을 클릭하여 로그인을 완료해 주세요.

+
+ 로그인 완료하기 +
+

간편 코드: %s

+

링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.

+
+ `, link, shortCode) + return subject, body +} + +func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID string) (string, string, bool) { + if req == nil || loginID == "" { + return "", "", false + } code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { - return "" + return "", "", false } shortCode := h.generateShortCode(code) if shortCode == "" { - return "" + return "", "", false } pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) @@ -1629,7 +1664,7 @@ func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID } link := fmt.Sprintf("%s/l/%s", baseURL, shortCode) - return fmt.Sprintf("[Baron 통합로그인] %s", link) + return shortCode, link, true } func (h *AuthHandler) generateShortCode(code string) string { diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index dd433c49..ee092bf4 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -42,6 +42,10 @@ class _LoginScreenState extends ConsumerState final TextEditingController _shortCodePrefixController = TextEditingController(); final TextEditingController _shortCodeDigitsController = TextEditingController(); String? _linkPendingRef; + String? _lastLinkLoginId; + bool _lastLinkIsEmail = true; + int _linkResendSeconds = 0; + Timer? _linkResendTimer; @override void initState() { @@ -111,10 +115,30 @@ class _LoginScreenState extends ConsumerState void _resetLinkLoginState() { _linkPendingRef = null; + _lastLinkLoginId = null; + _lastLinkIsEmail = true; + _linkResendTimer?.cancel(); + _linkResendTimer = null; + _linkResendSeconds = 0; _shortCodePrefixController.clear(); _shortCodeDigitsController.clear(); } + void _startLinkResendTimer(int seconds) { + _linkResendSeconds = seconds; + _linkResendTimer?.cancel(); + _linkResendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + if (_linkResendSeconds > 0) { + _linkResendSeconds--; + } else { + timer.cancel(); + } + }); + }); + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -406,6 +430,7 @@ class _LoginScreenState extends ConsumerState _passwordController.dispose(); _shortCodePrefixController.dispose(); _shortCodeDigitsController.dispose(); + _linkResendTimer?.cancel(); super.dispose(); } @@ -496,11 +521,14 @@ class _LoginScreenState extends ConsumerState final mode = (initResponse['mode'] ?? '').toString(); final provider = (initResponse['provider'] ?? '').toString(); final interval = initResponse['interval']; + final resendAfter = initResponse['resendAfter']; debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { setState(() { _linkPendingRef = pendingRef?.toString(); + _lastLinkLoginId = loginId; + _lastLinkIsEmail = isEmail; }); Navigator.of(context).pop(); // Close Loading @@ -512,15 +540,16 @@ class _LoginScreenState extends ConsumerState final initialInterval = (interval is int && interval > 0) ? Duration(seconds: interval) : const Duration(seconds: 2); + if (resendAfter is int && resendAfter > 0) { + _startLinkResendTimer(resendAfter); + } _pollForSession(pendingRef, initialInterval: initialInterval); } } catch (e) { debugPrint("[Auth] Initialization failed: $e"); if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); if (mounted) { - setState(() { - _linkPendingRef = null; - }); + setState(_resetLinkLoginState); } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); @@ -537,6 +566,9 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Starting poll for ref: $pendingRef"); while (attempts < maxAttempts && mounted) { + if (_linkPendingRef != pendingRef) { + return; + } await Future.delayed(pollInterval); attempts++; @@ -883,6 +915,24 @@ class _LoginScreenState extends ConsumerState ), child: const Text("코드로 로그인"), ), + const SizedBox(height: 12), + TextButton( + onPressed: _linkResendSeconds > 0 + ? null + : () { + final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError("이메일 또는 휴대폰 번호를 입력해 주세요."); + return; + } + _startEnchantedFlow(loginId, isEmail: _lastLinkIsEmail || loginId.contains('@')); + }, + child: Text( + _linkResendSeconds > 0 + ? "재발송 (${_formatTime(_linkResendSeconds)})" + : "재발송", + ), + ), ], ], ),