forked from baron/baron-sso
userfront 접속이력 타임라인 oathkeeper 세션 ID 보강
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user