1
0
forked from baron/baron-sso

userfront 접속이력 타임라인 oathkeeper 세션 ID 보강

This commit is contained in:
2026-04-07 14:47:04 +09:00
parent 6e312cc5fd
commit 9e473ae8a8
4 changed files with 166 additions and 15 deletions

View File

@@ -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 != "<nil>" {
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

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -161,9 +161,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
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),