package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" "context" crand "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "maps" "math" "math/rand" "net" "net/http" "net/url" "os" "slices" "sort" "strconv" "strings" "time" "github.com/go-jose/go-jose/v4" josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/gofiber/fiber/v2" "github.com/lib/pq" ) const ( // Redis Key Prefixes prefixSession = "enchanted_session:" prefixToken = "enchanted_token:" prefixLoginCode = "login_code_flow:" prefixLoginCodePending = "login_code_pending:" prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeShort = "login_code_short:" prefixLoginCodeValue = "login_code_value:" prefixLoginIDRaw = "login_id_raw:" prefixLoginMethod = "login_method:" prefixLoginFlow = "login_flow:" prefixLoginStrategy = "login_strategy:" prefixLoginApproverMeta = "login_approver_meta:" prefixLoginCodeSmsOnly = "login_code_sms_only:" prefixLoginCodeQrPending = "login_code_qr_pending:" prefixLoginCodeQr = "login_code_qr:" prefixHeadlessLinkState = "headless_link_state:" prefixPollMeta = "poll_meta:" prefixQrRef = "qr_ref:" prefixQrMeta = "qr_meta:" prefixQrApproverSession = "qr_approver_session:" prefixQrPending = "qr_pending:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" // Session Statuses statusPending = "pending" statusSuccess = "success" // Login Flows loginFlowCode = "code" loginFlowLink = "link" // Durations defaultExpiration = 5 * time.Minute signupStateExpiration = 10 * time.Minute signupBlockDuration = 10 * time.Minute maxSignupFailures = 5 emailCodeTTL = 5 * time.Minute smsCodeTTL = 3 * time.Minute prefixPwdResetToken = "pwdreset_token:" prefixPwdResetUsed = "pwdreset_used:" pwdResetExpiration = 15 * time.Minute pwdResetUsedExpiration = 2 * time.Minute minPollInterval = 2 * time.Second loginCodeExpiration = 10 * time.Minute linkResendCooldown = 60 * time.Second prefixDrySend = "dry_send:" headlessJWKSFetchTTL = 5 * time.Second defaultRefreshTokenTTL = 30 * 24 * time.Hour ) type AuthHandler struct { SmsService domain.SmsService EmailService domain.EmailService RedisService domain.RedisRepository HeadlessJWKS *service.HeadlessJWKSCacheService KratosAdmin service.KratosAdminService IdpProvider domain.IdentityProvider AuditRepo domain.AuditRepository OathkeeperRepo domain.OathkeeperLogRepository Hydra *service.HydraAdminService BackchannelLogout *service.BackchannelLogoutService TenantService service.TenantService KetoService service.KetoService KetoOutboxRepo repository.KetoOutboxRepository UserRepo repository.UserRepository ConsentRepo repository.ClientConsentRepository RPUserMetadataRepo repository.RPUserMetadataRepository RPUsageSink domain.RPUsageEventSink } type signupState struct { Code string `json:"code"` Verified bool `json:"verified"` FailCount int `json:"fail_count"` ExpiresAt int64 `json:"expires_at"` // Unix timestamp } type headlessLinkState struct { ClientID string `json:"clientId"` ClientName string `json:"clientName,omitempty"` LoginChallenge string `json:"loginChallenge"` LoginID string `json:"loginId"` RedirectTo string `json:"redirectTo,omitempty"` } type headlessClientAssertionClaims struct { Issuer string `json:"iss"` Subject string `json:"sub"` Audience headlessAssertionAud `json:"aud"` ExpiresAt int64 `json:"exp"` IssuedAt int64 `json:"iat,omitempty"` NotBefore int64 `json:"nbf,omitempty"` ID string `json:"jti,omitempty"` } type headlessAssertionAud []string type headlessLoginFailure struct { status int code string safeMessage string logMessage string debugFields map[string]any } func (e *headlessLoginFailure) Error() string { if e == nil { return "" } if e.code != "" { return e.code } return e.safeMessage } func newHeadlessLoginFailure(status int, code, safeMessage, logMessage string, debugFields map[string]any) *headlessLoginFailure { return &headlessLoginFailure{ status: status, code: code, safeMessage: safeMessage, logMessage: logMessage, debugFields: debugFields, } } func (a *headlessAssertionAud) UnmarshalJSON(data []byte) error { var single string if err := json.Unmarshal(data, &single); err == nil { *a = []string{single} return nil } var list []string if err := json.Unmarshal(data, &list); err != nil { return err } *a = list return nil } // GenerateSecureToken - Helper to generate secure random strings func GenerateSecureToken(length int) string { b := make([]byte, length) if _, err := crand.Read(b); err != nil { return "" } return hex.EncodeToString(b) } func GenerateSecureAlnumToken(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" if length <= 0 { return "" } buf := make([]byte, length) if _, err := crand.Read(buf); err != nil { return "" } for i := range buf { buf[i] = charset[int(buf[i])%len(charset)] } return string(buf) } func GenerateUserCode() string { const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" return fmt.Sprintf("%c%c-%03d", letters[rand.Intn(len(letters))], letters[rand.Intn(len(letters))], rand.Intn(1000), ) } func checkPollInterval(redis domain.RedisRepository, key string, interval time.Duration) (bool, int) { now := time.Now().UnixMilli() val, err := redis.Get(key) if err == nil && val != "" { if last, parseErr := strconv.ParseInt(val, 10, 64); parseErr == nil { if now-last < interval.Milliseconds() { _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) return true, int(interval.Seconds()) + 1 } } } _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) return false, int(interval.Seconds()) } func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler { backchannelLogout, err := service.NewBackchannelLogoutService() if err != nil { slog.Warn("failed to initialize backchannel logout service", "error", err) } return &AuthHandler{ SmsService: service.NewSmsService(), EmailService: service.NewEmailService(), RedisService: redisService, HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil), KratosAdmin: kratos, IdpProvider: idpProvider, AuditRepo: auditRepo, OathkeeperRepo: oathkeeperRepo, Hydra: service.NewHydraAdminService(), BackchannelLogout: backchannelLogout, TenantService: tenantService, KetoService: ketoService, KetoOutboxRepo: ketoOutboxRepo, UserRepo: userRepo, ConsentRepo: consentRepo, } } func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error { if consentRequest == nil { return nil } return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{ "auto_accepted": autoAccepted, "scopes": consentRequest.RequestedScope, }) } func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error { return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{}) } func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error { if h.RPUsageSink == nil { return nil } clientID := strings.TrimSpace(client.ClientID) if clientID == "" || strings.TrimSpace(subject) == "" { return nil } tenantID, tenantType := rpUsageTenantFromProfile(profile) event := domain.RPUsageEvent{ EventType: eventType, Subject: strings.TrimSpace(subject), TenantID: tenantID, TenantType: tenantType, ClientID: clientID, ClientName: strings.TrimSpace(client.ClientName), SessionID: strings.TrimSpace(sessionID), Scopes: pq.StringArray(scopes), Source: source, CorrelationID: strings.TrimSpace(correlationID), Payload: payload, OccurredAt: time.Now(), } if event.Payload == nil { event.Payload = domain.JSONMap{} } if event.ClientName != "" { event.Payload["client_name"] = event.ClientName } if tenantID != "" { event.Payload["tenant_id"] = tenantID } if tenantType != "" { event.Payload["tenant_type"] = tenantType } if c != nil { event.Payload["ip_address"] = c.IP() event.Payload["user_agent"] = string(c.Request().Header.UserAgent()) } ctx := context.Background() if c != nil && c.UserContext() != nil { ctx = c.UserContext() } return h.RPUsageSink.EmitRPUsageEvent(ctx, event) } func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) { if profile == nil { return "", "" } tenantID := "" if profile.SessionTenantID != nil { tenantID = strings.TrimSpace(*profile.SessionTenantID) } if tenantID == "" && profile.TenantID != nil { tenantID = strings.TrimSpace(*profile.TenantID) } tenantType := "" if profile.Tenant != nil { switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) { case domain.TenantTypeCompany, domain.TenantTypeOrganization: tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) if tenantID == "" { tenantID = strings.TrimSpace(profile.Tenant.ID) } case domain.TenantTypeUserGroup, domain.TenantTypePersonal: return "", "" } } return tenantID, tenantType } // --- Signup Flow Handlers --- // CheckEmail - 이메일 사용 가능 여부를 확인합니다. func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { var req domain.CheckEmailRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } // Email Format Validation if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") } if h.IdpProvider == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } exists, err := h.IdpProvider.UserExists(req.Email) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if exists { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) } return c.JSON(fiber.Map{"available": true}) } // CheckLoginID - 로그인 ID 사용 가능 여부를 확인합니다. func (h *AuthHandler) CheckLoginID(c *fiber.Ctx) error { var req domain.CheckLoginIDRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } if h.IdpProvider == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } // Basic validation via our ValidateLoginID helper (without email/phone since we just check format & collision with reserved words) if err := domain.ValidateLoginID(req.LoginID, []string{}, ""); err != nil { return c.JSON(fiber.Map{"available": false, "message": err.Error()}) } // We don't prepend companyCode to Kratos lookup if traits.id is unique globally // Assuming Kratos traits.id handles unique constraints per tenant or globally based on schema exists, err := h.IdpProvider.UserExists(req.LoginID) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if !exists && h.UserRepo != nil { // [New] Check local DB for custom login IDs (Plan A) taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), req.LoginID) if err != nil { slog.Error("Failed to check login ID in local DB", "error", err) } else if taken { exists = true } } if exists { return c.JSON(fiber.Map{"available": false, "message": "ID already registered"}) } return c.JSON(fiber.Map{"available": true}) } // SendSignupEmailCode - Sends verification code to email func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { var req domain.SendSignupCodeRequest if err := c.BodyParser(&req); err != nil || req.Target == "" { return errorJSON(c, fiber.StatusBadRequest, "Invalid email") } req.Type = "email" // Enforce type key := prefixSignupEmail + req.Target // 1. Check existing state (Rate Limit / Block) state, _ := h.getSignupState(key) if state != nil && state.FailCount > maxSignupFailures { // Check if block expired // Simple block implementation: if FailCount > 5, user is blocked until TTL expires // Since we refresh TTL on each update, we rely on Redis TTL. return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.") } // 2. Generate Code rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) // 3. Update State newState := &signupState{ Code: code, Verified: false, FailCount: 0, // Reset fail count on new code generation? Or keep it? // Requirement says "Auth fail > 5 -> block". New code usually resets or continues? // Usually getting a new code doesn't reset verify failure count if we want strict blocking. // But for simplicity let's say "fail count" applies to verification attempts. // If we are issuing a new code, it's a new attempt cycle usually. // However, spamming "send code" is also an attack. // Let's keep FailCount if exists, or 0. ExpiresAt: time.Now().Add(emailCodeTTL).Unix(), } if state != nil { newState.FailCount = state.FailCount } h.saveSignupState(key, newState, signupStateExpiration) // 4. Send Email if h.EmailService == nil { return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } subject := "[Baron 로그인] 회원가입 인증코드" body := fmt.Sprintf(`

이메일 인증

아래 인증코드를 입력하여 회원가입을 진행해 주세요.

%s

이 코드는 5분간 유효합니다.

