forked from baron/baron-sso
consent 변화 내부 머지 완료
This commit is contained in:
@@ -159,6 +159,20 @@ func main() {
|
|||||||
slog.Info("✅ Connected to ClickHouse")
|
slog.Info("✅ Connected to ClickHouse")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var oathkeeperRepo domain.OathkeeperLogRepository
|
||||||
|
oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse")
|
||||||
|
oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||||
|
oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory")
|
||||||
|
oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass")
|
||||||
|
oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory")
|
||||||
|
if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil {
|
||||||
|
slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err)
|
||||||
|
oathkeeperRepo = nil
|
||||||
|
} else {
|
||||||
|
oathkeeperRepo = repo
|
||||||
|
slog.Info("✅ Connected to Ory ClickHouse")
|
||||||
|
}
|
||||||
|
|
||||||
// PostgreSQL (Meta Store)
|
// PostgreSQL (Meta Store)
|
||||||
pgHost := getEnv("DB_HOST", "localhost")
|
pgHost := getEnv("DB_HOST", "localhost")
|
||||||
pgPort := getEnv("DB_PORT", "5432")
|
pgPort := getEnv("DB_PORT", "5432")
|
||||||
@@ -228,7 +242,7 @@ func main() {
|
|||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, userRepo)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler(redisService)
|
devHandler := handler.NewDevHandler(redisService)
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||||
|
|||||||
@@ -80,17 +80,18 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
ProjectID string
|
ProjectID string
|
||||||
SmsService domain.SmsService
|
SmsService domain.SmsService
|
||||||
EmailService domain.EmailService
|
EmailService domain.EmailService
|
||||||
RedisService *service.RedisService
|
RedisService *service.RedisService
|
||||||
DescopeClient *client.DescopeClient
|
DescopeClient *client.DescopeClient
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
IdpProvider domain.IdentityProvider
|
IdpProvider domain.IdentityProvider
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
Hydra *service.HydraAdminService
|
OathkeeperRepo domain.OathkeeperLogRepository
|
||||||
TenantService service.TenantService
|
Hydra *service.HydraAdminService
|
||||||
UserRepo repository.UserRepository
|
TenantService service.TenantService
|
||||||
|
UserRepo repository.UserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupState struct {
|
type signupState struct {
|
||||||
@@ -148,7 +149,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
|||||||
return false, int(interval.Seconds())
|
return false, int(interval.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
|
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
|
||||||
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||||
|
|
||||||
@@ -165,17 +166,18 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
SmsService: service.NewSmsService(),
|
SmsService: service.NewSmsService(),
|
||||||
EmailService: service.NewEmailService(),
|
EmailService: service.NewEmailService(),
|
||||||
RedisService: redisService,
|
RedisService: redisService,
|
||||||
DescopeClient: descopeClient,
|
DescopeClient: descopeClient,
|
||||||
KratosAdmin: service.NewKratosAdminService(),
|
KratosAdmin: service.NewKratosAdminService(),
|
||||||
IdpProvider: idpProvider,
|
IdpProvider: idpProvider,
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
Hydra: service.NewHydraAdminService(),
|
OathkeeperRepo: oathkeeperRepo,
|
||||||
TenantService: tenantService,
|
Hydra: service.NewHydraAdminService(),
|
||||||
UserRepo: userRepo,
|
TenantService: tenantService,
|
||||||
|
UserRepo: userRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,8 +1157,8 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|||||||
// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
|
// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
|
||||||
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
ShortCode string `json:"shortCode"`
|
ShortCode string `json:"shortCode"`
|
||||||
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
slog.Error("[LoginShortCode] Body parse error", "error", err)
|
slog.Error("[LoginShortCode] Body parse error", "error", err)
|
||||||
@@ -2700,8 +2702,31 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string {
|
|||||||
return c.IP()
|
return c.IP()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authTimelineItem struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
AppName string `json:"app_name,omitempty"`
|
||||||
|
ParentSessionID string `json:"parent_session_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type consentClientInfo struct {
|
||||||
|
ClientID string
|
||||||
|
Name string
|
||||||
|
ConsentAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
||||||
if h.AuditRepo == nil {
|
if h.AuditRepo == nil && h.OathkeeperRepo == nil {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2728,6 +2753,51 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subject := ""
|
||||||
|
if h.OathkeeperRepo != nil {
|
||||||
|
if value, err := h.resolveConsentSubject(c); err == nil {
|
||||||
|
subject = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consentMap := make(map[string]consentClientInfo)
|
||||||
|
if subject != "" && h.Hydra != nil {
|
||||||
|
if sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, ""); err == nil {
|
||||||
|
for _, session := range sessions {
|
||||||
|
clientID := strings.TrimSpace(session.Client.ClientID)
|
||||||
|
if clientID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(session.Client.ClientName)
|
||||||
|
if name == "" {
|
||||||
|
name = clientID
|
||||||
|
}
|
||||||
|
consentAt := time.Time{}
|
||||||
|
if session.AuthenticatedAt != nil {
|
||||||
|
consentAt = *session.AuthenticatedAt
|
||||||
|
} else if session.RequestedAt != nil {
|
||||||
|
consentAt = *session.RequestedAt
|
||||||
|
}
|
||||||
|
if existing, ok := consentMap[clientID]; ok {
|
||||||
|
if !consentAt.IsZero() && (existing.ConsentAt.IsZero() || consentAt.Before(existing.ConsentAt)) {
|
||||||
|
existing.ConsentAt = consentAt
|
||||||
|
consentMap[clientID] = existing
|
||||||
|
}
|
||||||
|
if existing.Name == "" {
|
||||||
|
existing.Name = name
|
||||||
|
consentMap[clientID] = existing
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consentMap[clientID] = consentClientInfo{
|
||||||
|
ClientID: clientID,
|
||||||
|
Name: name,
|
||||||
|
ConsentAt: consentAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
candidates := buildLoginCandidates(profile)
|
candidates := buildLoginCandidates(profile)
|
||||||
fetchLimit := limit * 10
|
fetchLimit := limit * 10
|
||||||
if fetchLimit < limit {
|
if fetchLimit < limit {
|
||||||
@@ -2737,69 +2807,177 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
fetchLimit = 500
|
fetchLimit = 500
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]domain.AuditLog, 0, limit)
|
authLogs := make([]domain.AuditLog, 0, fetchLimit)
|
||||||
nextCursor := ""
|
if h.AuditRepo != nil {
|
||||||
currentCursor := cursor
|
currentCursor := cursor
|
||||||
const maxBatches = 10
|
const maxBatches = 10
|
||||||
for batch := 0; batch < maxBatches && len(items) < limit; batch++ {
|
for batch := 0; batch < maxBatches && len(authLogs) < fetchLimit; batch++ {
|
||||||
logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
|
logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
|
||||||
}
|
|
||||||
if len(logs) == 0 {
|
|
||||||
nextCursor = ""
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastScanned *domain.AuditLog
|
|
||||||
for i := range logs {
|
|
||||||
log := logs[i]
|
|
||||||
lastScanned = &log
|
|
||||||
if !isAuthEventType(log.EventType) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if !matchesAuthTimelineUser(log, profile, candidates) {
|
if len(logs) == 0 {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if shouldSkipAuthTimeline(log) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if log.UserID == "" {
|
|
||||||
log.UserID = profile.ID
|
|
||||||
}
|
|
||||||
log.AuthMethod = deriveAuthMethod(log)
|
|
||||||
if log.AuthMethod == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if log.SessionID == "" {
|
|
||||||
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
|
|
||||||
}
|
|
||||||
items = append(items, log)
|
|
||||||
if len(items) >= limit {
|
|
||||||
nextCursor = encodeAuditCursor(log)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) >= limit {
|
var lastScanned *domain.AuditLog
|
||||||
break
|
for i := range logs {
|
||||||
}
|
log := logs[i]
|
||||||
|
lastScanned = &log
|
||||||
|
if !isAuthEventType(log.EventType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesAuthTimelineUser(log, profile, candidates) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shouldSkipAuthTimeline(log) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if log.UserID == "" {
|
||||||
|
log.UserID = profile.ID
|
||||||
|
}
|
||||||
|
log.AuthMethod = deriveAuthMethod(log)
|
||||||
|
if log.AuthMethod == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if log.SessionID == "" {
|
||||||
|
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
|
||||||
|
}
|
||||||
|
authLogs = append(authLogs, log)
|
||||||
|
if len(authLogs) >= fetchLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(logs) < fetchLimit {
|
if len(logs) < fetchLimit || lastScanned == nil {
|
||||||
nextCursor = ""
|
break
|
||||||
break
|
}
|
||||||
|
currentCursor = &domain.AuditCursor{
|
||||||
|
Timestamp: lastScanned.Timestamp,
|
||||||
|
EventID: lastScanned.EventID,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if lastScanned == nil {
|
oathkeeperLogs := make([]domain.OathkeeperAccessLog, 0, fetchLimit)
|
||||||
nextCursor = ""
|
if h.OathkeeperRepo != nil && subject != "" {
|
||||||
break
|
currentCursor := cursor
|
||||||
}
|
const maxBatches = 10
|
||||||
|
for batch := 0; batch < maxBatches && len(oathkeeperLogs) < fetchLimit; batch++ {
|
||||||
|
logs, err := h.OathkeeperRepo.FindPageBySubject(c.Context(), subject, fetchLimit, currentCursor)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve oathkeeper logs"})
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var lastScanned *domain.OathkeeperAccessLog
|
||||||
|
for i := range logs {
|
||||||
|
log := logs[i]
|
||||||
|
lastScanned = &log
|
||||||
|
clientID := extractClientIDFromOathkeeperLog(log)
|
||||||
|
if clientID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consent, ok := consentMap[clientID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oathkeeperLogs = append(oathkeeperLogs, log)
|
||||||
|
if len(oathkeeperLogs) >= fetchLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentCursor = &domain.AuditCursor{
|
if len(logs) < fetchLimit || lastScanned == nil {
|
||||||
Timestamp: lastScanned.Timestamp,
|
break
|
||||||
EventID: lastScanned.EventID,
|
}
|
||||||
|
currentCursor = &domain.AuditCursor{
|
||||||
|
Timestamp: lastScanned.Timestamp,
|
||||||
|
EventID: oathkeeperEventID(*lastScanned),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nextCursor = encodeAuditCursor(*lastScanned)
|
}
|
||||||
|
|
||||||
|
items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
|
||||||
|
for i := range authLogs {
|
||||||
|
log := authLogs[i]
|
||||||
|
item := authTimelineItem{
|
||||||
|
EventID: log.EventID,
|
||||||
|
Timestamp: log.Timestamp,
|
||||||
|
UserID: log.UserID,
|
||||||
|
SessionID: log.SessionID,
|
||||||
|
EventType: log.EventType,
|
||||||
|
Status: log.Status,
|
||||||
|
AuthMethod: log.AuthMethod,
|
||||||
|
IPAddress: log.IPAddress,
|
||||||
|
UserAgent: log.UserAgent,
|
||||||
|
Details: log.Details,
|
||||||
|
Source: "backend",
|
||||||
|
AppName: "Baron 통합로그인",
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range oathkeeperLogs {
|
||||||
|
log := oathkeeperLogs[i]
|
||||||
|
clientID := extractClientIDFromOathkeeperLog(log)
|
||||||
|
if clientID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consent := consentMap[clientID]
|
||||||
|
appName := consent.Name
|
||||||
|
if appName == "" {
|
||||||
|
appName = clientID
|
||||||
|
}
|
||||||
|
details := map[string]any{
|
||||||
|
"path": log.Path,
|
||||||
|
"client_id": clientID,
|
||||||
|
"decision": log.Decision,
|
||||||
|
"status_code": log.Status,
|
||||||
|
}
|
||||||
|
detailsJSON, _ := json.Marshal(details)
|
||||||
|
status := "success"
|
||||||
|
if log.Status >= 400 {
|
||||||
|
status = "failure"
|
||||||
|
}
|
||||||
|
eventID := oathkeeperEventID(log)
|
||||||
|
item := authTimelineItem{
|
||||||
|
EventID: eventID,
|
||||||
|
Timestamp: log.Timestamp,
|
||||||
|
UserID: profile.ID,
|
||||||
|
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
|
||||||
|
Status: status,
|
||||||
|
AuthMethod: "세션 위임",
|
||||||
|
IPAddress: log.ClientIP,
|
||||||
|
UserAgent: log.UserAgent,
|
||||||
|
Details: string(detailsJSON),
|
||||||
|
Source: "oathkeeper",
|
||||||
|
ClientID: clientID,
|
||||||
|
AppName: appName,
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].Timestamp.Equal(items[j].Timestamp) {
|
||||||
|
return items[i].EventID > items[j].EventID
|
||||||
|
}
|
||||||
|
return items[i].Timestamp.After(items[j].Timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
nextCursor := ""
|
||||||
|
hasMore := len(authLogs) >= fetchLimit || len(oathkeeperLogs) >= fetchLimit
|
||||||
|
if len(items) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
last := items[len(items)-1]
|
||||||
|
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
|
||||||
|
} else if hasMore && len(items) > 0 {
|
||||||
|
last := items[len(items)-1]
|
||||||
|
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
@@ -2810,6 +2988,27 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func encodeTimelineCursor(timestamp time.Time, eventID string) string {
|
||||||
|
if eventID == "" {
|
||||||
|
eventID = fmt.Sprintf("%d", timestamp.UnixNano())
|
||||||
|
}
|
||||||
|
payload := timestamp.UTC().Format(time.RFC3339Nano) + "|" + eventID
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func oathkeeperEventID(log domain.OathkeeperAccessLog) string {
|
||||||
|
if log.RequestID != "" {
|
||||||
|
return log.RequestID
|
||||||
|
}
|
||||||
|
if log.TraceID != "" {
|
||||||
|
return log.TraceID
|
||||||
|
}
|
||||||
|
if log.SpanID != "" {
|
||||||
|
return log.SpanID
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", log.Timestamp.UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
type linkedRpSummary struct {
|
type linkedRpSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -3365,6 +3564,80 @@ func extractLoginIDFromAuditDetails(details string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
|
||||||
|
if value := strings.TrimSpace(log.RP); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value := parseClientIDFromURL(log.Target); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value := parseClientIDFromURL(log.Path); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return parseClientIDFromRaw(log.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseClientIDFromURL(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(parsed.Query().Get("client_id")); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
if id := strings.TrimSpace(parsed.Query().Get("clientId")); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseClientIDFromRaw(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if id := readClientIDFromPayload(payload); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
if request, ok := payload["request"].(map[string]any); ok {
|
||||||
|
if id := readClientIDFromPayload(request); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
if urlRaw, ok := request["url"].(string); ok {
|
||||||
|
if id := parseClientIDFromURL(urlRaw); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pathRaw, ok := request["path"].(string); ok {
|
||||||
|
if id := parseClientIDFromURL(pathRaw); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClientIDFromPayload(payload map[string]any) string {
|
||||||
|
if payload == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if raw, ok := payload["client_id"].(string); ok && strings.TrimSpace(raw) != "" {
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
if raw, ok := payload["clientId"].(string); ok && strings.TrimSpace(raw) != "" {
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func extractSessionIDFromAuditDetails(details string) string {
|
func extractSessionIDFromAuditDetails(details string) string {
|
||||||
if details == "" {
|
if details == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -7,13 +7,23 @@ CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs (
|
|||||||
path String DEFAULT '',
|
path String DEFAULT '',
|
||||||
status UInt16 DEFAULT 0,
|
status UInt16 DEFAULT 0,
|
||||||
latency_ms UInt32 DEFAULT 0,
|
latency_ms UInt32 DEFAULT 0,
|
||||||
|
client_id String DEFAULT '',
|
||||||
rp String DEFAULT '',
|
rp String DEFAULT '',
|
||||||
action String DEFAULT '',
|
action String DEFAULT '',
|
||||||
target String DEFAULT '',
|
target String DEFAULT '',
|
||||||
|
rule_id String DEFAULT '',
|
||||||
|
host String DEFAULT '',
|
||||||
|
scheme String DEFAULT '',
|
||||||
|
query String DEFAULT '',
|
||||||
|
upstream_url String DEFAULT '',
|
||||||
subject String DEFAULT '',
|
subject String DEFAULT '',
|
||||||
|
parent_session_id String DEFAULT '',
|
||||||
client_ip String DEFAULT '',
|
client_ip String DEFAULT '',
|
||||||
user_agent String DEFAULT '',
|
user_agent String DEFAULT '',
|
||||||
|
referer String DEFAULT '',
|
||||||
decision String DEFAULT '',
|
decision String DEFAULT '',
|
||||||
|
bytes_in UInt64 DEFAULT 0,
|
||||||
|
bytes_out UInt64 DEFAULT 0,
|
||||||
trace_id String DEFAULT '',
|
trace_id String DEFAULT '',
|
||||||
span_id String DEFAULT '',
|
span_id String DEFAULT '',
|
||||||
raw String DEFAULT ''
|
raw String DEFAULT ''
|
||||||
|
|||||||
@@ -84,5 +84,33 @@
|
|||||||
"authenticators": [{ "handler": "noop" }],
|
"authenticators": [{ "handler": "noop" }],
|
||||||
"authorizer": { "handler": "allow" },
|
"authorizer": { "handler": "allow" },
|
||||||
"mutators": [{ "handler": "noop" }]
|
"mutators": [{ "handler": "noop" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rp-template-browser",
|
||||||
|
"description": "RP proxy (browser session). TODO: match.url/upstream.url을 실제 RP로 좁혀야 함.",
|
||||||
|
"match": {
|
||||||
|
"url": "http://<.*>/rp/<.*>",
|
||||||
|
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"url": "http://rp_upstream:8080"
|
||||||
|
},
|
||||||
|
"authenticators": [{ "handler": "cookie_session" }],
|
||||||
|
"authorizer": { "handler": "allow" },
|
||||||
|
"mutators": [{ "handler": "noop" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rp-template-bearer",
|
||||||
|
"description": "RP proxy (bearer). TODO: oauth2_introspection 또는 jwt 활성화 필요.",
|
||||||
|
"match": {
|
||||||
|
"url": "http://<.*>/rp-api/<.*>",
|
||||||
|
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"url": "http://rp_upstream:8080"
|
||||||
|
},
|
||||||
|
"authenticators": [{ "handler": "oauth2_introspection" }],
|
||||||
|
"authorizer": { "handler": "allow" },
|
||||||
|
"mutators": [{ "handler": "noop" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
request_method = get(parsed, ["request", "method"]) ?? ""
|
request_method = get(parsed, ["request", "method"]) ?? ""
|
||||||
request_path = get(parsed, ["request", "path"]) ?? ""
|
request_path = get(parsed, ["request", "path"]) ?? ""
|
||||||
request_url = get(parsed, ["request", "url"]) ?? ""
|
request_url = get(parsed, ["request", "url"]) ?? ""
|
||||||
|
request_host = get(parsed, ["request", "host"]) ?? ""
|
||||||
|
request_scheme = get(parsed, ["request", "scheme"]) ?? ""
|
||||||
|
request_query = get(parsed, ["request", "query"]) ?? ""
|
||||||
.method = parsed.method ?? parsed.http_method ?? request_method ?? ""
|
.method = parsed.method ?? parsed.http_method ?? request_method ?? ""
|
||||||
.path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? ""
|
.path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? ""
|
||||||
response_status = get(parsed, ["response", "status"]) ?? 0
|
response_status = get(parsed, ["response", "status"]) ?? 0
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
.user_agent = parsed.user_agent
|
.user_agent = parsed.user_agent
|
||||||
if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) }
|
if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) }
|
||||||
if is_null(.user_agent) { .user_agent = "" }
|
if is_null(.user_agent) { .user_agent = "" }
|
||||||
|
.referer = get(headers, ["Referer"]) ?? ""
|
||||||
|
|
||||||
.decision = parsed.decision
|
.decision = parsed.decision
|
||||||
if is_null(.decision) { .decision = parsed.result }
|
if is_null(.decision) { .decision = parsed.result }
|
||||||
@@ -38,9 +42,18 @@
|
|||||||
.span_id = parsed.span_id
|
.span_id = parsed.span_id
|
||||||
if is_null(.span_id) { .span_id = "" }
|
if is_null(.span_id) { .span_id = "" }
|
||||||
|
|
||||||
.rp = ""
|
.rp = parsed.rp ?? ""
|
||||||
.action = ""
|
.action = parsed.action ?? ""
|
||||||
.target = ""
|
.target = parsed.target ?? ""
|
||||||
|
.rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? ""
|
||||||
|
.client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? ""
|
||||||
|
.parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? ""
|
||||||
|
.host = parsed.host ?? request_host ?? ""
|
||||||
|
.scheme = parsed.scheme ?? request_scheme ?? ""
|
||||||
|
.query = parsed.query ?? request_query ?? ""
|
||||||
|
.upstream_url = parsed.upstream_url ?? get(parsed, ["upstream", "url"]) ?? ""
|
||||||
|
.bytes_in = to_int(parsed.bytes_in ?? parsed.request_bytes ?? 0) ?? 0
|
||||||
|
.bytes_out = to_int(parsed.bytes_out ?? parsed.response_bytes ?? 0) ?? 0
|
||||||
'''
|
'''
|
||||||
|
|
||||||
[sinks.clickhouse]
|
[sinks.clickhouse]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/constants/error_whitelist.dart';
|
||||||
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
|
||||||
class ErrorScreen extends StatelessWidget {
|
class ErrorScreen extends StatelessWidget {
|
||||||
final String? errorId;
|
final String? errorId;
|
||||||
@@ -16,15 +18,22 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final errorType = (errorCode == null || errorCode!.isEmpty)
|
final isProd = AuthProxyService.isProdEnv;
|
||||||
? 'unknown_error'
|
final normalizedCode = (errorCode ?? '').trim();
|
||||||
: errorCode!;
|
final hasCode = normalizedCode.isNotEmpty;
|
||||||
final title = errorCode == null || errorCode!.isEmpty
|
final whitelistMessage = errorWhitelistMessages[normalizedCode];
|
||||||
|
final isWhitelisted = whitelistMessage != null;
|
||||||
|
final errorType = isProd
|
||||||
|
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error')
|
||||||
|
: (hasCode ? normalizedCode : 'unknown_error');
|
||||||
|
final title = isProd
|
||||||
? '인증 과정에서 오류가 발생했습니다'
|
? '인증 과정에서 오류가 발생했습니다'
|
||||||
: '오류: $errorCode';
|
: (hasCode ? '오류: $normalizedCode' : '오류가 발생했습니다');
|
||||||
final detail = description?.isNotEmpty == true
|
final detail = isProd
|
||||||
? description!
|
? (isWhitelisted ? whitelistMessage! : '에러가 계속되면 관리자에게 문의해주세요')
|
||||||
: '요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
: ((description?.isNotEmpty == true)
|
||||||
|
? description!
|
||||||
|
: (hasCode ? '오류가 발생했습니다.' : '요청을 처리하는 중 문제가 발생했습니다.'));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F8FA),
|
backgroundColor: const Color(0xFFF7F8FA),
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class AuditLogEntry {
|
|||||||
final String userAgent;
|
final String userAgent;
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String details;
|
final String details;
|
||||||
|
final String source;
|
||||||
|
final String clientId;
|
||||||
|
final String appName;
|
||||||
|
final String parentSessionId;
|
||||||
|
|
||||||
AuditLogEntry({
|
AuditLogEntry({
|
||||||
required this.eventId,
|
required this.eventId,
|
||||||
@@ -33,6 +37,10 @@ class AuditLogEntry {
|
|||||||
required this.userAgent,
|
required this.userAgent,
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
required this.details,
|
required this.details,
|
||||||
|
required this.source,
|
||||||
|
required this.clientId,
|
||||||
|
required this.appName,
|
||||||
|
required this.parentSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
|
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -55,6 +63,10 @@ class AuditLogEntry {
|
|||||||
userAgent: json['user_agent'] ?? '',
|
userAgent: json['user_agent'] ?? '',
|
||||||
sessionId: json['session_id'] ?? '',
|
sessionId: json['session_id'] ?? '',
|
||||||
details: json['details'] ?? '',
|
details: json['details'] ?? '',
|
||||||
|
source: json['source'] ?? '',
|
||||||
|
clientId: json['client_id'] ?? '',
|
||||||
|
appName: json['app_name'] ?? '',
|
||||||
|
parentSessionId: json['parent_session_id'] ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +554,34 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _appLabelForLog(AuditLogEntry log) {
|
||||||
|
if (log.appName.isNotEmpty) {
|
||||||
|
return log.appName;
|
||||||
|
}
|
||||||
|
return _appLabelForPath(log.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppCell(AuditLogEntry log, {TextStyle? style}) {
|
||||||
|
final label = _appLabelForLog(log);
|
||||||
|
if (label == 'Baron 통합로그인') {
|
||||||
|
return _selectableText(label, style: style);
|
||||||
|
}
|
||||||
|
final tooltip = log.parentSessionId.isEmpty
|
||||||
|
? '부모 세션 ID 없음'
|
||||||
|
: '부모 세션 ID: ${log.parentSessionId}';
|
||||||
|
final baseStyle = style ?? const TextStyle();
|
||||||
|
final emphasisStyle = log.parentSessionId.isEmpty
|
||||||
|
? baseStyle
|
||||||
|
: baseStyle.copyWith(
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
);
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: _selectableText(label, style: emphasisStyle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _appLabelForPath(String path) {
|
String _appLabelForPath(String path) {
|
||||||
if (path.startsWith('/api/v1/auth')) {
|
if (path.startsWith('/api/v1/auth')) {
|
||||||
return 'Baron 통합로그인';
|
return 'Baron 통합로그인';
|
||||||
@@ -992,13 +1032,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
rows: logs.map((log) {
|
rows: logs.map((log) {
|
||||||
final statusLabel = log.status == 'success' ? '성공' : '실패';
|
final statusLabel = log.status == 'success' ? '성공' : '실패';
|
||||||
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
|
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
|
||||||
final appLabel = _appLabelForPath(log.path);
|
|
||||||
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
|
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
|
||||||
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
|
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
|
||||||
return DataRow(cells: [
|
return DataRow(cells: [
|
||||||
DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)),
|
DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)),
|
||||||
DataCell(_selectableText(_formatDateTime(log.timestamp))),
|
DataCell(_selectableText(_formatDateTime(log.timestamp))),
|
||||||
DataCell(_selectableText(appLabel)),
|
DataCell(_buildAppCell(log)),
|
||||||
DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
|
DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
|
||||||
DataCell(_selectableText(deviceLabel)),
|
DataCell(_selectableText(deviceLabel)),
|
||||||
DataCell(_buildAuthMethodCell(log, authMethod)),
|
DataCell(_buildAuthMethodCell(log, authMethod)),
|
||||||
@@ -1036,8 +1075,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _selectableText(
|
child: _buildAppCell(
|
||||||
_appLabelForPath(log.path),
|
log,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
|
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user