diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 1bd91fc9..476cd069 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -4,6 +4,7 @@ type EnchantedLinkInitRequest struct { LoginID string `json:"loginId"` URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow) Method string `json:"method,omitempty"` // "email" or "sms" + CodeOnly bool `json:"codeOnly,omitempty"` } type EnchantedLinkInitResponse struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 553ef9b6..f9969d23 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -35,6 +35,7 @@ const ( prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeShort = "login_code_short:" + prefixLoginCodeSmsOnly = "login_code_sms_only:" prefixPollMeta = "poll_meta:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -630,6 +631,11 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { if init.LoginID != "" { keyLoginID = init.LoginID } + if !strings.Contains(loginID, "@") && req.CodeOnly { + _ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration) + } else { + _ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID) + } if init.FlowID != "" { _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) } @@ -644,6 +650,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { if !init.ExpiresAt.IsZero() { expiresIn = int(time.Until(init.ExpiresAt).Seconds()) } + if expiresIn <= 0 { + expiresIn = int(loginCodeExpiration.Seconds()) + } return c.JSON(fiber.Map{ "linkId": "Sent", "pendingRef": pendingRef, @@ -1609,10 +1618,13 @@ type shortLoginCodePayload struct { } func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { - _, link, ok := h.prepareKratosShortLogin(req, loginID) + shortCode, link, ok := h.prepareKratosShortLogin(req, loginID) if !ok { return "" } + if h.isSmsCodeOnly(loginID) { + return fmt.Sprintf("[Baron 통합로그인] 로그인 코드: %s", shortCode) + } return fmt.Sprintf("[Baron 통합로그인] %s", link) } @@ -1667,6 +1679,14 @@ func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID return shortCode, link, true } +func (h *AuthHandler) isSmsCodeOnly(loginID string) bool { + if loginID == "" { + return false + } + val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID) + return val != "" +} + func (h *AuthHandler) generateShortCode(code string) string { const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for i := 0; i < 10; i++ { diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 43bd815d..ddd653bb 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -41,17 +41,24 @@ class AuthProxyService { } } - static Future> initEnchantedLink(String loginId, {String? method}) async { + static Future> initEnchantedLink( + String loginId, { + String? method, + bool? codeOnly, + }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr'); - final body = { + final body = { 'loginId': loginId, 'uri': userfrontUrl, }; if (method != null) { body['method'] = method; } + if (codeOnly == true) { + body['codeOnly'] = true; + } final response = await http.post( url, diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index ee092bf4..1294efe7 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -46,6 +46,8 @@ class _LoginScreenState extends ConsumerState bool _lastLinkIsEmail = true; int _linkResendSeconds = 0; Timer? _linkResendTimer; + int _linkExpireSeconds = 0; + Timer? _linkExpireTimer; @override void initState() { @@ -120,6 +122,9 @@ class _LoginScreenState extends ConsumerState _linkResendTimer?.cancel(); _linkResendTimer = null; _linkResendSeconds = 0; + _linkExpireTimer?.cancel(); + _linkExpireTimer = null; + _linkExpireSeconds = 0; _shortCodePrefixController.clear(); _shortCodeDigitsController.clear(); } @@ -139,6 +144,25 @@ class _LoginScreenState extends ConsumerState }); } + void _startLinkExpireTimer(int seconds) { + _linkExpireSeconds = seconds; + _linkExpireTimer?.cancel(); + _linkExpireTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + if (_linkExpireSeconds > 0) { + setState(() { + _linkExpireSeconds--; + }); + return; + } + timer.cancel(); + if (mounted) { + setState(_resetLinkLoginState); + context.go('/signin'); + } + }); + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -505,7 +529,7 @@ class _LoginScreenState extends ConsumerState } } - Future _startEnchantedFlow(String loginId, {required bool isEmail}) async { + Future _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async { try { if (mounted) { showDialog( @@ -516,12 +540,16 @@ class _LoginScreenState extends ConsumerState } // 1. Init via Backend API - final initResponse = await AuthProxyService.initEnchantedLink(loginId); + final initResponse = await AuthProxyService.initEnchantedLink( + loginId, + codeOnly: codeOnly, + ); final pendingRef = initResponse['pendingRef']; final mode = (initResponse['mode'] ?? '').toString(); final provider = (initResponse['provider'] ?? '').toString(); final interval = initResponse['interval']; final resendAfter = initResponse['resendAfter']; + final expiresIn = initResponse['expiresIn']; debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { @@ -543,6 +571,9 @@ class _LoginScreenState extends ConsumerState if (resendAfter is int && resendAfter > 0) { _startLinkResendTimer(resendAfter); } + if (expiresIn is int && expiresIn > 0) { + _startLinkExpireTimer(expiresIn); + } _pollForSession(pendingRef, initialInterval: initialInterval); } } catch (e) { @@ -890,9 +921,12 @@ class _LoginScreenState extends ConsumerState child: TextField( controller: _shortCodeDigitsController, keyboardType: TextInputType.number, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: "000000", - border: OutlineInputBorder(), + border: const OutlineInputBorder(), + hintText: _linkExpireSeconds > 0 + ? "유효시간 ${_formatTime(_linkExpireSeconds)}" + : "000000", ), maxLength: 6, ), @@ -917,22 +951,50 @@ class _LoginScreenState extends ConsumerState ), 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('@')); - }, + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); + return; + } + final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError("이메일 또는 휴대폰 번호를 입력해 주세요."); + return; + } + _startEnchantedFlow( + loginId, + isEmail: _lastLinkIsEmail || loginId.contains('@'), + codeOnly: false, + ); + }, child: Text( _linkResendSeconds > 0 ? "재발송 (${_formatTime(_linkResendSeconds)})" : "재발송", ), ), + if (!_lastLinkIsEmail) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); + return; + } + final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError("휴대폰 번호를 입력해 주세요."); + return; + } + _startEnchantedFlow( + loginId, + isEmail: false, + codeOnly: true, + ); + }, + child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), + ), + ], ], ], ),