From f14eb57ef8183cf49d13e34e78f194aca8d876cd Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 3 Feb 2026 15:05:46 +0900 Subject: [PATCH] =?UTF-8?q?RP=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/oathkeeper_models.go | 1 + backend/internal/handler/auth_handler.go | 109 +++++++++++++++- .../internal/middleware/audit_middleware.go | 3 + .../repository/oathkeeper_clickhouse_repo.go | 122 +++++++++++++----- .../internal/service/hydra_admin_service.go | 40 ++++++ .../presentation/dashboard_screen.dart | 32 +++-- 6 files changed, 259 insertions(+), 48 deletions(-) diff --git a/backend/internal/domain/oathkeeper_models.go b/backend/internal/domain/oathkeeper_models.go index a24a3f97..233e9ad9 100644 --- a/backend/internal/domain/oathkeeper_models.go +++ b/backend/internal/domain/oathkeeper_models.go @@ -12,6 +12,7 @@ type OathkeeperAccessLog struct { Path string Status int LatencyMs int + ClientID string RP string Action string Target string diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2b1e3494..744a6fde 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2976,6 +2976,11 @@ type consentClientInfo struct { ConsentAt time.Time } +type loginClientInfo struct { + ClientID string + Name string +} + func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if h.AuditRepo == nil && h.OathkeeperRepo == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) @@ -3123,7 +3128,8 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { 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"}) + slog.Warn("Failed to retrieve oathkeeper logs", "error", err) + break } if len(logs) == 0 { break @@ -3159,9 +3165,53 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { } } + loginChallengeCache := make(map[string]loginClientInfo) + resolveLoginClient := func(challenge string) (loginClientInfo, bool) { + challenge = strings.TrimSpace(challenge) + if challenge == "" || h.Hydra == nil { + return loginClientInfo{}, false + } + if cached, ok := loginChallengeCache[challenge]; ok { + return cached, cached.ClientID != "" + } + loginReq, err := h.Hydra.GetLoginRequest(c.Context(), challenge) + if err != nil || loginReq == nil { + loginChallengeCache[challenge] = loginClientInfo{} + return loginClientInfo{}, false + } + clientID := strings.TrimSpace(loginReq.Client.ClientID) + if clientID == "" { + loginChallengeCache[challenge] = loginClientInfo{} + return loginClientInfo{}, false + } + name := strings.TrimSpace(loginReq.Client.ClientName) + if name == "" { + name = clientID + } + info := loginClientInfo{ + ClientID: clientID, + Name: name, + } + loginChallengeCache[challenge] = info + return info, true + } + items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs)) for i := range authLogs { log := authLogs[i] + appName := "Baron 통합로그인" + clientID := "" + path := strings.ToLower(extractAuditPath(log)) + if strings.Contains(path, "/api/v1/auth/oidc/login/accept") { + appName = "OIDC 로그인" + loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) + if loginChallenge != "" { + if info, ok := resolveLoginClient(loginChallenge); ok { + appName = info.Name + clientID = info.ClientID + } + } + } item := authTimelineItem{ EventID: log.EventID, Timestamp: log.Timestamp, @@ -3174,7 +3224,8 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { UserAgent: log.UserAgent, Details: log.Details, Source: "backend", - AppName: "Baron 통합로그인", + AppName: appName, + ClientID: clientID, } items = append(items, item) } @@ -3432,6 +3483,10 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { if err != nil || identity == nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity") } + c.Locals("user_id", consentRequest.Subject) + if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { + c.Locals("login_id", loginID) + } sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) @@ -3458,6 +3513,21 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { if err != nil || subject == "" { return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } + c.Locals("user_id", subject) + if sessionID, ok := c.Locals("session_id").(string); ok && sessionID != "" { + c.Locals("approved_session_id", sessionID) + } else if token := h.getBearerToken(c); token != "" { + if derivedID := extractSessionIDFromJWT(token); derivedID != "" { + c.Locals("approved_session_id", derivedID) + } + } + if h.KratosAdmin != nil { + if identity, err := h.KratosAdmin.GetIdentity(c.Context(), subject); err == nil && identity != nil { + if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { + c.Locals("login_id", loginID) + } + } + } acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) if err != nil { @@ -3715,6 +3785,9 @@ func shouldSkipAuthTimeline(log domain.AuditLog) bool { if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") { return true } + if path != "" && strings.Contains(path, "/api/v1/auth/consent/accept") { + return true + } if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") || strings.Contains(path, "/api/v1/auth/login/code/verify")) { sessionID := log.SessionID @@ -3922,6 +3995,8 @@ func deriveAuthMethod(log domain.AuditLog) string { return "QR" case strings.Contains(path, "/api/v1/auth/qr/poll"): return "QR" + case strings.Contains(path, "/api/v1/auth/oidc/login/accept"): + return "OIDC 로그인" default: return "" } @@ -4001,7 +4076,37 @@ func extractLoginIDFromAuditDetails(details string) string { return "" } +func extractLoginChallengeFromAuditDetails(details string) string { + if details == "" { + return "" + } + payload, err := parseAuditDetails(details) + if err != nil { + return "" + } + if raw, ok := payload["login_challenge"].(string); ok && raw != "" { + return raw + } + if raw, ok := payload["loginChallenge"].(string); ok && raw != "" { + return raw + } + body := extractRequestBody(payload) + if body == nil { + return "" + } + if raw, ok := body["login_challenge"].(string); ok && raw != "" { + return raw + } + if raw, ok := body["loginChallenge"].(string); ok && raw != "" { + return raw + } + return "" +} + func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { + if value := strings.TrimSpace(log.ClientID); value != "" { + return value + } if value := strings.TrimSpace(log.RP); value != "" { return value } diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index b42e5756..17aed830 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -150,6 +150,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { if sessionID != "" { details["session_id"] = sessionID } + if approvedSessionID, ok := c.Locals("approved_session_id").(string); ok && approvedSessionID != "" { + details["approved_session_id"] = approvedSessionID + } if err != nil { details["error"] = err.Error() } diff --git a/backend/internal/repository/oathkeeper_clickhouse_repo.go b/backend/internal/repository/oathkeeper_clickhouse_repo.go index 609222b2..60c8541d 100644 --- a/backend/internal/repository/oathkeeper_clickhouse_repo.go +++ b/backend/internal/repository/oathkeeper_clickhouse_repo.go @@ -4,6 +4,7 @@ import ( "baron-sso-backend/internal/domain" "context" "fmt" + "strings" "github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" @@ -36,10 +37,77 @@ func (r *OathkeeperClickHouseRepository) FindPageBySubject(ctx context.Context, if limit <= 0 { limit = 50 } - query := ` - SELECT timestamp, request_id, method, path, status, latency_ms, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw - FROM oathkeeper_access_logs - ` + query, args := buildOathkeeperQuery(subject, limit, cursor, true) + rows, err := r.conn.Query(ctx, query, args...) + if err != nil && isMissingColumnError(err, "client_id") { + query, args = buildOathkeeperQuery(subject, limit, cursor, false) + rows, err = r.conn.Query(ctx, query, args...) + } + if err != nil { + return nil, fmt.Errorf("failed to query oathkeeper logs: %w", err) + } + defer rows.Close() + + withClientID := strings.Contains(query, "client_id") + var logs []domain.OathkeeperAccessLog + for rows.Next() { + var log domain.OathkeeperAccessLog + if withClientID { + if err := rows.Scan( + &log.Timestamp, + &log.RequestID, + &log.Method, + &log.Path, + &log.Status, + &log.LatencyMs, + &log.ClientID, + &log.RP, + &log.Action, + &log.Target, + &log.Subject, + &log.ClientIP, + &log.UserAgent, + &log.Decision, + &log.TraceID, + &log.SpanID, + &log.Raw, + ); err != nil { + return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err) + } + } else { + if err := rows.Scan( + &log.Timestamp, + &log.RequestID, + &log.Method, + &log.Path, + &log.Status, + &log.LatencyMs, + &log.RP, + &log.Action, + &log.Target, + &log.Subject, + &log.ClientIP, + &log.UserAgent, + &log.Decision, + &log.TraceID, + &log.SpanID, + &log.Raw, + ); err != nil { + return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err) + } + } + logs = append(logs, log) + } + return logs, nil +} + +func buildOathkeeperQuery(subject string, limit int, cursor *domain.AuditCursor, withClientID bool) (string, []any) { + selectCols := "timestamp, request_id, method, path, status, latency_ms, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw" + if withClientID { + selectCols = "timestamp, request_id, method, path, status, latency_ms, client_id, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw" + } + + query := fmt.Sprintf("SELECT %s FROM oathkeeper_access_logs", selectCols) args := make([]any, 0, 5) if subject != "" { query += ` @@ -63,39 +131,25 @@ func (r *OathkeeperClickHouseRepository) FindPageBySubject(ctx context.Context, LIMIT ? ` args = append(args, limit) + return query, args +} - rows, err := r.conn.Query(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("failed to query oathkeeper logs: %w", err) +func isMissingColumnError(err error, column string) bool { + if err == nil { + return false } - defer rows.Close() - - var logs []domain.OathkeeperAccessLog - for rows.Next() { - var log domain.OathkeeperAccessLog - if err := rows.Scan( - &log.Timestamp, - &log.RequestID, - &log.Method, - &log.Path, - &log.Status, - &log.LatencyMs, - &log.RP, - &log.Action, - &log.Target, - &log.Subject, - &log.ClientIP, - &log.UserAgent, - &log.Decision, - &log.TraceID, - &log.SpanID, - &log.Raw, - ); err != nil { - return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err) - } - logs = append(logs, log) + msg := strings.ToLower(err.Error()) + column = strings.ToLower(column) + if strings.Contains(msg, "unknown identifier") && strings.Contains(msg, column) { + return true } - return logs, nil + if strings.Contains(msg, "unknown expression identifier") && strings.Contains(msg, column) { + return true + } + if strings.Contains(msg, "missing columns") && strings.Contains(msg, column) { + return true + } + return false } func (r *OathkeeperClickHouseRepository) Ping(ctx context.Context) error { diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index eff0bdfe..408e56fb 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -45,6 +45,13 @@ type HydraConsentRequest struct { Client HydraClient `json:"client"` } +type HydraLoginRequest struct { + Challenge string `json:"challenge"` + Subject string `json:"subject"` + Skip bool `json:"skip"` + Client HydraClient `json:"client"` +} + type HydraConsentSession struct { ConsentRequestID string `json:"consent_request_id,omitempty"` Subject string `json:"subject,omitempty"` @@ -402,6 +409,39 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str return &consentReq, nil } +func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge string) (*HydraLoginRequest, error) { + params := map[string]string{ + "login_challenge": challenge, + } + endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/login", params) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("hydra admin: create request for get login failed: %w", err) + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("hydra admin: get login request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hydra admin: get login failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var loginReq HydraLoginRequest + if err := json.Unmarshal(body, &loginReq); err != nil { + return nil, fmt.Errorf("hydra admin: decode get login response failed: %w", err) + } + + return &loginReq, nil +} + func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) { params := map[string]string{ "consent_challenge": challenge, diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 8eeb3327..d2d44420 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -449,12 +449,13 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) { - if (authMethod != 'QR') { + final isOidc = authMethod.contains('OIDC'); + if (authMethod != 'QR' && !isOidc) { final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; if (!authMethod.startsWith('링크') || !hasApproverMeta) { - return _selectableText(authMethod); + return _selectableText(authMethod); } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ @@ -472,10 +473,13 @@ class _DashboardScreenState extends ConsumerState { ), ); } - final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; + final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) + ? log.detailMap['approved_session_id'].toString() + : log.sessionId; + final tooltipLabel = isOidc ? '승인한 Userfront 세션 ID' : '승인한 세션 ID'; final tooltip = approvedSessionId.isEmpty - ? '승인한 세션 ID 없음' - : '승인한 세션 ID: $approvedSessionId\n클릭하면 복사됩니다.'; + ? '$tooltipLabel 없음' + : '$tooltipLabel: $approvedSessionId\n클릭하면 복사됩니다.'; return InkWell( onTap: approvedSessionId.isEmpty ? null @@ -490,7 +494,7 @@ class _DashboardScreenState extends ConsumerState { child: Tooltip( message: tooltip, child: Text( - 'QR', + isOidc ? authMethod : 'QR', style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: @@ -502,12 +506,13 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) { - if (authMethod != 'QR') { + final isOidc = authMethod.contains('OIDC'); + if (authMethod != 'QR' && !isOidc) { final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? ''; final approvedIp = log.detailMap['approved_ip']?.toString() ?? ''; final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty; if (!authMethod.startsWith('링크') || !hasApproverMeta) { - return _selectableText('인증수단: $authMethod'); + return _selectableText('인증수단: $authMethod'); } final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent); final tooltip = [ @@ -525,7 +530,10 @@ class _DashboardScreenState extends ConsumerState { ), ); } - final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; + final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false) + ? log.detailMap['approved_session_id'].toString() + : log.sessionId; + final tooltipLabel = isOidc ? '승인한 Userfront 세션 ID' : '승인한 세션 ID'; return InkWell( onTap: approvedSessionId.isEmpty ? null @@ -539,10 +547,10 @@ class _DashboardScreenState extends ConsumerState { }, child: Tooltip( message: approvedSessionId.isEmpty - ? '승인한 세션 ID 없음' - : '승인한 세션 ID: $approvedSessionId\n탭하면 복사됩니다.', + ? '$tooltipLabel 없음' + : '$tooltipLabel: $approvedSessionId\n탭하면 복사됩니다.', child: Text( - '인증수단: QR', + '인증수단: ${isOidc ? authMethod : 'QR'}', style: TextStyle( color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, decoration: approvedSessionId.isEmpty