forked from baron/baron-sso
사용자 활성 세션 조회·종료 API 추가
This commit is contained in:
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user