`, code) go h.EmailService.SendEmail(req.Target, subject, body) return c.JSON(fiber.Map{"message": "Verification code sent"}) } // SendSignupSmsCode - Sends verification code to phone func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { var req domain.SendSignupCodeRequest if err := c.BodyParser(&req); err != nil || req.Target == "" { return errorJSON(c, fiber.StatusBadRequest, "Invalid phone number") } req.Type = "phone" // Sanitize phone phone := strings.ReplaceAll(req.Target, "-", "") key := prefixSignupPhone + phone // 1. Check existing state state, _ := h.getSignupState(key) if state != nil && state.FailCount > maxSignupFailures { return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.") } // 2. Generate Code rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) // 3. Save State newState := &signupState{ Code: code, Verified: false, FailCount: 0, ExpiresAt: time.Now().Add(smsCodeTTL).Unix(), } if state != nil { newState.FailCount = state.FailCount } h.saveSignupState(key, newState, signupStateExpiration) // 4. Send SMS content := fmt.Sprintf("[Baron 로그인] 인증번호 [%s]를 입력해주세요.", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "Verification code sent"}) } var affiliateSlugs = map[string]bool{ "hanmac": true, "saman": true, "ptc": true, "jangheon": true, "baron": true, "halla": true, } func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) { if h.TenantService == nil { return false, nil } tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName) if err != nil || tenant == nil { return false, nil } // [Strict] Check if the slug belongs to the predefined family company slugs return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant } // VerifySignupCode - Verifies the code for email or phone func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { var req domain.VerifySignupCodeRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } var key string if req.Type == "email" { key = prefixSignupEmail + req.Target } else if req.Type == "phone" { phone := strings.ReplaceAll(req.Target, "-", "") key = prefixSignupPhone + phone } else { return errorJSON(c, fiber.StatusBadRequest, "Invalid type") } state, err := h.getSignupState(key) if err != nil || state == nil { return errorJSON(c, fiber.StatusBadRequest, "Verification session expired or not found") } // Check Verified if state.Verified { return c.JSON(fiber.Map{"success": true, "message": "Already verified"}) } // Check Attempts if state.FailCount > maxSignupFailures { return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts") } // Check Code match (Allow magic code 000000 in non-production environments) isMagicCodeAllowed := service.IsDryRunAllowed() && req.Code == "000000" if state.Code != req.Code && !isMagicCodeAllowed { state.FailCount++ h.saveSignupState(key, state, signupStateExpiration) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "error": "Invalid code", "code": "invalid_code", "failCount": state.FailCount, }) } // Check Expiry (Logic time vs stored time) if time.Now().Unix() > state.ExpiresAt { return errorJSON(c, fiber.StatusBadRequest, "Code expired") } // Success state.Verified = true h.saveSignupState(key, state, signupStateExpiration) // [New] Check if this is a family affiliate domain to let frontend lock the choice isAffiliate := false parts := strings.Split(req.Target, "@") if req.Type == "email" && len(parts) == 2 { isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1]) } return c.JSON(fiber.Map{ "success": true, "isAffiliate": isAffiliate, }) } // Signup - Finalize registration // GetActiveTenants - List active tenants ONLY if the email is verified in Redis func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { if h.TenantService == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable") } email := c.Query("email") if email == "" { // No email provided, return empty list (Security policy) return c.JSON([]any{}) } // 1. Verify Verification Status in Redis emailKey := prefixSignupEmail + email state, _ := h.getSignupState(emailKey) if state == nil || !state.Verified { slog.Warn("[GetActiveTenants] Unverified access attempt", "email", email) return errorJSON(c, fiber.StatusForbidden, "Email verification is required before selecting an organization.") } // 2. Extract domain from verified email parts := strings.Split(email, "@") if len(parts) != 2 { return c.JSON([]any{}) } domainName := parts[1] // [Policy] Verify if the email belongs to any family affiliate domain isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) if !isInternal { // If not an affiliate email, do not show any tenants return c.JSON([]any{}) } // 3. List and Filter Tenants tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants") } type tenantResp struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Type string `json:"type"` Domains []string `json:"domains"` } var results []tenantResp for _, t := range tenants { // [Strict] Only allow choosing defined family company slugs if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] { continue } var domains []string for _, d := range t.Domains { domains = append(domains, d.Domain) } results = append(results, tenantResp{ ID: t.ID, Name: t.Name, Slug: t.Slug, Type: t.Type, Domains: domains, }) } return c.JSON(results) } func (h *AuthHandler) Signup(c *fiber.Ctx) error { var req domain.SignupRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } // 1. Validate Fields (Simple validation) if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" { return errorJSON(c, fiber.StatusBadRequest, "Missing required fields") } if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") } if !req.TermsAccepted { return errorJSON(c, fiber.StatusBadRequest, "Terms must be accepted") } // 비밀번호 정책 검증 policy := h.resolvePasswordPolicy() if err := validatePasswordWithPolicy(policy, req.Password); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } // 2. Verify Auth Status (Redis) emailKey := prefixSignupEmail + req.Email phoneKey := prefixSignupPhone + strings.ReplaceAll(req.Phone, "-", "") emailState, _ := h.getSignupState(emailKey) phoneState, _ := h.getSignupState(phoneKey) if emailState == nil || !emailState.Verified { return errorJSON(c, fiber.StatusUnauthorized, "Email not verified") } if phoneState == nil || !phoneState.Verified { return errorJSON(c, fiber.StatusUnauthorized, "Phone not verified") } if h.IdpProvider == nil { return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable") } if strings.TrimSpace(req.CompanyCode) != "" { return errorJSON(c, fiber.StatusBadRequest, "companyCode is deprecated; use tenantSlug") } // 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다. // 모든 온라인 가입자는 기본적으로 개인(Personal) 테넌트 소속으로 가입합니다. // 기업/가족사 소속 연동은 별도 문의를 통해 처리되므로 온라인 가입 흐름에서는 제외합니다. req.AffiliationType = "GENERAL" slog.Info("[Signup] Forcing AffiliationType to GENERAL (Default personal tenant signup policy)", "email", req.Email) var tenantID *string tenant, err := h.TenantService.GetTenantBySlug(c.Context(), "personal") if err != nil || tenant == nil { // Fallback: 만약 시드된 personal 테넌트가 없을 경우 개인별 테넌트를 자동 생성합니다. tenant, err = createPersonalTenantForUser(c.Context(), h.TenantService, req.Email) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, "failed to resolve personal tenant") } } tenantID = &tenant.ID // Normalize Phone (E.164 형태로 보관) normalizedPhone := domain.NormalizePhoneNumber(req.Phone) slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone) // IDP에 전달할 BrokerUser 스키마 구성 attributes := map[string]any{ "department": req.Department, "affiliationType": req.AffiliationType, "grade": "", "role": domain.RoleUser, } if tenantID != nil { attributes["tenant_id"] = *tenantID } // Sync all custom login IDs based on tenant schemas loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") // Validate all collected LoginIDs if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok { for _, lid := range collectedIDs { if err := domain.ValidateLoginID(lid, []string{req.Email}, normalizedPhone); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error()) } } } brokerUser := &domain.BrokerUser{ Email: req.Email, Name: req.Name, PhoneNumber: normalizedPhone, Attributes: attributes, } providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return errorJSON(c, fiber.StatusNotImplemented, "Signup method not supported") } slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err) if strings.Contains(err.Error(), "already exists") { return errorJSON(c, fiber.StatusConflict, "User or login identifier already exists") } // Include the actual error message in the response for debugging return errorJSON(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err)) } // 4. Cleanup Redis h.RedisService.Delete(emailKey) h.RedisService.Delete(phoneKey) slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID) // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. // 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다. localUser := &domain.User{ ID: providerID, Email: req.Email, Name: req.Name, Phone: normalizedPhone, Role: "user", AffiliationType: req.AffiliationType, Department: req.Department, Status: "active", CreatedAt: time.Now(), UpdatedAt: time.Now(), } if tenantID != nil { localUser.TenantID = tenantID } // Merge metadata localUser.Metadata = make(domain.JSONMap) maps.Copy(localUser.Metadata, req.Metadata) if h.UserRepo != nil { go func(u *domain.User, ids []domain.UserLoginID) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.UserRepo.Update(ctx, u); err != nil { slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err) } else { slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email) // Update User Login IDs for i := range ids { ids[i].UserID = u.ID } if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil { slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err) } // [Keto] Sync user-tenant relationship via Outbox if h.KetoOutboxRepo != nil && u.TenantID != nil { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: *u.TenantID, Relation: "members", Subject: "User:" + u.ID, Action: domain.KetoOutboxActionCreate, }) } } }(localUser, loginIDRecords) } return c.JSON(fiber.Map{ "success": true, "message": "User registered successfully", "provider": h.IdpProvider.Name(), "subject": providerID, }) } // --- Helpers --- func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { authHeader := c.Get("Authorization") if authHeader == "" { return "" } parts := strings.Split(authHeader, " ") if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { return "" } return parts[1] } func firstForwardedValue(raw string) string { for part := range strings.SplitSeq(raw, ",") { value := strings.TrimSpace(part) if value != "" { return value } } return "" } func forwardedDirective(raw, key string) string { for group := range strings.SplitSeq(raw, ",") { for directive := range strings.SplitSeq(group, ";") { pair := strings.SplitN(strings.TrimSpace(directive), "=", 2) if len(pair) != 2 { continue } if !strings.EqualFold(strings.TrimSpace(pair[0]), key) { continue } return strings.Trim(strings.TrimSpace(pair[1]), "\"") } } return "" } func normalizedAbsoluteBaseURL(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } parsed, err := url.Parse(trimmed) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "" } parsed.RawQuery = "" parsed.Fragment = "" return strings.TrimRight(parsed.String(), "/") } func forwardedRequestHost(c *fiber.Ctx) string { if c == nil { return "" } if host := firstForwardedValue(c.Get("X-Forwarded-Host")); host != "" { return host } if host := forwardedDirective(c.Get("Forwarded"), "host"); host != "" { return host } return "" } func forwardedRequestProto(c *fiber.Ctx) string { if c == nil { return "" } if proto := firstForwardedValue(c.Get("X-Forwarded-Proto")); proto != "" { return strings.ToLower(proto) } if proto := forwardedDirective(c.Get("Forwarded"), "proto"); proto != "" { return strings.ToLower(proto) } return "" } func resolvePublicRequestBaseURL(c *fiber.Ctx, configuredBaseURL string) string { if base := normalizedAbsoluteBaseURL(configuredBaseURL); base != "" { return base } if c == nil { return "" } host := forwardedRequestHost(c) proto := forwardedRequestProto(c) if host != "" && proto != "" { return fmt.Sprintf("%s://%s", proto, host) } base := strings.TrimRight(strings.TrimSpace(c.BaseURL()), "/") if base != "" { return base } host = strings.TrimSpace(c.Get("Host")) if host == "" { host = strings.TrimSpace(c.Hostname()) } proto = strings.ToLower(strings.TrimSpace(c.Protocol())) if host == "" || proto == "" { return "" } return fmt.Sprintf("%s://%s", proto, host) } func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string { envURL := os.Getenv("USERFRONT_URL") if envURL == "" { envURL = "http://sso.hmac.kr" } baseURL := resolvePublicRequestBaseURL(c, "") host := strings.TrimSpace(forwardedRequestHost(c)) if host == "" { host = strings.TrimSpace(c.Hostname()) } if host == "" || (host == "localhost" && os.Getenv("APP_ENV") != "dev") { return strings.TrimRight(envURL, "/") } if baseURL == "" { return strings.TrimRight(envURL, "/") } envParsed, envErr := url.Parse(strings.TrimRight(envURL, "/")) baseParsed, baseErr := url.Parse(strings.TrimRight(baseURL, "/")) if envErr == nil && baseErr == nil && strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) && envParsed.Scheme == "https" && baseParsed.Scheme == "http" { return strings.TrimRight(envURL, "/") } if os.Getenv("APP_ENV") == "dev" && envErr == nil && baseErr == nil && strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) && (envParsed.Hostname() == "localhost" || envParsed.Hostname() == "127.0.0.1") && envParsed.Port() != "" && baseParsed.Port() == "" { return strings.TrimRight(envURL, "/") } return baseURL } func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error { tenantID, _ := c.Locals("tenant_id").(string) if tenantID == "" { return c.JSON(fiber.Map{ "isCentral": true, }) } tenant, err := h.TenantService.GetTenant(c.Context(), tenantID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "Tenant not found") } res := fiber.Map{ "isCentral": false, "id": tenant.ID, "name": tenant.Name, "slug": tenant.Slug, "description": tenant.Description, "type": tenant.Type, } if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" { res["loginIdField"] = loginIdField // Find label in userSchema if schema, ok := tenant.Config["userSchema"].([]any); ok { for _, field := range schema { if f, ok := field.(map[string]any); ok { if f["key"] == loginIdField { res["loginIdLabel"] = f["label"] break } } } } } return c.JSON(res) } // normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다. func normalizePhoneForLoginID(phone string) string { return domain.NormalizePhoneNumber(phone) } func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any { claims := map[string]any{} if traits == nil { return claims } if tenantID == "" { tenantID = representativeTenantIDFromTraits(traits) } includeTenantDetails := tenantClaimScopeRequested(scopes) scopeSet := map[string]struct{}{} for _, scope := range scopes { scope = strings.TrimSpace(scope) if scope == "" { continue } scopeSet[scope] = struct{}{} } getString := func(key string) string { raw, ok := traits[key] if !ok || raw == nil { return "" } switch value := raw.(type) { case string: return strings.TrimSpace(value) default: return strings.TrimSpace(fmt.Sprint(value)) } } displayName := getString("displayname") if displayName == "" { displayName = getString("name") } if displayName != "" { claims["name"] = displayName } primaryEmail := getString("primary_email") if primaryEmail == "" { primaryEmail = getString("email") } if primaryEmail != "" { claims["email"] = primaryEmail } if _, ok := scopeSet["profile"]; ok { profile := map[string]any{} names := map[string]any{} for _, key := range []string{ "name", "displayname", "preferred_username", "given_name", "family_name", "middle_name", "nickname", } { if value := getString(key); value != "" { names[key] = value } } if len(names) > 0 { profile["names"] = names } emails := collectEmailList(traits, primaryEmail) if len(emails) > 0 { profile["emails"] = emails } if len(profile) > 0 { claims["profile"] = profile } for _, key := range []string{ "department", "affiliationType", "companyCode", "displayname", "team", "grade", "familyCompany", "taxCode", "familyUniqueKey", "personal", } { if raw, ok := traits[key]; ok && raw != nil { switch value := raw.(type) { case string: if strings.TrimSpace(value) != "" { claims[key] = strings.TrimSpace(value) } default: claims[key] = value } } } } if _, ok := scopeSet["phone"]; ok { if phone := getString("phone_number"); phone != "" { claims["phone_number"] = phone } } // [New] Dynamic Claim Injection for Multi-tenancy if tenantID != "" { claims["tenant_id"] = tenantID if includeTenantDetails { // tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다. if namespaced, ok := traits[tenantID].(map[string]any); ok { maps.Copy(claims, sanitizeTenantClaimMetadata(namespaced)) } } } // [Update] Pass ALL tenants the user belongs to allTenants := map[string]any{} joinedTenants := joinedTenantIDsFromTraits(traits, tenantID) // Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant for k, v := range traits { if isReservedTenantTraitKey(k) { continue } if m, ok := v.(map[string]any); ok { allTenants[k] = sanitizeTenantClaimMetadata(m) } } if len(joinedTenants) > 0 { claims["joined_tenants"] = joinedTenants } if includeTenantDetails && len(allTenants) > 0 { claims["tenants"] = allTenants } return applyGlobalCustomClaims(claims, traits) } func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any { if claims == nil { claims = map[string]any{} } sessionID = strings.TrimSpace(sessionID) if sessionID != "" { claims["session_id"] = sessionID claims["sid"] = sessionID } return claims } func hydraRefreshTokenTTL() time.Duration { raw := strings.TrimSpace(os.Getenv("HYDRA_REFRESH_TOKEN_TTL")) if raw == "" { return defaultRefreshTokenTTL } ttl, err := time.ParseDuration(raw) if err != nil || ttl <= 0 { slog.Warn("invalid HYDRA_REFRESH_TOKEN_TTL, falling back to default", "value", raw, "default", defaultRefreshTokenTTL.String(), "error", err) return defaultRefreshTokenTTL } return ttl } func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[string]any { if claims == nil { claims = map[string]any{} } claims["rt_expires_at"] = issuedAt.Add(hydraRefreshTokenTTL()).Unix() return claims } func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any { claims := buildOidcClaimsFromTraits(traits, scopes, tenantID) claims = applyConfiguredIDTokenClaims(claims, client.Metadata) claims = withRefreshTokenExpiryClaim(claims, time.Now()) return withOidcSessionMetadata(claims, sessionID) } func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any { if baseClaims == nil { baseClaims = map[string]any{} } if traits == nil { return baseClaims } rawClaims, ok := traits["global_custom_claims"] if !ok || rawClaims == nil { return baseClaims } customClaims, ok := rawClaims.(map[string]any) if !ok { return baseClaims } for key, value := range customClaims { key = strings.TrimSpace(key) if key == "" || value == nil { continue } if isReservedTopLevelCustomClaimKey(key) { continue } if _, exists := baseClaims[key]; exists { continue } baseClaims[key] = value } return baseClaims } func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims map[string]any, traits map[string]any, scopes []string) map[string]any { if claims == nil { claims = map[string]any{} } if h == nil || h.TenantService == nil { return claims } appointments := tenantClaimAppointmentsFromTraits(traits) includeTenantDetails := tenantClaimScopeRequested(scopes) tenants, hadTenantClaims := claims["tenants"].(map[string]any) if !hadTenantClaims { tenants = map[string]any{} } createdTenantClaims := map[string]bool{} if tenantID := tenantClaimString(claims, "tenant_id"); tenantID != "" { if _, exists := tenants[tenantID]; !exists { tenants[tenantID] = map[string]any{} createdTenantClaims[tenantID] = true } } for _, tenantKey := range tenantClaimAppointmentPrimaryKeys(appointments) { if _, exists := tenants[tenantKey]; !exists { tenants[tenantKey] = map[string]any{} createdTenantClaims[tenantKey] = true } } if len(tenants) == 0 { return claims } leadTenantIDs := make([]string, 0) joinedTenantIDs := make([]string, 0) for tenantKey, rawTenantClaim := range tenants { tenantClaim, ok := rawTenantClaim.(map[string]any) if !ok { continue } tenantClaim = sanitizeTenantClaimMetadata(tenantClaim) tenant, ancestors, inHanmacFamily := h.resolveHanmacFamilyTenantClaimAncestry(ctx, tenantKey) if !inHanmacFamily || tenant == nil { if createdTenantClaims[tenantKey] { delete(tenants, tenantKey) } continue } joinedTenantIDs = append(joinedTenantIDs, tenant.ID) if !includeTenantDetails { if createdTenantClaims[tenantKey] { delete(tenants, tenantKey) } continue } tenantClaim["id"] = tenant.ID tenantClaim["slug"] = tenant.Slug tenantClaim["name"] = tenant.Name tenantClaim["type"] = tenant.Type tenantClaim["ancestors"] = ancestors if len(ancestors) > 0 { tenantClaim["parentTenantId"] = ancestors[0]["id"] } else { tenantClaim["parentTenantId"] = nil } delete(tenantClaim, "parentTenant") if appointment := lookupTenantClaimAppointment(appointments, tenantKey, tenant); appointment != nil { mergeTenantAppointmentClaim(tenantClaim, appointment) } if lead, ok := metadataBoolFromMap(tenantClaim, "lead", "isLead", "isOwner", "isManager"); ok { tenantClaim["lead"] = lead if lead { leadTenantIDs = append(leadTenantIDs, tenant.ID) } } if representative, ok := metadataBoolFromMap(tenantClaim, "representative", "isPrimary", "primary"); ok { tenantClaim["representative"] = representative tenantClaim["isPrimary"] = representative } tenants[tenantKey] = tenantClaim } if len(leadTenantIDs) > 0 { claims["lead_tenants"] = uniqueSortedStrings(leadTenantIDs) } if len(joinedTenantIDs) > 0 { claims["joined_tenants"] = mergeClaimStringList(claims["joined_tenants"], joinedTenantIDs) } if !includeTenantDetails { if !hadTenantClaims { delete(claims, "tenants") } delete(claims, "lead_tenants") return claims } if len(tenants) > 0 { claims["tenants"] = tenants } else if !hadTenantClaims { delete(claims, "tenants") } return claims } func tenantClaimScopeRequested(scopes []string) bool { for _, scope := range scopes { if strings.EqualFold(strings.TrimSpace(scope), "tenant") { return true } } return false } func mergeClaimStringList(raw any, values []string) []string { merged := make([]string, 0, len(values)) switch current := raw.(type) { case []string: merged = append(merged, current...) case []any: for _, item := range current { if s, ok := item.(string); ok { merged = append(merged, s) } } } merged = append(merged, values...) return uniqueSortedStrings(merged) } func tenantClaimAppointmentPrimaryKeys(appointments map[string]map[string]any) []string { if len(appointments) == 0 { return nil } seen := map[string]bool{} keys := make([]string, 0, len(appointments)) for _, appointment := range appointments { for _, key := range []string{"tenantId", "tenant_id", "tenantSlug", "tenant_slug"} { value := tenantClaimString(appointment, key) if value == "" || seen[value] { continue } seen[value] = true keys = append(keys, value) break } } sort.Strings(keys) return keys } func tenantClaimAppointmentsFromTraits(traits map[string]any) map[string]map[string]any { raw := rawAdditionalAppointments(traits) if raw == nil { return nil } items, ok := raw.([]any) if !ok { return nil } appointments := make(map[string]map[string]any) for _, item := range items { appointment, ok := item.(map[string]any) if !ok { continue } for _, key := range []string{"tenantId", "tenant_id", "tenantSlug", "tenant_slug"} { if id := tenantClaimString(appointment, key); id != "" { appointments[id] = appointment } } } return appointments } func rawAdditionalAppointments(traits map[string]any) any { if traits == nil { return nil } if raw, ok := traits["additionalAppointments"]; ok { return raw } if metadata, ok := traits["metadata"].(map[string]any); ok { return metadata["additionalAppointments"] } return nil } func lookupTenantClaimAppointment(appointments map[string]map[string]any, tenantKey string, tenant *domain.Tenant) map[string]any { if len(appointments) == 0 { return nil } for _, key := range []string{tenantKey, tenant.ID, tenant.Slug} { if appointment, ok := appointments[key]; ok { return appointment } } return nil } func mergeTenantAppointmentClaim(tenantClaim map[string]any, appointment map[string]any) { for _, key := range []string{"grade", "jobTitle", "job_title", "position"} { if value := tenantClaimString(appointment, key); value != "" { switch key { case "job_title": tenantClaim["jobTitle"] = value default: tenantClaim[key] = value } } } if lead, ok := metadataBoolFromMap(appointment, "lead", "isLead", "isOwner", "isManager"); ok { tenantClaim["lead"] = lead } if representative, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok { tenantClaim["representative"] = representative tenantClaim["isPrimary"] = representative } } func tenantClaimString(values map[string]any, key string) string { raw, ok := values[key] if !ok || raw == nil { return "" } switch value := raw.(type) { case string: return strings.TrimSpace(value) default: return strings.TrimSpace(fmt.Sprint(value)) } } func (h *AuthHandler) resolveHanmacFamilyTenantClaimAncestry(ctx context.Context, identifier string) (*domain.Tenant, []map[string]any, bool) { tenant, err := h.resolveTenantClaimTenant(ctx, identifier) if err != nil || tenant == nil { return nil, nil, false } if strings.EqualFold(tenant.Slug, hanmacFamilyTenantSlug) { return tenant, []map[string]any{}, true } ancestors := make([]*domain.Tenant, 0) visited := map[string]bool{tenant.ID: true} current := tenant for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" { parentID := strings.TrimSpace(*current.ParentID) if visited[parentID] { return tenant, tenantClaimAncestorSummaries(ancestors), false } visited[parentID] = true parent, err := h.TenantService.GetTenant(ctx, parentID) if err != nil || parent == nil { return tenant, tenantClaimAncestorSummaries(ancestors), false } ancestors = append(ancestors, parent) if strings.EqualFold(parent.Slug, hanmacFamilyTenantSlug) { return tenant, tenantClaimAncestorSummaries(ancestors), true } current = parent } return tenant, tenantClaimAncestorSummaries(ancestors), false } func (h *AuthHandler) resolveTenantClaimTenant(ctx context.Context, identifier string) (*domain.Tenant, error) { identifier = strings.TrimSpace(identifier) if identifier == "" { return nil, errors.New("tenant identifier is required") } if tenant, err := h.TenantService.GetTenant(ctx, identifier); err == nil && tenant != nil { return tenant, nil } return h.TenantService.GetTenantBySlug(ctx, identifier) } func tenantClaimTenantSummary(tenant *domain.Tenant) map[string]any { return map[string]any{ "id": tenant.ID, "slug": tenant.Slug, "name": tenant.Name, "type": tenant.Type, } } func tenantClaimAncestorSummaries(ancestors []*domain.Tenant) []map[string]any { if len(ancestors) == 0 { return []map[string]any{} } items := make([]map[string]any, 0, len(ancestors)) for i, ancestor := range ancestors { item := tenantClaimTenantSummary(ancestor) if i+1 < len(ancestors) { item["parentTenantId"] = ancestors[i+1].ID } else { item["parentTenantId"] = nil } items = append(items, item) } return items } func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]any) map[string]any { if baseClaims == nil { baseClaims = map[string]any{} } if metadata == nil { return baseClaims } rawClaims, ok := metadata[domain.MetadataIDTokenClaims] if !ok || rawClaims == nil { return baseClaims } normalizedClaims, err := normalizeIDTokenClaims(rawClaims) if err != nil { slog.Warn("failed to normalize configured id token claims", "error", err) return baseClaims } rpClaims, _ := baseClaims["rp_claims"].(map[string]any) if rpClaims == nil { rpClaims = map[string]any{} } for _, claim := range normalizedClaims { if claim.Namespace == "rp_claims" && isReservedRPClaimKey(claim.Key) { continue } if claim.Nullable && strings.TrimSpace(claim.Value) == "" { if claim.Namespace == "rp_claims" { rpClaims[claim.Key] = buildRPClaimPayload(nil, claim, nil) continue } if _, exists := baseClaims[claim.Key]; !exists { baseClaims[claim.Key] = nil } continue } value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType) if err != nil { slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err) continue } if claim.Namespace == "rp_claims" { rpClaims[claim.Key] = buildRPClaimPayload(value, claim, nil) continue } if _, exists := baseClaims[claim.Key]; exists { continue } baseClaims[claim.Key] = value } if len(rpClaims) > 0 { baseClaims["rp_claims"] = rpClaims } return baseClaims } func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any { if claims == nil { claims = map[string]any{} } if h == nil || h.RPUserMetadataRepo == nil { return claims } clientID := strings.TrimSpace(client.ClientID) subject = strings.TrimSpace(subject) if clientID == "" || subject == "" { return claims } rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata) if len(rpClaimDefinitions) == 0 { return claims } row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject) if err != nil || row == nil || len(row.Metadata) == 0 { return claims } rpClaims, _ := claims["rp_claims"].(map[string]any) if rpClaims == nil { rpClaims = map[string]any{} } for _, claim := range rpClaimDefinitions { if isReservedRPClaimKey(claim.Key) { continue } raw, ok := row.Metadata[claim.Key] if !ok || raw == nil { continue } value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType) if !ok { slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType) continue } rpClaims[claim.Key] = buildRPClaimPayload(value, claim, row.Metadata[claim.Key+"_permissions"]) } if len(rpClaims) > 0 { claims["rp_claims"] = rpClaims } return claims } func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim { if metadata == nil { return nil } rawClaims, ok := metadata[domain.MetadataIDTokenClaims] if !ok || rawClaims == nil { return nil } normalizedClaims, err := normalizeIDTokenClaims(rawClaims) if err != nil { slog.Warn("failed to normalize rp claim definitions", "error", err) return nil } definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims)) seen := make(map[string]struct{}, len(normalizedClaims)) for _, claim := range normalizedClaims { if claim.Namespace != "rp_claims" { continue } if _, exists := seen[claim.Key]; exists { continue } seen[claim.Key] = struct{}{} definitions = append(definitions, claim) } return definitions } func isReservedTopLevelCustomClaimKey(key string) bool { return strings.HasPrefix(strings.TrimSpace(key), "rp_") } func isReservedRPClaimKey(key string) bool { key = strings.TrimSpace(key) if strings.HasPrefix(key, "rp_") { return true } switch key { case "", "tenant_id", "tenants", "joined_tenants", "lead_tenants": return true default: return false } } func isReservedTenantTraitKey(key string) bool { key = strings.TrimSpace(key) if strings.HasPrefix(key, "rp_") { return true } switch key { case "metadata", "global_custom_claims", "global_custom_claim_types", "global_custom_claim_permissions": return true default: return false } } func isRPClaimRelatedTenantMetadataKey(key string) bool { return strings.HasPrefix(strings.TrimSpace(key), "rp_") } func sanitizeTenantClaimMetadata(raw map[string]any) map[string]any { cleaned := make(map[string]any, len(raw)) for key, value := range raw { if isRPClaimRelatedTenantMetadataKey(key) { continue } cleaned[key] = sanitizeTenantClaimValue(value) } return cleaned } func sanitizeTenantClaimValue(value any) any { switch typed := value.(type) { case map[string]any: return sanitizeTenantClaimMetadata(typed) case []any: items := make([]any, 0, len(typed)) for _, item := range typed { items = append(items, sanitizeTenantClaimValue(item)) } return items default: return value } } func buildRPClaimPayload(value any, claim normalizedIDTokenClaim, rawPermission any) map[string]any { readPermission := normalizeCustomClaimPermission(claim.ReadPermission) writePermission := normalizeCustomClaimPermission(claim.WritePermission) if permissions, ok := rawPermission.(map[string]any); ok { if rawRead := readInterfaceString(permissions["readPermission"], ""); rawRead != "" { readPermission = normalizeCustomClaimPermission(rawRead) } if rawWrite := readInterfaceString(permissions["writePermission"], ""); rawWrite != "" { writePermission = normalizeCustomClaimPermission(rawWrite) } } if writePermission == "user_and_admin" { readPermission = "user_and_admin" } return map[string]any{ "value": value, "readPermission": readPermission, "writePermission": writePermission, } } func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) { switch value := raw.(type) { case string: if strings.TrimSpace(value) == "" { return nil, false } parsed, err := parseConfiguredClaimValue(value, valueType) return parsed, err == nil case []any: if valueType == "array" { return value, true } case []string: if valueType == "array" { items := make([]any, 0, len(value)) for _, item := range value { items = append(items, item) } return items, true } case map[string]any: if valueType == "object" { return value, true } case bool: if valueType == "boolean" { return value, true } case float64: if valueType == "date" || valueType == "datetime" { if value == math.Trunc(value) { return value, true } return nil, false } if valueType == "float" { return value, true } if valueType == "number" && value == math.Trunc(value) { return value, true } case float32: floatValue := float64(value) if valueType == "date" || valueType == "datetime" { if floatValue == math.Trunc(floatValue) { return floatValue, true } return nil, false } if valueType == "float" { return floatValue, true } if valueType == "number" && floatValue == math.Trunc(floatValue) { return floatValue, true } case int: if valueType == "date" || valueType == "datetime" { return float64(value), true } if valueType == "number" { return float64(value), true } if valueType == "float" { return float64(value), true } case int64: if valueType == "date" || valueType == "datetime" { return float64(value), true } if valueType == "number" { return float64(value), true } if valueType == "float" { return float64(value), true } case json.Number: if valueType == "date" || valueType == "datetime" { parsed, err := value.Int64() return float64(parsed), err == nil } if valueType == "number" { parsed, err := value.Int64() return float64(parsed), err == nil } if valueType == "float" { parsed, err := value.Float64() return parsed, err == nil } } parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType) return parsed, err == nil } func collectEmailList(traits map[string]any, primaryEmail string) []string { emails := make([]string, 0) seen := make(map[string]struct{}) add := func(value string) { value = strings.TrimSpace(value) if value == "" { return } if _, ok := seen[value]; ok { return } seen[value] = struct{}{} emails = append(emails, value) } add(primaryEmail) for _, key := range []string{"email", "primary_email"} { if raw, ok := traits[key]; ok { if value, ok := raw.(string); ok { add(value) } } } if raw, ok := traits["emails"]; ok { switch value := raw.(type) { case []string: for _, email := range value { add(email) } case []any: for _, email := range value { add(fmt.Sprint(email)) } } } if raw, ok := traits["sub_email"]; ok { switch value := raw.(type) { case []string: for _, email := range value { add(email) } case []any: for _, email := range value { add(fmt.Sprint(email)) } } } if raw, ok := traits["additional_emails"]; ok { switch value := raw.(type) { case []string: for _, email := range value { add(email) } case []any: for _, email := range value { add(fmt.Sprint(email)) } } } return emails } func buildIdentityLookupCandidates(loginID string) []string { seen := make(map[string]struct{}) candidates := make([]string, 0, 3) add := func(value string) { candidate := strings.TrimSpace(value) if candidate == "" { return } if _, ok := seen[candidate]; ok { return } seen[candidate] = struct{}{} candidates = append(candidates, candidate) } normalized := strings.TrimSpace(loginID) add(normalized) if normalized != "" { add(strings.ToLower(normalized)) } if normalized != "" && !strings.Contains(normalized, "@") { add(normalizePhoneForLoginID(normalized)) } return candidates } func (h *AuthHandler) resolveKratosIdentityID(ctx context.Context, identifiers ...string) (string, error) { if h.KratosAdmin == nil { return "", fmt.Errorf("kratos admin unavailable") } for _, identifier := range identifiers { candidate := strings.TrimSpace(identifier) if candidate == "" { continue } identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(ctx, candidate) if err == nil && identityID != "" { return identityID, nil } } return "", fmt.Errorf("kratos identity not found") } func (h *AuthHandler) resolveKratosIdentityIDFromLoginID(ctx context.Context, loginID string) (string, error) { candidates := buildIdentityLookupCandidates(loginID) return h.resolveKratosIdentityID(ctx, candidates...) } func (h *AuthHandler) getSignupState(key string) (*signupState, error) { val, err := h.RedisService.Get(key) if err != nil || val == "" { return nil, err } var state signupState if err := json.Unmarshal([]byte(val), &state); err != nil { return nil, err } return &state, nil } func (h *AuthHandler) saveSignupState(key string, state *signupState, ttl time.Duration) error { data, err := json.Marshal(state) if err != nil { return err } return h.RedisService.Set(key, string(data), ttl) } // resolvePasswordPolicy는 IDP 정책을 우선 사용하고, 없으면 기본 정책을 반환합니다. func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy { if h.IdpProvider != nil { policy, err := h.IdpProvider.GetPasswordPolicy() if err == nil && policy != nil { return policy } } return &domain.PasswordPolicy{ MinLength: 12, Lowercase: true, Uppercase: false, Number: true, NonAlphanumeric: true, MinCharacterTypes: 0, } } // validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다. func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error { return utils.ValidatePasswordWithPolicy(policy, password) } // GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다. func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error { policy := h.resolvePasswordPolicy() return c.JSON(fiber.Map{ "minLength": policy.MinLength, "lowercase": policy.Lowercase, "uppercase": policy.Uppercase, "number": policy.Number, "nonAlphanumeric": policy.NonAlphanumeric, "minCharacterTypes": policy.MinCharacterTypes, }) } // SendSms sends a verification code via SMS. (Restored for completeness) func (h *AuthHandler) SendSms(c *fiber.Ctx) error { var req domain.SmsRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } slog.Info("[SMS] Sending code", "phoneNumber", req.PhoneNumber) sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) content := fmt.Sprintf("[Baron 로그인] 인증번호: %s", code) h.RedisService.StoreVerificationCode(sanitizedPhone, code) if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } return c.JSON(fiber.Map{"message": "SMS sent successfully"}) } // VerifySms verifies the provided SMS code. (Restored) func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { var req domain.SmsVerifyRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") storedCode, _ := h.RedisService.GetVerificationCode(sanitizedPhone) if storedCode == "" || storedCode != req.Code { return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired code") } h.RedisService.DeleteVerificationCode(sanitizedPhone) if h.IdpProvider == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Authentication service not configured") } loginID := normalizePhoneForLoginID(req.PhoneNumber) authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) return c.JSON(fiber.Map{ "token": authInfo.SessionToken.JWT, "message": "Login successful", }) } // InitEnchantedLink - Custom Implementation (Restored) func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkInitRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Enchanted] Body parse error", "error", err) return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } loginID := strings.ReplaceAll(req.LoginID, "-", "") loginID = strings.ReplaceAll(loginID, " ", "") lookupLoginID := loginID if !strings.Contains(loginID, "@") { lookupLoginID = normalizePhoneForLoginID(loginID) } // 사용자 존재 여부 확인 if h.IdpProvider == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } exists, err := h.IdpProvider.UserExists(lookupLoginID) if err != nil { slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err) return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if !exists { slog.Warn("[Enchanted] User not found", "loginID", loginID) return errorJSON(c, fiber.StatusNotFound, "User not registered") } userfrontURL := h.resolveUserfrontURL(c) if req.URI != "" { userfrontURL = req.URI } drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed() if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() { slog.Warn("[Enchanted] DrySend ignored in production", "loginID", loginID) } if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { keyLoginID := lookupLoginID if init.LoginID != "" { keyLoginID = init.LoginID } if !strings.Contains(loginID, "@") && req.CodeOnly { _ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration) } else { _ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID) } if init.FlowID != "" { _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) } pendingRef := GenerateSecureToken(3) sessionData, _ := json.Marshal(map[string]string{ "status": statusPending, "loginId": keyLoginID, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) intent := loginFlowLink if req.CodeOnly { intent = loginFlowCode } h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration) if drySend { _ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration) if keyLoginID != lookupLoginID { _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, loginCodeExpiration) } } if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID { _ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration) } expiresIn := 0 if !init.ExpiresAt.IsZero() { expiresIn = int(time.Until(init.ExpiresAt).Seconds()) } if expiresIn <= 0 { expiresIn = int(loginCodeExpiration.Seconds()) } return c.JSON(fiber.Map{ "linkId": "Sent", "pendingRef": pendingRef, "maskedEmail": loginID, "mode": init.Mode, "provider": h.IdpProvider.Name(), "expiresIn": expiresIn, "interval": int(minPollInterval.Seconds()), "resendAfter": int(linkResendCooldown.Seconds()), }) } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } // [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정 userCode := GenerateUserCode() token := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3) slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef) // Store in Redis sessionData, _ := json.Marshal(map[string]string{ "status": statusPending, "loginId": lookupLoginID, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration) if drySend { _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration) } intent := loginFlowLink if req.CodeOnly { intent = loginFlowCode } h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration) // Generate Link slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL) link := fmt.Sprintf("%s/verify/%s", userfrontURL, token) // Route based on LoginID type if strings.Contains(loginID, "@") { // Send Email if !drySend && h.EmailService == nil { slog.Error("[Enchanted] Email Service not configured") return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } subject := "[Baron 로그인] 링크" body := fmt.Sprintf(`

