diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 62a5e680..f82e6ea7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -19,7 +19,10 @@ import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { fetchMe } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -221,6 +224,52 @@ function AppLayout() { isSessionExpiryEnabled, ]); + useEffect(() => { + const maybeKeepSessionAlive = async () => { + const now = Date.now(); + if ( + !shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: auth.user?.expires_at, + nowMs: now, + isEnabled: isSessionExpiryEnabled, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + isRenewInFlight: isRenewInFlightRef.current, + lastAttemptAtMs: lastRenewAttemptAtRef.current, + }) + ) { + return; + } + + isRenewInFlightRef.current = true; + lastRenewAttemptAtRef.current = now; + + try { + await auth.signinSilent(); + } catch (error) { + console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + } finally { + isRenewInFlightRef.current = false; + } + }; + + const timer = window.setInterval(() => { + void maybeKeepSessionAlive(); + }, 30_000); + + void maybeKeepSessionAlive(); + + return () => { + window.clearInterval(timer); + }; + }, [ + auth, + auth.isAuthenticated, + auth.isLoading, + auth.user?.expires_at, + isSessionExpiryEnabled, + ]); + useEffect(() => { const routeKey = `${location.pathname}${location.search}${location.hash}`; if (lastVisitedRouteRef.current === null) { diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index 8f46d964..aab02a2b 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -10,7 +10,7 @@ export const oidcConfig: AuthProviderProps = { scope: "openid offline_access profile email", // offline_access for refresh token post_logout_redirect_uri: window.location.origin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: true, + automaticSilentRenew: false, }; export const userManager = new UserManager({ diff --git a/adminfront/src/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts index 410ac63e..cce36661 100644 --- a/adminfront/src/lib/sessionSliding.test.ts +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SESSION_RENEW_THRESHOLD_MS, shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, } from "./sessionSliding"; describe("shouldAttemptSlidingSessionRenew", () => { @@ -71,3 +72,55 @@ describe("shouldAttemptSlidingSessionRenew", () => { ).toBe(false); }); }); + +describe("shouldAttemptUnlimitedSessionRenew", () => { + const nowMs = 1_700_000_000_000; + + it("returns false when unlimited mode is not active", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000, + ), + nowMs, + isEnabled: true, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(false); + }); + + it("returns true near expiry when session expiry management is disabled", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000, + ), + nowMs, + isEnabled: false, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(true); + }); + + it("returns false when the token still has enough remaining lifetime", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000, + ), + nowMs, + isEnabled: false, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(false); + }); +}); diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index 7096e7f3..be152778 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({ return true; } + +export function shouldAttemptUnlimitedSessionRenew({ + expiresAtSec, + nowMs, + isEnabled, + isAuthenticated, + isLoading, + isRenewInFlight, + lastAttemptAtMs, + thresholdMs = SESSION_RENEW_THRESHOLD_MS, + throttleMs = SESSION_RENEW_THROTTLE_MS, +}: SlidingSessionRenewDecisionParams) { + if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { + return false; + } + + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +} diff --git a/backend/cmd/server/headless_login_e2e_test.go b/backend/cmd/server/headless_login_e2e_test.go index f91a5b53..89a1822b 100644 --- a/backend/cmd/server/headless_login_e2e_test.go +++ b/backend/cmd/server/headless_login_e2e_test.go @@ -121,6 +121,18 @@ func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identity return nil } +func (m *e2eMockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + return nil, nil +} + +func (m *e2eMockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + return nil, nil +} + +func (m *e2eMockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + return nil +} + func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App { app := fiber.New(fiber.Config{ DisableStartupMessage: true, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0392a1d8..b8f3502d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -581,6 +581,8 @@ func main() { user.Post("/me/password", authHandler.ChangeMyPassword) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) + user.Get("/sessions", authHandler.ListMySessions) + user.Delete("/sessions/:id", authHandler.DeleteMySession) user.Get("/rp/linked", authHandler.ListLinkedRps) user.Get("/rp/history", authHandler.ListRpHistory) user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 53d105a1..28259b32 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1002,6 +1002,18 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[strin return claims } +func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any { + if claims == nil { + claims = map[string]any{} + } + sessionID = strings.TrimSpace(sessionID) + if sessionID != "" { + claims["session_id"] = sessionID + claims["sid"] = sessionID + } + return claims +} + func collectEmailList(traits map[string]any, primaryEmail string) []string { emails := make([]string, 0) seen := make(map[string]struct{}) @@ -2414,6 +2426,8 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error { return errorJSONCode(c, status, code, message) } + c.Locals("user_id", authInfo.Subject) + c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject) @@ -2720,7 +2734,15 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) + c.Locals("user_id", authInfo.Subject) + c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + if req.LoginChallenge == "" { + attachAuditClientDetails(c, domain.HydraClient{ + ClientID: "userfront", + ClientName: "UserFront", + }) + } ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) // --- OIDC 로그인 흐름 처리 --- @@ -2729,11 +2751,14 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) - if err == nil && loginReq != nil && loginReq.Client.Metadata != nil { - if status, ok := loginReq.Client.Metadata["status"].(string); ok { - if strings.ToLower(status) == "inactive" { - slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) - return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + if err == nil && loginReq != nil { + attachAuditClientDetails(c, loginReq.Client) + if loginReq.Client.Metadata != nil { + if status, ok := loginReq.Client.Metadata["status"].(string); ok { + if strings.ToLower(status) == "inactive" { + slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) + return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + } } } } @@ -2766,6 +2791,27 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { return c.JSON(resp) } +func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { + if c == nil { + return + } + + clientID := strings.TrimSpace(client.ClientID) + if clientID == "" { + return + } + + clientName := strings.TrimSpace(client.ClientName) + if clientName == "" { + clientName = clientID + } + + c.Locals("audit_details_extra", map[string]any{ + "client_id": clientID, + "client_name": clientName, + }) +} + // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다. func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { startTime := time.Now() @@ -3996,18 +4042,7 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string { if c == nil { return "" } - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } type authTimelineItem struct { @@ -4755,7 +4790,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject) // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { - sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + sessionClaims := withOidcSessionMetadata( + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + h.resolveCurrentSessionID(c), + ) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to auto-accept hydra consent request", "error", err) @@ -4875,7 +4913,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { c.Locals("login_id", loginID) } - sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + currentSessionID := h.resolveCurrentSessionID(c) + sessionClaims := withOidcSessionMetadata( + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + currentSessionID, + ) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { @@ -4902,12 +4944,17 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { "scopes": consentRequest.RequestedScope, "client_name": consentRequest.Client.ClientName, } + if currentSessionID != "" { + detailsMap["session_id"] = currentSessionID + detailsMap["approved_session_id"] = currentSessionID + } detailsBytes, _ := json.Marshal(detailsMap) _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, + SessionID: currentSessionID, EventType: "consent.granted", Status: "success", IPAddress: c.IP(), @@ -4959,18 +5006,7 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) if err == nil && loginReq != nil { - // Audit 상세 정보 보강: OIDC 로그인 시점에 client 정보를 저장 - clientID := strings.TrimSpace(loginReq.Client.ClientID) - if clientID != "" { - clientName := strings.TrimSpace(loginReq.Client.ClientName) - if clientName == "" { - clientName = clientID - } - c.Locals("audit_details_extra", map[string]any{ - "client_id": clientID, - "client_name": clientName, - }) - } + attachAuditClientDetails(c, loginReq.Client) if loginReq.Client.Metadata != nil { if status, ok := loginReq.Client.Metadata["status"].(string); ok { @@ -5050,12 +5086,12 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe // 1. Try to fetch real profile if token/cookie exists if token != "" || cookie != "" { // Try Redis Cache - if h.RedisService != nil && token != "" { - cacheKey = "cache:profile:token:" + token + if h.RedisService != nil && token == "" && cookie != "" { + cacheKey = "cache:profile:cookie:" + cookie cached, _ := h.RedisService.Get(cacheKey) if cached != "" { if json.Unmarshal([]byte(cached), &profile) == nil { - slog.Debug("Profile loaded from cache", "token", token[:10]+"...", "role", profile.Role) + slog.Debug("Profile loaded from cache", "role", profile.Role) } } } @@ -5146,7 +5182,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe // IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key // or we should include the mock role in the cache key. // For simplicity, let's skip caching if mockRole is present in dev. - if h.RedisService != nil && cacheKey != "" && err == nil && !(isDev && mockRole != "") { + if h.RedisService != nil && token == "" && cacheKey != "" && err == nil && !(isDev && mockRole != "") { if data, err := json.Marshal(profile); err == nil { ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttl := 30 * time.Minute // Default TTL @@ -6208,6 +6244,10 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai slog.Warn("Hydra token is not active") return nil, errors.New("token is not active") } + if err := h.validateHydraTokenSession(ctx, intro); err != nil { + slog.Warn("Hydra token session validation failed", "error", err) + return nil, err + } slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) @@ -6664,6 +6704,173 @@ type rpHistoryItem struct { Status string `json:"status"` } +type userSessionItem struct { + SessionID string `json:"session_id"` + AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + IssuedAt *time.Time `json:"issued_at,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + ClientID string `json:"client_id,omitempty"` + AppName string `json:"app_name,omitempty"` + IsCurrent bool `json:"is_current"` + IsActive bool `json:"is_active"` +} + +type userSessionListResponse struct { + Items []userSessionItem `json:"items"` +} + +func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error { + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + if strings.TrimSpace(profile.ID) == "" { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + + sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions") + } + + currentSessionID := h.resolveCurrentSessionID(c) + auditHints := h.loadSessionAuditHints(c.Context(), profile.ID) + + items := make([]userSessionItem, 0, len(sessions)) + for _, session := range sessions { + if !session.Active { + continue + } + item := userSessionItem{ + SessionID: session.ID, + IsCurrent: session.ID != "" && session.ID == currentSessionID, + IsActive: session.Active, + } + if !session.AuthenticatedAt.IsZero() { + ts := session.AuthenticatedAt + item.AuthenticatedAt = &ts + item.LastSeenAt = &ts + } + if !session.ExpiresAt.IsZero() { + ts := session.ExpiresAt + item.ExpiresAt = &ts + } + if !session.IssuedAt.IsZero() { + ts := session.IssuedAt + item.IssuedAt = &ts + if item.AuthenticatedAt == nil { + item.AuthenticatedAt = &ts + } + if item.LastSeenAt == nil { + item.LastSeenAt = &ts + } + } + if hint, ok := auditHints[session.ID]; ok { + if item.IPAddress == "" { + item.IPAddress = hint.IPAddress + } + if item.UserAgent == "" { + item.UserAgent = hint.UserAgent + } + if item.ClientID == "" { + item.ClientID = hint.ClientID + } + if item.AppName == "" { + item.AppName = hint.AppName + } + if hint.Timestamp != nil { + item.LastSeenAt = hint.Timestamp + } + } + if item.UserAgent == "" && len(session.Devices) > 0 { + deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent) + if !looksLikeInternalUserAgent(deviceUserAgent) { + item.UserAgent = deviceUserAgent + } + } + if item.IPAddress == "" && len(session.Devices) > 0 { + item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress) + } + if item.IsCurrent { + applyCurrentSessionRequestHints(c, &item) + } + items = append(items, item) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].IsCurrent != items[j].IsCurrent { + return items[i].IsCurrent + } + iTime := latestSessionTimestamp(items[i]) + jTime := latestSessionTimestamp(items[j]) + if iTime.Equal(jTime) { + return items[i].SessionID < items[j].SessionID + } + return iTime.After(jTime) + }) + + return c.JSON(userSessionListResponse{Items: items}) +} + +func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error { + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + targetSessionID := strings.TrimSpace(c.Params("id")) + if targetSessionID == "" { + return errorJSON(c, fiber.StatusBadRequest, "session id is required") + } + + mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions") + } + ownedSession := false + for _, candidate := range mySessions { + if strings.TrimSpace(candidate.ID) == targetSessionID { + ownedSession = true + break + } + } + if !ownedSession { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + + session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session") + } + if session == nil { + h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing") + return c.JSON(fiber.Map{"status": "ok"}) + } + + result := "revoked" + if !session.Active { + result = "already_inactive" + } else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session") + } + if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions") + } + + h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result) + return c.JSON(fiber.Map{"status": "ok"}) +} + func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { subject, err := h.resolveConsentSubject(c) if err != nil || subject == "" { @@ -6751,3 +6958,366 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { return c.JSON(fiber.Map{"items": items}) } + +type sessionAuditHint struct { + Timestamp *time.Time + IPAddress string + UserAgent string + ClientID string + AppName string +} + +func latestSessionTimestamp(item userSessionItem) time.Time { + for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} { + if candidate != nil { + return *candidate + } + } + return time.Time{} +} + +func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string { + if c == nil { + return "" + } + if token := h.getBearerToken(c); token != "" { + if sessionID := extractSessionIDFromJWT(token); sessionID != "" { + return sessionID + } + if sessionID, err := h.getKratosSessionID(token); err == nil { + return sessionID + } + } + if cookie := c.Get("Cookie"); cookie != "" { + if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil { + return sessionID + } + } + return "" +} + +func applyCurrentSessionRequestHints(c *fiber.Ctx, item *userSessionItem) { + if c == nil || item == nil || !item.IsCurrent { + return + } + + if item.IPAddress == "" { + item.IPAddress = strings.TrimSpace(resolveRequestClientIP(c)) + } + if item.UserAgent == "" { + userAgent := strings.TrimSpace(c.Get("User-Agent")) + if !looksLikeInternalUserAgent(userAgent) { + item.UserAgent = userAgent + } + } + if strings.TrimSpace(item.ClientID) == "" { + item.ClientID = "userfront" + } + if strings.TrimSpace(item.AppName) == "" { + item.AppName = "UserFront" + } +} + +func resolveRequestClientIP(c *fiber.Ctx) string { + if c == nil { + return "" + } + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) +} + +func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint { + hints := make(map[string]sessionAuditHint) + if h.AuditRepo == nil || strings.TrimSpace(userID) == "" { + return hints + } + + logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ + "login_success", + "qr_login_success", + "link_login_success", + "code_login_success", + "password_login_success", + "consent.granted", + "POST /api/v1/auth/oidc/login/accept", + "POST /api/v1/auth/password/login", + "POST /api/v1/auth/magic-link/verify", + "POST /api/v1/auth/login/code/verify", + "POST /api/v1/auth/qr/approve", + "session.revoked", + }, 200) + if err != nil { + return hints + } + + for _, log := range logs { + sessionID := strings.TrimSpace(log.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + continue + } + + ts := log.Timestamp + ipAddress := strings.TrimSpace(log.IPAddress) + userAgent := strings.TrimSpace(log.UserAgent) + clientID, appName := deriveSessionClientInfo(log) + if details, err := parseAuditDetails(log.Details); err == nil { + if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" { + ipAddress = strings.TrimSpace(approvedIP) + } + if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" { + userAgent = strings.TrimSpace(approvedUserAgent) + } + } + if looksLikeInternalUserAgent(userAgent) { + userAgent = "" + } + hints[sessionID] = mergeSessionAuditHint(hints[sessionID], sessionAuditHint{ + Timestamp: &ts, + IPAddress: ipAddress, + UserAgent: userAgent, + ClientID: clientID, + AppName: appName, + }) + } + return hints +} + +func mergeSessionAuditHint(existing sessionAuditHint, candidate sessionAuditHint) sessionAuditHint { + if candidate.Timestamp != nil && + (existing.Timestamp == nil || candidate.Timestamp.After(*existing.Timestamp)) { + existing.Timestamp = candidate.Timestamp + } + if shouldReplaceSessionIP(existing.IPAddress, candidate.IPAddress) { + existing.IPAddress = candidate.IPAddress + } + if existing.UserAgent == "" && candidate.UserAgent != "" { + existing.UserAgent = candidate.UserAgent + } + if existing.ClientID == "" && candidate.ClientID != "" { + existing.ClientID = candidate.ClientID + } + if existing.AppName == "" && candidate.AppName != "" { + existing.AppName = candidate.AppName + } + return existing +} + +func shouldReplaceSessionIP(existing string, candidate string) bool { + existing = strings.TrimSpace(existing) + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return false + } + if existing == "" { + return true + } + if isPrivateIPAddress(existing) && !isPrivateIPAddress(candidate) { + return true + } + return false +} + +func isPrivateIPAddress(raw string) bool { + return utils.IsPrivateOrReservedIP(raw) +} + +func parseAuditDetails(details string) (map[string]any, error) { + return utils.ParseAuditDetails(details) +} + +func deriveSessionClientInfo(log domain.AuditLog) (string, string) { + details, _ := parseAuditDetails(log.Details) + clientID := "" + appName := "" + if details != nil { + if value, ok := details["client_id"].(string); ok { + clientID = strings.TrimSpace(value) + } + if value, ok := details["client_name"].(string); ok { + appName = strings.TrimSpace(value) + } + } + path := strings.ToLower(extractAuditPath(log)) + if appName == "" { + switch { + case strings.Contains(path, "/api/v1/auth/oidc/login/accept"): + appName = "OIDC 로그인" + case strings.Contains(path, "/api/v1/auth/qr/approve"): + appName = "QR 로그인" + case strings.Contains(path, "/api/v1/auth/login/code/verify"): + appName = "코드 로그인" + case strings.Contains(path, "/api/v1/auth/magic-link/verify"): + appName = "링크 로그인" + case strings.Contains(path, "/api/v1/auth/password/login"): + appName = "비밀번호 로그인" + } + } + if appName == "" && clientID != "" { + appName = clientID + } + return clientID, appName +} + +func extractStringLikeValue(raw any) string { + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + default: + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" { + return "" + } + return text + } +} + +func extractHydraSessionID(ext map[string]interface{}) string { + if len(ext) == 0 { + return "" + } + for _, key := range []string{"session_id", "sid", "sessionId"} { + if value := extractStringLikeValue(ext[key]); value != "" { + return value + } + } + return "" +} + +func (h *AuthHandler) validateHydraTokenSession(ctx context.Context, intro *service.HydraIntrospectionResponse) error { + if h == nil || h.KratosAdmin == nil || intro == nil { + return nil + } + + sessionID := extractHydraSessionID(intro.Ext) + if sessionID == "" { + return nil + } + + session, err := h.KratosAdmin.GetSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("kratos session lookup failed: %w", err) + } + if session == nil { + return errors.New("linked session not found") + } + if !session.Active { + return errors.New("linked session is inactive") + } + if identityID := strings.TrimSpace(session.Identity.ID); identityID != "" && strings.TrimSpace(intro.Subject) != "" && identityID != strings.TrimSpace(intro.Subject) { + return errors.New("linked session subject mismatch") + } + return nil +} + +func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID string) map[string][]string { + bindings := make(map[string][]string) + if h == nil || h.AuditRepo == nil || strings.TrimSpace(userID) == "" { + return bindings + } + + logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ + "consent.granted", + "POST /api/v1/auth/oidc/login/accept", + "POST /api/v1/auth/password/login", + "password_login_success", + "login_success", + }, 200) + if err != nil { + return bindings + } + + for _, log := range logs { + sessionID := strings.TrimSpace(log.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + continue + } + + clientID, _ := deriveSessionClientInfo(log) + clientID = strings.TrimSpace(clientID) + if clientID == "" { + continue + } + + existing := bindings[sessionID] + seen := false + for _, candidate := range existing { + if candidate == clientID { + seen = true + break + } + } + if !seen { + bindings[sessionID] = append(existing, clientID) + } + } + + return bindings +} + +func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID string, sessionID string) error { + if h == nil || h.Hydra == nil { + return nil + } + + clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] + if len(clientIDs) == 0 { + return nil + } + for _, clientID := range clientIDs { + if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil { + return err + } + } + return nil +} + +func looksLikeInternalUserAgent(userAgent string) bool { + normalized := strings.ToLower(strings.TrimSpace(userAgent)) + if normalized == "" { + return false + } + return strings.HasPrefix(normalized, "go-http-client/") || + strings.HasPrefix(normalized, "fasthttp") || + strings.HasPrefix(normalized, "fiber") +} + +func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) { + if h.AuditRepo == nil { + return + } + + details := map[string]any{ + "target_session_id": strings.TrimSpace(targetSessionID), + "revoke_result": strings.TrimSpace(result), + } + if strings.TrimSpace(actorSessionID) != "" { + details["actor_session_id"] = strings.TrimSpace(actorSessionID) + } + raw, err := json.Marshal(details) + if err != nil { + return + } + + _ = h.AuditRepo.Create(&domain.AuditLog{ + EventID: fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()), + Timestamp: time.Now().UTC(), + UserID: strings.TrimSpace(actorIdentityID), + SessionID: strings.TrimSpace(actorSessionID), + EventType: "session.revoked", + Status: "success", + IPAddress: extractClientIPFromHeaders(c), + UserAgent: strings.TrimSpace(c.Get("User-Agent")), + Details: string(raw), + }) +} diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index d3fee7f9..b2dee1ce 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -80,6 +80,7 @@ func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error } return args.Error(0) } + func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { args := m.Called(ctx, user) if m.createCalled != nil { @@ -87,6 +88,7 @@ func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error } return args.Error(0) } + func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil } func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 5386bdc5..747f476e 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -7,6 +7,7 @@ package handler import ( "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" "bytes" "context" @@ -122,6 +123,27 @@ func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID return nil } +func (m *MockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + args := m.Called(ctx, sessionID) + return args.Error(0) +} + // --- Helper --- func newAuthLoginTestApp(h *AuthHandler) *fiber.App { @@ -616,6 +638,156 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) { } } +func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Client: domain.HydraClient{ + ClientID: "devfront", + ClientName: "DevFront", + Metadata: map[string]interface{}{"status": "active"}, + }, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/password/login", h.PasswordLogin) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + + log := auditRepo.logs[0] + if log.EventType != "POST /api/v1/auth/password/login" { + t.Fatalf("expected password login audit event, got %q", log.EventType) + } + if log.UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) + } + + details, err := parseAuditDetails(log.Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "devfront" { + t.Fatalf("expected client_id devfront, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "DevFront" { + t.Fatalf("expected client_name DevFront, got %v", details["client_name"]) + } +} + +func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/password/login", h.PasswordLogin) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + if auditRepo.logs[0].UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", auditRepo.logs[0].UserID) + } + + details, err := parseAuditDetails(auditRepo.logs[0].Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "userfront" { + t.Fatalf("expected client_id userfront, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "UserFront" { + t.Fatalf("expected client_name UserFront, got %v", details["client_name"]) + } +} + func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go new file mode 100644 index 00000000..7dfc0129 --- /dev/null +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -0,0 +1,618 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestListMySessions_Success(t *testing.T) { + now := time.Date(2026, 4, 2, 1, 2, 3, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "other-sid", + Active: true, + AuthenticatedAt: now.Add(-2 * time.Hour), + ExpiresAt: now.Add(22 * time.Hour), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "login_success", + SessionID: "other-sid", + Timestamp: now.Add(-30 * time.Minute), + IPAddress: "203.0.113.10", + UserAgent: "Mozilla/5.0", + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + IsCurrent bool `json:"is_current"` + IsActive bool `json:"is_active"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "current-sid", body.Items[0].SessionID) + assert.True(t, body.Items[0].IsCurrent) + assert.Equal(t, "other-sid", body.Items[1].SessionID) + assert.True(t, body.Items[1].IsActive) + assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) + assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_UsesConsentGrantForAppName(t *testing.T) { + now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "c7c721ea-session", + Active: true, + AuthenticatedAt: now.Add(-5 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + SessionID: "c7c721ea-session", + Timestamp: now, + Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`, + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) + assert.Equal(t, "DevFront", body.Items[1].AppName) + assert.Equal(t, "devfront", body.Items[1].ClientID) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) { + now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "c7c721ea-session", + Active: true, + AuthenticatedAt: now.Add(-5 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + SessionID: "c7c721ea-session", + Timestamp: now.Add(-30 * time.Second), + IPAddress: "203.0.113.10", + Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`, + }, + { + UserID: "user-123", + EventType: "login_success", + SessionID: "c7c721ea-session", + Timestamp: now, + IPAddress: "10.0.0.12", + UserAgent: "Mozilla/5.0", + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + IPAddress string `json:"ip_address"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) + assert.Equal(t, "DevFront", body.Items[1].AppName) + assert.Equal(t, "devfront", body.Items[1].ClientID) + assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) { + now := time.Date(2026, 4, 6, 1, 2, 3, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + }, nil).Once() + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: &mockAuditRepo{}, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + IsCurrent bool `json:"is_current"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "current-sid", body.Items[0].SessionID) + assert.True(t, body.Items[0].IsCurrent) + assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress) + assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0") + assert.Equal(t, "userfront", body.Items[0].ClientID) + assert.Equal(t, "UserFront", body.Items[0].AppName) + } + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_Success(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + if r.URL.Query().Get("subject") != "user-123" { + t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject")) + } + if r.URL.Query().Get("client") != "devfront" { + t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client")) + } + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ + UserID: "user-123", + EventType: "POST /api/v1/auth/oidc/login/accept", + SessionID: "target-sid", + Details: `{"client_id":"devfront","client_name":"Devfront"}`, + }) + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + if assert.Len(t, auditRepo.logs, 2) { + assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType) + assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID) + assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID) + assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid") + } + assert.Equal(t, 1, hydraRevokeCalls) + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 0, hydraRevokeCalls) + if assert.Len(t, auditRepo.logs, 1) { + assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Contains(t, auditRepo.logs[0].Details, "target-sid") + } + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + var revokedClient string + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + revokedClient = r.URL.Query().Get("client") + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ + UserID: "user-123", + EventType: "POST /api/v1/auth/password/login", + SessionID: "target-sid", + Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`, + }) + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, hydraRevokeCalls) + assert.Equal(t, "adminfront", revokedClient) + + mockKratos.AssertExpectations(t) +} + +func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" { + body, _ := io.ReadAll(r.Body) + if string(body) != "token=opaque-token" { + t.Fatalf("unexpected introspect body: %s", string(body)) + } + return httpJSONAny(r, http.StatusOK, map[string]any{ + "active": true, + "sub": "user-123", + "client_id": "devfront", + "ext": map[string]any{ + "session_id": "target-sid", + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + + mockKratos := new(MockKratosAdminService) + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: false, + Identity: &service.KratosIdentity{ + ID: "user-123", + }, + }, nil).Once() + + h := &AuthHandler{ + KratosAdmin: mockKratos, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + + profile, err := h.getHydraProfile(context.Background(), "opaque-token") + assert.Nil(t, profile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "inactive") + mockKratos.AssertExpectations(t) +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 0927a3c6..2adcea28 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -56,6 +56,26 @@ func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } +func (m *MockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} + type MockOryProvider struct { mock.Mock } diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index 59746e1d..a0c5c6fe 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "reflect" - "strings" "sync" "time" @@ -217,16 +216,5 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { } func extractClientIP(c *fiber.Ctx) string { - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index 9998429b..d553ad40 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -117,6 +117,30 @@ func TestAuditMiddleware(t *testing.T) { mockRepo.AssertExpectations(t) }) + t.Run("POST request - Prefer public forwarded IP", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool { + return log.IPAddress == "203.0.113.25" + })).Return(nil) + + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") + + resp, _ := app.Test(req) + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + mockRepo.AssertExpectations(t) + }) + t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { app := fiber.New() mockRepo := new(MockAuditRepository) diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 6bafc76c..8dda5318 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -264,6 +264,8 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, } if clientID != "" { params["client"] = clientID + } else { + params["all"] = "true" } endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params) if err != nil { diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index 35141017..f1e062ac 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -27,6 +27,21 @@ type KratosIdentity struct { UpdatedAt time.Time `json:"updated_at,omitempty"` } +type KratosSessionDevice struct { + UserAgent string `json:"user_agent,omitempty"` + IPAddress string `json:"ip_address,omitempty"` +} + +type KratosSession struct { + ID string `json:"id"` + Active bool `json:"active"` + AuthenticatedAt time.Time `json:"authenticated_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + IssuedAt time.Time `json:"issued_at,omitempty"` + Identity *KratosIdentity `json:"identity,omitempty"` + Devices []KratosSessionDevice `json:"devices,omitempty"` +} + type KratosAdminService interface { ListIdentities(ctx context.Context) ([]KratosIdentity, error) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) @@ -34,6 +49,9 @@ type KratosAdminService interface { UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error + ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) + GetSession(ctx context.Context, sessionID string) (*KratosSession, error) + DeleteSession(ctx context.Context, sessionID string) error } type kratosAdminService struct { @@ -239,6 +257,85 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit return nil } +func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + endpoint := fmt.Sprintf("%s/admin/identities/%s/sessions", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin list identity sessions failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var sessions []KratosSession + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin get session failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var session KratosSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, err + } + return &session, nil +} + +func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("kratos admin delete session failed status=%d body=%s", resp.StatusCode, string(body)) + } + return nil +} + func hashPasswordForKratosAdmin(password string) (string, error) { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index fdf0e6d0..e5330ba3 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -110,3 +110,23 @@ func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Contex func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error { return m.Called(ctx, identityID).Error(0) } + +func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]KratosSession), args.Error(1) +} + +func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*KratosSession), args.Error(1) +} + +func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 37c68b9b..4b24ad34 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -137,6 +137,7 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs } return args.Get(0).(map[string]int64), args.Error(1) } + func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { args := m.Called(ctx, codes) if args.Get(0) == nil { diff --git a/backend/internal/utils/client_ip.go b/backend/internal/utils/client_ip.go new file mode 100644 index 00000000..897cc1e6 --- /dev/null +++ b/backend/internal/utils/client_ip.go @@ -0,0 +1,87 @@ +package utils + +import ( + "net" + "strings" +) + +// ResolveClientIP selects the best client IP from proxy headers and the remote address. +// It prefers a public IP from X-Forwarded-For, then X-Real-IP, and finally the remote IP. +func ResolveClientIP(forwardedFor, realIP, remoteIP string) string { + forwardedCandidates := splitClientIPs(forwardedFor) + if ip := firstPublicIP(forwardedCandidates); ip != "" { + return ip + } + if ip := normalizeIP(realIP); ip != "" && !IsPrivateOrReservedIP(ip) { + return ip + } + if ip := normalizeIP(remoteIP); ip != "" && !IsPrivateOrReservedIP(ip) { + return ip + } + if len(forwardedCandidates) > 0 { + return forwardedCandidates[0] + } + if ip := normalizeIP(realIP); ip != "" { + return ip + } + return normalizeIP(remoteIP) +} + +// IsPrivateOrReservedIP reports whether the IP is private or from a non-public network range. +func IsPrivateOrReservedIP(raw string) bool { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + return false + } + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { + return true + } + for _, cidr := range []string{ + "100.64.0.0/10", + "fc00::/7", + } { + _, network, err := net.ParseCIDR(cidr) + if err == nil && network.Contains(ip) { + return true + } + } + return false +} + +func splitClientIPs(forwardedFor string) []string { + if strings.TrimSpace(forwardedFor) == "" { + return nil + } + parts := strings.Split(forwardedFor, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if ip := normalizeIP(part); ip != "" { + result = append(result, ip) + } + } + return result +} + +func firstPublicIP(candidates []string) string { + for _, candidate := range candidates { + if !IsPrivateOrReservedIP(candidate) { + return candidate + } + } + return "" +} + +func normalizeIP(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if host, _, err := net.SplitHostPort(raw); err == nil { + raw = host + } + ip := net.ParseIP(raw) + if ip == nil { + return "" + } + return ip.String() +} diff --git a/backend/internal/utils/client_ip_test.go b/backend/internal/utils/client_ip_test.go new file mode 100644 index 00000000..8128fc87 --- /dev/null +++ b/backend/internal/utils/client_ip_test.go @@ -0,0 +1,24 @@ +package utils + +import "testing" + +func TestResolveClientIP_PrefersPublicForwardedIP(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 203.0.113.25, 10.0.0.2", "", "172.18.0.5") + if got != "203.0.113.25" { + t.Fatalf("expected public forwarded IP, got %q", got) + } +} + +func TestResolveClientIP_FallsBackToFirstForwardedWhenAllPrivate(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 10.0.0.2", "192.168.0.10", "172.18.0.5") + if got != "100.100.100.1" { + t.Fatalf("expected first forwarded private IP, got %q", got) + } +} + +func TestResolveClientIP_PrefersPublicRealIPOverPrivateForwarded(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 10.0.0.2", "198.51.100.7", "172.18.0.5") + if got != "198.51.100.7" { + t.Fatalf("expected public real IP, got %q", got) + } +} diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 21c43d00..4e0eb33b 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -15,7 +15,10 @@ import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { fetchMe } from "../../features/auth/authApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; -import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; @@ -151,6 +154,52 @@ function AppLayout() { isSessionExpiryEnabled, ]); + useEffect(() => { + const maybeKeepSessionAlive = async () => { + const now = Date.now(); + if ( + !shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: auth.user?.expires_at, + nowMs: now, + isEnabled: isSessionExpiryEnabled, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + isRenewInFlight: isRenewInFlightRef.current, + lastAttemptAtMs: lastRenewAttemptAtRef.current, + }) + ) { + return; + } + + isRenewInFlightRef.current = true; + lastRenewAttemptAtRef.current = now; + + try { + await auth.signinSilent(); + } catch (error) { + console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + } finally { + isRenewInFlightRef.current = false; + } + }; + + const timer = window.setInterval(() => { + void maybeKeepSessionAlive(); + }, 30_000); + + void maybeKeepSessionAlive(); + + return () => { + window.clearInterval(timer); + }; + }, [ + auth, + auth.isAuthenticated, + auth.isLoading, + auth.user?.expires_at, + isSessionExpiryEnabled, + ]); + useEffect(() => { const routeKey = `${location.pathname}${location.search}${location.hash}`; if (lastVisitedRouteRef.current === null) { diff --git a/devfront/src/lib/apiClient.ts b/devfront/src/lib/apiClient.ts index 49b83cee..5a28cc72 100644 --- a/devfront/src/lib/apiClient.ts +++ b/devfront/src/lib/apiClient.ts @@ -27,17 +27,23 @@ apiClient.interceptors.request.use(async (config) => { apiClient.interceptors.response.use( (response) => response, async (error) => { - if (error.response?.status === 401) { - // 401 발생 시 로그인 페이지로 리다이렉트 - const isAuthPath = window.location.pathname.startsWith("/auth/callback"); - const isLoginPath = window.location.pathname === "/login"; - const user = await userManager.getUser(); - // 인증 토큰이 없는 경우에만 로그인으로 보낸다. - // 토큰이 있는데 401이면 권한/백엔드 정책 이슈로 간주하고 화면에서 에러를 노출한다. - const hasAccessToken = Boolean(user?.access_token); - if (!hasAccessToken && !isAuthPath && !isLoginPath) { - window.location.href = "/login"; - } + const status = error.response?.status; + const message = + error.response?.data?.error?.toString().toLowerCase() ?? + error.response?.data?.message?.toString().toLowerCase() ?? + ""; + const isAuthPath = window.location.pathname.startsWith("/auth/callback"); + const isLoginPath = window.location.pathname === "/login"; + const shouldRedirectToLogin = + status === 401 || + (status === 403 && + (message.includes("authentication required") || + message.includes("invalid session") || + message.includes("token is not active"))); + + if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) { + await userManager.removeUser(); + window.location.href = "/login"; } return Promise.reject(error); }, diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index f424d9d9..d0f0772e 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -11,7 +11,7 @@ export const oidcConfig: AuthProviderProps = { post_logout_redirect_uri: window.location.origin, popup_redirect_uri: `${window.location.origin}/auth/callback`, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: true, + automaticSilentRenew: false, }; export const userManager = new UserManager({ diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts index 7096e7f3..be152778 100644 --- a/devfront/src/lib/sessionSliding.ts +++ b/devfront/src/lib/sessionSliding.ts @@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({ return true; } + +export function shouldAttemptUnlimitedSessionRenew({ + expiresAtSec, + nowMs, + isEnabled, + isAuthenticated, + isLoading, + isRenewInFlight, + lastAttemptAtMs, + thresholdMs = SESSION_RENEW_THRESHOLD_MS, + throttleMs = SESSION_RENEW_THROTTLE_MS, +}: SlidingSessionRenewDecisionParams) { + if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { + return false; + } + + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +} diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index d3643940..e90806bd 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -196,6 +196,22 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); }; + await page.route("**/api/v1/user/me", async (route) => { + return json(route, { + id: "playwright-user", + loginId: "playwright@example.com", + email: "playwright@example.com", + name: "Playwright User", + phoneNumber: "", + department: "QA", + tenantId: "tenant-a", + tenantName: "Tenant A", + role: "rp_admin", + createdAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + }); + }); + await page.route("**/api/v1/dev/**", async (route) => { const request = route.request(); const url = new URL(request.url()); diff --git a/locales/en.toml b/locales/en.toml index 28da2b1f..9f07f641 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -559,6 +559,20 @@ empty = "No linked apps yet." empty_detail = "Linked apps and their latest activity will appear here." error = "Could not load linked apps." +[msg.userfront.dashboard.sessions] +browser = "Browser: {{value}}" +empty = "No active sessions." +empty_detail = "Devices signed in with this account will appear here." +error = "Could not load sessions." +os = "OS: {{value}}" +recent_app = "Recent app: {{app}}" +session_id = "Session ID: {{id}}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "End the session for {{target}}?\nThat device will need to sign in again." +error = "Could not end the session: {{error}}" +success = "The session has been ended." + [msg.userfront.dashboard.approved_session] copy_click = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy." copy_tap = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy." @@ -735,6 +749,7 @@ uppercase = "At least one uppercase letter" [msg.userfront.sections] apps_subtitle = "Your linked apps and their latest sign-in status." audit_subtitle = "Recent access history for Baron sign-in." +sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] disabled = "Account settings are currently unavailable." @@ -2070,6 +2085,17 @@ status_history = "Activity history" [ui.userfront.dashboard.activity] linked = "Linked" +[ui.userfront.dashboard.sessions] +active_badge = "Active" +current_badge = "Current" +current_disabled = "Current session" +unknown_device = "Unknown device" +unknown_session = "Session" + +[ui.userfront.dashboard.sessions.revoke] +action = "End session" +title = "End session" + [ui.userfront.dashboard.approved_session] default = "Default" userfront = "Approved UserFront session ID" @@ -2204,6 +2230,7 @@ title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" +sessions = "Sessions" [ui.userfront.session] active = "Active session" diff --git a/locales/ko.toml b/locales/ko.toml index 3c995a3d..72bf3eed 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -73,7 +73,402 @@ scope_admin = "Scoped to /admin" session_ttl = "Session TTL: 15m admin" tenant_headers = "Tenant-aware headers" -[msg.admin.api_keys] +[msg.admin.common] +forbidden = "이 작업을 수행할 권한이 없습니다." + +[msg.admin.audit] +empty = "아직 수집된 감사 로그가 없습니다." +end = "감사 로그의 마지막입니다." +load_error = "감사 로그를 불러오지 못했습니다: {{error}}" +loading = "감사 로그를 불러오는 중..." +subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다." + +[msg.admin.header] +subtitle = "Tenant isolation & least privilege by default" + +[msg.admin.notice] +idp_policy = "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다." +scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다." + +[msg.admin.org] +hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다." +import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다." +import_error = "조직도 임포트 중 오류가 발생했습니다." +import_success = "조직도가 성공적으로 임포트되었습니다." + +[msg.admin.overview] +description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다." +idp_fallback = "Fallback: Descope" +idp_primary = "IDP: Ory primary" + +[msg.admin.tenants] +approve_confirm = "이 테넌트를 승인하시겠습니까?" +approve_success = "테넌트가 승인되었습니다." +delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?" +delete_success = "테넌트가 삭제되었습니다." +empty = "아직 등록된 테넌트가 없습니다." +fetch_error = "테넌트 목록 조회에 실패했습니다." +missing_id = "테넌트 ID가 없습니다." +not_found = "테넌트를 찾을 수 없습니다." +remove_sub_confirm = "테넌트 \"{{name}}\"을(를) 하위 조직에서 제외할까요?" +subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." + +[msg.dev.auth] +access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요." +access_denied_title = "접근 권한이 없습니다." + +[msg.dev.forbidden] +default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요." +rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다." +tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." +user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." +title = "{{resource}} 접근 권한 없음" + +[msg.dev.audit] +empty = "조회된 감사 로그가 없습니다." +forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요." +load_error = "감사 로그 조회 실패: {{error}}" +loaded_count = "로드된 로그 {{count}}건" +loading = "감사 로그를 불러오는 중..." +subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다." + +[msg.dev.clients] +deleted = "앱이 삭제되었습니다." +delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." +delete_error = "삭제 실패: {{error}}" +load_error = "앱 정보를 불러오지 못했습니다: {{error}}" +loading = "앱 정보를 불러오는 중..." +showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." + +[msg.dev.sidebar] +notice = "개발자 전용 콘솔입니다." +notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다." + +[msg.dev.clients.general.public_key] +auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다." +auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다." +auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다." +guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json" +guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다." +guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다." +guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다." +guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다." +headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다." +jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다." +jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json" +request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다." +source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다." +subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다." + +[msg.dev.clients.general.public_key.validation] +headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다." +headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다." +headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다." +invalid_jwks_inline = "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다." +invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다." +missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다." +missing_jwks_uri = "JWKS URI를 입력해야 합니다." +private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다." + +[msg.userfront.audit] +date = "접속일자: {{value}}" +device = "접속환경: {{value}}" +end = "더 이상 항목이 없습니다." +ip = "접속 IP: {{value}}" +load_more_error = "더 불러오지 못했습니다." +result = "인증결과: {{value}}" +session_id = "Session ID: {{value}}" +status = "현황: (준비중)" + +[msg.userfront.dashboard] +approved_device = "승인 기기: {{device}}" +approved_ip = "승인 IP: {{ip}}" +audit_empty = "최근 접속 이력이 없습니다." +audit_load_error = "접속이력을 불러오지 못했습니다." +auth_method = "인증수단: {{method}}" +client_id = "Client ID: {{id}}" +client_id_missing = "Client ID 없음" +current_status = "현재 상태: {{status}}" +last_auth = "최근 인증: {{value}}" +link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." +link_open_error = "해당 링크를 열 수 없습니다." +render_error = "대시보드 렌더링 오류: {{error}}" +session_id_copied = "세션 ID가 복사되었습니다." + +[msg.userfront.error] +detail_contact = "관리자에게 문의해 주세요." +detail_generic = "오류가 발생했습니다." +detail_request = "요청을 처리하는 중 문제가 발생했습니다." +id = "오류 ID: {{id}}" +title = "인증 과정에서 오류가 발생했습니다" +title_generic = "오류가 발생했습니다" +title_with_code = "오류: {{code}}" +type = "오류 종류: {{type}}" + +[msg.userfront.forgot] +description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +error = "전송에 실패했습니다: {{error}}" +input_required = "이메일 또는 휴대폰 번호를 입력해주세요." +sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요." + +[msg.userfront.login] +cookie_check_failed = "로그인 확인 실패: {{error}}" +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +link_failed = "오류: {{error}}" +link_send_failed = "전송 실패: {{error}}" +link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다." +link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다." +link_timeout = "시간이 경과되었습니다." +no_account = "계정이 없으신가요?" +oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요." +qr_expired = "시간이 경과되었습니다." +qr_init_failed = "QR 초기화에 실패했습니다: {{error}}" +qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다" +token_missing = "로그인 토큰을 확인할 수 없습니다." +verification_failed = "승인 처리에 실패했습니다: {{error}}" + +[msg.userfront.login_success] +subtitle = "성공적으로 로그인되었습니다." + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {{error}}" +client_id = "클라이언트 ID: {{id}}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {{error}}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {{count}}개" + +[msg.userfront.profile] +department_missing = "소속 정보 없음" +department_required = "소속을 입력해주세요." +email_missing = "이메일 없음" +greeting = "안녕하세요, {{name}}님" +load_failed = "정보를 불러올 수 없습니다." +name_missing = "이름 없음" +name_required = "이름을 입력해주세요." +phone_required = "휴대폰 번호를 입력해주세요." +phone_verify_required = "휴대폰 번호 인증이 필요합니다." +update_failed = "수정 실패: {{error}}" +update_success = "정보가 수정되었습니다." + +[msg.userfront.qr] +camera_error = "카메라 오류: {{error}}" +permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." +permission_required = "카메라 권한이 필요합니다." + +[msg.userfront.reset] +invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요." +invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)" +invalid_title = "유효하지 않은 링크입니다." +policy_loading = "비밀번호 정책을 불러오는 중입니다..." +success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." + +[msg.userfront.sections] +apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." +audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." + +[msg.userfront.settings] +disabled = "현재 계정 설정 화면은 준비 중입니다." + +[msg.userfront.signup] +failed = "가입 실패: {{error}}" +privacy_full = "개인정보 수집 및 이용 동의 전문..." +tos_full = "서비스 이용약관 전문..." + +[ui.admin.audit] +export_csv = "Export CSV" +load_more = "Load more" +target = "Target · {{target}}" +title = "감사 로그" + +[ui.admin.groups] +import_csv = "CSV 임포트" + +[ui.admin.header] +plane = "Admin Plane" +subtitle = "관리 및 정책 운영" + +[ui.admin.nav] +api_keys = "API 키" +audit_logs = "감사 로그" +auth_guard = "인증 가드" +logout = "로그아웃" +overview = "개요" +relying_parties = "애플리케이션(RP)" +tenant_dashboard = "테넌트 대시보드" +user_groups = "유저 그룹" +tenants = "테넌트" +users = "사용자" + +[ui.admin.org] +download_template = "템플릿 다운로드" +import_btn = "임포트" +import_title = "조직도 대량 등록" +start_import = "임포트 시작" + +[ui.admin.overview] +kicker = "Global Overview" +title = "통합 대시보드" + +[ui.admin.profile] +manageable_tenants = "관리 가능한 테넌트" + +[ui.admin.role] +rp_admin = "RP ADMIN" +super_admin = "SUPER ADMIN" +tenant_admin = "TENANT ADMIN" +user = "TENANT MEMBER" + +[ui.admin.tenants] +add = "테넌트 추가" +title = "테넌트 목록" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" + +[ui.dev.nav] +clients = "연동 앱" +logout = "로그아웃" + +[ui.dev.tenant] +single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다." +switch_success = "테넌트 전환 완료" +workspace = "작업 테넌트 (컨텍스트)" +workspace_desc = "현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다." + +[ui.dev.audit] +load_more = "더 보기" +title = "감사 로그" + +[ui.dev.profile] +menu_aria = "계정 메뉴 열기" +menu_title = "계정" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" +title = "내 정보" +subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다." +loading = "프로필 정보를 불러오는 중..." +error = "프로필 정보를 불러오지 못했습니다." + +[ui.dev.clients] +new = "연동 앱 추가" +search_placeholder = "연동 앱 이름/ID로 검색..." +tenant_scoped = "Tenant-scoped" +untitled = "Untitled" + +[ui.dev.dashboard] +ready_badge = "devfront ready" + +[ui.dev.header] +plane = "Dev Plane" +subtitle = "Manage your applications" + +[ui.dev.session] +auto_extend = "세션 만료 관리" +active = "세션 활성" +disabled = "자동 연장 비활성화" +unknown = "알 수 없음" +expired = "세션 만료" +expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" +remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" +refresh = "세션 만료 시간 갱신" +refreshing = "세션 만료 시간 갱신 중..." + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron 로그인" +dev_console = "Dev Console" + +[ui.userfront.auth_method] +ory = "Ory 세션" +session = "세션" + +[ui.userfront.dashboard] +last_auth_label = "최근 인증" +status_history = "상태 이력" + +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "홈으로 이동" +go_login = "로그인으로 이동" + +[ui.userfront.forgot] +heading = "비밀번호를 잊으셨나요?" +input_label = "이메일 또는 휴대폰 번호" +submit = "재설정 링크 전송" +title = "비밀번호 재설정" + +[ui.userfront.login] +forgot_password = "비밀번호를 잊으셨나요?" +signup = "회원가입" + +[ui.userfront.login_success] +later = "나중에 하기 (대시보드로 이동)" +qr = "QR 인증 (카메라 켜기)" +title = "로그인 완료" + +[ui.userfront.consent] +accept = "동의하고 계속하기" +requested_scopes = "요청된 권한" +title = "접근 권한 요청" + +[ui.userfront.nav] +dashboard = "대시보드" +logout = "로그아웃" +profile = "내 정보" +qr_scan = "QR 스캔" + +[ui.userfront.profile] +department_empty = "소속 정보 없음" +manage = "프로필 관리" +user_fallback = "사용자" + +[ui.userfront.qr] +rescan = "다시 스캔" +result_success = "승인 완료" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "새 비밀번호 확인" +new_password = "새 비밀번호" +submit = "비밀번호 변경" +subtitle = "새로운 비밀번호 설정" +title = "새 비밀번호 설정" + +[ui.userfront.sections] +apps = "나의 App 현황" +audit = "접속이력" +sessions = "활성 세션" + +[ui.userfront.session] +active = "세션 활성" +unknown = "알 수 없음" + +[ui.userfront.signup] +complete = "가입 완료" +next_step = "다음 단계" +title = "회원가입" [msg.admin.api_keys.create] error = "API 키 생성에 실패했습니다." @@ -559,6 +954,20 @@ empty = "연동된 앱이 없습니다." empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." +[msg.userfront.dashboard.sessions] +browser = "브라우저: {{value}}" +empty = "활성 세션이 없습니다." +empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다." +error = "세션 정보를 불러오지 못했습니다." +os = "OS: {{value}}" +recent_app = "최근 접속 앱: {{app}}" +session_id = "세션 ID: {{id}}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "{{target}} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다." +error = "세션 종료 실패: {{error}}" +success = "세션이 종료되었습니다." + [msg.userfront.dashboard.approved_session] copy_click = "{{label}}: {{id}}\\\\n클릭하면 복사됩니다." copy_tap = "{{label}}: {{id}}\\\\n탭하면 복사됩니다." @@ -2070,6 +2479,17 @@ status_history = "상태 이력" [ui.userfront.dashboard.activity] linked = "연동됨" +[ui.userfront.dashboard.sessions] +active_badge = "활성화" +current_badge = "현재 접속중" +current_disabled = "현재 세션" +unknown_device = "알 수 없는 기기" +unknown_session = "세션 정보" + +[ui.userfront.dashboard.sessions.revoke] +action = "세션 종료" +title = "세션 종료" + [ui.userfront.dashboard.approved_session] default = "승인한 세션 ID" userfront = "승인한 Userfront 세션 ID" diff --git a/locales/template.toml b/locales/template.toml index adaca6bf..ff8d60a1 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -73,7 +73,280 @@ scope_admin = "" session_ttl = "" tenant_headers = "" -[msg.admin.api_keys] +[msg.userfront.error] +detail_contact = "" +detail_generic = "" +detail_request = "" +id = "" +title = "" +title_generic = "" +title_with_code = "" +type = "" + +[msg.userfront.forgot] +description = "" +dry_send = "" +error = "" +input_required = "" +sent = "" + +[msg.userfront.login] +cookie_check_failed = "" +dry_send = "" +link_failed = "" +link_send_failed = "" +link_sent_email = "" +link_sent_phone = "" +link_timeout = "" +no_account = "" +oidc_failed = "" +qr_expired = "" +qr_init_failed = "" +qr_login_required = "" +token_missing = "" +verification_failed = "" + +[msg.userfront.login_success] +subtitle = "" + +[msg.userfront.consent] +accept_error = "" +client_id = "" +client_unknown = "" +description = "" +load_error = "" +missing_redirect = "" +redirect_notice = "" +scope_count = "" + +[msg.userfront.profile] +department_missing = "" +department_required = "" +email_missing = "" +greeting = "" +load_failed = "" +name_missing = "" +name_required = "" +phone_required = "" +phone_verify_required = "" +update_failed = "" +update_success = "" + +[msg.userfront.qr] +camera_error = "" +permission_error = "" +permission_required = "" + +[msg.userfront.reset] +invalid_body = "" +invalid_link = "" +invalid_title = "" +policy_loading = "" +success = "" + +[msg.userfront.sections] +apps_subtitle = "" +audit_subtitle = "" +sessions_subtitle = "" + +[msg.userfront.settings] +disabled = "" + +[msg.userfront.signup] +failed = "" +privacy_full = "" +tos_full = "" + +[ui.admin.audit] +export_csv = "" +load_more = "" +target = "" +title = "" + +[ui.admin.groups] +import_csv = "" + +[ui.admin.header] +plane = "" +subtitle = "" + +[ui.admin.nav] +api_keys = "" +audit_logs = "" +auth_guard = "" +logout = "" +overview = "" +relying_parties = "" +tenant_dashboard = "" +user_groups = "" +tenants = "" +users = "" + +[ui.admin.org] +download_template = "" +import_btn = "" +import_title = "" +start_import = "" + +[ui.admin.overview] +kicker = "" +title = "" + +[ui.admin.profile] +manageable_tenants = "" + +[ui.admin.role] +rp_admin = "" +super_admin = "" +tenant_admin = "" +user = "" + +[ui.admin.tenants] +add = "" +title = "" + +[ui.common.badge] +admin_only = "" +command_only = "" +system = "" + +[ui.common.status] +active = "" +blocked = "" +failure = "" +inactive = "" +ok = "" +pending = "" +success = "" + +[ui.dev.nav] +clients = "" +logout = "" + +[ui.dev.tenant] +single_notice = "" +switch_success = "" +workspace = "" +workspace_desc = "" + +[ui.dev.audit] +load_more = "" +title = "" + +[ui.dev.profile] +menu_aria = "" +menu_title = "" +unknown_email = "" +unknown_name = "" +title = "" +subtitle = "" +loading = "" +error = "" + +[ui.dev.clients] +new = "" +search_placeholder = "" +tenant_scoped = "" +untitled = "" + +[ui.dev.dashboard] +ready_badge = "" + +[ui.dev.header] +plane = "" +subtitle = "" + +[ui.dev.session] +auto_extend = "" +active = "" +disabled = "" +unknown = "" +expired = "" +expiring = "" +remaining = "" +refresh = "" +refreshing = "" + +[ui.userfront.app_label] +admin_console = "" +baron = "" +dev_console = "" + +[ui.userfront.auth_method] +ory = "" +session = "" + +[ui.userfront.dashboard] +last_auth_label = "" +status_history = "" + +[ui.userfront.device] +android = "" +ios = "" +linux = "" +macos = "" +windows = "" + +[ui.userfront.error] +go_home = "" +go_login = "" + +[ui.userfront.forgot] +heading = "" +input_label = "" +submit = "" +title = "" + +[ui.userfront.login] +forgot_password = "" +signup = "" + +[ui.userfront.login_success] +later = "" +qr = "" +title = "" + +[ui.userfront.consent] +accept = "" +requested_scopes = "" +title = "" + +[ui.userfront.nav] +dashboard = "" +logout = "" +profile = "" +qr_scan = "" + +[ui.userfront.profile] +department_empty = "" +manage = "" +user_fallback = "" + +[ui.userfront.qr] +rescan = "" +result_success = "" +title = "" + +[ui.userfront.reset] +confirm_password = "" +new_password = "" +submit = "" +subtitle = "" +title = "" + +[ui.userfront.sections] +apps = "" +audit = "" +sessions = "" + +[ui.userfront.session] +active = "" +unknown = "" + +[ui.userfront.signup] +complete = "" +next_step = "" +title = "" [msg.admin.api_keys.create] error = "" @@ -559,6 +832,20 @@ empty = "" empty_detail = "" error = "" +[msg.userfront.dashboard.sessions] +browser = "" +empty = "" +empty_detail = "" +error = "" +os = "" +recent_app = "" +session_id = "" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "" +error = "" +success = "" + [msg.userfront.dashboard.approved_session] copy_click = "" copy_tap = "" @@ -2070,6 +2357,17 @@ status_history = "" [ui.userfront.dashboard.activity] linked = "" +[ui.userfront.dashboard.sessions] +active_badge = "" +current_badge = "" +current_disabled = "" +unknown_device = "" +unknown_session = "" + +[ui.userfront.dashboard.sessions.revoke] +action = "" +title = "" + [ui.userfront.dashboard.approved_session] default = "" userfront = "" diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts index 09728c53..e722ef6d 100644 --- a/userfront-e2e/tests/password-and-reset.spec.ts +++ b/userfront-e2e/tests/password-and-reset.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from '@playwright/test'; +import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; type RequestCapture = { loginBody?: Record; @@ -7,15 +7,26 @@ type RequestCapture = { clientLogs: string[]; }; +const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/; +const resetConfirmPasswordName = + /^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/; +const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/; + async function enableFlutterAccessibility(page: Page): Promise { - await page.waitForTimeout(300); const button = page.getByRole('button', { name: 'Enable accessibility' }); if (await button.count()) { - await button.click({ force: true }); - const placeholder = page.locator('flt-semantics-placeholder'); - if (await placeholder.count()) { - await placeholder.first().click({ force: true }); - } + await button.first().evaluate((node) => { + (node as HTMLElement).click(); + }); + await page.waitForTimeout(200); + return; + } + await page.waitForTimeout(300); + const placeholder = page.locator('flt-semantics-placeholder').first(); + if (await placeholder.count()) { + await placeholder.evaluate((node) => { + (node as HTMLElement).click(); + }); await page.waitForTimeout(800); } } @@ -109,6 +120,18 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< await page.keyboard.type(value); } +async function typeIntoAccessibleField( + page: Page, + field: Locator, + value: string, +): Promise { + await field.click({ force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + async function fillPasswordLoginForm( page: Page, loginId: string, @@ -128,25 +151,29 @@ async function fillPasswordLoginForm( async function submitPasswordLogin(page: Page): Promise { if (isMobileProject(page)) { + await enableFlutterAccessibility(page); await page.getByRole('button', { name: '로그인' }).click({ force: true }); return; } - const coords = coordsFor(page); - await page.locator('flt-glass-pane').click({ - position: { x: coords.signinSubmitX, y: coords.signinSubmitY }, - force: true, - }); + await page.keyboard.press('Enter'); } async function fillResetPasswordForm(page: Page, password: string): Promise { + await enableFlutterAccessibility(page); + const newPasswordInput = page.getByRole('textbox', { + name: resetNewPasswordName, + }); + const confirmPasswordInput = page.getByRole('textbox', { + name: resetConfirmPasswordName, + }); + if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) { + await typeIntoAccessibleField(page, newPasswordInput, password); + await typeIntoAccessibleField(page, confirmPasswordInput, password); + return; + } if (isMobileProject(page)) { - await enableFlutterAccessibility(page); - await page - .getByRole('textbox', { name: /^새 비밀번호$/ }) - .fill(password); - await page - .getByRole('textbox', { name: /^새 비밀번호 확인$/ }) - .fill(password); + await page.getByRole('textbox', { name: resetNewPasswordName }).fill(password); + await page.getByRole('textbox', { name: resetConfirmPasswordName }).fill(password); return; } const coords = coordsFor(page); @@ -160,8 +187,13 @@ async function fillResetPasswordForm(page: Page, password: string): Promise { + await enableFlutterAccessibility(page); + const submitButton = page.getByRole('button', { name: resetSubmitButtonName }); + if ((await submitButton.count()) > 0) { + await submitButton.click({ force: true }); + return; + } if (isMobileProject(page)) { - await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true }); return; } const coords = coordsFor(page); diff --git a/userfront-e2e/tests/session-cross-browser-debug.spec.ts b/userfront-e2e/tests/session-cross-browser-debug.spec.ts new file mode 100644 index 00000000..b22a025a --- /dev/null +++ b/userfront-e2e/tests/session-cross-browser-debug.spec.ts @@ -0,0 +1,200 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +const USERFRONT_BASE_URL = process.env.USERFRONT_BASE_URL ?? 'https://sso-test.hmac.kr'; +const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? 'http://localhost:5173'; +const LOGIN_ID = process.env.E2E_LOGIN_ID ?? ''; +const PASSWORD = process.env.E2E_PASSWORD ?? ''; + +type SessionApiResponse = { + items?: Array<{ + session_id?: string; + client_id?: string; + app_name?: string; + is_current?: boolean; + user_agent?: string; + ip_address?: string; + }>; +}; + +function ensureCredentials(): void { + if (!LOGIN_ID || !PASSWORD) { + test.skip(true, 'E2E credentials are required'); + } +} + +async function enableFlutterAccessibility(page: Page): Promise { + await page.waitForTimeout(300); + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + try { + await button.click({ force: true }); + } catch { + return; + } + const placeholder = page.locator('flt-semantics-placeholder'); + if (await placeholder.count()) { + await placeholder.first().click({ force: true }); + } + await page.waitForTimeout(800); + } +} + +async function clickPasswordTab(page: Page): Promise { + await page.waitForTimeout(900); + const pane = page.locator('flt-glass-pane'); + await pane.click({ + position: { x: 522, y: 158 }, + force: true, + }); + await page.waitForTimeout(120); + await pane.click({ + position: { x: 522, y: 158 }, + force: true, + }); + await page.waitForTimeout(200); +} + +async function fillAt(page: Page, x: number, y: number, value: string): Promise { + const pane = page.locator('flt-glass-pane'); + await pane.click({ position: { x, y }, force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + +async function loginViaUserFront(page: Page): Promise { + await page.waitForURL(/\/ko\/(signin|login)/, { timeout: 30_000 }); + const loginIdInput = page.getByPlaceholder(/이메일 또는 휴대폰 번호|email|phone/i); + const passwordInput = page.getByPlaceholder(/비밀번호|password/i); + const submitButton = page.getByRole('button', { name: /로그인|Login/i }); + + if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) { + await loginIdInput.first().fill(LOGIN_ID); + await passwordInput.first().fill(PASSWORD); + await submitButton.click(); + return; + } + + await clickPasswordTab(page); + await fillAt(page, 640, 245, LOGIN_ID); + await fillAt(page, 640, 311, PASSWORD); + await page.locator('flt-glass-pane').click({ + position: { x: 640, y: 381 }, + force: true, + }); +} + +async function ensureConsentIfNeeded(page: Page): Promise { + if (!/\/ko\/consent/.test(page.url())) { + return; + } + + const allowButton = page + .getByRole('button') + .filter({ hasText: /허용|동의|Accept|Allow/i }) + .first(); + + if (await allowButton.count()) { + await allowButton.click({ force: true }); + } +} + +async function captureUserSessionsOnReload(page: Page): Promise { + const responsePromise = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + response.url().includes('/api/v1/user/sessions'), + { timeout: 30_000 }, + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + const response = await responsePromise; + return (await response.json()) as SessionApiResponse; +} + +async function loginUserFront(context: BrowserContext): Promise { + const page = await context.newPage(); + await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, { + waitUntil: 'domcontentloaded', + }); + await loginViaUserFront(page); + await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 }); + return page; +} + +async function loginAdminFront(context: BrowserContext): Promise { + const page = await context.newPage(); + await page.goto(ADMINFRONT_URL, { waitUntil: 'domcontentloaded' }); + const ssoButton = page.getByRole('button', { name: /SSO 계정으로 로그인|SSO/i }); + if (await ssoButton.count()) { + await ssoButton.click({ force: true }); + await page.waitForTimeout(1500); + } + if (/\/login$/.test(page.url())) { + const authorizeUrl = await page.evaluate(() => { + const origin = window.location.origin; + const authority = 'https://sso-test.hmac.kr/oidc'; + const params = new URLSearchParams({ + client_id: 'adminfront', + redirect_uri: `${origin}/auth/callback`, + response_type: 'code', + scope: 'openid offline_access profile email', + state: `pw-${Date.now()}`, + nonce: `pw-${Date.now()}`, + code_challenge: 'test-code-challenge-test-code-challenge-test', + code_challenge_method: 'plain', + }); + return `${authority}/oauth2/auth?${params.toString()}`; + }); + await page.goto(authorizeUrl, { waitUntil: 'domcontentloaded' }); + } + await loginViaUserFront(page); + await ensureConsentIfNeeded(page); + await page.waitForURL(/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/, { + timeout: 60_000, + }); + return page; +} + +test.describe('cross-browser session debug', () => { + test('userfront session card should map adminfront session metadata across contexts', async ({ + browser, + }, testInfo) => { + ensureCredentials(); + + const userfrontContext = await browser.newContext({ locale: 'ko-KR' }); + const adminfrontContext = await browser.newContext({ locale: 'ko-KR' }); + + const userfrontPage = await loginUserFront(userfrontContext); + const adminfrontPage = await loginAdminFront(adminfrontContext); + + const sessionsPayload = await captureUserSessionsOnReload(userfrontPage); + const items = sessionsPayload.items ?? []; + const adminfrontItems = items.filter((item) => + (item.client_id ?? '').toLowerCase().includes('adminfront'), + ); + const unknownCards = await userfrontPage.locator('text=세션 정보').allTextContents(); + const adminFrontCards = await userfrontPage.locator('text=AdminFront').allTextContents(); + + await testInfo.attach('user-sessions.json', { + body: JSON.stringify(sessionsPayload, null, 2), + contentType: 'application/json', + }); + await testInfo.attach('card-summary.json', { + body: JSON.stringify( + { + unknownCards, + adminFrontCards, + currentUrl: userfrontPage.url(), + adminfrontUrl: adminfrontPage.url(), + }, + null, + 2, + ), + contentType: 'application/json', + }); + + expect(adminfrontItems.length).toBeGreaterThan(0); + }); +}); diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 86f56c94..3a27641a 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -94,6 +94,20 @@ empty = "No linked apps yet." empty_detail = "Linked apps and their latest activity will appear here." error = "Could not load linked apps." +[msg.userfront.dashboard.sessions] +browser = "Browser: {value}" +empty = "No active sessions." +empty_detail = "Devices signed in with this account will appear here." +error = "Could not load sessions." +os = "OS: {value}" +recent_app = "Recent app: {app}" +session_id = "Session ID: {id}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "End the session for {target}?\nThat device will need to sign in again." +error = "Could not end the session: {error}" +success = "The session has been ended." + [msg.userfront.dashboard.approved_session] copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy." copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy." @@ -270,6 +284,7 @@ uppercase = "At least one uppercase letter" [msg.userfront.sections] apps_subtitle = "Your linked apps and their latest sign-in status." audit_subtitle = "Recent access history for Baron sign-in." +sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] disabled = "Account settings are currently unavailable." @@ -450,6 +465,17 @@ status_history = "Activity history" [ui.userfront.dashboard.activity] linked = "Linked" +[ui.userfront.dashboard.sessions] +active_badge = "Active" +current_badge = "Current" +current_disabled = "Current session" +unknown_device = "Unknown device" +unknown_session = "Session" + +[ui.userfront.dashboard.sessions.revoke] +action = "End session" +title = "End session" + [ui.userfront.dashboard.approved_session] default = "Default" userfront = "Approved UserFront session ID" @@ -584,6 +610,7 @@ title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" +sessions = "Sessions" [ui.userfront.session] active = "Active session" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 482b2186..9fc24973 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -40,6 +40,210 @@ verify_code_failed = "인증 실패: {error}" [err.userfront.session] missing = "활성 세션이 없습니다." +[msg.userfront.audit] +date = "접속일자: {value}" +device = "접속환경: {value}" +end = "더 이상 항목이 없습니다." +ip = "접속 IP: {value}" +load_more_error = "더 불러오지 못했습니다." +result = "인증결과: {value}" +session_id = "Session ID: {value}" +status = "현황: (준비중)" + +[msg.userfront.dashboard] +approved_device = "승인 기기: {device}" +approved_ip = "승인 IP: {ip}" +audit_empty = "최근 접속 이력이 없습니다." +audit_load_error = "접속이력을 불러오지 못했습니다." +auth_method = "인증수단: {method}" +client_id = "Client ID: {id}" +client_id_missing = "Client ID 없음" +current_status = "현재 상태: {status}" +last_auth = "최근 인증: {value}" +link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." +link_open_error = "해당 링크를 열 수 없습니다." +render_error = "대시보드 렌더링 오류: {error}" +session_id_copied = "세션 ID가 복사되었습니다." + +[msg.userfront.error] +detail_contact = "관리자에게 문의해 주세요." +detail_generic = "오류가 발생했습니다." +detail_request = "요청을 처리하는 중 문제가 발생했습니다." +id = "오류 ID: {id}" +title = "인증 과정에서 오류가 발생했습니다" +title_generic = "오류가 발생했습니다" +title_with_code = "오류: {code}" +type = "오류 종류: {type}" + +[msg.userfront.forgot] +description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +error = "전송에 실패했습니다: {error}" +input_required = "이메일 또는 휴대폰 번호를 입력해주세요." +sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요." + +[msg.userfront.login] +cookie_check_failed = "로그인 확인 실패: {error}" +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +link_failed = "오류: {error}" +link_send_failed = "전송 실패: {error}" +link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다." +link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다." +link_timeout = "시간이 경과되었습니다." +no_account = "계정이 없으신가요?" +oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요." +qr_expired = "시간이 경과되었습니다." +qr_init_failed = "QR 초기화에 실패했습니다: {error}" +qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다" +token_missing = "로그인 토큰을 확인할 수 없습니다." +verification_failed = "승인 처리에 실패했습니다: {error}" + +[msg.userfront.login_success] +subtitle = "성공적으로 로그인되었습니다." + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {error}" +client_id = "클라이언트 ID: {id}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {error}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {count}개" + +[msg.userfront.profile] +department_missing = "소속 정보 없음" +department_required = "소속을 입력해주세요." +email_missing = "이메일 없음" +greeting = "안녕하세요, {name}님" +load_failed = "정보를 불러올 수 없습니다." +name_missing = "이름 없음" +name_required = "이름을 입력해주세요." +phone_required = "휴대폰 번호를 입력해주세요." +phone_verify_required = "휴대폰 번호 인증이 필요합니다." +update_failed = "수정 실패: {error}" +update_success = "정보가 수정되었습니다." + +[msg.userfront.qr] +camera_error = "카메라 오류: {error}" +permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." +permission_required = "카메라 권한이 필요합니다." + +[msg.userfront.reset] +invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요." +invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)" +invalid_title = "유효하지 않은 링크입니다." +policy_loading = "비밀번호 정책을 불러오는 중입니다..." +success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." + +[msg.userfront.sections] +apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." +audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." + +[msg.userfront.settings] +disabled = "현재 계정 설정 화면은 준비 중입니다." + +[msg.userfront.signup] +failed = "가입 실패: {error}" +privacy_full = "개인정보 수집 및 이용 동의 전문..." +tos_full = "서비스 이용약관 전문..." + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron 로그인" +dev_console = "Dev Console" + +[ui.userfront.auth_method] +ory = "Ory 세션" +session = "세션" + +[ui.userfront.dashboard] +last_auth_label = "최근 인증" +status_history = "상태 이력" + +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "홈으로 이동" +go_login = "로그인으로 이동" + +[ui.userfront.forgot] +heading = "비밀번호를 잊으셨나요?" +input_label = "이메일 또는 휴대폰 번호" +submit = "재설정 링크 전송" +title = "비밀번호 재설정" + +[ui.userfront.login] +forgot_password = "비밀번호를 잊으셨나요?" +signup = "회원가입" + +[ui.userfront.login_success] +later = "나중에 하기 (대시보드로 이동)" +qr = "QR 인증 (카메라 켜기)" +title = "로그인 완료" + +[ui.userfront.consent] +accept = "동의하고 계속하기" +requested_scopes = "요청된 권한" +title = "접근 권한 요청" + +[ui.userfront.nav] +dashboard = "대시보드" +logout = "로그아웃" +profile = "내 정보" +qr_scan = "QR 스캔" + +[ui.userfront.profile] +department_empty = "소속 정보 없음" +manage = "프로필 관리" +user_fallback = "사용자" + +[ui.userfront.qr] +rescan = "다시 스캔" +result_success = "승인 완료" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "새 비밀번호 확인" +new_password = "새 비밀번호" +submit = "비밀번호 변경" +subtitle = "새로운 비밀번호 설정" +title = "새 비밀번호 설정" + +[ui.userfront.sections] +apps = "나의 App 현황" +audit = "접속이력" +sessions = "활성 세션" + +[ui.userfront.session] +active = "세션 활성" +unknown = "알 수 없음" + +[ui.userfront.signup] +complete = "가입 완료" +next_step = "다음 단계" +title = "회원가입" + [msg.userfront] greeting = "안녕하세요, {name}님" @@ -94,6 +298,20 @@ empty = "연동된 앱이 없습니다." empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." +[msg.userfront.dashboard.sessions] +browser = "브라우저: {value}" +empty = "활성 세션이 없습니다." +empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다." +error = "세션 정보를 불러오지 못했습니다." +os = "OS: {value}" +recent_app = "최근 접속 앱: {app}" +session_id = "세션 ID: {id}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다." +error = "세션 종료 실패: {error}" +success = "세션이 종료되었습니다." + [msg.userfront.dashboard.approved_session] copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다." copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다." @@ -450,6 +668,17 @@ status_history = "상태 이력" [ui.userfront.dashboard.activity] linked = "연동됨" +[ui.userfront.dashboard.sessions] +active_badge = "활성화" +current_badge = "현재 접속중" +current_disabled = "현재 세션" +unknown_device = "알 수 없는 기기" +unknown_session = "세션 정보" + +[ui.userfront.dashboard.sessions.revoke] +action = "세션 종료" +title = "세션 종료" + [ui.userfront.dashboard.approved_session] default = "승인한 세션 ID" userfront = "승인한 Userfront 세션 ID" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 18c8594b..c902ac09 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -40,6 +40,185 @@ verify_code_failed = "" [err.userfront.session] missing = "" +[msg.userfront.error] +detail_contact = "" +detail_generic = "" +detail_request = "" +id = "" +title = "" +title_generic = "" +title_with_code = "" +type = "" + +[msg.userfront.forgot] +description = "" +dry_send = "" +error = "" +input_required = "" +sent = "" + +[msg.userfront.login] +cookie_check_failed = "" +dry_send = "" +link_failed = "" +link_send_failed = "" +link_sent_email = "" +link_sent_phone = "" +link_timeout = "" +no_account = "" +oidc_failed = "" +qr_expired = "" +qr_init_failed = "" +qr_login_required = "" +token_missing = "" +verification_failed = "" + +[msg.userfront.login_success] +subtitle = "" + +[msg.userfront.consent] +accept_error = "" +client_id = "" +client_unknown = "" +description = "" +load_error = "" +missing_redirect = "" +redirect_notice = "" +scope_count = "" + +[msg.userfront.profile] +department_missing = "" +department_required = "" +email_missing = "" +greeting = "" +load_failed = "" +name_missing = "" +name_required = "" +phone_required = "" +phone_verify_required = "" +update_failed = "" +update_success = "" + +[msg.userfront.qr] +camera_error = "" +permission_error = "" +permission_required = "" + +[msg.userfront.reset] +invalid_body = "" +invalid_link = "" +invalid_title = "" +policy_loading = "" +success = "" + +[msg.userfront.sections] +apps_subtitle = "" +audit_subtitle = "" +sessions_subtitle = "" + +[msg.userfront.settings] +disabled = "" + +[msg.userfront.signup] +failed = "" +privacy_full = "" +tos_full = "" + +[ui.common.badge] +admin_only = "" +command_only = "" +system = "" + +[ui.common.status] +active = "" +blocked = "" +failure = "" +inactive = "" +ok = "" +pending = "" +success = "" + +[ui.userfront.app_label] +admin_console = "" +baron = "" +dev_console = "" + +[ui.userfront.auth_method] +ory = "" +session = "" + +[ui.userfront.dashboard] +last_auth_label = "" +status_history = "" + +[ui.userfront.device] +android = "" +ios = "" +linux = "" +macos = "" +windows = "" + +[ui.userfront.error] +go_home = "" +go_login = "" + +[ui.userfront.forgot] +heading = "" +input_label = "" +submit = "" +title = "" + +[ui.userfront.login] +forgot_password = "" +signup = "" + +[ui.userfront.login_success] +later = "" +qr = "" +title = "" + +[ui.userfront.consent] +accept = "" +requested_scopes = "" +title = "" + +[ui.userfront.nav] +dashboard = "" +logout = "" +profile = "" +qr_scan = "" + +[ui.userfront.profile] +department_empty = "" +manage = "" +user_fallback = "" + +[ui.userfront.qr] +rescan = "" +result_success = "" +title = "" + +[ui.userfront.reset] +confirm_password = "" +new_password = "" +submit = "" +subtitle = "" +title = "" + +[ui.userfront.sections] +apps = "" +audit = "" +sessions = "" + +[ui.userfront.session] +active = "" +unknown = "" + +[ui.userfront.signup] +complete = "" +next_step = "" +title = "" + [msg.userfront] greeting = "" @@ -94,6 +273,20 @@ empty = "" empty_detail = "" error = "" +[msg.userfront.dashboard.sessions] +browser = "" +empty = "" +empty_detail = "" +error = "" +os = "" +recent_app = "" +session_id = "" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "" +error = "" +success = "" + [msg.userfront.dashboard.approved_session] copy_click = "" copy_tap = "" @@ -450,6 +643,17 @@ status_history = "" [ui.userfront.dashboard.activity] linked = "" +[ui.userfront.dashboard.sessions] +active_badge = "" +current_badge = "" +current_disabled = "" +unknown_device = "" +unknown_session = "" + +[ui.userfront.dashboard.sessions.revoke] +action = "" +title = "" + [ui.userfront.dashboard.approved_session] default = "" userfront = "" diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 5b21ea20..2e3ae269 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -241,6 +241,64 @@ class AuthProxyService { } } + static Future revokeSession(String sessionId) async { + final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.delete(url, headers: headers); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.revoke', + '세션 종료에 실패했습니다: {{error}}', + detail: response.body, + ); + } + } finally { + client.close(); + } + } + + static Future fetchCurrentSessionId() async { + final url = Uri.parse('$_baseUrl/api/v1/user/sessions'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.load', + '활성 세션을 불러오지 못했습니다: {{error}}', + detail: response.body, + ); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + for (final item in items.whereType>()) { + if (item['is_current'] == true) { + final sessionId = item['session_id']?.toString().trim() ?? ''; + if (sessionId.isNotEmpty) { + return sessionId; + } + } + } + return null; + } finally { + client.close(); + } + } + static Future> verifyLoginShortCode( String shortCode, { bool verifyOnly = false, diff --git a/userfront/lib/core/services/logout_service.dart b/userfront/lib/core/services/logout_service.dart new file mode 100644 index 00000000..38de877d --- /dev/null +++ b/userfront/lib/core/services/logout_service.dart @@ -0,0 +1,39 @@ +import '../notifiers/auth_notifier.dart'; +import 'auth_proxy_service.dart'; +import 'auth_token_store.dart'; + +typedef CurrentSessionLoader = Future Function(); +typedef SessionRevoker = Future Function(String sessionId); +typedef LogoutCallback = void Function(); + +class LogoutService { + LogoutService({ + CurrentSessionLoader? loadCurrentSessionId, + SessionRevoker? revokeSession, + LogoutCallback? clearAuth, + LogoutCallback? notifyAuthChanged, + }) : _loadCurrentSessionId = + loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId, + _revokeSession = revokeSession ?? AuthProxyService.revokeSession, + _clearAuth = clearAuth ?? AuthTokenStore.clear, + _notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify; + + final CurrentSessionLoader _loadCurrentSessionId; + final SessionRevoker _revokeSession; + final LogoutCallback _clearAuth; + final LogoutCallback _notifyAuthChanged; + + Future logout() async { + try { + final currentSessionId = await _loadCurrentSessionId(); + if (currentSessionId != null && currentSessionId.isNotEmpty) { + await _revokeSession(currentSessionId); + } + } catch (_) { + // 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다. + } finally { + _clearAuth(); + _notifyAuthChanged(); + } + } +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index ec300fe3..460e56db 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1032,13 +1032,22 @@ class _LoginScreenState extends ConsumerState webWindow.redirectTo(redirectTo); } else {} } catch (e) { + final errorMessage = e.toString().replaceFirst('Exception: ', ''); + try { + await AuthProxyService.logError( + '[PasswordLogin] $errorMessage', + error: e, + ); + } catch (_) { + // Ignore client-log relay failures and continue with user feedback. + } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { _showError( tr( 'msg.userfront.login.password.failed', - params: {'error': e.toString().replaceFirst('Exception: ', '')}, + params: {'error': errorMessage}, ), ); } diff --git a/userfront/lib/features/dashboard/domain/models.dart b/userfront/lib/features/dashboard/domain/models.dart index f45c858f..3f633490 100644 --- a/userfront/lib/features/dashboard/domain/models.dart +++ b/userfront/lib/features/dashboard/domain/models.dart @@ -170,3 +170,59 @@ class RpHistoryItem { ); } } + +class UserSessionSummary { + final String sessionId; + final DateTime? authenticatedAt; + final DateTime? expiresAt; + final DateTime? issuedAt; + final DateTime? lastSeenAt; + final String ipAddress; + final String userAgent; + final String clientId; + final String appName; + final bool isCurrent; + final bool isActive; + + UserSessionSummary({ + required this.sessionId, + this.authenticatedAt, + this.expiresAt, + this.issuedAt, + this.lastSeenAt, + required this.ipAddress, + required this.userAgent, + required this.clientId, + required this.appName, + required this.isCurrent, + required this.isActive, + }); + + factory UserSessionSummary.fromJson(Map json) { + DateTime? parseDate(dynamic raw) { + final value = raw?.toString(); + if (value == null || value.isEmpty) { + return null; + } + try { + return DateTime.parse(value).toLocal(); + } catch (_) { + return null; + } + } + + return UserSessionSummary( + sessionId: json['session_id']?.toString() ?? '', + authenticatedAt: parseDate(json['authenticated_at']), + expiresAt: parseDate(json['expires_at']), + issuedAt: parseDate(json['issued_at']), + lastSeenAt: parseDate(json['last_seen_at']), + ipAddress: json['ip_address']?.toString() ?? '', + userAgent: json['user_agent']?.toString() ?? '', + clientId: json['client_id']?.toString() ?? '', + appName: json['app_name']?.toString() ?? '', + isCurrent: json['is_current'] == true, + isActive: json['is_active'] != false, + ); + } +} diff --git a/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart b/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart new file mode 100644 index 00000000..881a88f0 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; +import '../models.dart'; + +class UserSessionsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchSessions(); + } + + String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future> _fetchSessions() async { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + final url = Uri.parse('$baseUrl/api/v1/user/sessions'); + + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + + final client = createHttpClient(withCredentials: useCookie); + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + try { + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw Exception('Failed to load sessions: ${response.statusCode}'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + return items + .whereType>() + .map(UserSessionSummary.fromJson) + .toList(); + } finally { + client.close(); + } + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(_fetchSessions); + } + + Future revokeSession(String sessionId) async { + await AuthProxyService.revokeSession(sessionId); + await refresh(); + } +} + +final userSessionsProvider = + AsyncNotifierProvider>(() { + return UserSessionsNotifier(); + }); diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 11f80c7a..cbbbc9fa 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -9,8 +9,10 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; +import '../domain/providers/user_sessions_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/logout_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; @@ -34,6 +36,7 @@ class _DashboardScreenState extends ConsumerState { static const _surface = Colors.white; static const _border = Color(0xFFE5E7EB); static const _subtle = Color(0xFFF7F8FA); + static const double _dashboardCardSpacing = 12; static const double _historySessionMinWidth = 92; static const double _historyOtherColumnsBaselineWidth = 780; static const int _historySessionMinVisibleChars = 8; @@ -45,6 +48,7 @@ class _DashboardScreenState extends ConsumerState { bool _auditLoading = false; bool _auditLoadingMore = false; bool _isRevoking = false; + String? _revokingSessionId; bool _redirectingToSignin = false; bool _authBootstrapInProgress = false; @@ -71,8 +75,7 @@ class _DashboardScreenState extends ConsumerState { } Future _logout() async { - AuthTokenStore.clear(); - AuthNotifier.instance.notify(); + await LogoutService().logout(); } Future _onRevokeLink(String clientId, String appName) async { @@ -130,6 +133,67 @@ class _DashboardScreenState extends ConsumerState { } } + Future _onRevokeSession(UserSessionSummary session) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')), + content: Text( + tr( + 'msg.userfront.dashboard.sessions.revoke.confirm', + params: { + 'target': session.isCurrent + ? tr('ui.userfront.dashboard.sessions.current_badge') + : _sessionDisplayLabel(session), + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(tr('ui.common.cancel')), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(tr('ui.userfront.dashboard.sessions.revoke.action')), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + setState(() => _revokingSessionId = session.sessionId); + try { + await ref + .read(userSessionsProvider.notifier) + .revokeSession(session.sessionId); + if (!mounted) { + return; + } + ToastService.success( + tr('msg.userfront.dashboard.sessions.revoke.success'), + ); + } catch (e) { + if (!mounted) { + return; + } + ToastService.error( + tr( + 'msg.userfront.dashboard.sessions.revoke.error', + params: {'error': '$e'}, + ), + ); + } finally { + if (mounted) { + setState(() => _revokingSessionId = null); + } + } + } + void _onScanQR() { context.push('/scan'); } @@ -310,9 +374,11 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.clear(); }); ref.invalidate(linkedRpsProvider); + ref.invalidate(userSessionsProvider); await Future.wait([ ref.read(linkedRpsProvider.future), + ref.read(userSessionsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); @@ -758,6 +824,13 @@ class _DashboardScreenState extends ConsumerState { ), const SizedBox(height: 28), ], + _buildSectionTitle( + tr('ui.userfront.sections.sessions'), + tr('msg.userfront.sections.sessions_subtitle'), + ), + const SizedBox(height: 12), + _buildSessionSection(isMobile), + const SizedBox(height: 28), _buildSectionTitle( tr('ui.userfront.sections.apps'), tr('msg.userfront.sections.apps_subtitle'), @@ -883,6 +956,370 @@ class _DashboardScreenState extends ConsumerState { ); } + Widget _buildSessionSection(bool isMobile) { + final sessionsState = ref.watch(userSessionsProvider); + return sessionsState.when( + data: (sessions) { + if (sessions.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr('msg.userfront.dashboard.sessions.empty'), + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + tr('msg.userfront.dashboard.sessions.empty_detail'), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ); + } + return _buildSessionGrid(sessions, isMobile); + }, + loading: () => const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr('msg.userfront.dashboard.sessions.error'), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.read(userSessionsProvider.notifier).refresh(), + child: Text(tr('ui.common.retry')), + ), + ], + ), + ); + } + + Widget _buildSessionGrid(List sessions, bool isMobile) { + return LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = _dashboardCardColumnCount(constraints.maxWidth); + final cardWidth = _dashboardCardWidth( + constraints.maxWidth, + crossAxisCount, + ); + + return Wrap( + spacing: _dashboardCardSpacing, + runSpacing: _dashboardCardSpacing, + children: sessions.map((session) { + return SizedBox( + width: cardWidth, + child: _buildSessionCard(session, cardWidth: cardWidth), + ); + }).toList(), + ); + }, + ); + } + + Widget _buildSessionCard(UserSessionSummary session, {double? cardWidth}) { + final isCurrent = session.isCurrent; + final statusColor = session.isActive ? Colors.green : Colors.grey; + final primaryTime = + session.lastSeenAt ?? + session.authenticatedAt ?? + session.issuedAt ?? + session.expiresAt; + final primaryTimeLabel = primaryTime != null + ? _formatDateTime(primaryTime) + : tr('ui.userfront.session.unknown'); + final sessionLabel = _sessionPrimaryLabel(session); + final clientLabel = _sessionClientLabel(session); + final browserLabel = _sessionBrowserLabel(session.userAgent); + final osLabel = _sessionOsLabel(session.userAgent); + final canRevoke = !isCurrent && _revokingSessionId == null; + + return Container( + width: cardWidth ?? 320, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isCurrent ? Colors.blueGrey : _border, + width: isCurrent ? 1.5 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 8), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + sessionLabel, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isCurrent ? Colors.blueGrey : statusColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isCurrent + ? tr('ui.userfront.dashboard.sessions.current_badge') + : session.isActive + ? tr('ui.userfront.dashboard.sessions.active_badge') + : tr('ui.common.status.inactive'), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + if (clientLabel.isNotEmpty) ...[ + Text( + clientLabel, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + const SizedBox(height: 8), + ], + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildInfoChip(Icons.access_time, primaryTimeLabel), + if (session.ipAddress.isNotEmpty) + _buildInfoChip(Icons.public, session.ipAddress), + ], + ), + if (browserLabel.isNotEmpty || osLabel.isNotEmpty) ...[ + const SizedBox(height: 12), + if (browserLabel.isNotEmpty) + Text( + tr( + 'msg.userfront.dashboard.sessions.browser', + params: {'value': browserLabel}, + ), + style: TextStyle(fontSize: 13, color: Colors.grey[700]), + ), + if (osLabel.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + tr( + 'msg.userfront.dashboard.sessions.os', + params: {'value': osLabel}, + ), + style: TextStyle(fontSize: 13, color: Colors.grey[700]), + ), + ], + ], + if (session.clientId.trim().isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + tr( + 'msg.userfront.dashboard.client_id', + params: {'id': session.clientId}, + ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.dashboard.sessions.session_id', + params: {'id': _compactSessionId(session.sessionId)}, + ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: canRevoke ? () => _onRevokeSession(session) : null, + style: OutlinedButton.styleFrom( + foregroundColor: canRevoke ? Colors.redAccent : Colors.grey, + side: BorderSide( + color: canRevoke ? Colors.redAccent : Colors.grey, + width: 0.6, + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: _revokingSessionId == session.sessionId + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.redAccent, + ), + ) + : Text( + isCurrent + ? tr( + 'ui.userfront.dashboard.sessions.current_disabled', + ) + : tr('ui.userfront.dashboard.sessions.revoke.action'), + ), + ), + ), + ], + ), + ); + } + + String _sessionDisplayLabel(UserSessionSummary session) { + if (session.userAgent.trim().isNotEmpty) { + return _sessionUserAgentLabel(session.userAgent); + } + return tr('ui.userfront.dashboard.sessions.unknown_device'); + } + + String _sessionPrimaryLabel(UserSessionSummary session) { + final appLabel = _sessionAppLabel(session); + if (appLabel.isNotEmpty) { + return appLabel; + } + if (session.isCurrent) { + return 'UserFront'; + } + return tr('ui.userfront.dashboard.sessions.unknown_session'); + } + + String _sessionClientLabel(UserSessionSummary session) { + return ''; + } + + String _sessionAppLabel(UserSessionSummary session) { + final appName = session.appName.trim(); + if (appName.isNotEmpty) { + return appName; + } + final clientId = session.clientId.trim().toLowerCase(); + if (clientId.isEmpty) { + return session.isCurrent ? 'UserFront' : ''; + } + if (clientId.contains('adminfront')) { + return 'AdminFront'; + } + if (clientId.contains('devfront')) { + return 'DevFront'; + } + if (clientId.contains('userfront')) { + return 'UserFront'; + } + if (clientId.contains('baron')) { + return tr('ui.userfront.app_label.baron'); + } + return session.clientId.trim(); + } + + String _sessionUserAgentLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty) { + return tr('ui.userfront.dashboard.sessions.unknown_device'); + } + if (_looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('iphone') || lower.contains('ios')) { + return tr('ui.userfront.device.ios'); + } + if (lower.contains('android')) { + return tr('ui.userfront.device.android'); + } + if (lower.contains('windows')) { + return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)'); + } + if (lower.contains('mac os') || lower.contains('macintosh')) { + return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)'); + } + if (lower.contains('linux')) { + return tr('ui.userfront.device.linux'); + } + return userAgent; + } + + String _sessionBrowserLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('edg/')) { + return 'Edge'; + } + if (lower.contains('chrome/') && !lower.contains('edg/')) { + return 'Chrome'; + } + if (lower.contains('firefox/')) { + return 'Firefox'; + } + if (lower.contains('safari/') && !lower.contains('chrome/')) { + return 'Safari'; + } + if (lower.contains('samsungbrowser/')) { + return 'Samsung Internet'; + } + if (lower.contains('flutter')) { + return 'Flutter'; + } + return ''; + } + + String _sessionOsLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('iphone') || lower.contains('ios')) { + return 'iOS'; + } + if (lower.contains('android')) { + return 'Android'; + } + if (lower.contains('windows')) { + return 'Windows'; + } + if (lower.contains('mac os') || lower.contains('macintosh')) { + return 'macOS'; + } + if (lower.contains('linux')) { + return 'Linux'; + } + return ''; + } + + bool _looksLikeInternalUserAgent(String userAgent) { + return userAgent.startsWith('go-http-client/') || + userAgent.startsWith('fasthttp') || + userAgent.startsWith('fiber'); + } + Widget _buildInfoChip(IconData icon, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -1021,15 +1458,7 @@ class _DashboardScreenState extends ConsumerState { builder: (context, constraints) { final maxWidth = constraints.maxWidth; - // 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정 - int crossAxisCount; - if (maxWidth > 1200) { - crossAxisCount = 4; - } else if (maxWidth > 800) { - crossAxisCount = 3; - } else { - crossAxisCount = 2; - } + final crossAxisCount = _dashboardCardColumnCount(maxWidth); // 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능) final int initialVisibleCount = crossAxisCount; @@ -1042,17 +1471,14 @@ class _DashboardScreenState extends ConsumerState { visibleActivities = activities.take(initialVisibleCount).toList(); } - // 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려) - const spacing = 12.0; - final double cardWidth = - (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount; + final cardWidth = _dashboardCardWidth(maxWidth, crossAxisCount); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( - spacing: spacing, - runSpacing: spacing, + spacing: _dashboardCardSpacing, + runSpacing: _dashboardCardSpacing, children: visibleActivities.map((item) { return SizedBox( width: cardWidth, @@ -1320,6 +1746,21 @@ class _DashboardScreenState extends ConsumerState { ); } + int _dashboardCardColumnCount(double maxWidth) { + if (maxWidth > 1200) { + return 4; + } + if (maxWidth > 800) { + return 3; + } + return 2; + } + + double _dashboardCardWidth(double maxWidth, int crossAxisCount) { + return (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) / + crossAxisCount; + } + Widget _buildHistoryTable(AuthTimelineState state) { return _buildHistoryContainer( child: Column( diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 80b80ae3..39987e19 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -3,10 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:userfront/i18n.dart'; -import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_proxy_service.dart'; -import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/logout_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../../../core/widgets/language_selector.dart'; @@ -164,8 +163,7 @@ class _ProfilePageState extends ConsumerState { } Future _logout() async { - AuthTokenStore.clear(); - AuthNotifier.instance.notify(); + await LogoutService().logout(); } void _ensureControllers(UserProfile profile) { diff --git a/userfront/test/logout_service_test.dart b/userfront/test/logout_service_test.dart new file mode 100644 index 00000000..b9cdc5ec --- /dev/null +++ b/userfront/test/logout_service_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/logout_service.dart'; + +void main() { + test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return 'current-sid'; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']); + }); + + test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return null; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'clear', 'notify']); + }); + + test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return 'current-sid'; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + throw Exception('revoke failed'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']); + }); +}