diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 1810dea7..c3b84696 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -17,6 +17,7 @@ import ( "io" "log/slog" "math/rand" + "net" "net/http" "net/url" "os" @@ -2426,6 +2427,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) @@ -2732,7 +2735,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 로그인 흐름 처리 --- @@ -2741,11 +2752,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.") + } } } } @@ -2778,6 +2792,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() @@ -4983,18 +5018,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 { @@ -6786,6 +6810,9 @@ func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error { 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) } @@ -6981,6 +7008,46 @@ func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string { 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 "" + } + 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() +} + func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint { hints := make(map[string]sessionAuditHint) if h.AuditRepo == nil || strings.TrimSpace(userID) == "" { @@ -6993,6 +7060,7 @@ func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) "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", @@ -7016,11 +7084,6 @@ func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) continue } - existing, ok := hints[sessionID] - if ok && existing.Timestamp != nil && existing.Timestamp.After(log.Timestamp) { - continue - } - ts := log.Timestamp ipAddress := strings.TrimSpace(log.IPAddress) userAgent := strings.TrimSpace(log.UserAgent) @@ -7036,17 +7099,75 @@ func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) if looksLikeInternalUserAgent(userAgent) { userAgent = "" } - hints[sessionID] = sessionAuditHint{ + 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 { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { + return true + } + for _, cidr := range []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "fc00::/7", + } { + _, network, err := net.ParseCIDR(cidr) + if err == nil && network.Contains(ip) { + return true + } + } + return false +} + func deriveSessionClientInfo(log domain.AuditLog) (string, string) { details, _ := parseAuditDetails(log.Details) clientID := "" diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 777f43ca..40490b52 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1201,25 +1201,42 @@ class _DashboardScreenState extends ConsumerState { } String _sessionPrimaryLabel(UserSessionSummary session) { - if (session.isCurrent) { - return tr('ui.userfront.dashboard.sessions.current_badge'); + final appLabel = _sessionAppLabel(session); + if (appLabel.isNotEmpty) { + return appLabel; } - final appName = session.appName.trim(); - if (appName.isNotEmpty) { - return appName; + 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.isEmpty || session.isCurrent) { - return ''; + if (appName.isNotEmpty) { + return appName; } - return tr( - 'msg.userfront.dashboard.sessions.recent_app', - params: {'app': 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) {