forked from baron/baron-sso
userfront 접속이력 타임라인 oathkeeper 세션 ID 보강
This commit is contained in:
@@ -4418,6 +4418,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
Timestamp: log.Timestamp,
|
Timestamp: log.Timestamp,
|
||||||
UserID: profile.ID,
|
UserID: profile.ID,
|
||||||
|
SessionID: extractSessionIDFromOathkeeperLog(log),
|
||||||
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
|
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
|
||||||
Status: status,
|
Status: status,
|
||||||
AuthMethod: "세션 위임",
|
AuthMethod: "세션 위임",
|
||||||
@@ -5741,6 +5742,16 @@ func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
|
|||||||
return parseClientIDFromRaw(log.Raw)
|
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 {
|
func parseClientIDFromURL(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -5759,6 +5770,23 @@ func parseClientIDFromURL(raw string) string {
|
|||||||
return ""
|
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 {
|
func parseClientIDFromRaw(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -5810,15 +5838,7 @@ func extractSessionIDFromAuditDetails(details string) string {
|
|||||||
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if raw, ok := payload["session_id"]; ok {
|
return readSessionIDFromAny(payload)
|
||||||
switch value := raw.(type) {
|
|
||||||
case string:
|
|
||||||
return value
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractApprovedSessionIDFromAuditDetails(details string) string {
|
func extractApprovedSessionIDFromAuditDetails(details string) string {
|
||||||
@@ -5848,6 +5868,51 @@ func extractApprovedSessionIDFromAuditDetails(details string) string {
|
|||||||
return ""
|
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) {
|
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
||||||
id, _, _, err := h.getKratosIdentity(token)
|
id, _, _, err := h.getKratosIdentity(token)
|
||||||
return id, err
|
return id, err
|
||||||
|
|||||||
@@ -616,3 +616,70 @@ func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "inactive")
|
assert.Contains(t, err.Error(), "inactive")
|
||||||
mockKratos.AssertExpectations(t)
|
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 }
|
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 ---
|
// --- Mock Consent Repository ---
|
||||||
|
|
||||||
type mockConsentRepo struct {
|
type mockConsentRepo struct {
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')),
|
title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')),
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.dashboard.sessions.revoke.confirm',
|
'msg.userfront.dashboard.sessions.revoke.confirm',
|
||||||
params: {
|
values: {
|
||||||
'target': session.isCurrent
|
'target': session.isCurrent
|
||||||
? tr('ui.userfront.dashboard.sessions.current_badge')
|
? tr('ui.userfront.dashboard.sessions.current_badge')
|
||||||
: _sessionDisplayLabel(session),
|
: _sessionDisplayLabel(session),
|
||||||
|
|||||||
Reference in New Issue
Block a user