Baron 로그인

안녕하세요,

아래 버튼을 클릭하여 로그인을 완료해 주세요. 이 링크는 5분 동안 유효합니다.

로그인 완료하기

간편 코드: %s

만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.

`, link, userCode) if drySend { slog.Info("[Enchanted][DrySend] Email send skipped", "loginID", loginID, "link", link, "userCode", userCode) } else { slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { slog.Error("[Enchanted] Email Failed", "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send Email") } } } else { // Send SMS content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode) if drySend { slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content) } else { slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { slog.Error("[Enchanted] SMS Failed", "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } } } return c.JSON(fiber.Map{ "linkId": "Sent", "pendingRef": pendingRef, "maskedEmail": loginID, "expiresIn": int(defaultExpiration.Seconds()), "interval": int(minPollInterval.Seconds()), "resendAfter": int(linkResendCooldown.Seconds()), "userCode": userCode, }) } // PollEnchantedLink - Check status (Restored) func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { var req domain.EnchantedLinkPollRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } pollKey := prefixPollMeta + "enchanted:" + req.PendingRef if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { return c.JSON(fiber.Map{ "error": "slow_down", "code": "slow_down", "interval": interval, }) } val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return c.JSON(fiber.Map{ "error": "expired_token", "code": "expired_token", }) } var data map[string]string json.Unmarshal([]byte(val), &data) if data["status"] == statusSuccess { if blocked, err := h.rejectSessionSubjectOverwrite(c, data["subject"], data["loginId"]); blocked || err != nil { return err } slog.Info("[Poll] Success", "pendingRef", req.PendingRef) return c.JSON(fiber.Map{ "sessionJwt": data["jwt"], "status": "ok", }) } if data["status"] == "approved" { _, authInfo, err := h.completeApprovedLinkLogin(c, req.PendingRef) if err != nil { return err } if authInfo == nil { return nil } return c.JSON(fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, "status": "ok", }) } return c.JSON(fiber.Map{ "error": "authorization_pending", "code": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } // VerifyMagicLink - Validate token and login (Restored) func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { var req domain.MagicLinkVerifyRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Verify] Body parse error", "error", err) return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } slog.Info("[Verify] Attempting to verify token", "token", req.Token) tokenKey := prefixToken + req.Token val, err := h.RedisService.Get(tokenKey) if err != nil || val == "" { slog.Warn("[Verify] Token not found or expired in Redis", "token", req.Token) return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired token") } var tokenData map[string]string json.Unmarshal([]byte(val), &tokenData) pendingRef := tokenData["pendingRef"] loginID := tokenData["loginId"] slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef) if req.VerifyOnly { c.Locals("auth_timeline_skip", true) if pendingRef == "" || loginID == "" { slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token) return errorJSON(c, fiber.StatusBadRequest, "Invalid session reference") } h.storeLoginApproverMeta(pendingRef, c, defaultExpiration) // 승인 전용: 세션 발급 없이 승인 상태만 기록 sessionData, _ := json.Marshal(map[string]string{ "status": "approved", "loginId": loginID, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) return c.JSON(fiber.Map{ "status": "approved", "pendingRef": pendingRef, "message": "Login approved", }) } if h.IdpProvider == nil { slog.Error("[Verify] IDP Provider is nil") return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { if errors.Is(err, domain.ErrNotSupported) { slog.Warn("[Verify] IDP session issue not supported", "provider", h.IdpProvider.Name()) return errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } slog.Error("[Verify] IDP session issue failed", "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { slog.Error("[Verify] IDP returned empty session") return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil { return err } sessionToken := authInfo.SessionToken.JWT c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) // Write Kratos session cookies to the response h.writeAuthCookies(c, authInfo.SetCookies) sessionID := extractSessionIDFromToken(authInfo.SessionToken) slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef) sessionData := map[string]string{ "status": statusSuccess, "jwt": sessionToken, } if sessionID != "" { sessionData["session_id"] = sessionID } if authInfo.Subject != "" { sessionData["subject"] = authInfo.Subject } if loginID != "" { sessionData["loginId"] = loginID } sessionDataJSON, _ := json.Marshal(sessionData) h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration) h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c) return c.JSON(fiber.Map{ "token": sessionToken, "message": "Login successful", }) } // VerifyLoginCode - Verify Kratos login code and issue session. func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { var req struct { LoginID string `json:"loginId"` Code string `json:"code"` PendingRef string `json:"pendingRef"` VerifyOnly bool `json:"verifyOnly,omitempty"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginCode] Body parse error", "error", err) return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) loginID = strings.ReplaceAll(loginID, " ", "+") if loginID == "" || req.Code == "" { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "loginId and code are required") } lookupLoginID := loginID if !strings.Contains(loginID, "@") { lookupLoginID = normalizePhoneForLoginID(loginID) } smsLookupLoginID := "" if !strings.Contains(loginID, "@") { smsLookupLoginID = lookupLoginID if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + smsLookupLoginID); mapped != "" { lookupLoginID = mapped } } if h.IdpProvider == nil { return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable") } flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID) if err != nil || flowID == "" { return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired") } if req.VerifyOnly { c.Locals("auth_timeline_skip", true) effectiveLoginID := lookupLoginID pendingRef := strings.TrimSpace(req.PendingRef) storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID) if pendingRef == "" { pendingRef = storedRef } else if storedRef != "" && pendingRef != storedRef { return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference") } if pendingRef == "" { return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference") } expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef) expectedCode = normalizeLoginCode(expectedCode) inputCode := normalizeLoginCode(req.Code) if expectedCode == "" || inputCode == "" || inputCode != expectedCode { return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code") } h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration) sessionData, _ := json.Marshal(map[string]string{ "status": "approved", "loginId": effectiveLoginID, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) return c.JSON(fiber.Map{ "status": "approved", "pendingRef": pendingRef, "message": "Login approved", }) } authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") } slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err) return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session") } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID) if resolveErr != nil || subject == "" { slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr) return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } authInfo.Subject = subject if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, lookupLoginID); blocked || err != nil { return err } c.Locals("login_id", lookupLoginID) setSessionIDLocal(c, authInfo.SessionToken) // Write Kratos session cookies to the response h.writeAuthCookies(c, authInfo.SetCookies) h.RedisService.Delete(prefixLoginCode + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) if smsLookupLoginID != "" { h.RedisService.Delete(prefixLoginCodeSmsLookup + smsLookupLoginID) } pendingRef := strings.TrimSpace(req.PendingRef) if pendingRef == "" { storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID) pendingRef = storedRef } if pendingRef != "" { sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": authInfo.SessionToken.JWT, "subject": subject, "loginId": lookupLoginID, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Delete(prefixLoginCodePending + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) if smsLookupLoginID != "" { h.RedisService.Delete(prefixLoginCodeSmsLookup + smsLookupLoginID) } return c.JSON(fiber.Map{ "status": "approved", "pendingRef": pendingRef, "provider": h.IdpProvider.Name(), "subject": subject, "message": "Login approved", }) } return c.JSON(fiber.Map{ "token": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT, "provider": h.IdpProvider.Name(), "subject": subject, "message": "Login successful", }) } // VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session. func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { var req struct { ShortCode string `json:"shortCode"` VerifyOnly bool `json:"verifyOnly,omitempty"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginShortCode] Body parse error", "error", err) return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode)) if shortCode == "" { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "shortCode is required") } val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode) if val == "" { return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code") } var payload shortLoginCodePayload if err := json.Unmarshal([]byte(val), &payload); err != nil { return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Invalid code payload") } if payload.LoginID == "" || payload.Code == "" { return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code") } if req.VerifyOnly { c.Locals("auth_timeline_skip", true) if payload.PendingRef == "" { return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference") } normalizedCode := normalizeLoginCode(payload.Code) if normalizedCode != "" { h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration) } h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration) sessionData, _ := json.Marshal(map[string]string{ "status": "approved", "loginId": payload.LoginID, }) h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Delete(prefixLoginCodeShort + shortCode) return c.JSON(fiber.Map{ "status": "approved", "pendingRef": payload.PendingRef, "message": "Login approved", }) } if h.IdpProvider == nil { return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable") } flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID) if err != nil || flowID == "" { return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired") } authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") } slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err) return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code") } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session") } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID) if resolveErr != nil || subject == "" { slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr) return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } authInfo.Subject = subject if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, payload.LoginID); blocked || err != nil { return err } c.Locals("login_id", payload.LoginID) setSessionIDLocal(c, authInfo.SessionToken) h.RedisService.Delete(prefixLoginCode + payload.LoginID) h.RedisService.Delete(prefixLoginCodeShort + shortCode) h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID) h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID) if payload.PendingRef != "" { sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": authInfo.SessionToken.JWT, "subject": subject, "loginId": payload.LoginID, }) h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Delete(prefixLoginCodePending + payload.LoginID) return c.JSON(fiber.Map{ "status": "approved", "pendingRef": payload.PendingRef, "token": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT, "provider": h.IdpProvider.Name(), "subject": subject, "message": "Login approved", }) } return c.JSON(fiber.Map{ "token": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT, "provider": h.IdpProvider.Name(), "subject": subject, "message": "Login successful", }) } // PasswordLogin - Authenticate a user with login ID and password. func logOidcRedirectSummary(source, redirectTo string) { parsed, err := url.Parse(redirectTo) if err != nil { slog.Warn( "OIDC redirect parse failed", "source", source, "redirectToLength", len(redirectTo), "error", err, ) return } query := parsed.Query() slog.Info( "OIDC redirect summary", "source", source, "redirectToLength", len(redirectTo), "redirectToHost", parsed.Host, "redirectToPath", parsed.Path, "redirectHasLoginVerifier", query.Has("login_verifier"), "redirectHasRedirectURI", query.Has("redirect_uri"), ) } func (h *AuthHandler) authenticatePasswordLogin(ctx context.Context, loginID, password string) (*domain.AuthInfo, error) { if h.IdpProvider == nil { return nil, fmt.Errorf("authentication service not configured") } authInfo, err := h.IdpProvider.SignIn(loginID, password) if err != nil { return nil, err } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(ctx, loginID) if resolveErr != nil || subject == "" { slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr) return nil, fmt.Errorf("failed to resolve user identity") } if err := h.ensureUserActivityAllowed(ctx, subject); err != nil { return nil, err } authInfo.Subject = subject return authInfo, nil } func passwordLoginErrorSpec(err error) (int, string, string) { if err == nil { return fiber.StatusOK, "", "" } if errors.Is(err, domain.ErrNotSupported) { return fiber.StatusNotImplemented, "not_supported", "Login method not supported" } if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") { return fiber.StatusNotFound, "not_found", "User not registered" } if strings.Contains(err.Error(), "failed to resolve user identity") { return fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity" } if strings.Contains(err.Error(), "cannot perform Baron activity") { return fiber.StatusForbidden, "user_status_forbidden", "This user status cannot sign in" } return fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials" } func (h *AuthHandler) ensureUserActivityAllowed(ctx context.Context, userID string) error { if h == nil || h.UserRepo == nil || strings.TrimSpace(userID) == "" { return nil } user, err := h.UserRepo.FindByID(ctx, userID) if err != nil || user == nil { return nil } if !domain.IsBaronActivityAllowedStatus(user.Status) { return fmt.Errorf("user status %s cannot perform Baron activity", domain.NormalizeUserStatus(user.Status)) } return nil } func isUserActivityForbiddenError(err error) bool { return err != nil && strings.Contains(err.Error(), "cannot perform Baron activity") } func headlessAssertionAudiences(c *fiber.Ctx) []string { if c == nil { return nil } path := strings.TrimSpace(c.Path()) if path == "" { return nil } base := resolvePublicRequestBaseURL(c, os.Getenv("BACKEND_PUBLIC_URL")) if base == "" { return []string{path} } return []string{base + path, path} } func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bool { for _, audience := range actual { for _, candidate := range expected { if strings.TrimSpace(audience) == strings.TrimSpace(candidate) { return true } } } return false } func joinHeadlessAudiences(values []string) string { if len(values) == 0 { return "" } trimmed := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } trimmed = append(trimmed, value) } return strings.Join(trimmed, ", ") } func headlessRequestID(c *fiber.Ctx) string { if c == nil { return "" } reqID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID)) if reqID != "" { return reqID } return strings.TrimSpace(c.Get(fiber.HeaderXRequestID)) } func isHeadlessDebugLoggingEnabled() bool { return slog.Default().Enabled(context.Background(), slog.LevelDebug) } func truncateHeadlessLogValue(value string, limit int) string { value = strings.TrimSpace(value) if limit <= 0 || len(value) <= limit { return value } return value[:limit] } func logHeadlessLoginFailure(c *fiber.Ctx, message string, failure *headlessLoginFailure, clientID, loginChallenge string) { if failure == nil { return } args := []any{ "reason_code", failure.code, "client_id", strings.TrimSpace(clientID), "path", c.Path(), } if reqID := headlessRequestID(c); reqID != "" { args = append(args, "req_id", reqID) } if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" { args = append(args, "login_challenge_prefix", trimmedChallenge) } if isHeadlessDebugLoggingEnabled() { keys := make([]string, 0, len(failure.debugFields)) for key := range failure.debugFields { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { args = append(args, key, failure.debugFields[key]) } } level := slog.LevelWarn if failure.status >= 500 { level = slog.LevelError } slog.Log(context.Background(), level, message, args...) } func logHeadlessLoginSuccess(c *fiber.Ctx, clientID, loginChallenge, redirectTo string) { args := []any{ "client_id", strings.TrimSpace(clientID), "path", c.Path(), "response_status", fiber.StatusOK, } if reqID := headlessRequestID(c); reqID != "" { args = append(args, "req_id", reqID) } if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" { args = append(args, "login_challenge_prefix", trimmedChallenge) } parsed, err := url.Parse(redirectTo) if err != nil { args = append(args, "redirect_to_length", len(redirectTo), "redirect_parse_error", err.Error()) slog.Info("headless password login succeeded", args...) return } query := parsed.Query() args = append( args, "redirect_to_length", len(redirectTo), "redirect_to_host", parsed.Host, "redirect_to_path", parsed.Path, "redirect_has_login_verifier", query.Has("login_verifier"), "redirect_has_redirect_uri", query.Has("redirect_uri"), ) slog.Info("headless password login succeeded", args...) } func respondHeadlessLoginFailure(c *fiber.Ctx, failure *headlessLoginFailure) error { if failure == nil { return nil } return errorJSONCode(c, failure.status, failure.code, failure.safeMessage) } func newHeadlessCredentialFailure(status int, code, safeMessage string) *headlessLoginFailure { return newHeadlessLoginFailure( status, code, safeMessage, "headless password login credential authentication failed", nil, ) } func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, bool, error) { if h.HeadlessJWKS == nil { h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.RedisService, nil) } keySet, _, refreshed, err := h.HeadlessJWKS.EnsureFreshKeySet(ctx, client, expectedKid) if err != nil { return nil, refreshed, err } return keySet, refreshed, nil } func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) *headlessLoginFailure { now := time.Now().Unix() expectedAudiences := headlessAssertionAudiences(c) receivedAudiences := []string(claims.Audience) debugFields := map[string]any{ "claim_issuer": claims.Issuer, "claim_subject": claims.Subject, "claim_expires_at": claims.ExpiresAt, "claim_not_before": claims.NotBefore, "claim_issued_at": claims.IssuedAt, "received_audiences": receivedAudiences, "expected_audiences": expectedAudiences, "received_audiences_text": joinHeadlessAudiences(receivedAudiences), "expected_audiences_text": joinHeadlessAudiences(expectedAudiences), } if claims.Issuer != clientID || claims.Subject != clientID { return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_iss_sub", "Client assertion issuer or subject mismatch", "headless password login client assertion claims mismatch", debugFields, ) } if claims.ExpiresAt == 0 || claims.ExpiresAt <= now { return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_expired", "Client assertion has expired", "headless password login client assertion expired", debugFields, ) } if claims.NotBefore != 0 && claims.NotBefore > now { return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_not_before", "Client assertion is not active yet", "headless password login client assertion not active yet", debugFields, ) } if claims.IssuedAt != 0 && claims.IssuedAt > now+60 { return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_iat_future", "Client assertion issued-at time is invalid", "headless password login client assertion issued in the future", debugFields, ) } if !containsHeadlessAudience(expectedAudiences, claims.Audience) { return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_audience", "Client assertion audience mismatch", "headless password login client assertion audience mismatch", debugFields, ) } return nil } func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.HydraClient, clientID, clientAssertion string) *headlessLoginFailure { assertion := strings.TrimSpace(clientAssertion) if assertion == "" { return newHeadlessLoginFailure( fiber.StatusBadRequest, "bad_request", "client_assertion is required", "headless password login client assertion missing", nil, ) } token, err := josejwt.ParseSigned(assertion, []jose.SignatureAlgorithm{ jose.RS256, jose.RS384, jose.RS512, jose.PS256, jose.PS384, jose.PS512, jose.ES256, jose.ES384, jose.ES512, jose.EdDSA, }) if err != nil { return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_parse", "Client assertion format is invalid", "headless password login client assertion parse failed", nil, ) } expectedKid := "" if len(token.Headers) > 0 { expectedKid = strings.TrimSpace(token.Headers[0].KeyID) } keySet, refreshed, err := h.loadHeadlessJWKS(c.Context(), client, expectedKid) if err != nil { slog.Error("failed to load jwks for headless client assertion", "clientID", clientID, "error", err) return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_jwks_load", headlessClientAssertionErrorMessage(err), "headless password login client assertion jwks load failed", map[string]any{ "received_kid": expectedKid, }, ) } matchingKidPresent := expectedKid != "" && containsHeadlessKeyID(keySet, expectedKid) for _, key := range keySet.Keys { if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid { continue } var claims headlessClientAssertionClaims if err := token.Claims(key.Key, &claims); err != nil { continue } if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil { if failure.debugFields == nil { failure.debugFields = map[string]any{} } failure.debugFields["received_kid"] = expectedKid failure.debugFields["jwks_refreshed"] = refreshed return failure } _ = h.HeadlessJWKS.MarkVerificationSuccess(clientID) return nil } if matchingKidPresent && !refreshed && h.HeadlessJWKS != nil { refreshedKeySet, _, refreshErr := h.HeadlessJWKS.ForceRefreshKeySet(c.Context(), client, "signature_verification_failed") if refreshErr == nil && refreshedKeySet != nil { for _, key := range refreshedKeySet.Keys { if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid { continue } var claims headlessClientAssertionClaims if err := token.Claims(key.Key, &claims); err != nil { continue } if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil { if failure.debugFields == nil { failure.debugFields = map[string]any{} } failure.debugFields["received_kid"] = expectedKid failure.debugFields["jwks_refreshed"] = true return failure } _ = h.HeadlessJWKS.MarkVerificationSuccess(clientID) return nil } } } return newHeadlessLoginFailure( fiber.StatusUnauthorized, "invalid_client_assertion_signature", "Client assertion signature verification failed", "headless password login client assertion signature verification failed", map[string]any{ "received_kid": expectedKid, "jwks_refreshed": refreshed, }, ) } func headlessClientAssertionErrorMessage(err error) string { if err == nil { return "Failed to verify client assertion" } message := strings.TrimSpace(err.Error()) switch { case strings.Contains(message, "requires jwksUri"): return "Headless login requires jwksUri. Inline jwks is not supported." case strings.Contains(message, "no keys"): return "Configured jwksUri returned no keys for headless login." case strings.Contains(message, "failed to fetch jwksUri"): return "Failed to refresh headless login jwks from jwksUri." case strings.Contains(message, "failed to decode jwks"): return "Configured jwksUri returned an invalid jwks document." default: return "Failed to verify client assertion" } } func containsHeadlessKeyID(keySet *jose.JSONWebKeySet, expectedKid string) bool { if keySet == nil { return false } for _, key := range keySet.Keys { if strings.TrimSpace(key.KeyID) == strings.TrimSpace(expectedKid) { return true } } return false } func (h *AuthHandler) storeHeadlessLinkState(pendingRef string, state headlessLinkState, ttl time.Duration) { if h.RedisService == nil || pendingRef == "" { return } raw, err := json.Marshal(state) if err != nil { return } _ = h.RedisService.Set(prefixHeadlessLinkState+pendingRef, string(raw), ttl) } func (h *AuthHandler) loadHeadlessLinkState(pendingRef string) (headlessLinkState, bool) { if h.RedisService == nil || pendingRef == "" { return headlessLinkState{}, false } raw, err := h.RedisService.Get(prefixHeadlessLinkState + pendingRef) if err != nil || raw == "" { return headlessLinkState{}, false } var state headlessLinkState if err := json.Unmarshal([]byte(raw), &state); err != nil { return headlessLinkState{}, false } return state, true } func (h *AuthHandler) resolveCurrentBrowserSessionEvidence(c *fiber.Ctx) (string, string) { if h == nil || c == nil { return "", "" } if token := h.getBearerToken(c); token != "" { if identityID, err := h.resolveIdentityID(c, token); err == nil && strings.TrimSpace(identityID) != "" { sessionID, _ := h.getKratosSessionID(token) return strings.TrimSpace(identityID), strings.TrimSpace(sessionID) } } if cookie := strings.TrimSpace(c.Get("Cookie")); cookie != "" { if identityID, _, _, sessionID, err := h.getKratosIdentityWithCookieAndSession(cookie); err == nil && strings.TrimSpace(identityID) != "" { return strings.TrimSpace(identityID), strings.TrimSpace(sessionID) } } return "", "" } func (h *AuthHandler) resolveCurrentBrowserSubject(c *fiber.Ctx) string { identityID, _ := h.resolveCurrentBrowserSessionEvidence(c) return identityID } func (h *AuthHandler) resolveHeadlessOIDCSubjectEvidence(c *fiber.Ctx, loginReq *domain.HydraLoginRequest, pendingRef string) string { if loginReq != nil && loginReq.Skip && strings.TrimSpace(loginReq.Subject) != "" { return strings.TrimSpace(loginReq.Subject) } if currentSubject := h.resolveCurrentBrowserSubject(c); currentSubject != "" { return currentSubject } if meta, ok := h.loadLoginApproverMeta(pendingRef); ok && strings.TrimSpace(meta.ApproverSubject) != "" { return strings.TrimSpace(meta.ApproverSubject) } return "" } func (h *AuthHandler) rejectSessionSubjectOverwrite(c *fiber.Ctx, targetSubject, targetLoginID string) (bool, error) { currentSubject := h.resolveCurrentBrowserSubject(c) if currentSubject == "" { return false, nil } targetSubject = strings.TrimSpace(targetSubject) if targetSubject == "" && strings.TrimSpace(targetLoginID) != "" && h.KratosAdmin != nil { if resolved, err := h.resolveKratosIdentityIDFromLoginID(c.Context(), targetLoginID); err == nil { targetSubject = strings.TrimSpace(resolved) } else { slog.Warn( "session-changing login target subject resolution failed", "loginID", targetLoginID, "error", err, ) } } if targetSubject == "" { slog.Warn("session-changing login blocked because target subject is unknown", "current_subject", currentSubject) return true, errorJSONCode(c, fiber.StatusConflict, "session_subject_conflict", "Current browser session must be signed out before signing in as another user") } if targetSubject != currentSubject { slog.Warn( "session-changing login blocked by subject conflict", "target_subject", targetSubject, "current_subject", currentSubject, ) return true, errorJSONCode(c, fiber.StatusConflict, "session_subject_conflict", "Current browser session must be signed out before signing in as another user") } return false, nil } func (h *AuthHandler) rejectHeadlessOIDCSubjectConflict(c *fiber.Ctx, currentSubject, targetSubject string) (bool, error) { currentSubject = strings.TrimSpace(currentSubject) targetSubject = strings.TrimSpace(targetSubject) if currentSubject == "" || targetSubject == "" || currentSubject == targetSubject { return false, nil } slog.Warn( "headless login blocked by OIDC/UserFront subject conflict", "current_subject", currentSubject, "target_subject", targetSubject, ) return true, c.Status(fiber.StatusConflict).JSON(fiber.Map{ "error": "OIDC/UserFront subject conflicts with headless login target. Sign out of UserFront or restart through the standard UserFront login flow.", "code": "oidc_subject_conflict", "status": "oidc_subject_conflict", "currentSubject": currentSubject, "targetSubject": targetSubject, "recommendedAction": "redirect_to_userfront_login", }) } func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) (string, *domain.AuthInfo, error) { val, err := h.RedisService.Get(prefixSession + pendingRef) if err != nil || val == "" { return "", nil, errorJSON(c, fiber.StatusBadRequest, "Invalid session reference") } var data map[string]string _ = json.Unmarshal([]byte(val), &data) loginID := data["loginId"] if loginID == "" { loginID = data["login_id"] } if loginID == "" { slog.Warn("[Poll] Approved but missing loginId", "pendingRef", pendingRef) return "", nil, errorJSON(c, fiber.StatusBadRequest, "Invalid session reference") } if h.IdpProvider == nil { return "", nil, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } loginStrategy := h.loadLoginStrategy(pendingRef) if loginStrategy == "" { loginStrategy = loginFlowLink } var authInfo *domain.AuthInfo if loginStrategy == loginFlowCode { code, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef) code = normalizeLoginCode(code) if code == "" { slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", pendingRef) return "", nil, errorJSON(c, fiber.StatusBadRequest, "Login code expired") } flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) if flowID == "" { return "", nil, errorJSON(c, fiber.StatusNotFound, "Login flow expired") } authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return "", nil, errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } slog.Error("[Poll] IDP code verify failed", "error", err) return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code") } } else { authInfo, err = h.IdpProvider.IssueSession(loginID) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return "", nil, errorJSON(c, fiber.StatusNotImplemented, "Login method not supported") } slog.Error("[Poll] IDP session issue failed", "error", err) return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil { return "", nil, err } c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) // Write Kratos session cookies to the response h.writeAuthCookies(c, authInfo.SetCookies) sessionID := extractSessionIDFromToken(authInfo.SessionToken) if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" { if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" { sessionID = resolved authInfo.SessionToken.SessionID = resolved setSessionIDLocal(c, authInfo.SessionToken) } } sessionData := map[string]string{ "status": statusSuccess, "jwt": authInfo.SessionToken.JWT, } if sessionID != "" { sessionData["session_id"] = sessionID } if authInfo.Subject != "" { sessionData["subject"] = authInfo.Subject } if loginID != "" { sessionData["loginId"] = loginID } sessionDataJSON, _ := json.Marshal(sessionData) _ = h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration) h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c) h.clearLoginMeta(pendingRef) if loginStrategy == loginFlowCode { _ = h.RedisService.Delete(prefixLoginCode + loginID) _ = h.RedisService.Delete(prefixLoginCodePending + loginID) _ = h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID) _ = h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID) _ = h.RedisService.Delete(prefixLoginCodeValue + pendingRef) } return loginID, authInfo, nil } func (h *AuthHandler) validateHeadlessPasswordLoginClient(loginReq *domain.HydraLoginRequest, clientID string) error { if loginReq == nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request") } if strings.TrimSpace(loginReq.Client.ClientID) != strings.TrimSpace(clientID) { return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use this login request.") } if metadata := loginReq.Client.Metadata; metadata != nil { if status, ok := metadata["status"].(string); ok && strings.ToLower(status) == "inactive" { return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") } } if !loginReq.Client.IsHeadlessLoginEnabled() { return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use headless password login.") } return nil } func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error { var req struct { ClientID string `json:"client_id"` ClientAssertion string `json:"client_assertion"` LoginID string `json:"loginId"` Password string `json:"password"` LoginChallenge string `json:"login_challenge"` } if err := c.BodyParser(&req); err != nil { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } clientID := strings.TrimSpace(req.ClientID) loginID := strings.TrimSpace(req.LoginID) loginChallenge := strings.TrimSpace(req.LoginChallenge) if clientID == "" || loginID == "" || strings.TrimSpace(req.Password) == "" || loginChallenge == "" { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, loginId, password and login_challenge are required") } if h.IdpProvider == nil || h.Hydra == nil { return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") } loginReq, err := h.Hydra.GetLoginRequest(c.Context(), loginChallenge) if err != nil { slog.Error("failed to get hydra login request for headless password login", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request") } if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil { return err } if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil { logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge) return respondHeadlessLoginFailure(c, failure) } authInfo, authErr := h.authenticatePasswordLogin(c.Context(), loginID, req.Password) if authErr != nil { status, code, message := passwordLoginErrorSpec(authErr) logHeadlessLoginFailure( c, "headless password login credential authentication failed", newHeadlessCredentialFailure(status, code, message), clientID, loginChallenge, ) return errorJSONCode(c, status, code, message) } if authInfo == nil || strings.TrimSpace(authInfo.Subject) == "" { return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } if blocked, err := h.rejectHeadlessOIDCSubjectConflict( c, h.resolveHeadlessOIDCSubjectEvidence(c, loginReq, ""), authInfo.Subject, ); blocked || err != nil { return err } c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) attachAuditClientDetails(c, loginReq.Client) appendAuditDetail(c, "login_challenge", loginChallenge) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject) if err != nil { slog.Error("failed to accept hydra login request in headless password login", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } logOidcRedirectSummary("headless_password_login", acceptResp.RedirectTo) if err := c.Status(fiber.StatusOK).JSON(fiber.Map{ "redirectTo": acceptResp.RedirectTo, "status": "ok", "provider": h.IdpProvider.Name(), }); err != nil { return err } logHeadlessLoginSuccess(c, clientID, loginChallenge, acceptResp.RedirectTo) return nil } func (h *AuthHandler) startHeadlessPhoneLink(c *fiber.Ctx, loginID string) (fiber.Map, string, string, time.Duration, error) { rawLoginID := strings.ReplaceAll(loginID, "-", "") rawLoginID = strings.ReplaceAll(rawLoginID, " ", "") if rawLoginID == "" || strings.Contains(rawLoginID, "@") { return nil, "", "", 0, errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "phone-based loginId is required") } lookupLoginID := normalizePhoneForLoginID(rawLoginID) if h.IdpProvider == nil { return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } exists, err := h.IdpProvider.UserExists(lookupLoginID) if err != nil { slog.Warn("[HeadlessLink] IDP user lookup failed", "loginID", rawLoginID, "error", err) return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if !exists { slog.Warn("[HeadlessLink] User not found", "loginID", rawLoginID) return nil, "", "", 0, errorJSON(c, fiber.StatusNotFound, "User not registered") } userfrontURL := h.resolveUserfrontURL(c) if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { keyLoginID := lookupLoginID if init.LoginID != "" { keyLoginID = init.LoginID } if init.FlowID != "" { _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) } pendingRef := GenerateSecureToken(3) sessionData, _ := json.Marshal(map[string]string{ "status": statusPending, "loginId": keyLoginID, }) _ = h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.storeLoginMeta(pendingRef, rawLoginID, "sms", loginFlowLink, loginFlowCode, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration) if keyLoginID != lookupLoginID { _ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration) } expiresIn := int(loginCodeExpiration.Seconds()) if !init.ExpiresAt.IsZero() { if seconds := int(time.Until(init.ExpiresAt).Seconds()); seconds > 0 { expiresIn = seconds } } return fiber.Map{ "pendingRef": pendingRef, "status": "pending", "mode": init.Mode, "provider": h.IdpProvider.Name(), "expiresIn": expiresIn, "interval": int(minPollInterval.Seconds()), "resendAfter": int(linkResendCooldown.Seconds()), }, pendingRef, keyLoginID, loginCodeExpiration, nil } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { slog.Error("[HeadlessLink] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable") } if h.SmsService == nil { return nil, "", "", 0, errorJSON(c, fiber.StatusInternalServerError, "SMS service not configured") } token := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3) sessionData, _ := json.Marshal(map[string]string{ "status": statusPending, "loginId": lookupLoginID, }) _ = h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration) _ = h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration) h.storeLoginMeta(pendingRef, rawLoginID, "sms", loginFlowLink, loginFlowLink, defaultExpiration) link := fmt.Sprintf("%s/verify/%s", strings.TrimRight(userfrontURL, "/"), token) content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s", link) if err := h.SmsService.SendSms(rawLoginID, content); err != nil { slog.Error("[HeadlessLink] SMS send failed", "error", err) return nil, "", "", 0, errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } return fiber.Map{ "pendingRef": pendingRef, "status": "pending", "provider": h.IdpProvider.Name(), "expiresIn": int(defaultExpiration.Seconds()), "interval": int(minPollInterval.Seconds()), "resendAfter": int(linkResendCooldown.Seconds()), }, pendingRef, lookupLoginID, defaultExpiration, nil } func (h *AuthHandler) HeadlessLinkInit(c *fiber.Ctx) error { var req struct { ClientID string `json:"client_id"` ClientAssertion string `json:"client_assertion"` LoginID string `json:"loginId"` LoginChallenge string `json:"login_challenge"` } if err := c.BodyParser(&req); err != nil { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } clientID := strings.TrimSpace(req.ClientID) loginChallenge := strings.TrimSpace(req.LoginChallenge) loginID := strings.TrimSpace(req.LoginID) if clientID == "" || loginChallenge == "" || loginID == "" { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, client_assertion, loginId and login_challenge are required") } if h.Hydra == nil { return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") } loginReq, err := h.Hydra.GetLoginRequest(c.Context(), loginChallenge) if err != nil { slog.Error("failed to get hydra login request for headless link init", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request") } if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil { return err } if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil { logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge) return respondHeadlessLoginFailure(c, failure) } resp, pendingRef, resolvedLoginID, ttl, err := h.startHeadlessPhoneLink(c, loginID) if err != nil { return err } h.storeHeadlessLinkState(pendingRef, headlessLinkState{ ClientID: clientID, ClientName: strings.TrimSpace(loginReq.Client.ClientName), LoginChallenge: loginChallenge, LoginID: resolvedLoginID, }, ttl) return c.JSON(resp) } func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error { var req struct { ClientID string `json:"client_id"` ClientAssertion string `json:"client_assertion"` PendingRef string `json:"pendingRef"` } if err := c.BodyParser(&req); err != nil { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } clientID := strings.TrimSpace(req.ClientID) pendingRef := strings.TrimSpace(req.PendingRef) if clientID == "" || pendingRef == "" { return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, client_assertion and pendingRef are required") } state, ok := h.loadHeadlessLinkState(pendingRef) if !ok { return c.JSON(fiber.Map{ "error": "expired_token", "code": "expired_token", }) } if state.ClientID != clientID { return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use this pending login.") } if state.RedirectTo != "" { return c.JSON(fiber.Map{ "redirectTo": state.RedirectTo, "status": "ok", }) } if h.Hydra == nil { return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") } loginReq, err := h.Hydra.GetLoginRequest(c.Context(), state.LoginChallenge) if err != nil { slog.Error("failed to get hydra login request for headless link poll", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request") } if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil { return err } if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil { logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, state.LoginChallenge) return respondHeadlessLoginFailure(c, failure) } val, err := h.RedisService.Get(prefixSession + pendingRef) if err != nil || val == "" { return c.JSON(fiber.Map{ "error": "expired_token", "code": "expired_token", }) } var session map[string]string _ = json.Unmarshal([]byte(val), &session) if session["status"] == statusPending { return c.JSON(fiber.Map{ "error": "authorization_pending", "code": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } loginID := strings.TrimSpace(state.LoginID) targetSubject := strings.TrimSpace(session["subject"]) if session["status"] == "approved" || session["status"] == statusSuccess { if storedLoginID := strings.TrimSpace(session["loginId"]); storedLoginID != "" { loginID = storedLoginID } else if storedLoginID := strings.TrimSpace(session["login_id"]); storedLoginID != "" { loginID = storedLoginID } } if loginID == "" { return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve approved user identity") } if targetSubject == "" { targetSubject, err = h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) } if err != nil || targetSubject == "" { slog.Error("failed to resolve kratos identity for headless link poll", "loginID", loginID, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve user identity") } if blocked, err := h.rejectHeadlessOIDCSubjectConflict( c, h.resolveHeadlessOIDCSubjectEvidence(c, loginReq, pendingRef), targetSubject, ); blocked || err != nil { return err } acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), state.LoginChallenge, targetSubject) if err != nil { slog.Error("failed to accept hydra login request in headless link poll", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } state.RedirectTo = acceptResp.RedirectTo h.storeHeadlessLinkState(pendingRef, state, defaultExpiration) h.writeLinkAuditLog(loginID, pendingRef, nil, c) h.clearLoginMeta(pendingRef) logOidcRedirectSummary("headless_link_poll", acceptResp.RedirectTo) return c.JSON(fiber.Map{ "redirectTo": acceptResp.RedirectTo, "status": "ok", }) } func (h *AuthHandler) writeAuthCookies(c *fiber.Ctx, cookies []*http.Cookie) { if len(cookies) == 0 { return } host := c.Hostname() domain := "" // IP address or localhost check if ip := net.ParseIP(host); ip != nil || host == "localhost" { domain = host } else { // Extract root domain (e.g., .hmac.kr from sso.hmac.kr) parts := strings.Split(host, ".") if len(parts) >= 2 { domain = "." + strings.Join(parts[len(parts)-2:], ".") } } for _, cookie := range cookies { c.Cookie(&fiber.Cookie{ Name: cookie.Name, Value: cookie.Value, Path: "/", Domain: domain, MaxAge: cookie.MaxAge, Expires: cookie.Expires, Secure: true, HTTPOnly: true, SameSite: "Lax", }) } } func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "login") ale.Operation = "Auth.Password().SignIn" var req struct { LoginID string `json:"loginId"` Password string `json:"password"` LoginChallenge string `json:"login_challenge,omitempty"` } if err := c.BodyParser(&req); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID ale.Log(slog.LevelInfo, "Attempting to login") if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") } authInfo, err := h.authenticatePasswordLogin(c.Context(), loginID, req.Password) if err != nil { status, code, message := passwordLoginErrorSpec(err) ale.Status = status ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name())) return errorJSONCode(c, status, code, message) } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil { ale.Status = fiber.StatusConflict ale.ProviderError = "session_subject_conflict" ale.Log(slog.LevelWarn, "Login blocked by existing browser session") return err } c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) // Write Kratos session cookies to the response h.writeAuthCookies(c, authInfo.SetCookies) if req.LoginChallenge == "" { attachAuditClientDetails(c, domain.HydraClient{ ClientID: "userfront", ClientName: "UserFront", }) } ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) // --- OIDC 로그인 흐름 처리 --- if req.LoginChallenge != "" { slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge) // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) if err == nil && loginReq != nil { attachAuditClientDetails(c, loginReq.Client) if loginReq.Client.Metadata != nil { if status, ok := loginReq.Client.Metadata["status"].(string); ok { if strings.ToLower(status) == "inactive" { slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") } } } } acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject) if err != nil { slog.Error("failed to accept hydra login request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } logOidcRedirectSummary("password_login", acceptResp.RedirectTo) // IMPORTANT: Also return sessionJwt and token during OIDC flow to ensure portal session. return c.JSON(fiber.Map{ "redirectTo": acceptResp.RedirectTo, "status": "ok", "provider": h.IdpProvider.Name(), "sessionJwt": authInfo.SessionToken.JWT, "token": authInfo.SessionToken.JWT, "subject": authInfo.Subject, }) } // --- OIDC 로그인 흐름 처리 끝 --- resp := fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, "token": authInfo.SessionToken.JWT, "status": "ok", "provider": h.IdpProvider.Name(), } if authInfo.RefreshToken != nil { resp["refreshJwt"] = authInfo.RefreshToken.JWT } if authInfo.Subject != "" { resp["subject"] = authInfo.Subject } return c.JSON(resp) } func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { if c == nil { return } clientID := strings.TrimSpace(client.ClientID) if clientID == "" { return } clientName := strings.TrimSpace(client.ClientName) if clientName == "" { clientName = clientID } if client.Metadata != nil { if tid, ok := client.Metadata["tenant_id"].(string); ok && tid != "" { c.Locals("tenant_id", tid) } } c.Locals("audit_details_extra", map[string]any{ "client_id": clientID, "client_name": clientName, }) } func appendAuditDetail(c *fiber.Ctx, key string, value any) { if c == nil || strings.TrimSpace(key) == "" || value == nil { return } extra, _ := c.Locals("audit_details_extra").(map[string]any) if extra == nil { extra = make(map[string]any) } extra[key] = value c.Locals("audit_details_extra", extra) } // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다. func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "initiate") var req domain.PasswordResetInitiateRequest if err := c.BodyParser(&req); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID if loginID == "" { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Login ID is required" ale.Log(slog.LevelWarn, "Login ID missing") return errorJSON(c, fiber.StatusBadRequest, "Login ID is required") } if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is not initialized" ale.Log(slog.LevelError, "IDP Provider is not initialized") return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } userfrontURL := h.resolveUserfrontURL(c) // 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다. // 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다. verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL) // 내부 토큰 발급 + 우리 채널로 전송 resetToken := GenerateSecureToken(32) if resetToken == "" { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Failed to generate reset token" ale.Log(slog.LevelError, "Failed to generate reset token") return errorJSON(c, fiber.StatusInternalServerError, "Failed to generate reset token") } if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to store reset token in Redis") return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token") } resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken) ale.RedirectTo = resetLink ale.Operation = "SendPasswordReset" ale.Log(slog.LevelInfo, "Initiating password reset via internal token") drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed() if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() { ale.Log(slog.LevelWarn, "DrySend ignored in production") } if strings.Contains(loginID, "@") { if !drySend && h.EmailService == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Email service not configured" ale.Log(slog.LevelError, "Email service not configured") return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } subject := "[Baron 로그인] 비밀번호 재설정" body := fmt.Sprintf(`

