diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 63d8a4c7..2935d298 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -159,6 +159,20 @@ func main() { slog.Info("✅ Connected to ClickHouse") } + var oathkeeperRepo domain.OathkeeperLogRepository + oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse") + oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000")) + oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory") + oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass") + oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory") + if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil { + slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err) + oathkeeperRepo = nil + } else { + oathkeeperRepo = repo + slog.Info("✅ Connected to Ory ClickHouse") + } + // PostgreSQL (Meta Store) pgHost := getEnv("DB_HOST", "localhost") pgPort := getEnv("DB_PORT", "5432") @@ -228,7 +242,7 @@ func main() { userRepo := repository.NewUserRepository(db) auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo) + authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, userRepo) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler(redisService) tenantHandler := handler.NewTenantHandler(db, tenantService) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index bd123dd6..3f4078db 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -80,17 +80,18 @@ const ( ) type AuthHandler struct { - ProjectID string - SmsService domain.SmsService - EmailService domain.EmailService - RedisService *service.RedisService - DescopeClient *client.DescopeClient - KratosAdmin *service.KratosAdminService - IdpProvider domain.IdentityProvider - AuditRepo domain.AuditRepository - Hydra *service.HydraAdminService - TenantService service.TenantService - UserRepo repository.UserRepository + ProjectID string + SmsService domain.SmsService + EmailService domain.EmailService + RedisService *service.RedisService + DescopeClient *client.DescopeClient + KratosAdmin *service.KratosAdminService + IdpProvider domain.IdentityProvider + AuditRepo domain.AuditRepository + OathkeeperRepo domain.OathkeeperLogRepository + Hydra *service.HydraAdminService + TenantService service.TenantService + UserRepo repository.UserRepository } type signupState struct { @@ -148,7 +149,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du return false, int(interval.Seconds()) } -func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler { +func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -165,17 +166,18 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident } return &AuthHandler{ - ProjectID: projectID, - SmsService: service.NewSmsService(), - EmailService: service.NewEmailService(), - RedisService: redisService, - DescopeClient: descopeClient, - KratosAdmin: service.NewKratosAdminService(), - IdpProvider: idpProvider, - AuditRepo: auditRepo, - Hydra: service.NewHydraAdminService(), - TenantService: tenantService, - UserRepo: userRepo, + ProjectID: projectID, + SmsService: service.NewSmsService(), + EmailService: service.NewEmailService(), + RedisService: redisService, + DescopeClient: descopeClient, + KratosAdmin: service.NewKratosAdminService(), + IdpProvider: idpProvider, + AuditRepo: auditRepo, + OathkeeperRepo: oathkeeperRepo, + Hydra: service.NewHydraAdminService(), + TenantService: tenantService, + UserRepo: userRepo, } } @@ -1155,8 +1157,8 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { // VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session. func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { var req struct { - ShortCode string `json:"shortCode"` - VerifyOnly bool `json:"verifyOnly,omitempty"` + ShortCode string `json:"shortCode"` + VerifyOnly bool `json:"verifyOnly,omitempty"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginShortCode] Body parse error", "error", err) @@ -2700,8 +2702,31 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string { return c.IP() } +type authTimelineItem struct { + EventID string `json:"event_id"` + Timestamp time.Time `json:"timestamp"` + UserID string `json:"user_id"` + SessionID string `json:"session_id,omitempty"` + EventType string `json:"event_type"` + Status string `json:"status"` + AuthMethod string `json:"auth_method,omitempty"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Details string `json:"details,omitempty"` + Source string `json:"source,omitempty"` + ClientID string `json:"client_id,omitempty"` + AppName string `json:"app_name,omitempty"` + ParentSessionID string `json:"parent_session_id,omitempty"` +} + +type consentClientInfo struct { + ClientID string + Name string + ConsentAt time.Time +} + func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { - if h.AuditRepo == nil { + if h.AuditRepo == nil && h.OathkeeperRepo == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) } @@ -2728,6 +2753,51 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } + subject := "" + if h.OathkeeperRepo != nil { + if value, err := h.resolveConsentSubject(c); err == nil { + subject = value + } + } + + consentMap := make(map[string]consentClientInfo) + if subject != "" && h.Hydra != nil { + if sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, ""); err == nil { + for _, session := range sessions { + clientID := strings.TrimSpace(session.Client.ClientID) + if clientID == "" { + continue + } + name := strings.TrimSpace(session.Client.ClientName) + if name == "" { + name = clientID + } + consentAt := time.Time{} + if session.AuthenticatedAt != nil { + consentAt = *session.AuthenticatedAt + } else if session.RequestedAt != nil { + consentAt = *session.RequestedAt + } + if existing, ok := consentMap[clientID]; ok { + if !consentAt.IsZero() && (existing.ConsentAt.IsZero() || consentAt.Before(existing.ConsentAt)) { + existing.ConsentAt = consentAt + consentMap[clientID] = existing + } + if existing.Name == "" { + existing.Name = name + consentMap[clientID] = existing + } + continue + } + consentMap[clientID] = consentClientInfo{ + ClientID: clientID, + Name: name, + ConsentAt: consentAt, + } + } + } + } + candidates := buildLoginCandidates(profile) fetchLimit := limit * 10 if fetchLimit < limit { @@ -2737,69 +2807,177 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { fetchLimit = 500 } - items := make([]domain.AuditLog, 0, limit) - nextCursor := "" - currentCursor := cursor - const maxBatches = 10 - for batch := 0; batch < maxBatches && len(items) < limit; batch++ { - logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"}) - } - if len(logs) == 0 { - nextCursor = "" - break - } - - var lastScanned *domain.AuditLog - for i := range logs { - log := logs[i] - lastScanned = &log - if !isAuthEventType(log.EventType) { - continue + authLogs := make([]domain.AuditLog, 0, fetchLimit) + if h.AuditRepo != nil { + currentCursor := cursor + const maxBatches = 10 + for batch := 0; batch < maxBatches && len(authLogs) < fetchLimit; batch++ { + logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"}) } - if !matchesAuthTimelineUser(log, profile, candidates) { - continue - } - if shouldSkipAuthTimeline(log) { - continue - } - if log.UserID == "" { - log.UserID = profile.ID - } - log.AuthMethod = deriveAuthMethod(log) - if log.AuthMethod == "" { - continue - } - if log.SessionID == "" { - log.SessionID = extractSessionIDFromAuditDetails(log.Details) - } - items = append(items, log) - if len(items) >= limit { - nextCursor = encodeAuditCursor(log) + if len(logs) == 0 { break } - } - if len(items) >= limit { - break - } + var lastScanned *domain.AuditLog + for i := range logs { + log := logs[i] + lastScanned = &log + if !isAuthEventType(log.EventType) { + continue + } + if !matchesAuthTimelineUser(log, profile, candidates) { + continue + } + if shouldSkipAuthTimeline(log) { + continue + } + if log.UserID == "" { + log.UserID = profile.ID + } + log.AuthMethod = deriveAuthMethod(log) + if log.AuthMethod == "" { + continue + } + if log.SessionID == "" { + log.SessionID = extractSessionIDFromAuditDetails(log.Details) + } + authLogs = append(authLogs, log) + if len(authLogs) >= fetchLimit { + break + } + } - if len(logs) < fetchLimit { - nextCursor = "" - break + if len(logs) < fetchLimit || lastScanned == nil { + break + } + currentCursor = &domain.AuditCursor{ + Timestamp: lastScanned.Timestamp, + EventID: lastScanned.EventID, + } } + } - if lastScanned == nil { - nextCursor = "" - break - } + oathkeeperLogs := make([]domain.OathkeeperAccessLog, 0, fetchLimit) + if h.OathkeeperRepo != nil && subject != "" { + currentCursor := cursor + const maxBatches = 10 + for batch := 0; batch < maxBatches && len(oathkeeperLogs) < fetchLimit; batch++ { + logs, err := h.OathkeeperRepo.FindPageBySubject(c.Context(), subject, fetchLimit, currentCursor) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve oathkeeper logs"}) + } + if len(logs) == 0 { + break + } + var lastScanned *domain.OathkeeperAccessLog + for i := range logs { + log := logs[i] + lastScanned = &log + clientID := extractClientIDFromOathkeeperLog(log) + if clientID == "" { + continue + } + consent, ok := consentMap[clientID] + if !ok { + continue + } + if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) { + continue + } + oathkeeperLogs = append(oathkeeperLogs, log) + if len(oathkeeperLogs) >= fetchLimit { + break + } + } - currentCursor = &domain.AuditCursor{ - Timestamp: lastScanned.Timestamp, - EventID: lastScanned.EventID, + if len(logs) < fetchLimit || lastScanned == nil { + break + } + currentCursor = &domain.AuditCursor{ + Timestamp: lastScanned.Timestamp, + EventID: oathkeeperEventID(*lastScanned), + } } - nextCursor = encodeAuditCursor(*lastScanned) + } + + items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs)) + for i := range authLogs { + log := authLogs[i] + item := authTimelineItem{ + EventID: log.EventID, + Timestamp: log.Timestamp, + UserID: log.UserID, + SessionID: log.SessionID, + EventType: log.EventType, + Status: log.Status, + AuthMethod: log.AuthMethod, + IPAddress: log.IPAddress, + UserAgent: log.UserAgent, + Details: log.Details, + Source: "backend", + AppName: "Baron 통합로그인", + } + items = append(items, item) + } + + for i := range oathkeeperLogs { + log := oathkeeperLogs[i] + clientID := extractClientIDFromOathkeeperLog(log) + if clientID == "" { + continue + } + consent := consentMap[clientID] + appName := consent.Name + if appName == "" { + appName = clientID + } + details := map[string]any{ + "path": log.Path, + "client_id": clientID, + "decision": log.Decision, + "status_code": log.Status, + } + detailsJSON, _ := json.Marshal(details) + status := "success" + if log.Status >= 400 { + status = "failure" + } + eventID := oathkeeperEventID(log) + item := authTimelineItem{ + EventID: eventID, + Timestamp: log.Timestamp, + UserID: profile.ID, + EventType: fmt.Sprintf("%s %s", log.Method, log.Path), + Status: status, + AuthMethod: "세션 위임", + IPAddress: log.ClientIP, + UserAgent: log.UserAgent, + Details: string(detailsJSON), + Source: "oathkeeper", + ClientID: clientID, + AppName: appName, + } + items = append(items, item) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Timestamp.Equal(items[j].Timestamp) { + return items[i].EventID > items[j].EventID + } + return items[i].Timestamp.After(items[j].Timestamp) + }) + + nextCursor := "" + hasMore := len(authLogs) >= fetchLimit || len(oathkeeperLogs) >= fetchLimit + if len(items) > limit { + items = items[:limit] + last := items[len(items)-1] + nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID) + } else if hasMore && len(items) > 0 { + last := items[len(items)-1] + nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID) } return c.JSON(fiber.Map{ @@ -2810,6 +2988,27 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { }) } +func encodeTimelineCursor(timestamp time.Time, eventID string) string { + if eventID == "" { + eventID = fmt.Sprintf("%d", timestamp.UnixNano()) + } + payload := timestamp.UTC().Format(time.RFC3339Nano) + "|" + eventID + return base64.RawURLEncoding.EncodeToString([]byte(payload)) +} + +func oathkeeperEventID(log domain.OathkeeperAccessLog) string { + if log.RequestID != "" { + return log.RequestID + } + if log.TraceID != "" { + return log.TraceID + } + if log.SpanID != "" { + return log.SpanID + } + return fmt.Sprintf("%d", log.Timestamp.UnixNano()) +} + type linkedRpSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -3365,6 +3564,80 @@ func extractLoginIDFromAuditDetails(details string) string { return "" } +func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { + if value := strings.TrimSpace(log.RP); value != "" { + return value + } + if value := parseClientIDFromURL(log.Target); value != "" { + return value + } + if value := parseClientIDFromURL(log.Path); value != "" { + return value + } + return parseClientIDFromRaw(log.Raw) +} + +func parseClientIDFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + if id := strings.TrimSpace(parsed.Query().Get("client_id")); id != "" { + return id + } + if id := strings.TrimSpace(parsed.Query().Get("clientId")); id != "" { + return id + } + return "" +} + +func parseClientIDFromRaw(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var payload map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "" + } + if id := readClientIDFromPayload(payload); id != "" { + return id + } + if request, ok := payload["request"].(map[string]any); ok { + if id := readClientIDFromPayload(request); id != "" { + return id + } + if urlRaw, ok := request["url"].(string); ok { + if id := parseClientIDFromURL(urlRaw); id != "" { + return id + } + } + if pathRaw, ok := request["path"].(string); ok { + if id := parseClientIDFromURL(pathRaw); id != "" { + return id + } + } + } + return "" +} + +func readClientIDFromPayload(payload map[string]any) string { + if payload == nil { + return "" + } + if raw, ok := payload["client_id"].(string); ok && strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw) + } + if raw, ok := payload["clientId"].(string); ok && strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw) + } + return "" +} + func extractSessionIDFromAuditDetails(details string) string { if details == "" { return "" diff --git a/docker/ory/clickhouse/init.sql b/docker/ory/clickhouse/init.sql index 37dc8d1e..0d46e863 100644 --- a/docker/ory/clickhouse/init.sql +++ b/docker/ory/clickhouse/init.sql @@ -7,13 +7,23 @@ CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs ( path String DEFAULT '', status UInt16 DEFAULT 0, latency_ms UInt32 DEFAULT 0, + client_id String DEFAULT '', rp String DEFAULT '', action String DEFAULT '', target String DEFAULT '', + rule_id String DEFAULT '', + host String DEFAULT '', + scheme String DEFAULT '', + query String DEFAULT '', + upstream_url String DEFAULT '', subject String DEFAULT '', + parent_session_id String DEFAULT '', client_ip String DEFAULT '', user_agent String DEFAULT '', + referer String DEFAULT '', decision String DEFAULT '', + bytes_in UInt64 DEFAULT 0, + bytes_out UInt64 DEFAULT 0, trace_id String DEFAULT '', span_id String DEFAULT '', raw String DEFAULT '' diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json index 95c387f5..42d10b92 100755 --- a/docker/ory/oathkeeper/rules.draft.json +++ b/docker/ory/oathkeeper/rules.draft.json @@ -84,5 +84,33 @@ "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] + }, + { + "id": "rp-template-browser", + "description": "RP proxy (browser session). TODO: match.url/upstream.url을 실제 RP로 좁혀야 함.", + "match": { + "url": "http://<.*>/rp/<.*>", + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + }, + "upstream": { + "url": "http://rp_upstream:8080" + }, + "authenticators": [{ "handler": "cookie_session" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "rp-template-bearer", + "description": "RP proxy (bearer). TODO: oauth2_introspection 또는 jwt 활성화 필요.", + "match": { + "url": "http://<.*>/rp-api/<.*>", + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + }, + "upstream": { + "url": "http://rp_upstream:8080" + }, + "authenticators": [{ "handler": "oauth2_introspection" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] } ] diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml index c237bece..4a00d55c 100644 --- a/docker/ory/vector/vector.toml +++ b/docker/ory/vector/vector.toml @@ -15,6 +15,9 @@ request_method = get(parsed, ["request", "method"]) ?? "" request_path = get(parsed, ["request", "path"]) ?? "" request_url = get(parsed, ["request", "url"]) ?? "" + request_host = get(parsed, ["request", "host"]) ?? "" + request_scheme = get(parsed, ["request", "scheme"]) ?? "" + request_query = get(parsed, ["request", "query"]) ?? "" .method = parsed.method ?? parsed.http_method ?? request_method ?? "" .path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? "" response_status = get(parsed, ["response", "status"]) ?? 0 @@ -27,6 +30,7 @@ .user_agent = parsed.user_agent if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) } if is_null(.user_agent) { .user_agent = "" } + .referer = get(headers, ["Referer"]) ?? "" .decision = parsed.decision if is_null(.decision) { .decision = parsed.result } @@ -38,9 +42,18 @@ .span_id = parsed.span_id if is_null(.span_id) { .span_id = "" } - .rp = "" - .action = "" - .target = "" + .rp = parsed.rp ?? "" + .action = parsed.action ?? "" + .target = parsed.target ?? "" + .rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? "" + .client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? "" + .parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? "" + .host = parsed.host ?? request_host ?? "" + .scheme = parsed.scheme ?? request_scheme ?? "" + .query = parsed.query ?? request_query ?? "" + .upstream_url = parsed.upstream_url ?? get(parsed, ["upstream", "url"]) ?? "" + .bytes_in = to_int(parsed.bytes_in ?? parsed.request_bytes ?? 0) ?? 0 + .bytes_out = to_int(parsed.bytes_out ?? parsed.response_bytes ?? 0) ?? 0 ''' [sinks.clickhouse] diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index bf19ba79..2361e1c9 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/constants/error_whitelist.dart'; +import '../../../core/services/auth_proxy_service.dart'; class ErrorScreen extends StatelessWidget { final String? errorId; @@ -16,15 +18,22 @@ class ErrorScreen extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final errorType = (errorCode == null || errorCode!.isEmpty) - ? 'unknown_error' - : errorCode!; - final title = errorCode == null || errorCode!.isEmpty + final isProd = AuthProxyService.isProdEnv; + final normalizedCode = (errorCode ?? '').trim(); + final hasCode = normalizedCode.isNotEmpty; + final whitelistMessage = errorWhitelistMessages[normalizedCode]; + final isWhitelisted = whitelistMessage != null; + final errorType = isProd + ? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error') + : (hasCode ? normalizedCode : 'unknown_error'); + final title = isProd ? '인증 과정에서 오류가 발생했습니다' - : '오류: $errorCode'; - final detail = description?.isNotEmpty == true - ? description! - : '요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.'; + : (hasCode ? '오류: $normalizedCode' : '오류가 발생했습니다'); + final detail = isProd + ? (isWhitelisted ? whitelistMessage! : '에러가 계속되면 관리자에게 문의해주세요') + : ((description?.isNotEmpty == true) + ? description! + : (hasCode ? '오류가 발생했습니다.' : '요청을 처리하는 중 문제가 발생했습니다.')); return Scaffold( backgroundColor: const Color(0xFFF7F8FA), diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 16d817b5..7b5251b0 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -21,6 +21,10 @@ class AuditLogEntry { final String userAgent; final String sessionId; final String details; + final String source; + final String clientId; + final String appName; + final String parentSessionId; AuditLogEntry({ required this.eventId, @@ -33,6 +37,10 @@ class AuditLogEntry { required this.userAgent, required this.sessionId, required this.details, + required this.source, + required this.clientId, + required this.appName, + required this.parentSessionId, }); factory AuditLogEntry.fromJson(Map json) { @@ -55,6 +63,10 @@ class AuditLogEntry { userAgent: json['user_agent'] ?? '', sessionId: json['session_id'] ?? '', details: json['details'] ?? '', + source: json['source'] ?? '', + clientId: json['client_id'] ?? '', + appName: json['app_name'] ?? '', + parentSessionId: json['parent_session_id'] ?? '', ); } @@ -542,6 +554,34 @@ class _DashboardScreenState extends ConsumerState { ); } + String _appLabelForLog(AuditLogEntry log) { + if (log.appName.isNotEmpty) { + return log.appName; + } + return _appLabelForPath(log.path); + } + + Widget _buildAppCell(AuditLogEntry log, {TextStyle? style}) { + final label = _appLabelForLog(log); + if (label == 'Baron 통합로그인') { + return _selectableText(label, style: style); + } + final tooltip = log.parentSessionId.isEmpty + ? '부모 세션 ID 없음' + : '부모 세션 ID: ${log.parentSessionId}'; + final baseStyle = style ?? const TextStyle(); + final emphasisStyle = log.parentSessionId.isEmpty + ? baseStyle + : baseStyle.copyWith( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ); + return Tooltip( + message: tooltip, + child: _selectableText(label, style: emphasisStyle), + ); + } + String _appLabelForPath(String path) { if (path.startsWith('/api/v1/auth')) { return 'Baron 통합로그인'; @@ -992,13 +1032,12 @@ class _DashboardScreenState extends ConsumerState { rows: logs.map((log) { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; - final appLabel = _appLabelForPath(log.path); final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return DataRow(cells: [ DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)), DataCell(_selectableText(_formatDateTime(log.timestamp))), - DataCell(_selectableText(appLabel)), + DataCell(_buildAppCell(log)), DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)), DataCell(_selectableText(deviceLabel)), DataCell(_buildAuthMethodCell(log, authMethod)), @@ -1036,8 +1075,8 @@ class _DashboardScreenState extends ConsumerState { Row( children: [ Expanded( - child: _selectableText( - _appLabelForPath(log.path), + child: _buildAppCell( + log, style: const TextStyle(fontWeight: FontWeight.w600, color: _ink), ), ),