1
0
forked from baron/baron-sso

사용자 활성 세션 조회·종료 API 추가

This commit is contained in:
2026-04-02 11:01:23 +09:00
parent cdf2c36915
commit a2f2b2dd71
15 changed files with 1922 additions and 1 deletions

View File

@@ -6664,6 +6664,167 @@ type rpHistoryItem struct {
Status string `json:"status"`
}
type userSessionItem struct {
SessionID string `json:"session_id"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IssuedAt *time.Time `json:"issued_at,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ClientID string `json:"client_id,omitempty"`
AppName string `json:"app_name,omitempty"`
IsCurrent bool `json:"is_current"`
IsActive bool `json:"is_active"`
}
type userSessionListResponse struct {
Items []userSessionItem `json:"items"`
}
func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error {
if h.KratosAdmin == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
}
profile, err := h.resolveCurrentProfile(c)
if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
if strings.TrimSpace(profile.ID) == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
}
currentSessionID := h.resolveCurrentSessionID(c)
auditHints := h.loadSessionAuditHints(c.Context(), profile.ID)
items := make([]userSessionItem, 0, len(sessions))
for _, session := range sessions {
if !session.Active {
continue
}
item := userSessionItem{
SessionID: session.ID,
IsCurrent: session.ID != "" && session.ID == currentSessionID,
IsActive: session.Active,
}
if !session.AuthenticatedAt.IsZero() {
ts := session.AuthenticatedAt
item.AuthenticatedAt = &ts
item.LastSeenAt = &ts
}
if !session.ExpiresAt.IsZero() {
ts := session.ExpiresAt
item.ExpiresAt = &ts
}
if !session.IssuedAt.IsZero() {
ts := session.IssuedAt
item.IssuedAt = &ts
if item.AuthenticatedAt == nil {
item.AuthenticatedAt = &ts
}
if item.LastSeenAt == nil {
item.LastSeenAt = &ts
}
}
if hint, ok := auditHints[session.ID]; ok {
if item.IPAddress == "" {
item.IPAddress = hint.IPAddress
}
if item.UserAgent == "" {
item.UserAgent = hint.UserAgent
}
if item.ClientID == "" {
item.ClientID = hint.ClientID
}
if item.AppName == "" {
item.AppName = hint.AppName
}
if hint.Timestamp != nil {
item.LastSeenAt = hint.Timestamp
}
}
if item.UserAgent == "" && len(session.Devices) > 0 {
deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent)
if !looksLikeInternalUserAgent(deviceUserAgent) {
item.UserAgent = deviceUserAgent
}
}
if item.IPAddress == "" && len(session.Devices) > 0 {
item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress)
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].IsCurrent != items[j].IsCurrent {
return items[i].IsCurrent
}
iTime := latestSessionTimestamp(items[i])
jTime := latestSessionTimestamp(items[j])
if iTime.Equal(jTime) {
return items[i].SessionID < items[j].SessionID
}
return iTime.After(jTime)
})
return c.JSON(userSessionListResponse{Items: items})
}
func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
if h.KratosAdmin == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
}
profile, err := h.resolveCurrentProfile(c)
if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
targetSessionID := strings.TrimSpace(c.Params("id"))
if targetSessionID == "" {
return errorJSON(c, fiber.StatusBadRequest, "session id is required")
}
mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
}
ownedSession := false
for _, candidate := range mySessions {
if strings.TrimSpace(candidate.ID) == targetSessionID {
ownedSession = true
break
}
}
if !ownedSession {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session")
}
if session == nil {
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing")
return c.JSON(fiber.Map{"status": "ok"})
}
result := "revoked"
if !session.Active {
result = "already_inactive"
} else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session")
}
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
@@ -6751,3 +6912,179 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"items": items})
}
type sessionAuditHint struct {
Timestamp *time.Time
IPAddress string
UserAgent string
ClientID string
AppName string
}
func latestSessionTimestamp(item userSessionItem) time.Time {
for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} {
if candidate != nil {
return *candidate
}
}
return time.Time{}
}
func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string {
if c == nil {
return ""
}
if token := h.getBearerToken(c); token != "" {
if sessionID := extractSessionIDFromJWT(token); sessionID != "" {
return sessionID
}
if sessionID, err := h.getKratosSessionID(token); err == nil {
return sessionID
}
}
if cookie := c.Get("Cookie"); cookie != "" {
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
return sessionID
}
}
return ""
}
func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint {
hints := make(map[string]sessionAuditHint)
if h.AuditRepo == nil || strings.TrimSpace(userID) == "" {
return hints
}
logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{
"login_success",
"qr_login_success",
"link_login_success",
"code_login_success",
"password_login_success",
"POST /api/v1/auth/oidc/login/accept",
"POST /api/v1/auth/password/login",
"POST /api/v1/auth/magic-link/verify",
"POST /api/v1/auth/login/code/verify",
"POST /api/v1/auth/qr/approve",
"session.revoked",
}, 200)
if err != nil {
return hints
}
for _, log := range logs {
sessionID := strings.TrimSpace(log.SessionID)
if sessionID == "" {
sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details))
}
if sessionID == "" {
sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details))
}
if sessionID == "" {
continue
}
existing, ok := hints[sessionID]
if ok && existing.Timestamp != nil && existing.Timestamp.After(log.Timestamp) {
continue
}
ts := log.Timestamp
ipAddress := strings.TrimSpace(log.IPAddress)
userAgent := strings.TrimSpace(log.UserAgent)
clientID, appName := deriveSessionClientInfo(log)
if details, err := parseAuditDetails(log.Details); err == nil {
if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" {
ipAddress = strings.TrimSpace(approvedIP)
}
if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" {
userAgent = strings.TrimSpace(approvedUserAgent)
}
}
if looksLikeInternalUserAgent(userAgent) {
userAgent = ""
}
hints[sessionID] = sessionAuditHint{
Timestamp: &ts,
IPAddress: ipAddress,
UserAgent: userAgent,
ClientID: clientID,
AppName: appName,
}
}
return hints
}
func deriveSessionClientInfo(log domain.AuditLog) (string, string) {
details, _ := parseAuditDetails(log.Details)
clientID := ""
appName := ""
if details != nil {
if value, ok := details["client_id"].(string); ok {
clientID = strings.TrimSpace(value)
}
if value, ok := details["client_name"].(string); ok {
appName = strings.TrimSpace(value)
}
}
path := strings.ToLower(extractAuditPath(log))
if appName == "" {
switch {
case strings.Contains(path, "/api/v1/auth/oidc/login/accept"):
appName = "OIDC 로그인"
case strings.Contains(path, "/api/v1/auth/qr/approve"):
appName = "QR 로그인"
case strings.Contains(path, "/api/v1/auth/login/code/verify"):
appName = "코드 로그인"
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
appName = "링크 로그인"
case strings.Contains(path, "/api/v1/auth/password/login"):
appName = "비밀번호 로그인"
}
}
if appName == "" && clientID != "" {
appName = clientID
}
return clientID, appName
}
func looksLikeInternalUserAgent(userAgent string) bool {
normalized := strings.ToLower(strings.TrimSpace(userAgent))
if normalized == "" {
return false
}
return strings.HasPrefix(normalized, "go-http-client/") ||
strings.HasPrefix(normalized, "fasthttp") ||
strings.HasPrefix(normalized, "fiber")
}
func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) {
if h.AuditRepo == nil {
return
}
details := map[string]any{
"target_session_id": strings.TrimSpace(targetSessionID),
"revoke_result": strings.TrimSpace(result),
}
if strings.TrimSpace(actorSessionID) != "" {
details["actor_session_id"] = strings.TrimSpace(actorSessionID)
}
raw, err := json.Marshal(details)
if err != nil {
return
}
_ = h.AuditRepo.Create(&domain.AuditLog{
EventID: fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()),
Timestamp: time.Now().UTC(),
UserID: strings.TrimSpace(actorIdentityID),
SessionID: strings.TrimSpace(actorSessionID),
EventType: "session.revoked",
Status: "success",
IPAddress: extractClientIPFromHeaders(c),
UserAgent: strings.TrimSpace(c.Get("User-Agent")),
Details: string(raw),
})
}