Baron 로그인 비밀번호 재설정

아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.

비밀번호 재설정

요청하지 않았다면 이 메일을 무시하세요.

`, resetLink) if drySend { ale.Log(slog.LevelInfo, "Email send skipped (dry-send)", slog.String("loginId", loginID), slog.String("link", resetLink)) } else { if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID)) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send reset email") } } } else { resetSms := fmt.Sprintf("[Baron 로그인] 비밀번호 재설정 링크: %s", resetLink) if drySend { ale.Log(slog.LevelInfo, "SMS send skipped (dry-send)", slog.String("loginId", loginID), slog.String("content", resetSms)) } else { if err := h.SmsService.SendSms(loginID, resetSms); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID)) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send reset SMS") } } } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.Log(slog.LevelInfo, "Password reset link sent successfully (internal token)") return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."}) } // VerifyPasswordResetPage - Serves an interstitial page to prevent link scanners from consuming the token. func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error { token := c.Query("token") if token == "" { token = c.Query("t") } if token == "" { token = c.Params("token") } if token == "" { return c.Status(fiber.StatusBadRequest).SendString("Missing token") } // Simple HTML page with a form to trigger the POST request html := fmt.Sprintf(` Baron 로그인 - 비밀번호 재설정

비밀번호 재설정

아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.

