From 1c0a5ed2720348f0ef14eca36b214210a5390b44 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Mon, 2 Feb 2026 14:03:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/playwright-report/index.html | 85 +++ adminfront/test-results/.last-run.json | 4 + backend/internal/handler/auth_handler.go | 513 ++++++++++++++++-- .../internal/middleware/audit_middleware.go | 3 + devfront/playwright-report/index.html | 85 +++ devfront/test-results/.last-run.json | 4 + docs/AGENTS.md | 59 +- docs/auth-flow.md | 41 +- docs/issue-146-remote-login.md | 35 ++ docs/test-plan.md | 132 +++++ .../lib/core/services/auth_proxy_service.dart | 14 +- .../auth/presentation/login_screen.dart | 69 ++- .../presentation/dashboard_screen.dart | 422 +++++++++----- userfront/lib/main.dart | 8 +- userfront/nginx.conf | 22 +- 15 files changed, 1265 insertions(+), 231 deletions(-) create mode 100644 adminfront/playwright-report/index.html create mode 100644 adminfront/test-results/.last-run.json create mode 100644 devfront/playwright-report/index.html create mode 100644 devfront/test-results/.last-run.json create mode 100644 docs/issue-146-remote-login.md create mode 100644 docs/test-plan.md diff --git a/adminfront/playwright-report/index.html b/adminfront/playwright-report/index.html new file mode 100644 index 00000000..8371ce48 --- /dev/null +++ b/adminfront/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/adminfront/test-results/.last-run.json b/adminfront/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/adminfront/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d8ddd928..7ba07c90 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -38,6 +38,12 @@ const ( prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeShort = "login_code_short:" + prefixLoginCodeValue = "login_code_value:" + prefixLoginIDRaw = "login_id_raw:" + prefixLoginMethod = "login_method:" + prefixLoginFlow = "login_flow:" + prefixLoginStrategy = "login_strategy:" + prefixLoginApproverMeta = "login_approver_meta:" prefixLoginCodeSmsOnly = "login_code_sms_only:" prefixLoginCodeQrPending = "login_code_qr_pending:" prefixLoginCodeQr = "login_code_qr:" @@ -53,6 +59,10 @@ const ( statusPending = "pending" statusSuccess = "success" + // Login Flows + loginFlowCode = "code" + loginFlowLink = "link" + // Durations defaultExpiration = 5 * time.Minute signupStateExpiration = 10 * time.Minute @@ -635,7 +645,16 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) } pendingRef := GenerateSecureToken(3) - h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration) + sessionData, _ := json.Marshal(map[string]string{ + "status": statusPending, + "loginId": keyLoginID, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + intent := loginFlowLink + if req.CodeOnly { + intent = loginFlowCode + } + h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration) if drySend { _ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration) @@ -677,11 +696,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef) // Store in Redis - h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration) + sessionData, _ := json.Marshal(map[string]string{ + "status": statusPending, + "loginId": lookupLoginID, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration) if drySend { _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration) } + intent := loginFlowLink + if req.CodeOnly { + intent = loginFlowCode + } + h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration) // Generate Link slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL) @@ -787,13 +815,41 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { 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"}) + loginStrategy := h.loadLoginStrategy(req.PendingRef) + if loginStrategy == "" { + loginStrategy = loginFlowLink + } + + var authInfo *domain.AuthInfo + var err error + if loginStrategy == loginFlowCode { + code, _ := h.RedisService.Get(prefixLoginCodeValue + req.PendingRef) + code = normalizeLoginCode(code) + if code == "" { + slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", req.PendingRef) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login code expired"}) + } + flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) + if flowID == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + } + authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code) + 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 code verify failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"}) + } + } else { + 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"}) } - 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"}) @@ -802,6 +858,13 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) sessionID := extractSessionIDFromToken(authInfo.SessionToken) + if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" { + if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" { + sessionID = resolved + authInfo.SessionToken.SessionID = resolved + setSessionIDLocal(c, authInfo.SessionToken) + } + } sessionData := map[string]string{ "status": statusSuccess, @@ -814,6 +877,14 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { h.RedisService.Set(prefixSession+req.PendingRef, string(sessionDataJSON), defaultExpiration) h.writeLinkAuditLog(loginID, req.PendingRef, authInfo.SessionToken, c) + h.clearLoginMeta(req.PendingRef) + if loginStrategy == loginFlowCode { + h.RedisService.Delete(prefixLoginCode + loginID) + h.RedisService.Delete(prefixLoginCodePending + loginID) + h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID) + h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID) + h.RedisService.Delete(prefixLoginCodeValue + req.PendingRef) + } return c.JSON(fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, @@ -852,11 +923,14 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef) if req.VerifyOnly { + c.Locals("auth_timeline_skip", true) 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"}) } + h.storeLoginApproverMeta(pendingRef, c, defaultExpiration) + // 승인 전용: 세션 발급 없이 승인 상태만 기록 sessionData, _ := json.Marshal(map[string]string{ "status": "approved", @@ -919,6 +993,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { LoginID string `json:"loginId"` Code string `json:"code"` PendingRef string `json:"pendingRef"` + VerifyOnly bool `json:"verifyOnly,omitempty"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginCode] Body parse error", "error", err) @@ -945,6 +1020,43 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) } + if req.VerifyOnly { + c.Locals("auth_timeline_skip", true) + effectiveLoginID := lookupLoginID + if !strings.Contains(loginID, "@") { + if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookupLoginID); mapped != "" { + effectiveLoginID = mapped + } + } + pendingRef := strings.TrimSpace(req.PendingRef) + storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID) + if pendingRef == "" { + pendingRef = storedRef + } else if storedRef != "" && pendingRef != storedRef { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + } + if pendingRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + } + expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef) + expectedCode = normalizeLoginCode(expectedCode) + inputCode := normalizeLoginCode(req.Code) + if expectedCode == "" || inputCode == "" || inputCode != expectedCode { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + } + h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration) + sessionData, _ := json.Marshal(map[string]string{ + "status": "approved", + "loginId": effectiveLoginID, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": pendingRef, + "message": "Login approved", + }) + } + authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { @@ -997,6 +1109,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { var req struct { ShortCode string `json:"shortCode"` + VerifyOnly bool `json:"verifyOnly,omitempty"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginShortCode] Body parse error", "error", err) @@ -1021,6 +1134,29 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) } + if req.VerifyOnly { + c.Locals("auth_timeline_skip", true) + if payload.PendingRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"}) + } + normalizedCode := normalizeLoginCode(payload.Code) + if normalizedCode != "" { + h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration) + } + h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration) + sessionData, _ := json.Marshal(map[string]string{ + "status": "approved", + "loginId": payload.LoginID, + }) + h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixLoginCodeShort + shortCode) + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": payload.PendingRef, + "message": "Login approved", + }) + } + if h.IdpProvider == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } @@ -1825,6 +1961,20 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri } } + if loginCode != "" && label == "로그인" { + loginID := req.Recipient + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) + if pendingRef != "" { + normalizedCode := normalizeLoginCode(loginCode) + if normalizedCode != "" { + _ = h.RedisService.Set(prefixLoginCodeValue+pendingRef, normalizedCode, loginCodeExpiration) + } + } + } + if code == "" { return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label) } @@ -2241,6 +2391,39 @@ func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) { return meta, true } +func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) { + if h.RedisService == nil || pendingRef == "" || c == nil { + return + } + meta := qrMeta{ + IPAddress: extractClientIPFromHeaders(c), + UserAgent: c.Get("User-Agent"), + } + raw, err := json.Marshal(meta) + if err != nil { + return + } + _ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl) +} + +func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) { + if h.RedisService == nil || pendingRef == "" { + return qrMeta{}, false + } + val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef) + if err != nil || val == "" { + return qrMeta{}, false + } + var meta qrMeta + if err := json.Unmarshal([]byte(val), &meta); err != nil { + return qrMeta{}, false + } + if meta.IPAddress == "" && meta.UserAgent == "" { + return qrMeta{}, false + } + return meta, true +} + func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) { if h.RedisService == nil || pendingRef == "" || sessionID == "" { return @@ -2259,6 +2442,57 @@ func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string { return strings.TrimSpace(val) } +func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) { + if h.RedisService == nil || pendingRef == "" { + return + } + method := resolveLoginMethod(rawMethod, loginID) + if method != "" { + _ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl) + } + if flow != "" { + _ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl) + } + if strategy != "" { + _ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl) + } + if strings.TrimSpace(loginID) != "" { + _ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl) + } +} + +func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) { + if h.RedisService == nil || pendingRef == "" { + return "", "", "", "" + } + method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef) + flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef) + strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef) + rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef) + return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID) +} + +func (h *AuthHandler) loadLoginFlow(pendingRef string) string { + _, flow, _, _ := h.loadLoginMeta(pendingRef) + return flow +} + +func (h *AuthHandler) loadLoginStrategy(pendingRef string) string { + _, _, strategy, _ := h.loadLoginMeta(pendingRef) + return strategy +} + +func (h *AuthHandler) clearLoginMeta(pendingRef string) { + if h.RedisService == nil || pendingRef == "" { + return + } + _ = h.RedisService.Delete(prefixLoginMethod + pendingRef) + _ = h.RedisService.Delete(prefixLoginFlow + pendingRef) + _ = h.RedisService.Delete(prefixLoginStrategy + pendingRef) + _ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef) + _ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef) +} + func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) { if h.AuditRepo == nil || pendingRef == "" { return @@ -2314,14 +2548,53 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken meta.UserAgent = c.Get("User-Agent") } sessionID := extractSessionIDFromToken(sessionToken) + loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef) + path := "/api/v1/auth/magic-link/verify" + authLabel := "링크" + if loginStrategy == loginFlowCode { + path = "/api/v1/auth/login/code/verify" + } + displayFlow := loginFlow + if displayFlow == "" { + displayFlow = loginStrategy + } + if displayFlow == loginFlowCode { + authLabel = "코드" + } else if displayFlow == loginFlowLink { + authLabel = "링크" + } + logLoginID := loginID + if rawLoginID != "" { + logLoginID = rawLoginID + } details := map[string]any{ - "path": "/api/v1/auth/magic-link/verify", - "login_id": loginID, + "path": path, + "login_id": logLoginID, "pending_ref": pendingRef, } if sessionID != "" { details["session_id"] = sessionID } + if loginMethod != "" { + details["login_method"] = loginMethod + } + if loginFlow != "" { + details["login_flow"] = loginFlow + } + if loginStrategy != "" { + details["login_strategy"] = loginStrategy + } + if rawLoginID != "" && rawLoginID != loginID { + details["login_id_effective"] = loginID + } + if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok { + if approverMeta.IPAddress != "" { + details["approved_ip"] = approverMeta.IPAddress + } + if approverMeta.UserAgent != "" { + details["approved_user_agent"] = approverMeta.UserAgent + } + } detailsJSON, _ := json.Marshal(details) log := &domain.AuditLog{ @@ -2329,12 +2602,12 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken Timestamp: time.Now(), UserID: "", SessionID: sessionID, - EventType: "POST /api/v1/auth/magic-link/verify", + EventType: fmt.Sprintf("POST %s", path), Status: "success", IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, Details: string(detailsJSON), - AuthMethod: "링크", + AuthMethod: authLabel, } _ = h.AuditRepo.Create(log) } @@ -2370,6 +2643,16 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { limit = 100 } + cursorRaw := strings.TrimSpace(c.Query("cursor")) + var cursor *domain.AuditCursor + if cursorRaw != "" { + var err error + cursor, err = parseAuditCursor(cursorRaw) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cursor"}) + } + } + profile, err := h.resolveCurrentProfile(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) @@ -2384,38 +2667,76 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { fetchLimit = 500 } - logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, nil) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"}) - } - items := make([]domain.AuditLog, 0, limit) - for _, log := range logs { - if !isAuthEventType(log.EventType) { - continue + nextCursor := "" + currentCursor := cursor + const maxBatches = 10 + for batch := 0; batch < maxBatches && len(items) < limit; batch++ { + logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"}) } - if !matchesAuthTimelineUser(log, profile, candidates) { - continue + if len(logs) == 0 { + nextCursor = "" + break } - if log.UserID == "" { - log.UserID = profile.ID + + var lastScanned *domain.AuditLog + for i := range logs { + log := logs[i] + lastScanned = &log + if !isAuthEventType(log.EventType) { + continue + } + if !matchesAuthTimelineUser(log, profile, candidates) { + continue + } + if shouldSkipAuthTimeline(log) { + continue + } + if log.UserID == "" { + log.UserID = profile.ID + } + log.AuthMethod = deriveAuthMethod(log) + if log.AuthMethod == "" { + continue + } + if log.SessionID == "" { + log.SessionID = extractSessionIDFromAuditDetails(log.Details) + } + items = append(items, log) + if len(items) >= limit { + nextCursor = encodeAuditCursor(log) + break + } } - log.AuthMethod = deriveAuthMethod(log) - if log.AuthMethod == "" { - continue - } - if log.SessionID == "" { - log.SessionID = extractSessionIDFromAuditDetails(log.Details) - } - items = append(items, log) + if len(items) >= limit { break } + + if len(logs) < fetchLimit { + nextCursor = "" + break + } + + if lastScanned == nil { + nextCursor = "" + break + } + + currentCursor = &domain.AuditCursor{ + Timestamp: lastScanned.Timestamp, + EventID: lastScanned.EventID, + } + nextCursor = encodeAuditCursor(*lastScanned) } return c.JSON(fiber.Map{ - "items": items, - "limit": limit, + "items": items, + "limit": limit, + "cursor": cursorRaw, + "next_cursor": nextCursor, }) } @@ -2640,6 +2961,54 @@ func extractRequestBody(details map[string]any) map[string]any { return body } +func shouldSkipAuthTimeline(log domain.AuditLog) bool { + details, _ := parseAuditDetails(log.Details) + path := strings.ToLower(extractAuditPath(log)) + if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") { + return true + } + if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") || + strings.Contains(path, "/api/v1/auth/login/code/verify")) { + sessionID := log.SessionID + if sessionID == "" { + sessionID = extractSessionIDFromAuditDetails(log.Details) + } + if sessionID == "" { + return true + } + } + if details != nil { + if raw, ok := details["auth_timeline_skip"]; ok { + switch value := raw.(type) { + case bool: + if value { + return true + } + case string: + if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { + return true + } + } + } + } + requestBody := extractRequestBody(details) + if requestBody != nil { + if raw, ok := requestBody["verifyOnly"]; ok { + switch value := raw.(type) { + case bool: + if value { + return true + } + case string: + if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { + return true + } + } + } + } + return false +} + func loginIDKind(loginID string) string { normalized := strings.TrimSpace(loginID) if normalized == "" { @@ -2651,6 +3020,31 @@ func loginIDKind(loginID string) string { return "phone" } +func resolveLoginMethod(rawMethod, loginID string) string { + method := strings.ToLower(strings.TrimSpace(rawMethod)) + if method == "sms" || method == "email" { + return method + } + if strings.TrimSpace(loginID) == "" { + return "" + } + if strings.Contains(loginID, "@") { + return "email" + } + return "sms" +} + +func loginMethodLabel(method string) string { + switch strings.ToLower(strings.TrimSpace(method)) { + case "sms": + return "SMS" + case "email": + return "Email" + default: + return "" + } +} + func deriveAuthMethod(log domain.AuditLog) string { path := strings.ToLower(extractAuditPath(log)) if path == "" { @@ -2661,6 +3055,57 @@ func deriveAuthMethod(log domain.AuditLog) string { kind := loginIDKind(loginID) details, _ := parseAuditDetails(log.Details) requestBody := extractRequestBody(details) + if details != nil { + if raw, ok := details["auth_timeline_skip"]; ok { + switch value := raw.(type) { + case bool: + if value { + return "" + } + case string: + if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { + return "" + } + } + } + } + if requestBody != nil { + if raw, ok := requestBody["verifyOnly"]; ok { + switch value := raw.(type) { + case bool: + if value { + return "" + } + case string: + if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { + return "" + } + } + } + } + if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") || + strings.Contains(path, "/api/v1/auth/qr/poll") || + strings.Contains(path, "/api/v1/auth/qr/approve")) { + return "QR" + } + if details != nil { + rawFlow, _ := details["login_flow"].(string) + rawMethod, _ := details["login_method"].(string) + flow := strings.ToLower(strings.TrimSpace(rawFlow)) + methodLabel := loginMethodLabel(rawMethod) + switch flow { + case loginFlowCode: + if methodLabel != "" { + return fmt.Sprintf("코드(%s)", methodLabel) + } + return "코드" + case loginFlowLink: + if methodLabel != "" { + return fmt.Sprintf("링크(%s)", methodLabel) + } + return "링크" + } + } switch { case strings.Contains(path, "/api/v1/auth/password/login"): diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index a88982c0..b42e5756 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -144,6 +144,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { "tenant_id": tenantID, "request_body": maskedBody, } + if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline { + details["auth_timeline_skip"] = true + } if sessionID != "" { details["session_id"] = sessionID } diff --git a/devfront/playwright-report/index.html b/devfront/playwright-report/index.html new file mode 100644 index 00000000..c98a4a9a --- /dev/null +++ b/devfront/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/devfront/test-results/.last-run.json b/devfront/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/devfront/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/docs/AGENTS.md b/docs/AGENTS.md index f621b3cb..227b1214 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,22 +1,49 @@ # AGENTS 가이드 (Baron SSO) ## 목적 -- Inbound Auth/Launcher와 관리(Admin) 기능을 하나의 백엔드에서 운영하되, 네임스페이스·도메인·권한으로 강하게 분리한다. -- 사용자 플로우(가입/로그인)와 관리 플로우(Descope Management Key 사용)를 명확히 구분해 보안 사고면을 축소한다. +- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지 +- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리 +- 네트워크/보안 경계를 문서화해 회귀/설정 오류를 방지 -## 현재 원칙 -- **경계 분리**: `/admin/*` + admin 서브도메인에서만 관리 기능 노출. 일반 사용자 번들과 관리자 번들(또는 라우트)을 분리. -- **관리 키 취급**: Descope Management Key는 서버 내부에서만 사용, 비동기 잡/관리 API에서 래핑. 모든 관리 액션을 감사 로그/알람/레이트리밋으로 보호. -- **권한/가드**: role/permission 기반 접근 제어. 관리자 세션 TTL은 짧게, step-up MFA 고려. +## 시스템 요약 +- **Backend**: Command 단일 진입점, 감사 로그를 ClickHouse에 적재 +- **Ory Stack**: Kratos/Hydra/Keto/Oathkeeper (인증/토큰/정책) +- **Front**: UserFront(Flutter)-사용자 접점, AdminFront/DevFront(React)-내부 관리도구 +- **원칙**: Front는 Backend API를 통해서만 IDP 기능을 호출 -## 인증 플로우 핵심 -- **최초 회원가입**: SMS 인증(Enchanted Link/OTP) 필수 → 인증 성공 후 계정 생성 및 초기 세션 발급. -- **재로그인 분기 (앱 세션 보유 + 사용자 선택)**: - - 앱 로그인 상태 + 사용자가 “앱 승인” 선택: 앱을 MFA/IDPW 대체 수단으로 사용(푸시/딥링크 승인) → 승인 시 웹 세션 발급. - - 앱 세션이 없거나, 사용자가 이번 로그인에서 앱을 사용하지 않기로 선택: SMS 또는 이메일/비밀번호 경로로 진행. -- **세션 TTL**: 앱 기반 세션 유지시간을 `APP_SESSION_TTL_MINUTES` 환경 변수로 관리(기본 예: 30분). +## 네트워크/보안 경계 +- `ory-net`: Ory 내부 통신 전용 네트워크 +- `baron_net`: App(backend/userfront/adminfront/devfront) 네트워크 +- `public_net`: Oathkeeper, userfront 외부 공개. Gateway를 이용해 Proxy 분기 -## 작업 시 체크리스트 -- 관리 기능 개발 시 admin 네임스페이스, 권한 체크, 감사 로깅, 레이트리밋을 기본 포함. -- 인증/로그인 변경 시 “폴백은 사용자 선택일 때만” 규칙을 준수하고, UI에도 선택 흐름을 노출. -- 새 설정/비밀값은 .env.sample에 반영하고 서버에서만 소비하게 설계한다. +핵심 규칙: +- **Ory Admin 포트는 외부 노출 금지** (Backend만 `ory-net`을 통해 접근) +- **UserFront는 Oathkeeper 뒤에 있지 않음** +- **모든 Front(User/Admin/Dev)는 Ory Admin 엔드포인트에 직접 접근하지 않음** + +## 인증/세션 핵심 +- `IDP_PROVIDER` Ory 전용 저장 구조지만 향후 마이그레이션으로 추가 스택 지원할 수 있음 +- `sessionJwt`는 **JWT가 아닐 수 있음** (Kratos session token은 opaque 가능) +- OIDC Consent 플로우는 UserFront의 `/consent` 경로에서 처리 +- 토큰/쿠키 전달 방식 변경 시 `docs/auth-flow.md`를 반드시 갱신 + +## 작업 체크리스트 +- 인증/로그인 변경 시 + - `docs/auth-flow.md` 업데이트 + - 세션/쿠키/Authorization 전달 방식 영향도 점검 + - UserFront가 Ory/Oathkeeper 직접 호출하지 않도록 확인 + +- Ory 설정 변경 시 + - `compose.ory.yaml`, `docker/ory/*` 변경 범위 명시 + - `ory-net`/`public_net` 경계 유지 여부 확인 + +- 환경 변수 추가/변경 시 + - `.env.sample` 반영 + - 문서/가이드 갱신 + +- 배포/운영 변경 시 + - `Makefile`/compose 실행 절차 영향 확인 + - 최소 Smoke 테스트 수행 + +## 테스트 참고 +- 테스트 계획 및 수동 실행 기준은 `docs/test-plan.md`를 따른다. diff --git a/docs/auth-flow.md b/docs/auth-flow.md index 45ba01e0..d5bab1de 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -11,8 +11,8 @@ | 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 | |---|---|---|---| | ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) | -| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 | -| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 | +| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | Ory는 `/api/v1/auth/login/code/verify`/`verify-short`(verifyOnly) 사용 | +| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | 비-Ory 경로(verifyOnly 가능) | | SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 | | QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` | @@ -27,8 +27,10 @@ ### 2.2 Enchanted Link (Email/SMS) 1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신 2. `POST /api/v1/auth/enchanted-link/poll`로 폴링 -3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출 -4. Polling 응답에서 `sessionJwt` 수신 +3. 사용자가 링크 클릭하면 UserFront가 아래 중 하나 호출 + - Ory: `POST /api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` (verifyOnly=1) + - 비-Ory: `POST /api/v1/auth/magic-link/verify` (verifyOnly=1) +4. Polling 응답에서 `sessionJwt` 수신 (승인 후 Backend에서 세션 발급) ### 2.3 QR 로그인 1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신 @@ -75,14 +77,41 @@ --- -## 5) UserFront 주의사항 +## 5) 링크 로그인 ↔ QR 로그인 공유/분리 로직 + +### 5.1 공유되는 로직 (공통 기반) +- **IDP 코드 검증 로직 공유**: Ory 기준으로 링크 로그인과 QR 로그인 모두 `VerifyLoginCode`(코드 기반 로그인 검증)를 사용합니다. +- **Kratos courier relay 공유**: Kratos에서 발송되는 `login_code`를 `HandleKratosCourierRelay`에서 처리하며, 링크/QR 모두 이 경로를 거칩니다. +- **코드/플로우 상태 저장**: 코드 로그인 플로우의 `flow_id`는 공통 키(`prefixLoginCode`)에 저장됩니다. + +### 5.2 분리되는 로직 (pendingRef/승인 경로) +- **pendingRef 네임스페이스 분리**: + - 링크 로그인: `prefixSession`, `prefixLoginCodePending`, `prefixLoginMethod`, `prefixLoginFlow` + - QR 로그인: `prefixLoginCodeQrPending`, `prefixLoginCodeQr`, `prefixQrPending`, `prefixQrMeta`, `prefixQrApproverSession` +- **승인 엔드포인트 분리**: + - 링크 로그인: `/api/v1/auth/magic-link/verify` 또는 `/api/v1/auth/login/code/verify*` (verify-only) + - QR 로그인: `/api/v1/auth/qr/approve` +- **세션 발급 주체 분리**: + - 링크 로그인: Polling(요청 기기 A)에서 세션 발급 + - QR 로그인: Polling(웹)에서 세션 발급, 모바일은 승인만 수행 +- **audit 기록 경로 분리**: + - 링크 로그인: `writeLinkAuditLog` + - QR 로그인: `writeQrAuditLog` + +### 5.3 verify-only 적용 범위 +- 링크 로그인/코드 로그인 경로에만 적용됩니다. +- QR 로그인은 approve/poll 구조이므로 verify-only를 사용하지 않습니다. + +--- + +## 6) UserFront 주의사항 - `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능) - 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함 --- -## 6) 다음 액션 제안 +## 7) 다음 액션 제안 1. **Kratos 세션 쿠키 전달 방식(A) 구현** 2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계 diff --git a/docs/issue-146-remote-login.md b/docs/issue-146-remote-login.md new file mode 100644 index 00000000..5f34bbdc --- /dev/null +++ b/docs/issue-146-remote-login.md @@ -0,0 +1,35 @@ +# #146 원격 링크 로그인 세션/이력 불일치 대응 + +## 요약 +- Ory 링크 로그인은 실제로 `/api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` 경로를 사용합니다. +- 기존에는 `verifyOnly`가 `/api/v1/auth/magic-link/verify`에만 적용되어, 링크를 클릭한 기기에서 세션이 발급되는 문제가 있었습니다. +- 인증수단 표기는 loginId 기반 추론에 의존해 SMS 요청이 Email로 표시되는 문제가 있었습니다. + +## 원인 +- verify-only 적용 범위가 magic link에 한정되어 있었고, Ory 코드 기반 경로는 세션을 즉시 발급했습니다. +- audit 로그의 인증수단 표기는 request_body/loginId 기반 추론만 사용했습니다. + +## 변경 사항 +### 1) verify-only 범위 확장 +- `/api/v1/auth/login/code/verify`, `/api/v1/auth/login/code/verify-short`에 `verifyOnly` 지원 추가 +- verify-only일 때는 승인 상태만 저장하고 세션 발급은 Polling(Desktop)에서 수행 + +### 2) Polling 시 세션 발급 주체 정리 +- 승인 상태(`status=approved`)는 **요청한 기기(A)**에서만 세션 발급 +- Ory 코드 플로우는 Polling 시점에 `VerifyLoginCode`를 수행해 세션 생성 + +### 3) 인증수단 표기 개선 +- `pendingRef` 기준으로 `login_method`(sms/email), `login_flow`(code/link) 저장 +- audit 로그에 해당 메타를 기록하여 SMS/Email, 코드/링크 구분을 명확히 표시 +- verify-only 요청 로그는 타임라인에서 제외 + +## 영향 범위 +- Backend: 링크 로그인 승인/세션 발급 경로 변경 +- Front: verify-only 플래그 전달 확장 +- 문서: auth-flow/test-plan 업데이트 + +## 테스트 계획 (요약) +- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Polling으로 세션 발급 +- Mobile 단말에서 세션/로그인 이력 미생성 확인 +- 인증수단 표기(SMS/Email) 정확성 확인 +- 코드/링크 만료/재사용 시나리오 점검 diff --git a/docs/test-plan.md b/docs/test-plan.md new file mode 100644 index 00000000..f9546356 --- /dev/null +++ b/docs/test-plan.md @@ -0,0 +1,132 @@ +# 테스트 계획 및 원칙 (Baron SSO) + +## 1) 목적 +- 인증/인가 핵심 플로우의 안정성과 회귀 방지 +- 멀티 서비스(Backend/Ory Stack/Front) 연동 품질 확보 +- 릴리즈 기준과 장애 분석 기준의 표준화 + +## 2) 범위 +### 포함 +- Backend (Go Fiber) +- UserFront (Flutter Web/App) +- AdminFront / DevFront (React) +- Ory Stack (Kratos/Hydra/Keto/Oathkeeper) +- Gateway/네트워크 구성 (baron_net, ory-net, public_net) +- DB (PostgreSQL, ClickHouse, Redis) + +### 제외(별도 계획) +- 외부 IDP 벤더의 장애 대응 (Descope 등) +- 프로덕션 데이터 복구 시나리오(백업/DR) + +## 3) 원칙 +- **Shift-left**: 개발 단계에서 최대한 조기 검증 +- **단계적 신뢰**: Unit → Integration → E2E 순으로 신뢰도 상승 +- **환경 분리**: 로컬/스테이징/프로덕션 구성 차이를 문서로 명시 +- **결정적 테스트**: 시간/랜덤/외부 의존성 최소화 +- **Idempotent**: 반복 실행 시 동일 결과 보장 +- **보안 우선**: 민감정보(PII/Token)는 테스트 로그에 노출 금지 +- **실패 우선 기록**: 실패 로그/재현 절차를 우선 확보 + +## 4) 테스트 레이어 및 목표 +### 4.1 Unit Test +- Backend: 비즈니스 로직, 유효성 검증, Mapper/Adapter +- Frontend: 유틸/상태관리/컴포넌트 로직 +- 목표: 빠른 피드백(수초~수분) + +### 4.2 Integration Test +- Backend + DB(Postgres/ClickHouse/Redis) +- Backend + Ory Admin API (Kratos/Hydra/Keto) +- 목표: 네트워크/스토리지 연동 검증 + +### 4.3 Contract Test +- Backend ↔ Frontend API 스키마/응답 계약 검증 +- OIDC/OpenID Connect 표준 응답 형식 검증 + +### 4.4 E2E Test (Happy/Edge Path) +- 로그인 플로우(Password / Magic Link / SMS / QR) +- Consent 플로우 (Hydra login/consent) +- 토큰 발급/재발급/로그아웃/세션 만료 +- 목표: 핵심 사용자 여정의 회귀 방지 + +### 4.5 Smoke Test +- 배포 직후 필수 엔드포인트 헬스체크 +- `GET /health`, Ory readiness, UserFront 정적 리소스 + +### 4.6 Regression / Non-functional +- 성능: 로그인/토큰 발급 지연, 대량 감사 로그 적재 +- 보안: 인증 우회, 권한 상승, 세션 고정 공격 +- 관측성: 핵심 로그/메트릭 누락 여부 + +## 5) 환경 전략 +- 로컬: `make up-all` 또는 `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d` +- 스테이징: 프로덕션과 동일한 네트워크/도메인 구성 +- 프로덕션: 최소한의 smoke/관측성 점검 + +## 6) 테스트 데이터 정책 +- 표준 시드 사용자/테넌트/클라이언트 세트 정의 +- PII 마스킹 규칙(이메일/전화번호/토큰) +- 재현용 고정 데이터와 랜덤 데이터 분리 +- 테스트 종료 후 클린업 규칙 정의 + +## 7) 자동화 및 CI/CD 기준 (현행) +- **현재 상태**: 레포에 CI/CD 워크플로우 정의가 없음. 테스트는 로컬/수동 실행 기준으로 운영. +- **CI 변수 활용**: AdminFront/DevFront Playwright 설정은 `CI` 환경 변수에 따라 재시도/워커 수를 조정함. +- **수동 실행 기준**: + - Backend: `go test ./...` (위치: `backend/`) + - UserFront: `flutter test` (위치: `userfront/`) + - AdminFront: `npm test` (Playwright, 위치: `adminfront/`, baseURL `http://localhost:5173`) + - DevFront: `npm test` (Playwright, 위치: `devfront/`, baseURL `http://localhost:5174`) + +### 7.1 수동 게이트 제안(현행 기준) +- PR/머지 전 최소 기준: Backend Unit + 해당 Front 테스트(변경 범위) +- 배포 전 최소 기준: Smoke + 핵심 E2E(로그인/Consent) + +## 8) 핵심 플로우 테스트 시나리오 +### 인증/세션 +- Password 로그인 성공/실패/락/재시도 +- Magic Link 발송/검증/만료 +- SMS 코드 발송/검증/재시도 제한 +- QR 승인/거절/타임아웃 +- 로그아웃 시 세션/쿠키/토큰 무효화 + +### 원격 링크 로그인(verify-only) +- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Poll로 세션 발급 +- Mobile 단말에 세션 생성/로그인이 발생하지 않는지 확인 +- Audit/로그인 이력에 Desktop 세션 ID만 기록되는지 확인 +- 인증수단 표기(SMS/Email)가 요청 수단과 일치하는지 확인 +- 코드/링크 만료 시 승인 실패 및 재요청 안내 + +### OIDC/Hydra +- Login Challenge 처리 +- Consent 승인/거절 +- Token/Refresh Token 발급 +- Redirect URI 검증 + +### 권한/정책(Keto) +- 권한 부여/회수 시 접근 제어 확인 +- 관리자/일반 사용자 분리 + +### 네트워크/프록시 +- `baron_net`와 `ory-net` 경계 준수 +- Frontend에서 Ory 내부 Admin 포트 접근 불가 + +## 9) 관측성/장애 대응 테스트 +- 에러 로그 구조(필수 필드 포함) 확인 +- Audit Log 누락/중복 체크 +- 실패 시 재시도 정책 검증 + +## 10) 책임 및 운영 프로세스 +- 각 영역별 오너 지정(Backend/Front/Ory) +- 실패 시 triage 기준: 재현 가능 여부 → 영향도 → 우선순위 +- 테스트 케이스/기대 결과는 이슈/PR에 링크 + +## 11) 유지보수 원칙 +- 신규 기능은 반드시 관련 테스트 추가 +- 회귀 버그 발생 시 재현 테스트를 우선 추가 +- 불안정 테스트는 원인 분석 후 격리 또는 개선 + +## 12) 체크리스트 (릴리즈 전) +- Smoke 통과 +- 핵심 E2E 통과 +- 보안 관련 회귀 없음 +- 장애/모니터링 대시보드 정상 diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index c566d7f9..8389815f 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -144,12 +144,18 @@ class AuthProxyService { } } - static Future> verifyLoginCode(String loginId, String code, {String? pendingRef}) async { + static Future> verifyLoginCode( + String loginId, + String code, { + String? pendingRef, + bool verifyOnly = false, + }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify'); final payload = { 'loginId': loginId, 'code': code, + 'verifyOnly': verifyOnly, }; if (pendingRef != null && pendingRef.isNotEmpty) { payload['pendingRef'] = pendingRef; @@ -168,7 +174,10 @@ class AuthProxyService { } } - static Future> verifyLoginShortCode(String shortCode) async { + static Future> verifyLoginShortCode( + String shortCode, { + bool verifyOnly = false, + }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short'); final response = await http.post( @@ -176,6 +185,7 @@ class AuthProxyService { headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'shortCode': shortCode, + 'verifyOnly': verifyOnly, }), ); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 31e33d42..8bc03e11 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -417,7 +417,17 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 24), FilledButton( - onPressed: () => context.go(_verificationActionPath), + onPressed: () { + final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie(); + final target = hasLocalSession ? '/' : '/signin'; + if (mounted) { + setState(() { + _verificationOnly = false; + _verificationApproved = false; + }); + } + context.go(target); + }, child: Text(_verificationActionLabel), ), ], @@ -438,10 +448,14 @@ class _LoginScreenState extends ConsumerState final jwt = res['token'] ?? res['sessionJwt']; final status = res['status']?.toString(); final hasLocalSession = await _hasValidLocalSession(); + final actionPath = hasLocalSession ? '/' : '/signin'; if (status == 'approved' || (jwt == null && _verificationOnly)) { if (mounted) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); } return; } @@ -450,15 +464,22 @@ class _LoginScreenState extends ConsumerState if (hasLocalSession) { _markVerificationApproved( "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + actionPath: actionPath, ); return; } - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); return; } if (mounted) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); } } catch (e) { debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); @@ -476,15 +497,20 @@ class _LoginScreenState extends ConsumerState sanitizedLoginId, code, pendingRef: pendingRef, + verifyOnly: _verificationOnly, ); final jwt = res['sessionJwt'] ?? res['token']; final status = res['status']?.toString(); debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); final hasLocalSession = await _hasValidLocalSession(); + final actionPath = hasLocalSession ? '/' : '/signin'; if (jwt == null && status == 'approved') { if (mounted) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); } return; } @@ -493,11 +519,15 @@ class _LoginScreenState extends ConsumerState if (hasLocalSession) { _markVerificationApproved( "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + actionPath: actionPath, ); return; } if (_verificationOnly) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); return; } _markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", @@ -511,7 +541,10 @@ class _LoginScreenState extends ConsumerState } if (_verificationOnly && mounted) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); } } catch (e) { debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); @@ -526,15 +559,22 @@ class _LoginScreenState extends ConsumerState if (sanitized.isEmpty) return; debugPrint("[Auth] Starting short code verification for code: $sanitized"); try { - final res = await AuthProxyService.verifyLoginShortCode(sanitized); + final res = await AuthProxyService.verifyLoginShortCode( + sanitized, + verifyOnly: _verificationOnly, + ); final jwt = res['sessionJwt'] ?? res['token']; final status = res['status']?.toString(); debugPrint("[Auth] Short code verification successful"); final hasLocalSession = await _hasValidLocalSession(); + final actionPath = hasLocalSession ? '/' : '/signin'; if (jwt == null && status == 'approved') { if (mounted) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); } return; } @@ -543,11 +583,15 @@ class _LoginScreenState extends ConsumerState if (hasLocalSession) { _markVerificationApproved( "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", + actionPath: actionPath, ); return; } if (_verificationOnly) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); return; } _completeLoginFromToken(jwt, provider: res['provider'] as String?); @@ -555,7 +599,10 @@ class _LoginScreenState extends ConsumerState } if (_verificationOnly && mounted) { - _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved( + "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", + actionPath: actionPath, + ); } } catch (e) { debugPrint("[Auth] Short code verification FAILED. Error: $e"); diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 10752bed..16d817b5 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -82,6 +82,13 @@ class AuditLogEntry { } } +class _AuditPage { + final List items; + final String? nextCursor; + + const _AuditPage({required this.items, this.nextCursor}); +} + class LinkedRp { final String id; final String name; @@ -134,17 +141,30 @@ class _DashboardScreenState extends ConsumerState { static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); - Future>? _auditFuture; + final ScrollController _pageScrollController = ScrollController(); + final List _auditLogs = []; + String? _auditNextCursor; + bool _auditLoading = false; + bool _auditLoadingMore = false; + String? _auditError; + Future>? _linkedRpsFuture; bool _showAllActivities = false; @override void initState() { super.initState(); - _auditFuture = _fetchAuditLogs(); + _pageScrollController.addListener(_onPageScroll); + _loadAuditLogs(reset: true); _linkedRpsFuture = _fetchLinkedRps(); } + @override + void dispose() { + _pageScrollController.dispose(); + super.dispose(); + } + Future _logout() async { AuthTokenStore.clear(); AuthNotifier.instance.notify(); @@ -154,6 +174,15 @@ class _DashboardScreenState extends ConsumerState { context.push('/scan'); } + void _onPageScroll() { + if (!_pageScrollController.hasClients) { + return; + } + if (_pageScrollController.position.extentAfter < 240) { + _loadAuditLogs(); + } + } + Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) { return SafeArea( child: ListView( @@ -208,13 +237,10 @@ class _DashboardScreenState extends ConsumerState { Future _refreshAll() async { await ref.read(profileProvider.notifier).loadProfile(); + await _loadAuditLogs(reset: true); setState(() { - _auditFuture = _fetchAuditLogs(); _linkedRpsFuture = _fetchLinkedRps(); }); - if (_auditFuture != null) { - await _auditFuture; - } if (_linkedRpsFuture != null) { await _linkedRpsFuture; } @@ -227,9 +253,16 @@ class _DashboardScreenState extends ConsumerState { return dotenv.env[key] ?? fallback; } - Future> _fetchAuditLogs() async { + Future<_AuditPage> _fetchAuditLogs({String? cursor}) async { final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); - final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline?limit=20'); + final queryParameters = { + 'limit': '20', + }; + if (cursor != null && cursor.isNotEmpty) { + queryParameters['cursor'] = cursor; + } + final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline') + .replace(queryParameters: queryParameters); final useCookie = AuthTokenStore.usesCookie(); final token = AuthTokenStore.getToken(); @@ -250,12 +283,53 @@ class _DashboardScreenState extends ConsumerState { final body = jsonDecode(response.body) as Map; final items = (body['items'] as List?) ?? []; + final nextCursor = body['next_cursor']?.toString(); final logs = items .whereType>() .map(AuditLogEntry.fromJson) .toList(); - return logs; + return _AuditPage(items: logs, nextCursor: nextCursor); + } + + Future _loadAuditLogs({bool reset = false}) async { + if (_auditLoading || _auditLoadingMore) { + return; + } + if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) { + return; + } + + if (reset) { + setState(() { + _auditLogs.clear(); + _auditNextCursor = null; + _auditError = null; + _auditLoading = true; + }); + } else { + setState(() { + _auditLoadingMore = true; + }); + } + + try { + final page = await _fetchAuditLogs(cursor: _auditNextCursor); + setState(() { + _auditLogs.addAll(page.items); + _auditNextCursor = page.nextCursor; + _auditError = null; + }); + } catch (_) { + setState(() { + _auditError = '접속이력을 불러오지 못했습니다.'; + }); + } finally { + setState(() { + _auditLoading = false; + _auditLoadingMore = false; + }); + } } Future> _fetchLinkedRps() async { @@ -320,6 +394,10 @@ class _DashboardScreenState extends ConsumerState { return '$yyyy.$mm.$dd $hh:$min'; } + Widget _selectableText(String text, {TextStyle? style}) { + return SelectableText(text, style: style); + } + String _authMethodLabel() { if (AuthTokenStore.usesCookie()) { return 'Ory 세션'; @@ -360,7 +438,27 @@ class _DashboardScreenState extends ConsumerState { Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) { if (authMethod != 'QR') { - return Text(authMethod); + final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; + final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; + final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; + if (!authMethod.startsWith('링크') || !hasApproverMeta) { + return _selectableText(authMethod); + } + final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); + final tooltip = [ + '승인 기기: $deviceLabel', + '승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}', + ].join('\n'); + return Tooltip( + message: tooltip, + child: _selectableText( + authMethod, + style: const TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + ), + ); } final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; final tooltip = approvedSessionId.isEmpty @@ -393,7 +491,27 @@ class _DashboardScreenState extends ConsumerState { Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) { if (authMethod != 'QR') { - return Text('인증수단: $authMethod'); + final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; + final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; + final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; + if (!authMethod.startsWith('링크') || !hasApproverMeta) { + return _selectableText('인증수단: $authMethod'); + } + final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); + final tooltip = [ + '승인 기기: $deviceLabel', + '승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}', + ].join('\n'); + return Tooltip( + message: tooltip, + child: _selectableText( + '인증수단: $authMethod', + style: const TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + ), + ); } final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; return InkWell( @@ -496,6 +614,7 @@ class _DashboardScreenState extends ConsumerState { final timelineWide = constraints.maxWidth >= 900; final isMobile = constraints.maxWidth < 600; return SingleChildScrollView( + controller: _pageScrollController, physics: const AlwaysScrollableScrollPhysics(), child: Padding( padding: const EdgeInsets.all(24), @@ -793,55 +912,45 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildAccessHistory(bool isWide) { - return FutureBuilder>( - future: _auditFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return _buildHistoryContainer( - child: const Center(child: CircularProgressIndicator()), - ); - } + if (_auditLoading && _auditLogs.isEmpty) { + return _buildHistoryContainer( + child: const Center(child: CircularProgressIndicator()), + ); + } - if (snapshot.hasError) { - return _buildHistoryContainer( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('접속이력을 불러오지 못했습니다.'), - const SizedBox(height: 8), - TextButton( - onPressed: () { - setState(() { - _auditFuture = _fetchAuditLogs(); - }); - }, - child: const Text('다시 시도'), - ), - ], + if (_auditError != null && _auditLogs.isEmpty) { + return _buildHistoryContainer( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('접속이력을 불러오지 못했습니다.'), + const SizedBox(height: 8), + TextButton( + onPressed: () => _loadAuditLogs(reset: true), + child: const Text('다시 시도'), ), - ), - ); - } + ], + ), + ), + ); + } - final logs = snapshot.data ?? []; - if (logs.isEmpty) { - return _buildHistoryContainer( - child: Center( - child: Text( - '최근 접속 이력이 없습니다.', - style: TextStyle(color: Colors.grey[600]), - ), - ), - ); - } + if (_auditLogs.isEmpty) { + return _buildHistoryContainer( + child: Center( + child: Text( + '최근 접속 이력이 없습니다.', + style: TextStyle(color: Colors.grey[600]), + ), + ), + ); + } - if (isWide) { - return _buildHistoryTable(logs); - } - return _buildHistoryList(logs); - }, - ); + if (isWide) { + return _buildHistoryTable(_auditLogs); + } + return _buildHistoryList(_auditLogs); } Widget _buildHistoryContainer({required Widget child}) { @@ -859,46 +968,51 @@ class _DashboardScreenState extends ConsumerState { Widget _buildHistoryTable(List logs) { return _buildHistoryContainer( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: DataTable( - columnSpacing: 16, - horizontalMargin: 12, - columns: const [ - DataColumn(label: Text('Session ID')), - DataColumn(label: Text('접속일자')), - DataColumn(label: Text('애플리케이션')), - DataColumn(label: Text('IP')), - DataColumn(label: Text('접속환경')), - DataColumn(label: Text('인증수단')), - DataColumn(label: Text('인증결과')), - DataColumn(label: Text('현황')), - ], - rows: logs.take(10).map((log) { - final statusLabel = log.status == 'success' ? '성공' : '실패'; - final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; - final appLabel = _appLabelForPath(log.path); - final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); - final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); - return DataRow(cells: [ - DataCell(Text(log.sessionId.isEmpty ? '-' : log.sessionId)), - DataCell(Text(_formatDateTime(log.timestamp))), - DataCell(Text(appLabel)), - DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)), - DataCell(Text(deviceLabel)), - DataCell(_buildAuthMethodCell(log, authMethod)), - DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), - const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))), - ]); - }).toList(), - ), - ), - ); - }, + child: Column( + children: [ + LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: DataTable( + columnSpacing: 16, + horizontalMargin: 12, + columns: const [ + DataColumn(label: Text('Session ID')), + DataColumn(label: Text('접속일자')), + DataColumn(label: Text('애플리케이션')), + DataColumn(label: Text('IP')), + DataColumn(label: Text('접속환경')), + DataColumn(label: Text('인증수단')), + DataColumn(label: Text('인증결과')), + DataColumn(label: Text('현황')), + ], + rows: logs.map((log) { + final statusLabel = log.status == 'success' ? '성공' : '실패'; + final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; + final appLabel = _appLabelForPath(log.path); + final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); + final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); + return DataRow(cells: [ + DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)), + DataCell(_selectableText(_formatDateTime(log.timestamp))), + DataCell(_selectableText(appLabel)), + DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)), + DataCell(_selectableText(deviceLabel)), + DataCell(_buildAuthMethodCell(log, authMethod)), + DataCell(_selectableText(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), + DataCell(_selectableText('(준비중)', style: const TextStyle(color: Colors.grey))), + ]); + }).toList(), + ), + ), + ); + }, + ), + _buildHistoryFooter(), + ], ), ); } @@ -906,52 +1020,86 @@ class _DashboardScreenState extends ConsumerState { Widget _buildHistoryList(List logs) { return _buildHistoryContainer( child: Column( - children: logs.take(10).map((log) { - final statusLabel = log.status == 'success' ? '성공' : '실패'; - final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; - final appLabel = _appLabelForPath(log.path); - final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); - final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _subtle, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - appLabel, - style: const TextStyle(fontWeight: FontWeight.w600, color: _ink), + children: [ + for (final log in logs) + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _selectableText( + _appLabelForPath(log.path), + style: const TextStyle(fontWeight: FontWeight.w600, color: _ink), + ), ), - ), - Text( - statusLabel, - style: TextStyle(color: statusColor, fontWeight: FontWeight.w600), - ), - ], - ), - const SizedBox(height: 6), - Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'), - Text('접속일자: ${_formatDateTime(log.timestamp)}'), - Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), - Text('접속환경: $deviceLabel'), - _buildAuthMethodLine(log, authMethod), - Text('인증결과: $statusLabel'), - Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])), - ], + _selectableText( + log.status == 'success' ? '성공' : '실패', + style: TextStyle( + color: log.status == 'success' ? Colors.green : Colors.redAccent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + _selectableText('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'), + _selectableText('접속일자: ${_formatDateTime(log.timestamp)}'), + _selectableText('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), + _selectableText('접속환경: ${_deviceLabelFromUserAgent(log.userAgent)}'), + _buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()), + _selectableText('인증결과: ${log.status == 'success' ? '성공' : '실패'}'), + _selectableText('현황: (준비중)', style: TextStyle(color: Colors.grey[600])), + ], + ), ), - ); - }).toList(), + _buildHistoryFooter(), + ], ), ); } + + Widget _buildHistoryFooter() { + if (_auditLoadingMore) { + return const Padding( + padding: EdgeInsets.only(top: 8), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_auditError != null) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('더 불러오지 못했습니다.'), + TextButton( + onPressed: () => _loadAuditLogs(), + child: const Text('재시도'), + ), + ], + ), + ); + } + if (_auditNextCursor == null || _auditNextCursor!.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '더 이상 항목이 없습니다.', + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ); + } + return const SizedBox.shrink(); + } } class _ActivityItem { diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index e87146b4..71d0f98d 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -91,7 +91,7 @@ final _router = GoRouter( path: '/signin', builder: (context, state) { _routerLogger.info("Navigating to /signin"); - return const LoginScreen(); + return LoginScreen(key: state.pageKey); } ), GoRoute( @@ -105,7 +105,7 @@ final _router = GoRouter( path: '/verify', builder: (context, state) { _routerLogger.info("Navigating to /verify (query)"); - return const LoginScreen(); + return LoginScreen(key: state.pageKey); }, ), GoRoute( @@ -113,7 +113,7 @@ final _router = GoRouter( builder: (context, state) { final token = state.pathParameters['token']; _routerLogger.info("Navigating to /verify with token: $token"); - return LoginScreen(verificationToken: token); + return LoginScreen(key: state.pageKey, verificationToken: token); }, ), GoRoute( @@ -121,7 +121,7 @@ final _router = GoRouter( builder: (context, state) { final shortCode = state.pathParameters['shortCode']; _routerLogger.info("Navigating to /l with code: $shortCode"); - return const LoginScreen(); + return LoginScreen(key: state.pageKey); }, ), GoRoute( diff --git a/userfront/nginx.conf b/userfront/nginx.conf index a84a609c..8a4fcffa 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -35,30 +35,10 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # --- Ory Stack Proxy (via Oathkeeper) --- - # Kratos Public API - location /auth { - proxy_pass http://oathkeeper:4455; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Hydra Public API (Rewrite /oidc/... to /...) - location /oidc { - rewrite ^/oidc/(.*)$ /$1 break; - proxy_pass http://oathkeeper:4455; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - # --- UserFront Static Files --- location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } -} \ No newline at end of file +}