diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index c5e191a5..f1ecda2e 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -970,12 +970,18 @@ components: properties: token: type: string + verifyOnly: + type: boolean MagicLinkVerifyResponse: type: object properties: token: type: string + status: + type: string + pendingRef: + type: string message: type: string diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index b87fdf9b..005b6d43 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -26,7 +26,8 @@ type EnchantedLinkPollResponse struct { } type MagicLinkVerifyRequest struct { - Token string `json:"token"` + Token string `json:"token"` + VerifyOnly bool `json:"verifyOnly,omitempty"` } type QRInitResponse struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 651d664b..d8ddd928 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -774,6 +774,53 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { }) } + if data["status"] == "approved" { + loginID := data["loginId"] + if loginID == "" { + loginID = data["login_id"] + } + if loginID == "" { + slog.Warn("[Poll] Approved but missing loginId", "pendingRef", req.PendingRef) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + } + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + + authInfo, err := h.IdpProvider.IssueSession(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[Poll] IDP session issue failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + c.Locals("login_id", loginID) + setSessionIDLocal(c, authInfo.SessionToken) + sessionID := extractSessionIDFromToken(authInfo.SessionToken) + + sessionData := map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + } + if sessionID != "" { + sessionData["session_id"] = sessionID + } + sessionDataJSON, _ := json.Marshal(sessionData) + h.RedisService.Set(prefixSession+req.PendingRef, string(sessionDataJSON), defaultExpiration) + + h.writeLinkAuditLog(loginID, req.PendingRef, authInfo.SessionToken, c) + + return c.JSON(fiber.Map{ + "sessionJwt": authInfo.SessionToken.JWT, + "status": "ok", + }) + } + return c.JSON(fiber.Map{ "error": "authorization_pending", "interval": int(minPollInterval.Seconds()), @@ -804,6 +851,26 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef) + if req.VerifyOnly { + if pendingRef == "" || loginID == "" { + slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + } + + // 승인 전용: 세션 발급 없이 승인 상태만 기록 + sessionData, _ := json.Marshal(map[string]string{ + "status": "approved", + "loginId": loginID, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) + + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": pendingRef, + "message": "Login approved", + }) + } + if h.IdpProvider == nil { slog.Error("[Verify] IDP Provider is nil") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 2a07288b..c566d7f9 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -125,7 +125,7 @@ class AuthProxyService { throw Exception('Polling failed: ${response.body}'); } - static Future> verifyMagicLink(String token) async { + static Future> verifyMagicLink(String token, {bool verifyOnly = false}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify'); final response = await http.post( @@ -133,6 +133,7 @@ class AuthProxyService { headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'token': token, + 'verifyOnly': verifyOnly, }), ); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index f71c025b..31e33d42 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -430,11 +430,22 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Starting verification for token: $token"); try { // Use Backend to verify the token (Backend-Driven Flow) - final res = await AuthProxyService.verifyMagicLink(token); + final res = await AuthProxyService.verifyMagicLink( + token, + verifyOnly: _verificationOnly, + ); debugPrint("[Auth] Verification successful for token: $token"); final jwt = res['token'] ?? res['sessionJwt']; + final status = res['status']?.toString(); final hasLocalSession = await _hasValidLocalSession(); + if (status == 'approved' || (jwt == null && _verificationOnly)) { + if (mounted) { + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + } + return; + } + if (jwt is String && jwt.isNotEmpty) { if (hasLocalSession) { _markVerificationApproved( @@ -442,14 +453,7 @@ class _LoginScreenState extends ConsumerState ); return; } - _markVerificationApproved( - "링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", - title: '링크 로그인 완료', - pageTitle: '링크 로그인', - actionLabel: '로그인 화면으로 이동', - actionPath: '/signin', - autoRedirect: true, - ); + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); return; } @@ -492,8 +496,11 @@ class _LoginScreenState extends ConsumerState ); return; } - _markVerificationApproved( - "링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", + if (_verificationOnly) { + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + return; + } + _markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", title: '링크 로그인 완료', pageTitle: '링크 로그인', actionLabel: '로그인 화면으로 이동', @@ -539,6 +546,10 @@ class _LoginScreenState extends ConsumerState ); return; } + if (_verificationOnly) { + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + return; + } _completeLoginFromToken(jwt, provider: res['provider'] as String?); return; }