From 9e473ae8a866c20841506f6f9ac5db50dc203044 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 7 Apr 2026 14:47:04 +0900 Subject: [PATCH] =?UTF-8?q?userfront=20=EC=A0=91=EC=86=8D=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20oathkeeper?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20ID=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 91 ++++++++++++++++--- .../handler/auth_handler_sessions_test.go | 67 ++++++++++++++ backend/internal/handler/common_test.go | 19 ++++ .../presentation/dashboard_screen.dart | 4 +- 4 files changed, 166 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f26e8683..d1195a30 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4330,10 +4330,10 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { appName := "Baron 로그인" clientID := "" path := strings.ToLower(extractAuditPath(log)) - + isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept") isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") - + // 우선 audit details의 client 정보를 사용 if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" { @@ -4343,7 +4343,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { appName = strings.TrimSpace(name) } } - + // 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강 if appName == "Baron 로그인" || appName == "" { if isOidcAccept { @@ -4391,7 +4391,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if clientID == "" { continue } - + appName := clientID if consent, ok := consentMap[clientID]; ok { appName = consent.Name @@ -4418,6 +4418,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { EventID: eventID, Timestamp: log.Timestamp, UserID: profile.ID, + SessionID: extractSessionIDFromOathkeeperLog(log), EventType: fmt.Sprintf("%s %s", log.Method, log.Path), Status: status, AuthMethod: "세션 위임", @@ -5741,6 +5742,16 @@ func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { return parseClientIDFromRaw(log.Raw) } +func extractSessionIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { + if value := parseSessionIDFromURL(log.Target); value != "" { + return value + } + if value := parseSessionIDFromURL(log.Path); value != "" { + return value + } + return parseSessionIDFromRaw(log.Raw) +} + func parseClientIDFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -5759,6 +5770,23 @@ func parseClientIDFromURL(raw string) string { return "" } +func parseSessionIDFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} { + if id := strings.TrimSpace(parsed.Query().Get(key)); id != "" { + return id + } + } + return "" +} + func parseClientIDFromRaw(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -5810,15 +5838,7 @@ func extractSessionIDFromAuditDetails(details string) string { if err := json.Unmarshal([]byte(details), &payload); err != nil { return "" } - if raw, ok := payload["session_id"]; ok { - switch value := raw.(type) { - case string: - return value - default: - return fmt.Sprint(value) - } - } - return "" + return readSessionIDFromAny(payload) } func extractApprovedSessionIDFromAuditDetails(details string) string { @@ -5848,6 +5868,51 @@ func extractApprovedSessionIDFromAuditDetails(details string) string { return "" } +func parseSessionIDFromRaw(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "" + } + return readSessionIDFromAny(payload) +} + +func readSessionIDFromAny(payload any) string { + switch value := payload.(type) { + case map[string]any: + for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} { + if raw, ok := value[key]; ok { + switch sid := raw.(type) { + case string: + if strings.TrimSpace(sid) != "" { + return strings.TrimSpace(sid) + } + default: + rendered := strings.TrimSpace(fmt.Sprint(sid)) + if rendered != "" && rendered != "" { + return rendered + } + } + } + } + for _, nested := range value { + if sid := readSessionIDFromAny(nested); sid != "" { + return sid + } + } + case []any: + for _, nested := range value { + if sid := readSessionIDFromAny(nested); sid != "" { + return sid + } + } + } + return "" +} + func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { id, _, _, err := h.getKratosIdentity(token) return id, err diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 7dfc0129..817daf86 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -616,3 +616,70 @@ func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) { assert.Contains(t, err.Error(), "inactive") mockKratos.AssertExpectations(t) } + +func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) { + now := time.Date(2026, 4, 7, 4, 39, 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 + })) + + h := &AuthHandler{ + AuditRepo: &mockAuditRepo{}, + OathkeeperRepo: &mockOathkeeperRepo{ + logs: []domain.OathkeeperAccessLog{ + { + Timestamp: now, + RequestID: "req-1", + Method: http.MethodGet, + Path: "/api/v1/dev/sessions", + Status: http.StatusOK, + Subject: "user-123", + ClientIP: "203.0.113.7", + UserAgent: "Mozilla/5.0", + Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`, + }, + }, + }, + } + + app := fiber.New() + app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", 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"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + Source string `json:"source"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "target-sid", body.Items[0].SessionID) + assert.Equal(t, "devfront", body.Items[0].ClientID) + assert.Equal(t, "devfront", body.Items[0].AppName) + assert.Equal(t, "oathkeeper", body.Items[0].Source) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 32bd1d21..c499eb39 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -115,6 +115,25 @@ func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } +type mockOathkeeperRepo struct { + logs []domain.OathkeeperAccessLog +} + +func (m *mockOathkeeperRepo) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) { + if subject == "" { + return m.logs, nil + } + results := make([]domain.OathkeeperAccessLog, 0, len(m.logs)) + for _, log := range m.logs { + if log.Subject == subject { + results = append(results, log) + } + } + return results, nil +} + +func (m *mockOathkeeperRepo) Ping(ctx context.Context) error { return nil } + // --- Mock Consent Repository --- type mockConsentRepo struct { diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index b201bdd8..d968c49d 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -161,9 +161,9 @@ class _DashboardScreenState extends ConsumerState { builder: (context) => AlertDialog( title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')), content: Text( - tr( + _renderTranslatedText( 'msg.userfront.dashboard.sessions.revoke.confirm', - params: { + values: { 'target': session.isCurrent ? tr('ui.userfront.dashboard.sessions.current_badge') : _sessionDisplayLabel(session),