`, token) c.Set("Content-Type", "text/html; charset=utf-8") return c.SendString(html) } // ProcessPasswordResetToken - Handles the POST request from the interstitial page. // Verifies the token, sets the refresh token cookie, and redirects to the userfront. func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "verify") ale.Operation = "Verify" // Token comes from Form Body in POST or query token := c.FormValue("token") if token == "" { token = c.Query("token") if token == "" { token = c.Query("t") } } if token == "" { token = c.Params("token") } ale.Token = token if token == "" { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Missing token" ale.Log(slog.LevelWarn, "Missing token in request") return c.Status(fiber.StatusBadRequest).SendString("Missing token") } loginID, err := h.RedisService.Get(prefixPwdResetToken + token) if err != nil || loginID == "" { ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Invalid or expired reset token" ale.Log(slog.LevelWarn, "Reset token invalid or expired") return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired token") } ale.LoginIDs["loginId"] = loginID ale.LoginIDs["loginId_normalized"] = loginID userfrontURL := h.resolveUserfrontURL(c) locale := "ko" acceptLang := c.Get("Accept-Language") if acceptLang != "" { // Accept-Language 헤더는 선호도가 가장 높은 순서대로 쉼표로 구분되어 나열됩니다. (예: ko-KR,ko;q=0.9,en-US;q=0.8) // 따라서 첫 번째 쉼표 이전의 가장 첫 번째 세그먼트가 사용자가 최우선으로 선호하는 언어입니다. firstLang := strings.Split(acceptLang, ",")[0] if strings.Contains(strings.ToLower(firstLang), "en") { locale = "en" } } redirectBase, parseErr := url.Parse(fmt.Sprintf("%s/%s/reset-password", strings.TrimRight(userfrontURL, "/"), locale)) if parseErr != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = parseErr.Error() ale.Log(slog.LevelError, "Failed to compose reset redirect URL") return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL") } query := redirectBase.Query() query.Set("loginId", loginID) query.Set("token", token) redirectBase.RawQuery = query.Encode() redirectURL := redirectBase.String() ale.RedirectTo = redirectURL ale.Status = fiber.StatusFound ale.LatencyMs = time.Since(startTime) ale.Log(slog.LevelInfo, "Token verified, redirecting to userfront") return c.Redirect(redirectURL) } // CompletePasswordReset - 제공된 loginID와 새 비밀번호로 IDP 비밀번호를 업데이트합니다. // 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다. func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "complete") ale.Operation = "UpdateUserPassword" providerName := "unknown" if h.IdpProvider != nil { providerName = h.IdpProvider.Name() } var req struct { NewPassword string `json:"newPassword"` } if err := c.BodyParser(&req); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } // loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다. loginID := strings.TrimSpace(c.Query("loginId")) resetToken := strings.TrimSpace(c.Query("token")) if resetToken != "" { val, err := h.RedisService.Get(prefixPwdResetToken + resetToken) if err != nil || strings.TrimSpace(val) == "" { if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.Token = resetToken ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID) ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use") return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) } ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Invalid or expired reset token" ale.Token = resetToken ale.Log(slog.LevelWarn, "Reset token invalid or expired") return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired reset token") } loginID = strings.TrimSpace(val) ale.Token = resetToken } if loginID != "" && !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } ale.LoginIDs["loginId"] = loginID // 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다. if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" { if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" { ale.ParsedCookieDSRF = dsrfCookie ale.HasCookieDSRF = true } else { ale.HasCookieDSRF = false } } if loginID == "" || req.NewPassword == "" { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Login ID and new password are required" ale.Log(slog.LevelWarn, "Login ID or new password missing") return errorJSON(c, fiber.StatusBadRequest, "Login ID and new password are required") } // 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다. ale.Log(slog.LevelInfo, "Received new password for reset") policy := h.resolvePasswordPolicy() if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelWarn, "Validation failed: "+err.Error()) return errorJSON(c, fiber.StatusBadRequest, err.Error()) } ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName)) if h.IdpProvider == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Failed to update password via IDP") return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password") } ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID)) if resetToken != "" { _ = h.RedisService.Delete(prefixPwdResetToken + resetToken) _ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration) } return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) } // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) qrRef := GenerateSecureAlnumToken(64) if qrRef == "" { qrRef = GenerateSecureToken(16) } // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. userfrontURL := h.resolveUserfrontURL(c) qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef) slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload) // Redis에 초기 상태 저장 (5분 만료) h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute) h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute) h.storeQrMeta(pendingRef, c) return c.JSON(fiber.Map{ "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 "pendingRef": pendingRef, "expiresIn": 300, "interval": int(minPollInterval.Seconds()), }) } // PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다. func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { var req struct { PendingRef string `json:"pendingRef"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid body") } pollKey := prefixPollMeta + "qr:" + req.PendingRef if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "slow_down", "code": "slow_down", "interval": interval, }) } val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return errorJSON(c, fiber.StatusBadRequest, "expired_token") } var data map[string]string json.Unmarshal([]byte(val), &data) if data["status"] == statusSuccess { slog.Info("[QR] Poll Success", "pendingRef", req.PendingRef) return c.JSON(fiber.Map{ "status": "ok", "sessionJwt": data["jwt"], }) } return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "authorization_pending", "code": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) } // ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다. // (이미 로그인된 세션이 필요함) func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { var req struct { PendingRef string `json:"pendingRef"` Token string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용) } if err := c.BodyParser(&req); err != nil { slog.Error("[QR] Scan body parse error", "error", err) return errorJSON(c, fiber.StatusBadRequest, "Invalid body") } rawRef := strings.TrimSpace(req.PendingRef) pendingRef, err := h.resolveQrPendingRef(rawRef) if err != nil || pendingRef == "" { return errorJSON(c, fiber.StatusBadRequest, "Invalid pendingRef") } slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef) // 1. Redis에서 세션 확인 val, err := h.RedisService.Get(prefixSession + pendingRef) if err != nil || val == "" { return errorJSON(c, fiber.StatusNotFound, "Session expired or not found") } if req.Token == "" { cookie := c.Get(fiber.HeaderCookie) if cookie == "" { return errorJSON(c, fiber.StatusUnauthorized, "Missing session token") } _, traits, _, err := h.getKratosIdentityWithCookie(cookie) if err != nil { slog.Warn("[QR] Cookie session invalid", "error", err) return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" { h.storeQrApproverSessionID(pendingRef, sessionID) } loginID := pickLoginIDFromTraits(traits) if loginID == "" { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil { slog.Error("[QR] Start code login failed", "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session") } return c.JSON(fiber.Map{"message": "QR Login Approved"}) } // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급 approvedSessionID := "" if req.Token != "" { if sessionID, err := h.getKratosSessionID(req.Token); err == nil { approvedSessionID = sessionID } } if approvedSessionID != "" { h.storeQrApproverSessionID(pendingRef, approvedSessionID) } loginID, err := h.resolveKratosLoginID(req.Token) if err != nil { slog.Warn("[QR] Invalid token", "error", err) return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil { slog.Error("[QR] Start code login failed", "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session") } return c.JSON(fiber.Map{"message": "QR Login Approved"}) } type kratosCourierRequest struct { Recipient string `json:"recipient"` TemplateType string `json:"template_type"` TemplateData map[string]any `json:"template_data"` Subject string `json:"subject"` Body string `json:"body"` } // HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다. func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { var req kratosCourierRequest if err := c.BodyParser(&req); err != nil { slog.Error("[Kratos Courier] Body parsing failed", "error", err) return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } if req.Recipient == "" { slog.Warn("[Kratos Courier] Missing recipient") return errorJSON(c, fiber.StatusBadRequest, "Missing recipient") } loginID := req.Recipient if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } drySend := false if service.IsDryRunAllowed() { if val, _ := h.RedisService.Get(prefixDrySend + loginID); val != "" { if pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID); pendingRef != "" && pendingRef == val { drySend = true } } } if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" { code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { slog.Error("[QR] Missing login code in courier", "loginID", loginID) return errorJSON(c, fiber.StatusInternalServerError, "Missing login code") } flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) if flowID == "" { slog.Error("[QR] Missing login flow for code verify", "loginID", loginID) return errorJSON(c, fiber.StatusInternalServerError, "Login flow expired") } authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code) if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code") } sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": authInfo.SessionToken.JWT, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "") h.RedisService.Delete(prefixLoginCodeQrPending + loginID) h.RedisService.Delete(prefixLoginCode + loginID) h.RedisService.Delete(prefixLoginCodeQr + pendingRef) slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef) return c.JSON(fiber.Map{"status": "ok"}) } if pendingRef, _ := h.RedisService.Get(prefixQrPending + loginID); pendingRef != "" { code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { slog.Error("[QR] Missing login code in courier", "loginID", loginID) return errorJSON(c, fiber.StatusInternalServerError, "Missing login code") } flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) if flowID == "" { slog.Error("[QR] Missing login flow for code verify", "loginID", loginID) return errorJSON(c, fiber.StatusInternalServerError, "Login flow expired") } authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code) if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code") } sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, "jwt": authInfo.SessionToken.JWT, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "") h.RedisService.Delete(prefixQrPending + loginID) h.RedisService.Delete(prefixLoginCode + loginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID) h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID) slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef) return c.JSON(fiber.Map{"status": "ok"}) } subject, body := h.buildKratosCourierMessage(&req) if strings.TrimSpace(body) == "" { slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType) return errorJSON(c, fiber.StatusBadRequest, "Empty message") } if strings.Contains(req.Recipient, "@") { if target, _ := h.RedisService.Get(prefixLoginCodeSmsTarget + req.Recipient); target != "" { phone := sanitizePhoneForSms(target) smsBody := h.buildKratosShortSmsBody(&req, req.Recipient, phone) if smsBody == "" { smsBody = body } if drySend { slog.Info("[Kratos Courier][DrySend] SMS send skipped (email relay)", "to", phone, "template", req.TemplateType, "content", smsBody) return c.JSON(fiber.Map{"status": "ok"}) } if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType) return c.JSON(fiber.Map{"status": "ok"}) } } if strings.Contains(req.Recipient, "@") { if !drySend && h.EmailService == nil { return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured") } if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" { subject = shortSubject body = shortBody } if drySend { slog.Info("[Kratos Courier][DrySend] Email send skipped", "to", req.Recipient, "template", req.TemplateType, "subject", subject) return c.JSON(fiber.Map{"status": "ok"}) } if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send email") } slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType) return c.JSON(fiber.Map{"status": "ok"}) } if !drySend && h.SmsService == nil { return errorJSON(c, fiber.StatusInternalServerError, "SMS service not configured") } phone := sanitizePhoneForSms(req.Recipient) smsLoginID := req.Recipient if !strings.Contains(smsLoginID, "@") { lookup := normalizePhoneForLoginID(smsLoginID) if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" { smsLoginID = email } else { smsLoginID = lookup } } smsBody := h.buildKratosShortSmsBody(&req, smsLoginID, phone) if smsBody == "" { smsBody = body } if drySend { slog.Info("[Kratos Courier][DrySend] SMS send skipped", "to", phone, "template", req.TemplateType, "content", smsBody) return c.JSON(fiber.Map{"status": "ok"}) } if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS") } slog.Info("[Kratos Courier] SMS sent", "to", phone, "template", req.TemplateType) return c.JSON(fiber.Map{"status": "ok"}) } func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (string, string) { subject := strings.TrimSpace(req.Subject) body := strings.TrimSpace(req.Body) if body != "" || subject != "" { if subject == "" { subject = "[Baron 로그인] 알림" } return subject, body } templateType := strings.ToLower(req.TemplateType) loginCode := extractFirstString(req.TemplateData, "login_code") verificationCode := extractFirstString(req.TemplateData, "verification_code") recoveryCode := extractFirstString(req.TemplateData, "recovery_code") code := firstNonEmpty(loginCode, verificationCode, recoveryCode, extractFirstString(req.TemplateData, "code")) label := "알림" if loginCode != "" || strings.Contains(templateType, "login") { label = "로그인" } else if verificationCode != "" || strings.Contains(templateType, "verification") { label = "인증" } else if recoveryCode != "" || strings.Contains(templateType, "recovery") { label = "복구" } else if strings.Contains(templateType, "code") { label = "인증" } if subject == "" { if label == "알림" { subject = "[Baron 로그인] 알림" } else { subject = fmt.Sprintf("[Baron 로그인] %s 코드", label) } } if loginCode != "" && label == "로그인" { loginID := req.Recipient if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) if pendingRef != "" { normalizedCode := normalizeLoginCode(loginCode) if normalizedCode != "" { _ = h.RedisService.Set(prefixLoginCodeValue+pendingRef, normalizedCode, loginCodeExpiration) } } } if code == "" { return subject, fmt.Sprintf("[Baron 로그인] %s 요청이 도착했습니다", label) } message := fmt.Sprintf("[Baron 로그인] %s 코드: %s", label, code) if label == "로그인" { baseURL := os.Getenv("USERFRONT_URL") if baseURL == "" { baseURL = "http://localhost:5000" } baseURL = strings.TrimRight(baseURL, "/") loginID := req.Recipient if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) if pendingRef != "" { message = fmt.Sprintf("%s | 링크: %s/verify?loginId=%s&code=%s&pendingRef=%s", message, baseURL, url.QueryEscape(req.Recipient), url.QueryEscape(code), url.QueryEscape(pendingRef), ) return subject, message } link := fmt.Sprintf("%s/verify?loginId=%s&code=%s", baseURL, url.QueryEscape(req.Recipient), url.QueryEscape(code), ) message = fmt.Sprintf("%s | 링크: %s", message, link) } return subject, message } type shortLoginCodePayload struct { LoginID string `json:"loginId"` Code string `json:"code"` PendingRef string `json:"pendingRef"` } func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { shortCode, link, ok := h.prepareKratosShortLogin(req, loginID) if !ok { return "" } if h.isSmsCodeOnly(loginID) { return fmt.Sprintf("[Baron 로그인] 로그인 코드: %s", shortCode) } return fmt.Sprintf("[Baron 로그인] %s", link) } func (h *AuthHandler) buildKratosShortEmailBody(req *kratosCourierRequest, loginID string) (string, string) { shortCode, link, ok := h.prepareKratosShortLogin(req, loginID) if !ok { return "", "" } subject := "[Baron 로그인] 로그인 링크" body := fmt.Sprintf(`

Baron 로그인

아래 버튼을 클릭하여 로그인을 완료해 주세요.

로그인 완료하기

간편 코드: %s

링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.

