1
0
forked from baron/baron-sso

링크로그인 동작

This commit is contained in:
Lectom C Han
2026-01-29 09:40:01 +09:00
parent 742964cf71
commit 8faa08e377
2 changed files with 100 additions and 15 deletions

View File

@@ -28,16 +28,16 @@ import (
const ( const (
// Redis Key Prefixes // Redis Key Prefixes
prefixSession = "enchanted_session:" prefixSession = "enchanted_session:"
prefixToken = "enchanted_token:" prefixToken = "enchanted_token:"
prefixLoginCode = "login_code_flow:" prefixLoginCode = "login_code_flow:"
prefixLoginCodePending = "login_code_pending:" prefixLoginCodePending = "login_code_pending:"
prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsTarget = "login_code_sms_target:"
prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
prefixLoginCodeShort = "login_code_short:" prefixLoginCodeShort = "login_code_short:"
prefixPollMeta = "poll_meta:" prefixPollMeta = "poll_meta:"
prefixSignupEmail = "signup:email:" prefixSignupEmail = "signup:email:"
prefixSignupPhone = "signup:phone:" prefixSignupPhone = "signup:phone:"
// Session Statuses // Session Statuses
statusPending = "pending" statusPending = "pending"
@@ -54,6 +54,7 @@ const (
pwdResetExpiration = 15 * time.Minute pwdResetExpiration = 15 * time.Minute
minPollInterval = 2 * time.Second minPollInterval = 2 * time.Second
loginCodeExpiration = 10 * time.Minute loginCodeExpiration = 10 * time.Minute
linkResendCooldown = 60 * time.Second
) )
type AuthHandler struct { type AuthHandler struct {
@@ -651,6 +652,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
"expiresIn": expiresIn, "expiresIn": expiresIn,
"interval": int(minPollInterval.Seconds()), "interval": int(minPollInterval.Seconds()),
"resendAfter": int(linkResendCooldown.Seconds()),
}) })
} else if err != nil && !errors.Is(err, domain.ErrNotSupported) { } else if err != nil && !errors.Is(err, domain.ErrNotSupported) {
slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) 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, "maskedEmail": loginID,
"expiresIn": int(defaultExpiration.Seconds()), "expiresIn": int(defaultExpiration.Seconds()),
"interval": int(minPollInterval.Seconds()), "interval": int(minPollInterval.Seconds()),
"resendAfter": int(linkResendCooldown.Seconds()),
"userCode": userCode, "userCode": userCode,
}) })
} }
@@ -1490,6 +1493,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
if h.EmailService == nil { if h.EmailService == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) 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 { if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil {
slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) 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"}) 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 { func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string {
if req == nil || loginID == "" { _, link, ok := h.prepareKratosShortLogin(req, loginID)
if !ok {
return "" 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(`
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
<h2 style="color: #1A1F2C;">Baron SSO 로그인</h2>
<p>아래 버튼을 클릭하여 로그인을 완료해 주세요.</p>
<div style="margin: 24px 0;">
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">로그인 완료하기</a>
</div>
<p style="font-size: 14px;">간편 코드: <strong>%s</strong></p>
<p style="font-size: 12px; color: #888;">링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.</p>
</div>
`, 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")) code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
if code == "" { if code == "" {
return "" return "", "", false
} }
shortCode := h.generateShortCode(code) shortCode := h.generateShortCode(code)
if shortCode == "" { if shortCode == "" {
return "" return "", "", false
} }
pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) 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) 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 { func (h *AuthHandler) generateShortCode(code string) string {

View File

@@ -42,6 +42,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final TextEditingController _shortCodePrefixController = TextEditingController(); final TextEditingController _shortCodePrefixController = TextEditingController();
final TextEditingController _shortCodeDigitsController = TextEditingController(); final TextEditingController _shortCodeDigitsController = TextEditingController();
String? _linkPendingRef; String? _linkPendingRef;
String? _lastLinkLoginId;
bool _lastLinkIsEmail = true;
int _linkResendSeconds = 0;
Timer? _linkResendTimer;
@override @override
void initState() { void initState() {
@@ -111,10 +115,30 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _resetLinkLoginState() { void _resetLinkLoginState() {
_linkPendingRef = null; _linkPendingRef = null;
_lastLinkLoginId = null;
_lastLinkIsEmail = true;
_linkResendTimer?.cancel();
_linkResendTimer = null;
_linkResendSeconds = 0;
_shortCodePrefixController.clear(); _shortCodePrefixController.clear();
_shortCodeDigitsController.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 // Helper to decode JWT and get loginId
String _getLoginIdFromJwt(String jwt) { String _getLoginIdFromJwt(String jwt) {
try { try {
@@ -406,6 +430,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_passwordController.dispose(); _passwordController.dispose();
_shortCodePrefixController.dispose(); _shortCodePrefixController.dispose();
_shortCodeDigitsController.dispose(); _shortCodeDigitsController.dispose();
_linkResendTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -496,11 +521,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final mode = (initResponse['mode'] ?? '').toString(); final mode = (initResponse['mode'] ?? '').toString();
final provider = (initResponse['provider'] ?? '').toString(); final provider = (initResponse['provider'] ?? '').toString();
final interval = initResponse['interval']; final interval = initResponse['interval'];
final resendAfter = initResponse['resendAfter'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
if (mounted) { if (mounted) {
setState(() { setState(() {
_linkPendingRef = pendingRef?.toString(); _linkPendingRef = pendingRef?.toString();
_lastLinkLoginId = loginId;
_lastLinkIsEmail = isEmail;
}); });
Navigator.of(context).pop(); // Close Loading Navigator.of(context).pop(); // Close Loading
@@ -512,15 +540,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final initialInterval = (interval is int && interval > 0) final initialInterval = (interval is int && interval > 0)
? Duration(seconds: interval) ? Duration(seconds: interval)
: const Duration(seconds: 2); : const Duration(seconds: 2);
if (resendAfter is int && resendAfter > 0) {
_startLinkResendTimer(resendAfter);
}
_pollForSession(pendingRef, initialInterval: initialInterval); _pollForSession(pendingRef, initialInterval: initialInterval);
} }
} catch (e) { } catch (e) {
debugPrint("[Auth] Initialization failed: $e"); debugPrint("[Auth] Initialization failed: $e");
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
if (mounted) { if (mounted) {
setState(() { setState(_resetLinkLoginState);
_linkPendingRef = null;
});
} }
if (e.toString().contains("User not registered")) { if (e.toString().contains("User not registered")) {
_showUnregisteredDialog(); _showUnregisteredDialog();
@@ -537,6 +566,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Starting poll for ref: $pendingRef"); debugPrint("[Auth] Starting poll for ref: $pendingRef");
while (attempts < maxAttempts && mounted) { while (attempts < maxAttempts && mounted) {
if (_linkPendingRef != pendingRef) {
return;
}
await Future.delayed(pollInterval); await Future.delayed(pollInterval);
attempts++; attempts++;
@@ -883,6 +915,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
child: const Text("코드로 로그인"), 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)})"
: "재발송",
),
),
], ],
], ],
), ),