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)})"
+ : "재발송",
+ ),
+ ),
],
],
),