`, link, shortCode) return subject, body } func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID string) (string, string, bool) { if req == nil || loginID == "" { return "", "", false } code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { return "", "", false } shortCode := h.generateShortCode(code) if shortCode == "" { return "", "", false } pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) payload := shortLoginCodePayload{ LoginID: loginID, Code: code, PendingRef: pendingRef, } raw, _ := json.Marshal(payload) _ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration) baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") if baseURL == "" { baseURL = "http://localhost:5000" } link := fmt.Sprintf("%s/l/%s", baseURL, shortCode) return shortCode, link, true } func (h *AuthHandler) isSmsCodeOnly(loginID string) bool { if loginID == "" { return false } val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID) return val != "" } func (h *AuthHandler) generateShortCode(code string) string { const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for range 10 { b := make([]byte, 2) if _, err := crand.Read(b); err != nil { break } prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)]) shortCode := prefix + code if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" { return shortCode } } return "" } func normalizeLoginCode(code string) string { if code == "" { return "" } digits := make([]rune, 0, len(code)) for _, ch := range code { if ch >= '0' && ch <= '9' { digits = append(digits, ch) } } if len(digits) < 6 { return "" } if len(digits) > 6 { digits = digits[:6] } return string(digits) } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func extractFirstString(data map[string]any, keys ...string) string { if data == nil { return "" } for _, key := range keys { if val, ok := data[key]; ok { if str, ok := val.(string); ok && str != "" { return str } // Handle numeric types by converting to string if num, ok := val.(float64); ok { return fmt.Sprint(num) } if num, ok := val.(int); ok { return fmt.Sprint(num) } } } return "" } func sanitizePhoneForSms(phone string) string { sanitized := domain.NormalizePhoneNumber(phone) if strings.HasPrefix(sanitized, "+82") { sanitized = "0" + sanitized[3:] } sanitized = strings.ReplaceAll(sanitized, "-", "") sanitized = strings.ReplaceAll(sanitized, " ", "") return sanitized } // --- User Profile Handlers --- func (h *AuthHandler) formatPhoneForDisplay(phone string) string { if strings.HasPrefix(phone, "+8210") { return "010" + phone[5:] } return phone } func (h *AuthHandler) formatPhoneForStorage(phone string) string { return domain.NormalizePhoneNumber(phone) } // GetMe - Returns current user's profile with enriched data from local DB func (h *AuthHandler) GetMe(c *fiber.Ctx) error { profile, err := h.resolveCurrentProfile(c) if err != nil { if isUserActivityForbiddenError(err) { return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity") } return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } return c.JSON(profile) } func (h *AuthHandler) resolveProfileForSubject(ctx context.Context, subject string) (*domain.UserProfileResponse, error) { subject = strings.TrimSpace(subject) if subject == "" || h.KratosAdmin == nil { return nil, fmt.Errorf("subject profile unavailable") } identity, err := h.KratosAdmin.GetIdentity(ctx, subject) if err != nil { return nil, err } if identity == nil { return nil, fmt.Errorf("identity not found") } profile := h.mapKratosIdentityToProfile(identity.ID, identity.Traits) if profile == nil { return nil, fmt.Errorf("failed to map identity profile") } return h.hydrateResolvedProfile(ctx, profile), nil } func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domain.UserProfileResponse) *domain.UserProfileResponse { if profile == nil { return nil } profile.Role = domain.NormalizeRole(profile.Role) if profile.Role == "" { profile.Role = domain.RoleUser } if h.TenantService != nil { if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { if tenant, err := h.TenantService.GetTenant(ctx, *profile.TenantID); err == nil { profile.Tenant = tenant } } } if h.TenantService != nil { if profile.Role == "tenant_admin" { manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID) if err == nil { profile.ManageableTenants = manageable } } joined, err := h.TenantService.ListJoinedTenants(ctx, profile.ID) if err == nil { profile.JoinedTenants = joined } } if h.KetoService != nil { subject := "User:" + profile.ID var sp domain.SystemPermissions if profile.Role == "super_admin" { sp = domain.SystemPermissions{ Overview: true, Tenants: true, OrgChart: true, Worksmobile: true, OrySSOT: true, DataIntegrity: true, Users: true, PermissionsDirect: true, AuthGuard: true, ApiKeys: true, AuditLogs: true, ManageOverview: true, ManageTenants: true, ManageOrgChart: true, ManageWorksmobile: true, ManageOrySSOT: true, ManageDataIntegrity: true, ManageUsers: true, ManagePermissionsDirect: true, ManageAuthGuard: true, ManageApiKeys: true, ManageAuditLogs: true, } } else { // Query Keto in parallel for maximum performance type checkResult struct { menu string allowed bool } menus := map[string]string{ "overview": "access_overview", "manage_overview": "manage_overview", "tenants": "access_tenants", "manage_tenants": "manage_tenants", "org_chart": "access_org_chart", "manage_org_chart": "manage_org_chart", "worksmobile": "access_worksmobile", "manage_worksmobile": "manage_worksmobile", "ory_ssot": "access_ory_ssot", "manage_ory_ssot": "manage_ory_ssot", "data_integrity": "access_data_integrity", "manage_data_integrity": "manage_data_integrity", "users": "access_users", "manage_users": "manage_users", "permissions_direct": "access_permissions_direct", "manage_permissions_direct": "manage_permissions_direct", "auth_guard": "access_auth_guard", "manage_auth_guard": "manage_auth_guard", "api_keys": "access_api_keys", "manage_api_keys": "manage_api_keys", "audit_logs": "access_audit_logs", "manage_audit_logs": "manage_audit_logs", } ch := make(chan checkResult, len(menus)) for m, rel := range menus { go func(menuName, relation string) { allowed, _ := h.KetoService.CheckPermission(ctx, subject, "System", "system", relation) ch <- checkResult{menu: menuName, allowed: allowed} }(m, rel) } for range menus { res := <-ch switch res.menu { case "overview": sp.Overview = res.allowed case "manage_overview": sp.ManageOverview = res.allowed case "tenants": sp.Tenants = res.allowed case "manage_tenants": sp.ManageTenants = res.allowed case "org_chart": sp.OrgChart = res.allowed case "manage_org_chart": sp.ManageOrgChart = res.allowed case "worksmobile": sp.Worksmobile = res.allowed case "manage_worksmobile": sp.ManageWorksmobile = res.allowed case "ory_ssot": sp.OrySSOT = res.allowed case "manage_ory_ssot": sp.ManageOrySSOT = res.allowed case "data_integrity": sp.DataIntegrity = res.allowed case "manage_data_integrity": sp.ManageDataIntegrity = res.allowed case "users": sp.Users = res.allowed case "manage_users": sp.ManageUsers = res.allowed case "permissions_direct": sp.PermissionsDirect = res.allowed case "manage_permissions_direct": sp.ManagePermissionsDirect = res.allowed case "auth_guard": sp.AuthGuard = res.allowed case "manage_auth_guard": sp.ManageAuthGuard = res.allowed case "api_keys": sp.ApiKeys = res.allowed case "manage_api_keys": sp.ManageApiKeys = res.allowed case "audit_logs": sp.AuditLogs = res.allowed case "manage_audit_logs": sp.ManageAuditLogs = res.allowed } } } profile.SystemPermissions = &sp } return profile } // GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { return h.resolveCurrentProfile(c) } func looksLikeJWT(token string) bool { return strings.Count(token, ".") == 2 } func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) { if c == nil || token == nil { return } if sessionID := extractSessionIDFromToken(token); sessionID != "" { c.Locals("session_id", sessionID) } } func extractSessionIDFromToken(token *domain.Token) string { if token == nil { return "" } if token.SessionID != "" { return token.SessionID } if token.JWT != "" { return extractSessionIDFromJWT(token.JWT) } return "" } func extractSessionIDFromJWT(token string) string { if !looksLikeJWT(token) { return "" } parts := strings.Split(token, ".") if len(parts) != 3 { return "" } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { payload, err = base64.URLEncoding.DecodeString(parts[1]) if err != nil { return "" } } var claims map[string]any if err := json.Unmarshal(payload, &claims); err != nil { return "" } for _, key := range []string{"sid", "session_id", "sessionId", "jti"} { if raw, ok := claims[key]; ok { switch value := raw.(type) { case string: if value != "" { return value } default: return fmt.Sprint(value) } } } return "" } type qrMeta struct { IPAddress string `json:"ip_address"` UserAgent string `json:"user_agent"` ApproverSubject string `json:"approver_subject,omitempty"` ApproverSessionID string `json:"approver_session_id,omitempty"` } func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) { if h.RedisService == nil || pendingRef == "" || c == nil { return } meta := qrMeta{ IPAddress: extractClientIPFromHeaders(c), UserAgent: c.Get("User-Agent"), } raw, err := json.Marshal(meta) if err != nil { return } _ = h.RedisService.Set(prefixQrMeta+pendingRef, string(raw), 5*time.Minute) } func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) { if h.RedisService == nil || pendingRef == "" { return qrMeta{}, false } val, err := h.RedisService.Get(prefixQrMeta + pendingRef) if err != nil || val == "" { return qrMeta{}, false } var meta qrMeta if err := json.Unmarshal([]byte(val), &meta); err != nil { return qrMeta{}, false } return meta, true } func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) { if h.RedisService == nil || pendingRef == "" || c == nil { return } approverSubject, approverSessionID := h.resolveCurrentBrowserSessionEvidence(c) meta := qrMeta{ IPAddress: extractClientIPFromHeaders(c), UserAgent: c.Get("User-Agent"), ApproverSubject: approverSubject, ApproverSessionID: approverSessionID, } raw, err := json.Marshal(meta) if err != nil { return } _ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl) } func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) { if h.RedisService == nil || pendingRef == "" { return qrMeta{}, false } val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef) if err != nil || val == "" { return qrMeta{}, false } var meta qrMeta if err := json.Unmarshal([]byte(val), &meta); err != nil { return qrMeta{}, false } if meta.IPAddress == "" && meta.UserAgent == "" { return qrMeta{}, false } return meta, true } func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) { if h.RedisService == nil || pendingRef == "" || sessionID == "" { return } _ = h.RedisService.Set(prefixQrApproverSession+pendingRef, sessionID, loginCodeExpiration) } func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string { if h.RedisService == nil || pendingRef == "" { return "" } val, err := h.RedisService.Get(prefixQrApproverSession + pendingRef) if err != nil { return "" } return strings.TrimSpace(val) } func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) { if h.RedisService == nil || pendingRef == "" { return } method := resolveLoginMethod(rawMethod, loginID) if method != "" { _ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl) } if flow != "" { _ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl) } if strategy != "" { _ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl) } if strings.TrimSpace(loginID) != "" { _ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl) } } func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) { if h.RedisService == nil || pendingRef == "" { return "", "", "", "" } method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef) flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef) strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef) rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef) return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID) } func (h *AuthHandler) loadLoginFlow(pendingRef string) string { _, flow, _, _ := h.loadLoginMeta(pendingRef) return flow } func (h *AuthHandler) loadLoginStrategy(pendingRef string) string { _, _, strategy, _ := h.loadLoginMeta(pendingRef) return strategy } func (h *AuthHandler) clearLoginMeta(pendingRef string) { if h.RedisService == nil || pendingRef == "" { return } _ = h.RedisService.Delete(prefixLoginMethod + pendingRef) _ = h.RedisService.Delete(prefixLoginFlow + pendingRef) _ = h.RedisService.Delete(prefixLoginStrategy + pendingRef) _ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef) _ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef) } func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) { if h.AuditRepo == nil || pendingRef == "" { return } meta, ok := h.loadQrMeta(pendingRef) if !ok { meta = qrMeta{ IPAddress: "", UserAgent: "", } } if approvedSessionID == "" { approvedSessionID = h.loadQrApproverSessionID(pendingRef) } sessionID := extractSessionIDFromToken(sessionToken) details := map[string]any{ "path": "/api/v1/auth/qr/approve", "login_id": loginID, "pending_ref": pendingRef, } if sessionID != "" { details["session_id"] = sessionID } if approvedSessionID != "" { details["approved_session_id"] = approvedSessionID } detailsJSON, _ := json.Marshal(details) log := &domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: "", SessionID: sessionID, EventType: "POST /api/v1/auth/qr/approve", Status: "success", IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, Details: string(detailsJSON), AuthMethod: "QR", } _ = h.AuditRepo.Create(log) } func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken *domain.Token, c *fiber.Ctx) { if h.AuditRepo == nil { return } meta := qrMeta{ IPAddress: extractClientIPFromHeaders(c), UserAgent: "", } if c != nil { meta.UserAgent = c.Get("User-Agent") } sessionID := extractSessionIDFromToken(sessionToken) loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef) path := "/api/v1/auth/magic-link/verify" authLabel := "링크" if loginStrategy == loginFlowCode { path = "/api/v1/auth/login/code/verify" } displayFlow := loginFlow if displayFlow == "" { displayFlow = loginStrategy } if displayFlow == loginFlowCode { authLabel = "코드" } else if displayFlow == loginFlowLink { authLabel = "링크" } logLoginID := loginID if rawLoginID != "" { logLoginID = rawLoginID } details := map[string]any{ "path": path, "login_id": logLoginID, "pending_ref": pendingRef, } if sessionID != "" { details["session_id"] = sessionID } if loginMethod != "" { details["login_method"] = loginMethod } if loginFlow != "" { details["login_flow"] = loginFlow } if loginStrategy != "" { details["login_strategy"] = loginStrategy } if rawLoginID != "" && rawLoginID != loginID { details["login_id_effective"] = loginID } if state, ok := h.loadHeadlessLinkState(pendingRef); ok { if strings.TrimSpace(state.ClientID) != "" { details["client_id"] = strings.TrimSpace(state.ClientID) } clientName := strings.TrimSpace(state.ClientName) if clientName == "" && strings.TrimSpace(state.ClientID) != "" { clientName = strings.TrimSpace(state.ClientID) } if clientName != "" { details["client_name"] = clientName } if strings.TrimSpace(state.LoginChallenge) != "" { details["login_challenge"] = strings.TrimSpace(state.LoginChallenge) } } if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok { if approverMeta.IPAddress != "" { details["approved_ip"] = approverMeta.IPAddress } if approverMeta.UserAgent != "" { details["approved_user_agent"] = approverMeta.UserAgent } } detailsJSON, _ := json.Marshal(details) log := &domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: "", SessionID: sessionID, EventType: fmt.Sprintf("POST %s", path), Status: "success", IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, Details: string(detailsJSON), AuthMethod: authLabel, } _ = h.AuditRepo.Create(log) } func extractClientIPFromHeaders(c *fiber.Ctx) string { if c == nil { return "" } return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), 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 } type loginClientInfo struct { ClientID string Name string } func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if h.AuditRepo == nil && h.OathkeeperRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } limit := c.QueryInt("limit", 20) if limit <= 0 { limit = 20 } if limit > 100 { limit = 100 } cursorRaw := strings.TrimSpace(c.Query("cursor")) var cursor *domain.AuditCursor if cursorRaw != "" { var err error cursor, err = parseAuditCursor(cursorRaw) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor") } } profile, err := h.resolveCurrentProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } currentSessionID := "" if token := h.getBearerToken(c); token != "" { currentSessionID = extractSessionIDFromJWT(token) } if currentSessionID == "" { if cookie := c.Get("Cookie"); cookie != "" { if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil { currentSessionID = sessionID } } } 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 { client := session.Client if client.ClientID == "" && session.ConsentRequest != nil { client = session.ConsentRequest.Client } clientID := strings.TrimSpace(client.ClientID) if clientID == "" { continue } name := strings.TrimSpace(client.ClientName) if name == "" { name = clientID } consentAt := time.Time{} if session.AuthenticatedAt != nil { consentAt = *session.AuthenticatedAt } else if session.RequestedAt != nil { consentAt = *session.RequestedAt } else if session.HandledAt != nil { consentAt = *session.HandledAt } 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) fetchLimit := max(limit*10, limit) if fetchLimit > 500 { fetchLimit = 500 } authLogs := make([]domain.AuditLog, 0, fetchLimit) if h.AuditRepo != nil { currentCursor := cursor const maxBatches = 10 for batch := 0; batch < maxBatches && len(authLogs) < fetchLimit; batch++ { logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs") } if len(logs) == 0 { break } var lastScanned *domain.AuditLog for i := range logs { log := logs[i] lastScanned = &log if !isAuthEventType(log.EventType) { continue } if !matchesAuthTimelineUser(log, profile, candidates, currentSessionID) { 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 || lastScanned == nil { break } currentCursor = &domain.AuditCursor{ Timestamp: lastScanned.Timestamp, EventID: lastScanned.EventID, } } } oathkeeperLogs := make([]domain.OathkeeperAccessLog, 0, fetchLimit) if h.OathkeeperRepo != nil && subject != "" { 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 { slog.Warn("Failed to retrieve oathkeeper logs", "error", err) break } 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 { if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) { continue } } oathkeeperLogs = append(oathkeeperLogs, log) if len(oathkeeperLogs) >= fetchLimit { break } } if len(logs) < fetchLimit || lastScanned == nil { break } currentCursor = &domain.AuditCursor{ Timestamp: lastScanned.Timestamp, EventID: oathkeeperEventID(*lastScanned), } } } loginChallengeCache := make(map[string]loginClientInfo) resolveLoginClient := func(challenge string) (loginClientInfo, bool) { challenge = strings.TrimSpace(challenge) if challenge == "" || h.Hydra == nil { return loginClientInfo{}, false } if cached, ok := loginChallengeCache[challenge]; ok { return cached, cached.ClientID != "" } loginReq, err := h.Hydra.GetLoginRequest(c.Context(), challenge) if err != nil || loginReq == nil { loginChallengeCache[challenge] = loginClientInfo{} return loginClientInfo{}, false } clientID := strings.TrimSpace(loginReq.Client.ClientID) if clientID == "" { loginChallengeCache[challenge] = loginClientInfo{} return loginClientInfo{}, false } name := strings.TrimSpace(loginReq.Client.ClientName) if name == "" { name = clientID } info := loginClientInfo{ ClientID: clientID, Name: name, } loginChallengeCache[challenge] = info return info, true } clientCache := make(map[string]loginClientInfo) resolveClientByID := func(cid string) (loginClientInfo, bool) { cid = strings.TrimSpace(cid) if cid == "" || h.Hydra == nil { return loginClientInfo{}, false } if cached, ok := clientCache[cid]; ok { return cached, cached.ClientID != "" } client, err := h.Hydra.GetClient(c.Context(), cid) if err != nil || client == nil { clientCache[cid] = loginClientInfo{} return loginClientInfo{}, false } name := strings.TrimSpace(client.ClientName) if name == "" { name = cid } info := loginClientInfo{ ClientID: cid, Name: name, } clientCache[cid] = info return info, true } items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs)) for i := range authLogs { log := authLogs[i] 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") || strings.Contains(path, "/api/v1/auth/headless/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) != "" { clientID = strings.TrimSpace(cid) } if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { appName = strings.TrimSpace(name) } } // 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강 if appName == "Baron 로그인" || appName == "" { if isOidcAccept { appName = "OIDC 로그인" } if clientID != "" { appName = clientID if info, ok := resolveClientByID(clientID); ok { appName = info.Name } } } if (isOidcAccept || isPasswordLogin) && (appName == "OIDC 로그인" || appName == "Baron 로그인" || appName == clientID) { loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) if loginChallenge != "" { if info, ok := resolveLoginClient(loginChallenge); ok { appName = info.Name clientID = info.ClientID } } } 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: appName, ClientID: clientID, } items = append(items, item) } for i := range oathkeeperLogs { log := oathkeeperLogs[i] clientID := extractClientIDFromOathkeeperLog(log) if clientID == "" { continue } appName := clientID if consent, ok := consentMap[clientID]; ok { appName = consent.Name } if appName == "" || appName == clientID { if info, ok := resolveClientByID(clientID); ok { appName = info.Name } } 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, SessionID: extractSessionIDFromOathkeeperLog(log), 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{ "items": items, "limit": limit, "cursor": cursorRaw, "next_cursor": nextCursor, }) } 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 { ID string `json:"id"` Name string `json:"name"` Logo string `json:"logo,omitempty"` URL string `json:"url,omitempty"` InitURL string `json:"init_url,omitempty"` AutoLoginSupported bool `json:"auto_login_supported"` AutoLoginURL string `json:"auto_login_url,omitempty"` LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"` Status string `json:"status"` Scopes []string `json:"scopes,omitempty"` } type linkedRpListResponse struct { Items []linkedRpSummary `json:"items"` } type linkedRpRecord struct { linkedRpSummary lastAuth time.Time } func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if h.Hydra == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "hydra admin unavailable") } subjects, err := h.resolveConsentSubjects(c) if err != nil || len(subjects) == 0 { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } var sessions []domain.HydraConsentSession var lastErr error hasSuccess := false for _, subject := range subjects { subject = strings.TrimSpace(subject) if subject == "" { continue } linked, listErr := h.Hydra.ListConsentSessions(c.Context(), subject, "") if listErr != nil { lastErr = listErr continue } hasSuccess = true sessions = append(sessions, linked...) } if !hasSuccess && lastErr != nil { return errorJSON(c, fiber.StatusInternalServerError, lastErr.Error()) } records := make(map[string]*linkedRpRecord) for _, session := range sessions { client := session.Client if client.ClientID == "" && session.ConsentRequest != nil { client = session.ConsentRequest.Client } clientID := strings.TrimSpace(client.ClientID) if clientID == "" { continue } name := strings.TrimSpace(client.ClientName) if name == "" { name = clientID } clientURL := resolveLinkedRPURL( client.ClientID, client.ClientURI, client.RedirectURIs, ) lastAuth := time.Time{} if session.AuthenticatedAt != nil { lastAuth = *session.AuthenticatedAt } else if session.RequestedAt != nil { lastAuth = *session.RequestedAt } else if session.HandledAt != nil { lastAuth = *session.HandledAt } scopes := session.GrantedScope if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" { scopes = strings.Fields(client.Scope) } autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata) autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata) initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata) existing := records[clientID] if existing == nil { records[clientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ ID: clientID, Name: name, Logo: extractHydraClientLogo(client.Metadata), URL: clientURL, InitURL: initURL, AutoLoginSupported: autoLoginSupported, AutoLoginURL: autoLoginURL, Status: "active", // Hydra 세션이 있으면 활성 Scopes: scopes, }, lastAuth: lastAuth, } continue } if existing.Name == "" { existing.Name = name } if existing.Logo == "" { existing.Logo = extractHydraClientLogo(client.Metadata) } if existing.URL == "" { existing.URL = clientURL } if existing.InitURL == "" { existing.InitURL = initURL } if !existing.AutoLoginSupported { existing.AutoLoginSupported = autoLoginSupported } if existing.AutoLoginURL == "" { existing.AutoLoginURL = autoLoginURL } existing.Scopes = mergeScopes(existing.Scopes, scopes) if lastAuth.After(existing.lastAuth) { existing.lastAuth = lastAuth } } // Consent session payload may omit metadata fields such as logo_url. // Rehydrate missing display fields from the full Hydra client object. for clientID, record := range records { if record == nil { continue } needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == "" if !needsHydraLookup { continue } client, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { continue } if record.Name == "" { name := strings.TrimSpace(client.ClientName) if name == "" { name = client.ClientID } record.Name = name } if record.Logo == "" { record.Logo = extractHydraClientLogo(client.Metadata) } if record.URL == "" { record.URL = resolveLinkedRPURL( client.ClientID, client.ClientURI, client.RedirectURIs, ) } if record.InitURL == "" { record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata) } if !record.AutoLoginSupported { record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata) } if record.AutoLoginURL == "" { record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata) } } // [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함) if h.ConsentRepo != nil { for _, subject := range subjects { dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject) if err != nil { slog.Error("failed to list db consents for subject", "subject", subject, "error", err) continue } for _, dc := range dbConsents { if _, exists := records[dc.ClientID]; exists { // 이미 Hydra 세션으로 존재하면 skip (active 우선) continue } // 삭제된 권한일 경우 status := "inactive" if dc.DeletedAt.Valid { status = "revoked" } // Hydra에서 클라이언트 정보 조회 (메타데이터용) client, err := h.Hydra.GetClient(c.Context(), dc.ClientID) if err != nil { slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err) // Hydra에 정보가 없더라도 기본 정보로 추가 records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ ID: dc.ClientID, Name: dc.ClientID, Status: status, Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, } continue } name := strings.TrimSpace(client.ClientName) if name == "" { name = client.ClientID } clientURL := resolveLinkedRPURL( client.ClientID, client.ClientURI, client.RedirectURIs, ) autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata) autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata) initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata) records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ ID: dc.ClientID, Name: name, Logo: extractHydraClientLogo(client.Metadata), URL: clientURL, InitURL: initURL, AutoLoginSupported: autoLoginSupported, AutoLoginURL: autoLoginURL, Status: status, Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, } } } } // [New] Audit Log Scan for recent history fallback (timeline 200 items) // Hydra 세션이나 로컬 DB(ConsentRepo)에 없지만 최근 활동 이력이 있는 앱을 보강 if h.AuditRepo != nil { for _, subject := range subjects { auditLogs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 200) if err != nil { slog.Error("failed to scan audit logs for linked rps", "error", err, "subject", subject) continue } for _, log := range auditLogs { var details struct { ClientID string `json:"client_id"` ClientName string `json:"client_name"` Scopes any `json:"scopes"` } // 로그 Details 파싱 if err := json.Unmarshal([]byte(log.Details), &details); err != nil { continue } if details.ClientID == "" { continue } // 이미 records에 있으면(Active or ConsentRepo) 패스 if _, exists := records[details.ClientID]; exists { continue } // 스코프 추출 (consent.granted인 경우) scopes := []string{} if sList, ok := details.Scopes.([]any); ok { for _, s := range sList { if str, ok := s.(string); ok { scopes = append(scopes, str) } } } // 기본 레코드 생성 record := &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ ID: details.ClientID, Name: details.ClientName, // revoked 로그일 경우 비어있을 수 있음 Status: "inactive", Scopes: scopes, }, lastAuth: log.Timestamp, } // Hydra에서 최신 메타데이터 조회 시도 client, err := h.Hydra.GetClient(c.Context(), details.ClientID) if err == nil { name := strings.TrimSpace(client.ClientName) if name == "" { name = client.ClientID } record.Name = name record.Logo = extractHydraClientLogo(client.Metadata) clientURL := strings.TrimSpace(client.ClientURI) if clientURL == "" && len(client.RedirectURIs) > 0 { if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) } } record.URL = clientURL record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata) record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata) record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata) } else { // Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체 if record.Name == "" { record.Name = details.ClientID } } records[details.ClientID] = record } } } ordered := make([]*linkedRpRecord, 0, len(records)) for _, record := range records { ordered = append(ordered, record) } sort.Slice(ordered, func(i, j int) bool { return ordered[i].lastAuth.After(ordered[j].lastAuth) }) items := make([]linkedRpSummary, 0, len(ordered)) for i, record := range ordered { if i >= 100 { break } if !record.lastAuth.IsZero() { record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339) } items = append(items, record.linkedRpSummary) } return c.JSON(linkedRpListResponse{Items: items}) } func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error { clientID := c.Params("id") if clientID == "" { return fiber.NewError(fiber.StatusBadRequest, "client_id is required") } subject, err := h.resolveConsentSubject(c) if err != nil || subject == "" { return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } profile, profileErr := h.resolveCurrentProfile(c) if (profileErr != nil || profile == nil) && subject != "" { if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil { profile = fallbackProfile } } slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID) if h.Hydra == nil { return fiber.NewError(fiber.StatusServiceUnavailable, "hydra admin unavailable") } // Hydra에서 해당 사용자와 클라이언트의 모든 동의 세션을 삭제 if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil { slog.Error("failed to revoke hydra consent sessions", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link") } if h.ConsentRepo != nil { if err := h.ConsentRepo.Delete(c.Context(), subject, clientID); err != nil { slog.Error("failed to delete local consent after hydra revoke", "error", err, "subject", subject, "client_id", clientID) return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke local consent") } } if h.AuditRepo != nil { detailsMap := map[string]any{ "client_id": clientID, } detailsBytes, _ := json.Marshal(detailsMap) _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: subject, EventType: "consent.revoked", Status: "success", IPAddress: c.IP(), UserAgent: string(c.Request().Header.UserAgent()), Details: string(detailsBytes), }) } if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil { slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject) return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") } h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "") return c.Status(fiber.StatusOK).JSON(fiber.Map{ "status": "success", "message": "Link revoked successfully", }) } func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { challenge := c.Query("consent_challenge") if challenge == "" { return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required") } consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), challenge) if err != nil { slog.Error("failed to get hydra consent request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") } consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope) // [DEBUG] Hydra 응답 상세 로깅 slog.Info("GetConsentRequest Debug", "challenge", challenge, "skip", consentRequest.Skip, "subject", consentRequest.Subject, "client_id", consentRequest.Client.ClientID, "scopes", consentRequest.RequestedScope, ) profile, err := h.resolveCurrentProfile(c) if (err != nil || profile == nil) && consentRequest.Subject != "" { if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil { profile = fallbackProfile err = nil } } if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) { return nil } // [New] 로컬 DB에서 기존 동의 내역 확인 (강제 자동 승인 전략) // Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함 if !consentRequest.Skip && h.ConsentRepo != nil && consentRequest.Subject != "" { existingConsent, err := h.ConsentRepo.Find(c.Context(), consentRequest.Client.ClientID, consentRequest.Subject) if err == nil && existingConsent != nil { // 요청된 스코프가 이미 동의된 스코프 내에 있는지 확인 allGranted := true grantedMap := make(map[string]bool) for _, s := range existingConsent.GrantedScopes { grantedMap[s] = true } for _, s := range consentRequest.RequestedScope { if !grantedMap[s] { allGranted = false break } } if allGranted { slog.Info("Auto-approving based on local DB consent record", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID) identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject) if err == nil && identity != nil { currentSessionID := h.resolveCurrentSessionID(c) sessionClaims := composeOIDCSessionClaims( consentRequest.Client, identity.Traits, consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err == nil { if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil { slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") } return c.JSON(acceptResp) } slog.Error("failed to force auto-accept based on local DB", "error", err) } } } } // Hydra가 이전에 동의한 이력이 있어 skip을 권장하는 경우, 즉시 승인 처리 if consentRequest.Skip { identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject) if err != nil || identity == nil { slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject, "client_id", consentRequest.Client.ClientID) // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { currentSessionID := h.resolveCurrentSessionID(c) var clientTenantID string if consentRequest.Client.Metadata != nil { if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { clientTenantID = tid } } sessionClaims := composeOIDCSessionClaims( consentRequest.Client, identity.Traits, consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) appEnv := strings.ToLower(os.Getenv("APP_ENV")) if appEnv == "dev" || appEnv == "development" || appEnv == "" { if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON)) } } acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to auto-accept hydra consent request", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) // 자동 승인 실패 시 일반 흐름으로 진행 } else { // [New] Sync to local DB even on auto-accept to ensure data consistency if h.ConsentRepo != nil { consent := &domain.ClientConsent{ ClientID: consentRequest.Client.ClientID, Subject: consentRequest.Subject, GrantedScopes: consentRequest.RequestedScope, } _ = h.ConsentRepo.Upsert(c.Context(), consent) } if h.AuditRepo != nil { detailsMap := map[string]any{ "client_id": consentRequest.Client.ClientID, "scopes": consentRequest.RequestedScope, "client_name": consentRequest.Client.ClientName, } if currentSessionID != "" { detailsMap["session_id"] = currentSessionID detailsMap["approved_session_id"] = currentSessionID } detailsMap["auto_accepted"] = true detailsBytes, _ := json.Marshal(detailsMap) _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, TenantID: clientTenantID, SessionID: currentSessionID, EventType: "consent.granted", Status: "success", IPAddress: c.IP(), UserAgent: string(c.Request().Header.UserAgent()), Details: string(detailsBytes), }) } if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil { slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") } slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID) return c.JSON(acceptResp) } } } // Hydra 응답을 기본으로 하되, 메타데이터에서 커스텀 스코프 설명을 추출하여 추가 response := fiber.Map{ "challenge": consentRequest.Challenge, "requested_scope": consentRequest.RequestedScope, "requested_access_token_audience": consentRequest.RequestedAudience, "skip": consentRequest.Skip, "subject": consentRequest.Subject, "client": consentRequest.Client, } // structured_scopes 파싱 및 scope_details 생성 if metadata := consentRequest.Client.Metadata; metadata != nil { if rawScopes, ok := metadata["structured_scopes"]; ok { scopeDetails := make(map[string]map[string]any) // JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴 // 안전하게 처리 rawBytes, _ := json.Marshal(rawScopes) var scopesList []map[string]any if err := json.Unmarshal(rawBytes, &scopesList); err == nil { for _, item := range scopesList { name, _ := item["name"].(string) if name == "" { continue } desc, _ := item["description"].(string) mandatory, _ := item["mandatory"].(bool) scopeDetails[name] = map[string]any{ "description": desc, "mandatory": mandatory, } } } response["scope_details"] = scopeDetails } } return c.JSON(response) } func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { var req struct { ConsentChallenge string `json:"consent_challenge"` GrantScope []string `json:"grant_scope"` // 사용자가 선택한 스코프 } if err := c.BodyParser(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if reqJson, err := json.Marshal(req); err == nil { slog.Info("AcceptConsentRequest: received request body", "body", string(reqJson)) } else { slog.Error("AcceptConsentRequest: failed to marshal request for logging", "error", err) } if req.ConsentChallenge == "" { return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required") } // 1. Hydra에서 원래 요청 정보 조회 consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), req.ConsentChallenge) if err != nil { slog.Error("failed to get hydra consent request before accepting", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") } consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope) // 2. 스코프 필터링 (사용자가 선택한 것만 허용) if len(req.GrantScope) > 0 { allowedScopes := make(map[string]bool) for _, s := range consentRequest.RequestedScope { allowedScopes[s] = true } filteredScopes := make([]string, 0, len(req.GrantScope)) for _, s := range req.GrantScope { if allowedScopes[s] { filteredScopes = append(filteredScopes, s) } } consentRequest.RequestedScope = filteredScopes } consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope) profile, err := h.resolveCurrentProfile(c) if (err != nil || profile == nil) && consentRequest.Subject != "" { if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil { profile = fallbackProfile err = nil } } if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) { return nil } // 3. Hydra에 승인 요청 if consentRequest.Subject == "" { return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing") } if h.KratosAdmin == nil { return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable") } identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject) if err != nil || identity == nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity") } c.Locals("user_id", consentRequest.Subject) if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { c.Locals("login_id", loginID) } currentSessionID := h.resolveCurrentSessionID(c) var clientTenantID string if consentRequest.Client.Metadata != nil { if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { clientTenantID = tid } } sessionClaims := composeOIDCSessionClaims( consentRequest.Client, identity.Traits, consentRequest.RequestedScope, representativeTenantIDFromTraits(identity.Traits), currentSessionID, ) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) appEnv := strings.ToLower(os.Getenv("APP_ENV")) if appEnv == "dev" || appEnv == "development" || appEnv == "" { if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil { slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON)) } } acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to accept hydra consent request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request") } // [New] Sync to local DB for "List All Consents" feature if h.ConsentRepo != nil { consent := &domain.ClientConsent{ ClientID: consentRequest.Client.ClientID, Subject: consentRequest.Subject, GrantedScopes: consentRequest.RequestedScope, } if err := h.ConsentRepo.Upsert(c.Context(), consent); err != nil { slog.Error("failed to sync consent to local DB", "error", err, "subject", consent.Subject, "client", consent.ClientID) // Don't fail the whole request, but log it } } if h.AuditRepo != nil { detailsMap := map[string]any{ "client_id": consentRequest.Client.ClientID, "scopes": consentRequest.RequestedScope, "client_name": consentRequest.Client.ClientName, } if currentSessionID != "" { detailsMap["session_id"] = currentSessionID detailsMap["approved_session_id"] = currentSessionID } detailsBytes, _ := json.Marshal(detailsMap) _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, TenantID: clientTenantID, SessionID: currentSessionID, EventType: "consent.granted", Status: "success", IPAddress: c.IP(), UserAgent: string(c.Request().Header.UserAgent()), Details: string(detailsBytes), }) } if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil { slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") } return c.JSON(acceptResp) } func (h *AuthHandler) RejectConsentRequest(c *fiber.Ctx) error { var req struct { ConsentChallenge string `json:"consent_challenge"` } if err := c.BodyParser(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if req.ConsentChallenge == "" { return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required") } slog.Info("RejectConsentRequest called", "challenge", req.ConsentChallenge) rejectResp, err := h.Hydra.RejectConsentRequest(c.Context(), req.ConsentChallenge) if err != nil { slog.Error("failed to reject hydra consent request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to reject consent request") } return c.JSON(rejectResp) } func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { var req struct { LoginChallenge string `json:"login_challenge"` ApprovedSessionID string `json:"approved_session_id,omitempty"` SessionID string `json:"session_id,omitempty"` } if err := c.BodyParser(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if req.LoginChallenge == "" { return fiber.NewError(fiber.StatusBadRequest, "login_challenge is required") } // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) if err == nil && loginReq != nil { attachAuditClientDetails(c, loginReq.Client) if loginReq.Client.Metadata != nil { if status, ok := loginReq.Client.Metadata["status"].(string); ok { if strings.ToLower(status) == "inactive" { slog.Warn("Login rejected for inactive client in AcceptOidcLoginRequest", "client_id", loginReq.Client.ClientID) return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") } } } } profile, err := h.resolveCurrentProfile(c) if (err != nil || profile == nil) && loginReq != nil && strings.TrimSpace(loginReq.Subject) != "" { if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), loginReq.Subject); fallbackErr == nil { profile = fallbackProfile err = nil } } if loginReq != nil { if enforceClientTenantAccess(c, h.TenantService, loginReq.Client, profile, err) { return nil } } subject, err := h.resolveConsentSubject(c) if err != nil || subject == "" { return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } if err := h.ensureUserActivityAllowed(c.Context(), subject); err != nil { return fiber.NewError(fiber.StatusForbidden, "This user status cannot sign in") } c.Locals("user_id", subject) approvedSessionID := strings.TrimSpace(req.ApprovedSessionID) if approvedSessionID == "" { approvedSessionID = strings.TrimSpace(req.SessionID) } if approvedSessionID == "" { if sessionID, ok := c.Locals("session_id").(string); ok && sessionID != "" { approvedSessionID = sessionID } } if approvedSessionID == "" { if token := h.getBearerToken(c); token != "" { if resolved, err := h.getKratosSessionID(token); err == nil { approvedSessionID = resolved } } } if approvedSessionID == "" { if cookie := c.Get("Cookie"); cookie != "" { if derivedID, err := h.getKratosSessionIDWithCookie(cookie); err == nil { approvedSessionID = derivedID } } } if approvedSessionID != "" { c.Locals("approved_session_id", approvedSessionID) } if h.KratosAdmin != nil { if identity, err := h.KratosAdmin.GetIdentity(c.Context(), subject); err == nil && identity != nil { if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { c.Locals("login_id", loginID) } } } acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) if err != nil { slog.Error("failed to accept hydra login request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo) return c.JSON(acceptResp) } func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { return profile, nil } appEnv := strings.ToLower(os.Getenv("APP_ENV")) isDev := appEnv == "dev" || appEnv == "development" || appEnv == "" mockRole := c.Get("X-Test-Role") if mockRole == "" { mockRole = c.Get("X-Mock-Role") } token := h.getBearerToken(c) cookie := c.Get("Cookie") var profile *domain.UserProfileResponse var err error cacheKey := "" // 1. Try to fetch real profile if token/cookie exists if token != "" || cookie != "" { // Try Redis Cache if h.RedisService != nil && token == "" && cookie != "" { cacheKey = "cache:profile:cookie:" + cookie cached, _ := h.RedisService.Get(cacheKey) if cached != "" { if json.Unmarshal([]byte(cached), &profile) == nil { slog.Debug("Profile loaded from cache", "role", profile.Role) } } } if profile == nil { // Fetch from Kratos (SoT) if token != "" { profile, err = h.getKratosProfile(token) if err != nil && h.Hydra != nil { // Fallback to Hydra introspection. This is expected for API calls using Bearer tokens. slog.Debug("Kratos cookie session absent, falling back to Hydra token", "error", err.Error()) profile, err = h.getHydraProfile(c.Context(), token) } } else if cookie != "" { profile, err = h.getKratosProfileWithCookie(cookie) } } } // 2. Role Override for real profile or fallback to Mock Profile if profile != nil { if isDev && mockRole != "" { normalizedMockRole := domain.NormalizeRole(mockRole) if profile.Role != normalizedMockRole { slog.Info("🔑 [AUTH] Overriding real profile role", "email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole) profile.Role = normalizedMockRole } } } else if isDev && mockRole != "" && token == "" && cookie == "" { normalizedMockRole := domain.NormalizeRole(mockRole) slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole) profile = &domain.UserProfileResponse{ ID: "00000000-0000-0000-0000-000000000000", Email: "mock@hmac.kr", Name: "Dev Mock User", Role: normalizedMockRole, } if tid := c.Get("X-Tenant-ID"); tid != "" { profile.TenantID = &tid } } if profile == nil { slog.Warn("No profile resolved", "token_len", len(token), "cookie_len", len(cookie), "mockRole", mockRole) return nil, errors.New("invalid session (trace:resolve_profile)") } // 3. Post-Process (Defaults & Metadata Enrichment) profile.Role = domain.NormalizeRole(profile.Role) if profile.Role == "" { profile.Role = domain.RoleUser } // [New] Backtracking Logic for Session Tenant (Plan A) if usedID, ok := profile.Metadata["_used_identifier"].(string); ok && usedID != "" && h.UserRepo != nil { if tid, err := h.UserRepo.FindTenantIDByLoginID(c.Context(), usedID); err == nil && tid != "" { profile.SessionTenantID = &tid slog.Debug("Auto-assigned session tenant via backtracking", "loginID", usedID, "tenantID", tid) } delete(profile.Metadata, "_used_identifier") // Cleanup } profile = h.hydrateResolvedProfile(c.Context(), profile) // 4. Save to Redis Cache (Short TTL) // IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key // or we should include the mock role in the cache key. // For simplicity, let's skip caching if mockRole is present in dev. if h.RedisService != nil && token == "" && cacheKey != "" && err == nil && !(isDev && mockRole != "") { if data, err := json.Marshal(profile); err == nil { ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttl := 30 * time.Minute // Default TTL if ttlStr != "" { if parsed, err := time.ParseDuration(ttlStr); err == nil { ttl = parsed } } _ = h.RedisService.Set(cacheKey, string(data), ttl) } } return profile, nil } func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { token := h.getBearerToken(c) if token != "" { identityID, resolveErr := h.resolveIdentityID(c, token) if resolveErr == nil && identityID != "" { return identityID, nil } if cookie := c.Get("Cookie"); cookie != "" { cookieID, _, _, cookieErr := h.getKratosIdentityWithCookie(cookie) if cookieErr == nil && cookieID != "" { return cookieID, nil } } return "", resolveErr } cookie := c.Get("Cookie") if cookie == "" { return "", fmt.Errorf("missing authorization token") } identityID, _, _, err := h.getKratosIdentityWithCookie(cookie) return identityID, err } func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) { token := h.getBearerToken(c) if token != "" { identityID, traits, _, err := h.getKratosIdentity(token) if err == nil && identityID != "" { subjects := []string{identityID} subjects = appendLoginIDsFromTraits(subjects, traits) return uniqueStrings(subjects), nil } } cookie := c.Get("Cookie") if cookie == "" { return nil, fmt.Errorf("missing authorization token") } identityID, traits, _, err := h.getKratosIdentityWithCookie(cookie) if err != nil { return nil, err } subjects := []string{identityID} subjects = appendLoginIDsFromTraits(subjects, traits) return uniqueStrings(subjects), nil } func uniqueStrings(items []string) []string { seen := make(map[string]struct{}, len(items)) result := make([]string, 0, len(items)) for _, item := range items { item = strings.TrimSpace(item) if item == "" { continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} result = append(result, item) } return result } func appendLoginIDsFromValues(subjects []string, email string, phone string) []string { if strings.TrimSpace(email) != "" { subjects = append(subjects, strings.TrimSpace(email)) } if strings.TrimSpace(phone) != "" { subjects = append(subjects, normalizePhoneForLoginID(phone)) } return subjects } func appendLoginIDsFromTraits(subjects []string, traits map[string]any) []string { if traits == nil { return subjects } if raw, ok := traits["email"]; ok { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { subjects = append(subjects, strings.TrimSpace(value)) } } if raw, ok := traits["phone"]; ok { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { subjects = append(subjects, normalizePhoneForLoginID(value)) } } if raw, ok := traits["phone_number"]; ok { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { subjects = append(subjects, normalizePhoneForLoginID(value)) } } if raw, ok := traits["phoneNumber"]; ok { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { subjects = append(subjects, normalizePhoneForLoginID(value)) } } if raw, ok := traits["mobile"]; ok { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { subjects = append(subjects, normalizePhoneForLoginID(value)) } } if raw, ok := traits["mobile_number"]; ok { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { subjects = append(subjects, normalizePhoneForLoginID(value)) } } return subjects } func isAuthEventType(eventType string) bool { normalized := strings.ToLower(eventType) return strings.Contains(normalized, " /api/v1/auth/") } func extractAuditPath(log domain.AuditLog) string { if log.Details != "" { if payload, err := utils.ParseAuditDetails(log.Details); err == nil { if path, ok := payload["path"].(string); ok && path != "" { return path } } } parts := strings.SplitN(log.EventType, " ", 2) if len(parts) == 2 { return strings.TrimSpace(parts[1]) } return "" } func extractRequestBody(details map[string]any) map[string]any { if details == nil { return nil } raw, ok := details["request_body"].(string) if !ok || raw == "" { return nil } var body map[string]any if err := json.Unmarshal([]byte(raw), &body); err != nil { return nil } return body } func shouldSkipAuthTimeline(log domain.AuditLog) bool { details, _ := utils.ParseAuditDetails(log.Details) path := strings.ToLower(extractAuditPath(log)) if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") { return true } if path != "" && strings.Contains(path, "/api/v1/auth/consent/accept") { return true } if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") || strings.Contains(path, "/api/v1/auth/login/code/verify")) { sessionID := log.SessionID if sessionID == "" { sessionID = extractSessionIDFromAuditDetails(log.Details) } if sessionID == "" { return true } } if details != nil { if raw, ok := details["auth_timeline_skip"]; ok { switch value := raw.(type) { case bool: if value { return true } case string: if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { return true } } } } requestBody := extractRequestBody(details) if requestBody != nil { if raw, ok := requestBody["verifyOnly"]; ok { switch value := raw.(type) { case bool: if value { return true } case string: if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { return true } } } } return false } func loginIDKind(loginID string) string { normalized := strings.TrimSpace(loginID) if normalized == "" { return "" } if strings.Contains(normalized, "@") { return "email" } return "phone" } func resolveLoginMethod(rawMethod, loginID string) string { method := strings.ToLower(strings.TrimSpace(rawMethod)) if method == "sms" || method == "email" { return method } if strings.TrimSpace(loginID) == "" { return "" } if strings.Contains(loginID, "@") { return "email" } return "sms" } func loginMethodLabel(method string) string { switch strings.ToLower(strings.TrimSpace(method)) { case "sms": return "SMS" case "email": return "Email" default: return "" } } func deriveAuthMethod(log domain.AuditLog) string { path := strings.ToLower(extractAuditPath(log)) if path == "" { return "" } loginID := extractLoginIDFromAuditDetails(log.Details) kind := loginIDKind(loginID) details, _ := utils.ParseAuditDetails(log.Details) requestBody := extractRequestBody(details) if details != nil { if raw, ok := details["auth_timeline_skip"]; ok { switch value := raw.(type) { case bool: if value { return "" } case string: if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { return "" } } } } if requestBody != nil { if raw, ok := requestBody["verifyOnly"]; ok { switch value := raw.(type) { case bool: if value { return "" } case string: if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" { return "" } } } } if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") || strings.Contains(path, "/api/v1/auth/qr/poll") || strings.Contains(path, "/api/v1/auth/qr/approve")) { return "QR" } if details != nil { rawFlow, _ := details["login_flow"].(string) rawMethod, _ := details["login_method"].(string) flow := strings.ToLower(strings.TrimSpace(rawFlow)) methodLabel := loginMethodLabel(rawMethod) switch flow { case loginFlowCode: if methodLabel != "" { return fmt.Sprintf("코드(%s)", methodLabel) } return "코드" case loginFlowLink: if methodLabel != "" { return fmt.Sprintf("링크(%s)", methodLabel) } return "링크" } } switch { case strings.Contains(path, "/api/v1/auth/password/login"), strings.Contains(path, "/api/v1/auth/headless/password/login"): if kind == "email" { return "비밀번호(Email)" } if kind == "phone" { return "비밀번호(전화번호)" } return "비밀번호" case strings.Contains(path, "/api/v1/auth/enchanted-link/init"): if requestBody != nil { if raw, ok := requestBody["codeOnly"]; ok { if value, ok := raw.(bool); ok && value { if kind == "phone" { return "코드(SMS)" } if kind == "email" { return "코드(Email)" } return "코드" } } } if requestBody != nil { if raw, ok := requestBody["method"].(string); ok { method := strings.ToLower(strings.TrimSpace(raw)) if method == "sms" { return "링크(SMS)" } if method == "email" { return "링크(Email)" } } } if kind == "phone" { return "링크(SMS)" } if kind == "email" { return "링크(Email)" } return "링크" case strings.Contains(path, "/api/v1/auth/magic-link/verify"): if kind == "phone" { return "링크(SMS)" } if kind == "email" { return "링크(Email)" } return "링크" case strings.Contains(path, "/api/v1/auth/login/code/verify"): if kind == "phone" { return "코드(SMS)" } if kind == "email" { return "코드(Email)" } return "코드" case strings.Contains(path, "/api/v1/auth/login/code/verify-short"): return "코드(간편)" case strings.Contains(path, "/api/v1/auth/verify-sms"): return "코드(SMS)" case strings.Contains(path, "/api/v1/auth/qr/approve"): return "QR" case strings.Contains(path, "/api/v1/auth/qr/init"): return "QR" case strings.Contains(path, "/api/v1/auth/qr/poll"): return "QR" case strings.Contains(path, "/api/v1/auth/oidc/login/accept"): return "OIDC 로그인" default: return "" } } func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} { candidates := make(map[string]struct{}) if profile == nil { return candidates } for _, raw := range []string{profile.Email, profile.Phone, normalizePhoneForLoginID(profile.Phone)} { if normalized := normalizeLoginIdentifier(raw); normalized != "" { candidates[normalized] = struct{}{} } } return candidates } func normalizeLoginIdentifier(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "" } if strings.Contains(trimmed, "@") { return strings.ToLower(trimmed) } return normalizePhoneForLoginID(trimmed) } func matchesAuthTimelineUser(log domain.AuditLog, profile *domain.UserProfileResponse, candidates map[string]struct{}, sessionID string) bool { if profile == nil { return false } if profile.ID != "" && log.UserID == profile.ID { return true } loginID := extractLoginIDFromAuditDetails(log.Details) normalized := normalizeLoginIdentifier(loginID) if normalized != "" { if _, ok := candidates[normalized]; ok { return true } } if sessionID == "" { return false } if log.SessionID != "" && log.SessionID == sessionID { return true } if extracted := extractSessionIDFromAuditDetails(log.Details); extracted != "" && extracted == sessionID { return true } if approved := extractApprovedSessionIDFromAuditDetails(log.Details); approved != "" && approved == sessionID { return true } return false } func extractLoginIDFromAuditDetails(details string) string { if details == "" { return "" } var payload map[string]any if err := json.Unmarshal([]byte(details), &payload); err != nil { return "" } if raw, ok := payload["login_id"]; ok { if value, ok := raw.(string); ok && value != "" { return value } } if raw, ok := payload["loginId"]; ok { if value, ok := raw.(string); ok && value != "" { return value } } if raw, ok := payload["request_body"]; ok { if value, ok := raw.(string); ok && value != "" { var body map[string]any if err := json.Unmarshal([]byte(value), &body); err == nil { if loginID := extractLoginIDFromClaims(body); loginID != "" { return loginID } if target, ok := body["target"].(string); ok && target != "" { return target } } } } return "" } func extractLoginChallengeFromAuditDetails(details string) string { if details == "" { return "" } payload, err := utils.ParseAuditDetails(details) if err != nil { return "" } if raw, ok := payload["login_challenge"].(string); ok && raw != "" { return raw } if raw, ok := payload["loginChallenge"].(string); ok && raw != "" { return raw } body := extractRequestBody(payload) if body == nil { return "" } if raw, ok := body["login_challenge"].(string); ok && raw != "" { return raw } if raw, ok := body["loginChallenge"].(string); ok && raw != "" { return raw } return "" } func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { if value := strings.TrimSpace(log.ClientID); value != "" { return value } 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 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 == "" { 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 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 == "" { 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 { if details == "" { return "" } var payload map[string]any if err := json.Unmarshal([]byte(details), &payload); err != nil { return "" } return readSessionIDFromAny(payload) } func extractApprovedSessionIDFromAuditDetails(details string) string { if details == "" { return "" } var payload map[string]any if err := json.Unmarshal([]byte(details), &payload); err != nil { return "" } if raw, ok := payload["approved_session_id"]; ok { switch value := raw.(type) { case string: return value default: return fmt.Sprint(value) } } if raw, ok := payload["approvedSessionId"]; ok { switch value := raw.(type) { case string: return value default: return fmt.Sprint(value) } } 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 != "" { 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 } func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) { _, traits, _, err := h.getKratosIdentity(token) if err != nil { return "", err } loginID := pickLoginIDFromTraits(traits) if loginID == "" { return "", fmt.Errorf("kratos login id missing") } if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } return loginID, nil } func pickLoginIDFromTraits(traits map[string]any) string { if traits == nil { return "" } keys := []string{"email", "phone", "phone_number", "phoneNumber", "mobile", "mobile_number"} for _, key := range keys { if raw, ok := traits[key]; ok { if value, ok := raw.(string); ok && value != "" { return value } } } return "" } func (h *AuthHandler) resolveQrPendingRef(raw string) (string, error) { ref := strings.TrimSpace(raw) if ref == "" { return "", fmt.Errorf("empty ref") } if strings.HasPrefix(ref, "http") { if parsed, err := url.Parse(ref); err == nil { if value := parsed.Query().Get("ref"); value != "" { ref = value } else if len(parsed.Path) > 0 { segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(segments) >= 2 && segments[0] == "ql" { ref = segments[1] } } } } if ref == "" { return "", fmt.Errorf("invalid ref") } if mapped, _ := h.RedisService.Get(prefixQrRef + ref); mapped != "" { return mapped, nil } return ref, nil } func (h *AuthHandler) resolveQrRef(raw string) string { ref := strings.TrimSpace(raw) if ref == "" { return "" } if strings.HasPrefix(ref, "http") { if parsed, err := url.Parse(ref); err == nil { if value := parsed.Query().Get("ref"); value != "" { return value } if len(parsed.Path) > 0 { segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(segments) >= 2 && segments[0] == "ql" { return segments[1] } } } } return ref } func (h *AuthHandler) startQrCodeLogin(loginID, pendingRef string) error { if h.IdpProvider == nil { return fmt.Errorf("identity provider unavailable") } userfrontURL := os.Getenv("USERFRONT_URL") if userfrontURL == "" { userfrontURL = "http://sso.hmac.kr" } _ = h.RedisService.Set(prefixQrPending+loginID, pendingRef, loginCodeExpiration) init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL) if err != nil { h.RedisService.Delete(prefixQrPending + loginID) if errors.Is(err, domain.ErrNotSupported) { return fmt.Errorf("login method not supported") } return err } effectiveLoginID := loginID if init != nil && init.LoginID != "" { effectiveLoginID = init.LoginID } if effectiveLoginID != loginID { _ = h.RedisService.Set(prefixQrPending+effectiveLoginID, pendingRef, loginCodeExpiration) } if init != nil && init.FlowID != "" { _ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration) } return nil } func (h *AuthHandler) startQrCodeLoginForQr(loginID, pendingRef, rawRef string) error { if h.IdpProvider == nil { return fmt.Errorf("identity provider unavailable") } userfrontURL := os.Getenv("USERFRONT_URL") if userfrontURL == "" { userfrontURL = "http://sso.hmac.kr" } init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL) if err != nil { if errors.Is(err, domain.ErrNotSupported) { return fmt.Errorf("login method not supported") } return err } effectiveLoginID := loginID if init != nil && init.LoginID != "" { effectiveLoginID = init.LoginID } if init == nil || init.FlowID == "" { return fmt.Errorf("login flow missing") } qrRef := h.resolveQrRef(rawRef) qrPayload, _ := json.Marshal(map[string]string{ "pendingRef": pendingRef, "qrRef": qrRef, "loginId": effectiveLoginID, "approvedAt": time.Now().UTC().Format(time.RFC3339), }) _ = h.RedisService.Set(prefixLoginCodeQr+pendingRef, string(qrPayload), loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodeQrPending+effectiveLoginID, pendingRef, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration) return nil } func pickPrimaryLoginID(loginIDs []string) string { for _, id := range loginIDs { if strings.Contains(id, "@") { return id } } if len(loginIDs) > 0 { return loginIDs[0] } return "" } func extractLoginIDFromClaims(claims map[string]any) string { if claims == nil { return "" } candidateKeys := []string{"loginId", "login_id", "email", "phone_number", "phone", "phoneNumber"} for _, key := range candidateKeys { if raw, ok := claims[key]; ok { if value, ok := raw.(string); ok && value != "" { return value } } } if raw, ok := claims["loginIds"]; ok { switch ids := raw.(type) { case []string: return pickPrimaryLoginID(ids) case []any: casted := make([]string, 0, len(ids)) for _, item := range ids { if value, ok := item.(string); ok && value != "" { casted = append(casted, value) } } return pickPrimaryLoginID(casted) } } return "" } func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]any, string, error) { identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken) return identityID, traits, usedID, err } func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]any, string, string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { return "", nil, "", "", err } req.Header.Set("X-Session-Token", sessionToken) resp, err := http.DefaultClient.Do(req) if err != nil { return "", nil, "", "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { AuthenticatedAt string `json:"authenticated_at"` AuthenticationMethods []struct { Method string `json:"method"` Identifier string `json:"identifier"` } `json:"authentication_methods"` Identity struct { ID string `json:"id"` Traits map[string]any `json:"traits"` } `json:"identity"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", nil, "", "", err } usedIdentifier := "" for _, m := range result.AuthenticationMethods { if m.Identifier != "" { usedIdentifier = m.Identifier break } } return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil } func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { return "", err } req.Header.Set("X-Session-Token", sessionToken) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } return result.ID, nil } func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) { if identityID == "" { return "", fmt.Errorf("kratos identity id is empty") } kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") if kratosAdminURL == "" { kratosAdminURL = "http://kratos:4434" } payload := map[string]any{ "identity_id": identityID, } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, kratosAdminURL+"/admin/sessions", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { return "", fmt.Errorf("kratos admin create session failed status=%d body=%s", resp.StatusCode, string(respBody)) } var parsed struct { SessionToken string `json:"session_token"` } if err := json.Unmarshal(respBody, &parsed); err != nil { return "", err } if parsed.SessionToken == "" { return "", fmt.Errorf("kratos admin session token missing: %s", string(respBody)) } return parsed.SessionToken, nil } func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]any, string, error) { identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie) return identityID, traits, usedID, err } func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]any, string, string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { return "", nil, "", "", err } req.Header.Set("Cookie", cookie) resp, err := http.DefaultClient.Do(req) if err != nil { return "", nil, "", "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { AuthenticatedAt string `json:"authenticated_at"` AuthenticationMethods []struct { Method string `json:"method"` Identifier string `json:"identifier"` } `json:"authentication_methods"` Identity struct { ID string `json:"id"` Traits map[string]any `json:"traits"` } `json:"identity"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", nil, "", "", err } usedIdentifier := "" for _, m := range result.AuthenticationMethods { if m.Identifier != "" { usedIdentifier = m.Identifier break } } return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil } func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { return "", err } req.Header.Set("Cookie", cookie) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } return result.ID, nil } func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) { intro, err := h.Hydra.IntrospectToken(ctx, token) if err != nil { slog.Error("Hydra introspection failed", "error", err) return nil, err } if !intro.Active { slog.Warn("Hydra token is not active") return nil, errors.New("token is not active") } if err := h.validateHydraTokenSession(ctx, intro); err != nil { slog.Warn("Hydra token session validation failed", "error", err) return nil, err } if err := h.ensureUserActivityAllowed(ctx, intro.Subject); err != nil { return nil, err } slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) // Fetch identity details from Kratos by subject (identityID) identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject) if err != nil || identity == nil { slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject) // Fallback to minimal profile if Kratos identity not found return &domain.UserProfileResponse{ ID: intro.Subject, Email: "unknown@hydra.local", Name: "Hydra User", Role: domain.RoleUser, }, nil } return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil } func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]any) *domain.UserProfileResponse { email, _ := traits["email"].(string) name, _ := traits["name"].(string) phone, _ := traits["phone_number"].(string) dept, _ := traits["department"].(string) affType, _ := traits["affiliationType"].(string) role, _ := traits["role"].(string) tenantID, _ := traits["tenant_id"].(string) relyingPartyID, _ := traits["relying_party_id"].(string) profile := &domain.UserProfileResponse{ ID: identityID, Email: email, Name: name, Phone: h.formatPhoneForDisplay(phone), Department: dept, AffiliationType: affType, Role: domain.NormalizeRole(role), Metadata: make(map[string]any), } if tenantID != "" { profile.TenantID = &tenantID } if strings.TrimSpace(relyingPartyID) != "" { rpID := strings.TrimSpace(relyingPartyID) profile.RelyingPartyID = &rpID } coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, "affiliationType": true, "role": true, "tenant_id": true, "relying_party_id": true, } for k, v := range traits { if !coreTraits[k] { profile.Metadata[k] = v } } return profile } func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]any, existing *domain.User) *domain.User { now := time.Now() localUser := &domain.User{ ID: identityID, Status: domain.UserStatusActive, CreatedAt: now, UpdatedAt: now, Metadata: make(domain.JSONMap), } if existing != nil { copied := *existing localUser = &copied localUser.UpdatedAt = now if localUser.Metadata == nil { localUser.Metadata = make(domain.JSONMap) } } if email := extractTraitString(traits, "email"); email != "" { localUser.Email = email } if name := extractTraitString(traits, "name"); name != "" { localUser.Name = name } if phone := extractTraitString(traits, "phone_number"); phone != "" { localUser.Phone = phone } if department := extractTraitString(traits, "department"); department != "" { localUser.Department = department } if position := extractTraitString(traits, "position"); position != "" { localUser.Position = position } if jobTitle := extractTraitString(traits, "jobTitle"); jobTitle != "" { localUser.JobTitle = jobTitle } if affType := extractTraitString(traits, "affiliationType"); affType != "" { localUser.AffiliationType = affType } if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" { localUser.TenantID = &tenantID } if relyingPartyID := extractTraitString(traits, "relying_party_id"); relyingPartyID != "" { localUser.RelyingPartyID = &relyingPartyID } role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")) if !ok { role, ok = domain.NormalizeRoleAlias(extractTraitString(traits, "grade")) if !ok { role = domain.RoleUser } } localUser.Role = role if localUser.Status == "" { localUser.Status = domain.UserStatusActive } if localUser.CreatedAt.IsZero() { localUser.CreatedAt = now } coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true, "position": true, "jobTitle": true, "affiliationType": true, "role": true, "tenant_id": true, "relying_party_id": true, "custom_login_ids": true, "id": true, } metadata := make(domain.JSONMap) for k, v := range traits { if !coreTraits[k] { metadata[k] = v } } localUser.Metadata = metadata return localUser } func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]any) error { if h == nil || h.UserRepo == nil { return nil } var existing *domain.User if current, err := h.UserRepo.FindByID(ctx, identityID); err == nil { existing = current } else { slog.Warn("[UpdateMe] Failed to load existing local user before read-model sync", "userID", identityID, "error", err) } localUser := h.mapKratosTraitsToLocalUser(identityID, traits, existing) return h.UserRepo.Update(ctx, localUser) } func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse { if profile == nil { return nil } profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt) if usedIdentifier != "" { if profile.Metadata == nil { profile.Metadata = make(map[string]any) } profile.Metadata["_used_identifier"] = usedIdentifier } return profile } func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithSession(sessionToken) if err != nil { return nil, err } if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil { return nil, err } return h.applySessionInfoFromWhoami( h.mapKratosIdentityToProfile(identityID, traits), authenticatedAt, usedIdentifier, ), nil } func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithCookieAndSession(cookie) if err != nil { return nil, err } if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil { return nil, err } return h.applySessionInfoFromWhoami( h.mapKratosIdentityToProfile(identityID, traits), authenticatedAt, usedIdentifier, ), nil } // UpdateMe - Updates current user's profile with phone verification check func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { token := h.getBearerToken(c) var req domain.UpdateUserRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } var ( identityID string traits map[string]any err error ) if token != "" { identityID, traits, _, err = h.getKratosIdentity(token) } else { cookie := c.Get("Cookie") if cookie == "" { return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token") } identityID, traits, _, err = h.getKratosIdentityWithCookie(cookie) } if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if err := h.ensureUserActivityAllowed(c.Context(), identityID); err != nil { return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity") } currentPhone, _ := traits["phone_number"].(string) newPhoneStorage := h.formatPhoneForStorage(req.Phone) slog.Info("[UpdateMe] Checking changes (Kratos)", "identityID", identityID, "oldPhone", currentPhone, "newPhone", newPhoneStorage, "newName", req.Name) if newPhoneStorage != "" && newPhoneStorage != currentPhone { verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage val, _ := h.RedisService.Get(verifyKey) if val != "verified" { slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey) return errorJSON(c, fiber.StatusForbidden, "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다.") } traits["phone_number"] = newPhoneStorage h.RedisService.Delete(verifyKey) } if req.Name != "" { traits["name"] = req.Name } if req.Department != "" { traits["department"] = req.Department } // Merge custom metadata into traits if len(req.Metadata) > 0 { for k, v := range req.Metadata { // Do not overwrite core fields if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore { // [Fix] Support merging namespaced metadata maps if incomingMap, ok := v.(map[string]any); ok { if existingMap, ok := traits[k].(map[string]any); ok { maps.Copy(existingMap, incomingMap) traits[k] = existingMap } else { traits[k] = incomingMap } } else { traits[k] = v } } } } // [LoginID Sync based on Tenant Settings] // Perform sync AFTER metadata merge to ensure traits contains current values loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, identityID) // Validate all collected LoginIDs userEmail := extractTraitString(traits, "email") userPhone := extractTraitString(traits, "phone_number") if collectedIDs, ok := traits["custom_login_ids"].([]string); ok { for _, lid := range collectedIDs { if err := domain.ValidateLoginID(lid, []string{userEmail}, userPhone); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error()) } } } if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } updatedIdentity, err := h.KratosAdmin.UpdateIdentity(c.Context(), identityID, traits, "") if err != nil { slog.Error("Failed to update profile in Kratos", "error", err) return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.") } if updatedIdentity != nil && updatedIdentity.Traits != nil { traits = updatedIdentity.Traits } // [New] Local DB Sync - Sync synchronously to ensure immediate consistency if h.UserRepo != nil { ctx := context.Background() if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil { slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err) } if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil { slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err) } } // Invalidate token-based profile cache so refreshed /user/me returns latest traits. if h.RedisService != nil && token != "" { cacheKey := "cache:profile:token:" + token _ = h.RedisService.Delete(cacheKey) } slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID) return c.JSON(fiber.Map{ "status": "success", "updatedAt": time.Now().Format(time.RFC3339), }) } // ChangeMyPassword - 로그인 상태에서 현재 비밀번호를 확인한 뒤 변경합니다. func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error { var req domain.PasswordChangeRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request body") } currentPassword := strings.TrimSpace(req.CurrentPassword) newPassword := strings.TrimSpace(req.NewPassword) if currentPassword == "" || newPassword == "" { return errorJSON(c, fiber.StatusBadRequest, "Current password and new password are required") } policy := h.resolvePasswordPolicy() if err := validatePasswordWithPolicy(policy, newPassword); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } loginID := "" token := h.getBearerToken(c) if token != "" { if resolved, err := h.resolveKratosLoginID(token); err == nil { loginID = resolved } } if loginID == "" { cookie := c.Get("Cookie") if cookie == "" { return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token") } _, traits, _, err := h.getKratosIdentityWithCookie(cookie) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } loginID = pickLoginIDFromTraits(traits) if loginID == "" { return errorJSON(c, fiber.StatusUnauthorized, "Login ID not found") } if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } } if _, err := h.IdpProvider.SignIn(loginID, currentPassword); err != nil { return errorJSON(c, fiber.StatusUnauthorized, "Current password is invalid") } if err := h.IdpProvider.UpdateUserPassword(loginID, newPassword, nil); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password") } return c.JSON(fiber.Map{"message": "Password updated"}) } // SendUpdateCode - Sends OTP for phone number change func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) var ( userID string err error ) if token != "" { userID, err = h.resolveIdentityID(c, token) } else { cookie := c.Get("Cookie") if cookie == "" { return errorJSON(c, fiber.StatusUnauthorized, "Unauthorized") } userID, _, _, err = h.getKratosIdentityWithCookie(cookie) } if err != nil || userID == "" { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } var req struct { Phone string `json:"phone"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid phone") } phone := h.formatPhoneForStorage(req.Phone) code := fmt.Sprintf("%06d", rand.Intn(1000000)) // Store code in Redis key := "otp_update_phone:" + userID + ":" + phone h.RedisService.Set(key, code, 5*time.Minute) // Send SMS content := fmt.Sprintf("[Baron 로그인] 정보 수정 인증번호: [%s]", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."}) } // VerifyUpdateCode - Verifies OTP for phone number change func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) var ( userID string err error ) if token != "" { userID, err = h.resolveIdentityID(c, token) } else { cookie := c.Get("Cookie") if cookie == "" { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } userID, _, _, err = h.getKratosIdentityWithCookie(cookie) } if err != nil || userID == "" { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } var req struct { Phone string `json:"phone"` Code string `json:"code"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid request") } phone := h.formatPhoneForStorage(req.Phone) key := "otp_update_phone:" + userID + ":" + phone storedCode, _ := h.RedisService.Get(key) if storedCode == "" || storedCode != req.Code { return errorJSON(c, fiber.StatusUnauthorized, "인증번호가 일치하지 않거나 만료되었습니다.") } // Mark as verified for 10 minutes verifyKey := "verify_update_phone:" + userID + ":" + phone h.RedisService.Set(verifyKey, "verified", 10*time.Minute) h.RedisService.Delete(key) return c.JSON(fiber.Map{"success": true}) } func hydraClientStatus(metadata map[string]any) string { if metadata == nil { return "active" } if value, ok := metadata["status"].(string); ok { normalized := strings.ToLower(strings.TrimSpace(value)) if normalized != "" { return normalized } } return "active" } func extractHydraClientLogo(metadata map[string]any) string { if metadata == nil { return "" } candidates := []string{ "logo", "logo_url", "logoUrl", "logo_uri", "logoUri", "app_logo", "appLogo", } for _, key := range candidates { if value, ok := metadata[key]; ok { if logo, ok := value.(string); ok { logo = strings.TrimSpace(logo) if logo != "" { return logo } } } } return "" } func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string { switch strings.TrimSpace(clientID) { case "adminfront": if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" { return value } case "devfront": if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" { return value } case "orgfront": if value := strings.TrimSpace(os.Getenv("ORGFRONT_URL")); value != "" { return value } } clientURL := strings.TrimSpace(clientURI) if clientURL != "" { return clientURL } if len(redirectURIs) > 0 { if parsed, err := url.Parse(redirectURIs[0]); err == nil { return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) } } return "" } func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]any) bool { if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) { return true } switch strings.TrimSpace(clientID) { case "adminfront", "devfront", "orgfront": return resolveLinkedRPAutoLoginURL(clientID, nil) != "" default: return false } } func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]any) string { clientID = strings.TrimSpace(clientID) if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" { if clientID == "orgfront" { return ensureOrgfrontAutoLoginURL(metadataURL) } return metadataURL } switch clientID { case "adminfront": if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" { return value + "/login?auto=1" } case "devfront": if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" { return value + "/login?auto=1&returnTo=%2Fclients" } case "orgfront": if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" { return value + "/login?auto=1" } } return "" } func ensureOrgfrontAutoLoginURL(rawURL string) string { parsed, err := url.Parse(rawURL) if err != nil { return rawURL } if strings.TrimRight(parsed.Path, "/") != "/login" { return rawURL } query := parsed.Query() if query.Get("auto") != "1" { query.Set("auto", "1") parsed.RawQuery = query.Encode() } return parsed.String() } func resolveLinkedRPInitURL(clientID string, metadata map[string]any) string { if !resolveLinkedRPAutoLoginSupported(clientID, metadata) { return "" } return resolveLinkedRPAutoLoginURL(clientID, metadata) } func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs []string) string { hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/") if hydraPublicURL == "" { userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") if userfrontURL == "" { userfrontURL = "https://sso.hmac.kr" } hydraPublicURL = userfrontURL + "/oidc" } redirectURI := "" if len(redirectURIs) > 0 { redirectURI = strings.TrimSpace(redirectURIs[0]) } mergedScopes := make([]string, 0, len(scopes)+1) seen := map[string]struct{}{} for _, scope := range append([]string{"openid"}, scopes...) { scope = strings.TrimSpace(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { continue } seen[scope] = struct{}{} mergedScopes = append(mergedScopes, scope) } params := url.Values{} params.Set("client_id", clientID) params.Set("response_type", "code") params.Set("scope", strings.Join(mergedScopes, " ")) params.Set("state", GenerateSecureAlnumToken(16)) if redirectURI != "" { params.Set("redirect_uri", redirectURI) } return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode()) } func mergeScopes(current []string, next []string) []string { if len(next) == 0 { return current } seen := make(map[string]struct{}, len(current)+len(next)) for _, scope := range current { scope = strings.TrimSpace(scope) if scope == "" { continue } seen[scope] = struct{}{} } for _, scope := range next { scope = strings.TrimSpace(scope) if scope == "" { continue } if _, ok := seen[scope]; ok { continue } seen[scope] = struct{}{} current = append(current, scope) } return current } type rpHistoryItem struct { ClientID string `json:"client_id"` ClientName string `json:"client_name"` Scopes []string `json:"scopes"` LastApprovedAt *time.Time `json:"last_approved_at"` LastRevokedAt *time.Time `json:"last_revoked_at"` 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) } if item.IsCurrent { applyCurrentSessionRequestHints(c, &item) } 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") } if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions") } h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID) 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 == "" { return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") } if h.AuditRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 100) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history") } historyMap := make(map[string]*rpHistoryItem) // Logs are DESC (newest first). Iterate in reverse (oldest first) to build state. for i := len(logs) - 1; i >= 0; i-- { log := logs[i] details, _ := utils.ParseAuditDetails(log.Details) clientID, _ := details["client_id"].(string) if clientID == "" { continue } item, ok := historyMap[clientID] if !ok { item = &rpHistoryItem{ ClientID: clientID, Status: "unknown", } historyMap[clientID] = item } if name, ok := details["client_name"].(string); ok && name != "" { item.ClientName = name } if log.EventType == "consent.granted" { item.Status = "active" ts := log.Timestamp item.LastApprovedAt = &ts if scopesRaw, ok := details["scopes"].([]any); ok { scopes := make([]string, 0, len(scopesRaw)) for _, s := range scopesRaw { if str, ok := s.(string); ok { scopes = append(scopes, str) } } item.Scopes = scopes } } else if log.EventType == "consent.revoked" { item.Status = "revoked" ts := log.Timestamp item.LastRevokedAt = &ts } } items := make([]rpHistoryItem, 0, len(historyMap)) for _, item := range historyMap { items = append(items, *item) } sort.Slice(items, func(i, j int) bool { t1 := time.Time{} if items[i].LastApprovedAt != nil { t1 = *items[i].LastApprovedAt } if items[i].LastRevokedAt != nil && items[i].LastRevokedAt.After(t1) { t1 = *items[i].LastRevokedAt } t2 := time.Time{} if items[j].LastApprovedAt != nil { t2 = *items[j].LastApprovedAt } if items[j].LastRevokedAt != nil && items[j].LastRevokedAt.After(t2) { t2 = *items[j].LastRevokedAt } return t1.After(t2) }) 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 applyCurrentSessionRequestHints(c *fiber.Ctx, item *userSessionItem) { if c == nil || item == nil || !item.IsCurrent { return } if item.IPAddress == "" { item.IPAddress = strings.TrimSpace(resolveRequestClientIP(c)) } if item.UserAgent == "" { userAgent := strings.TrimSpace(c.Get("User-Agent")) if !looksLikeInternalUserAgent(userAgent) { item.UserAgent = userAgent } } if strings.TrimSpace(item.ClientID) == "" { item.ClientID = "userfront" } if strings.TrimSpace(item.AppName) == "" { item.AppName = "UserFront" } } func resolveRequestClientIP(c *fiber.Ctx) string { if c == nil { return "" } return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } 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", "consent.granted", "POST /api/v1/auth/oidc/login/accept", "POST /api/v1/auth/password/login", "POST /api/v1/auth/headless/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 } 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] = mergeSessionAuditHint(hints[sessionID], sessionAuditHint{ Timestamp: &ts, IPAddress: ipAddress, UserAgent: userAgent, ClientID: clientID, AppName: appName, }) } return hints } func mergeSessionAuditHint(existing sessionAuditHint, candidate sessionAuditHint) sessionAuditHint { if candidate.Timestamp != nil && (existing.Timestamp == nil || candidate.Timestamp.After(*existing.Timestamp)) { existing.Timestamp = candidate.Timestamp } if shouldReplaceSessionIP(existing.IPAddress, candidate.IPAddress) { existing.IPAddress = candidate.IPAddress } if existing.UserAgent == "" && candidate.UserAgent != "" { existing.UserAgent = candidate.UserAgent } if existing.ClientID == "" && candidate.ClientID != "" { existing.ClientID = candidate.ClientID } if existing.AppName == "" && candidate.AppName != "" { existing.AppName = candidate.AppName } return existing } func shouldReplaceSessionIP(existing string, candidate string) bool { existing = strings.TrimSpace(existing) candidate = strings.TrimSpace(candidate) if candidate == "" { return false } if existing == "" { return true } if isPrivateIPAddress(existing) && !isPrivateIPAddress(candidate) { return true } return false } func isPrivateIPAddress(raw string) bool { return utils.IsPrivateOrReservedIP(raw) } func parseAuditDetails(details string) (map[string]any, error) { return utils.ParseAuditDetails(details) } 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"), strings.Contains(path, "/api/v1/auth/headless/password/login"): appName = "비밀번호 로그인" } } if appName == "" && clientID != "" { appName = clientID } return clientID, appName } func extractStringLikeValue(raw any) string { switch value := raw.(type) { case string: return strings.TrimSpace(value) default: text := strings.TrimSpace(fmt.Sprint(value)) if text == "" || text == "" { return "" } return text } } func extractHydraSessionID(ext map[string]any) string { if len(ext) == 0 { return "" } for _, key := range []string{"session_id", "sid", "sessionId"} { if value := extractStringLikeValue(ext[key]); value != "" { return value } } return "" } func (h *AuthHandler) validateHydraTokenSession(ctx context.Context, intro *service.HydraIntrospectionResponse) error { if h == nil || h.KratosAdmin == nil || intro == nil { return nil } sessionID := extractHydraSessionID(intro.Ext) if sessionID == "" { return nil } session, err := h.KratosAdmin.GetSession(ctx, sessionID) if err != nil { return fmt.Errorf("kratos session lookup failed: %w", err) } if session == nil { return errors.New("linked session not found") } if !session.Active { return errors.New("linked session is inactive") } if identityID := strings.TrimSpace(session.Identity.ID); identityID != "" && strings.TrimSpace(intro.Subject) != "" && identityID != strings.TrimSpace(intro.Subject) { return errors.New("linked session subject mismatch") } return nil } func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID string) map[string][]string { bindings := make(map[string][]string) if h == nil || h.AuditRepo == nil || strings.TrimSpace(userID) == "" { return bindings } logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ "consent.granted", "POST /api/v1/auth/oidc/login/accept", "POST /api/v1/auth/password/login", "POST /api/v1/auth/headless/password/login", "password_login_success", "login_success", }, 200) if err != nil { return bindings } 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 } clientID, _ := deriveSessionClientInfo(log) clientID = strings.TrimSpace(clientID) if clientID == "" { continue } existing := bindings[sessionID] seen := slices.Contains(existing, clientID) if !seen { bindings[sessionID] = append(existing, clientID) } } return bindings } func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID string, sessionID string) error { if h == nil || h.Hydra == nil { return nil } clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] if len(clientIDs) == 0 { return nil } for _, clientID := range clientIDs { if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil { return err } } return nil } func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) { if h == nil || h.Hydra == nil { return } clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] for _, clientID := range clientIDs { h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID) } } func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) { if h == nil || h.Hydra == nil || h.BackchannelLogout == nil { return } clientID = strings.TrimSpace(clientID) userID = strings.TrimSpace(userID) sessionID = strings.TrimSpace(sessionID) if clientID == "" || userID == "" { return } client, err := h.Hydra.GetClient(ctx, clientID) if err != nil { h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed") return } if client == nil { h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found") return } endpoint := client.BackchannelLogoutURI() if endpoint == "" { h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured") return } if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" { h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required") return } logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID) if err != nil { h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed") return } statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken) if err != nil { h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed") return } h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "") } func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) { if h == nil || h.AuditRepo == nil { return } endpointHost := "" if endpoint != "" { if parsed, err := url.Parse(endpoint); err == nil { endpointHost = parsed.Host } } details := map[string]any{ "client_id": strings.TrimSpace(clientID), "session_id": strings.TrimSpace(sessionID), "endpoint_host": strings.TrimSpace(endpointHost), "status_code": statusCode, "retry_count": 0, "logout_issuer": h.BackchannelLogout.Issuer(), } if reason != "" { details["reason"] = reason } raw, err := json.Marshal(details) if err != nil { return } status := "success" if strings.HasSuffix(eventType, ".failed") { status = "failure" } else if strings.HasSuffix(eventType, ".skipped") { status = "skipped" } ipAddress := "" userAgent := "" if c != nil { ipAddress = extractClientIPFromHeaders(c) userAgent = strings.TrimSpace(c.Get("User-Agent")) } _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()), Timestamp: time.Now().UTC(), UserID: strings.TrimSpace(userID), SessionID: strings.TrimSpace(sessionID), EventType: eventType, Status: status, IPAddress: ipAddress, UserAgent: userAgent, Details: string(raw), }) } func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error { if h == nil || h.BackchannelLogout == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable") } c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8) c.Set(fiber.HeaderCacheControl, "no-store") return c.JSON(h.BackchannelLogout.PublicJWKS()) } 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), }) }