1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/auth_handler.go

5494 lines
169 KiB
Go

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"
"math/rand"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
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:"
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:"
pwdResetExpiration = 15 * time.Minute
minPollInterval = 2 * time.Second
loginCodeExpiration = 10 * time.Minute
linkResendCooldown = 60 * time.Second
prefixDrySend = "dry_send:"
)
type AuthHandler struct {
SmsService domain.SmsService
EmailService domain.EmailService
RedisService domain.RedisRepository
KratosAdmin service.KratosAdminService
IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository
OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository
}
type signupState struct {
Code string `json:"code"`
Verified bool `json:"verified"`
FailCount int `json:"fail_count"`
ExpiresAt int64 `json:"expires_at"` // Unix timestamp
}
// 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 {
return &AuthHandler{
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
RedisService: redisService,
KratosAdmin: kratos,
IdpProvider: idpProvider,
AuditRepo: auditRepo,
OathkeeperRepo: oathkeeperRepo,
Hydra: service.NewHydraAdminService(),
TenantService: tenantService,
KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
ConsentRepo: consentRepo,
}
}
// --- 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})
}
// 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(`
<div style="padding: 20px; font-family: sans-serif;">
<h2>이메일 인증</h2>
<p>아래 인증코드를 입력하여 회원가입을 진행해 주세요.</p>
<h1 style="color: #1A1F2C; letter-spacing: 5px;">%s</h1>
<p>이 코드는 5분간 유효합니다.</p>
</div>
`, 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"})
}
// 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
if state.Code != req.Code {
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)
return c.JSON(fiber.Map{"success": true})
}
// Signup - Finalize registration
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")
}
// [Strict] Enforce Tenant Auto-Assignment
companyCode := ""
var tenantID *string
parts := strings.Split(req.Email, "@")
if len(parts) == 2 {
domainName := parts[1]
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
if err == nil && tenant != nil {
if tenant.Status == domain.TenantStatusActive {
slog.Info("[Signup] Auto-assigning tenant by domain", "email", req.Email, "tenant", tenant.Slug)
companyCode = tenant.Slug
tenantID = &tenant.ID
} else {
slog.Warn("[Signup] Attempted to join non-active tenant by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status)
return errorJSON(c, fiber.StatusForbidden, "Your organization's tenant is currently not active.")
}
}
}
// Fallback/Validation for manually provided CompanyCode if domain lookup didn't yield a tenant
if tenantID == nil && req.CompanyCode != "" {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
if err == nil && tenant != nil {
if tenant.Status == domain.TenantStatusActive {
// Policy: Should we allow manual joining by Slug?
// For now, let's allow it but log it as manual.
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
companyCode = tenant.Slug
tenantID = &tenant.ID
} else {
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
}
} else {
// If companyCode provided but not found, we should probably reject if we want strictness,
// or just treat as GENERAL user. Given the risk "존재하지 않는 테넌트도 저장됨", we should reject.
return errorJSON(c, fiber.StatusBadRequest, "Invalid company code.")
}
}
// Normalize Phone (E.164 형태로 보관)
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
if strings.HasPrefix(normalizedPhone, "010") {
normalizedPhone = "+82" + normalizedPhone[1:]
} else if strings.HasPrefix(normalizedPhone, "82") {
normalizedPhone = "+" + normalizedPhone
}
// IDP에 전달할 BrokerUser 스키마 구성
attributes := map[string]interface{}{
"department": req.Department,
"affiliationType": req.AffiliationType,
"companyCode": companyCode,
// grade는 기존 스키마 필수 키이므로 기본값을 설정
"grade": "member",
}
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 already exists")
}
return errorJSON(c, fiber.StatusInternalServerError, "Failed to create user")
}
// 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, // Match IDP Subject
Email: req.Email,
Name: req.Name,
Phone: normalizedPhone,
AffiliationType: req.AffiliationType,
CompanyCode: companyCode,
TenantID: tenantID,
Department: req.Department,
Role: "user",
Status: "active",
Metadata: req.Metadata,
}
if h.UserRepo != nil {
go func(u *domain.User) {
// 요청 Context가 취소될 수 있으므로 Background Context 사용
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.UserRepo.Create(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)
// [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)
}
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]
}
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
func normalizePhoneForLoginID(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
return normalized
}
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[string]any {
claims := map[string]any{}
if traits == nil {
return claims
}
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
}
}
return claims
}
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["secondary_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["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{})
add := func(value string) {
candidate := strings.TrimSpace(value)
if candidate == "" {
return
}
if _, ok := seen[candidate]; ok {
return
}
seen[candidate] = struct{}{}
}
normalized := strings.TrimSpace(loginID)
add(normalized)
if normalized != "" {
add(strings.ToLower(normalized))
}
if normalized != "" && !strings.Contains(normalized, "@") {
add(normalizePhoneForLoginID(normalized))
}
candidates := make([]string, 0, len(seen))
for candidate := range seen {
candidates = append(candidates, candidate)
}
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 := os.Getenv("USERFRONT_URL")
if userfrontURL == "" {
userfrontURL = "http://sso.hmac.kr"
}
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(`
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
<h2 style="color: #1A1F2C;">Baron 로그인</h2>
<p>안녕하세요,</p>
<p>아래 버튼을 클릭하여 로그인을 완료해 주세요. 이 링크는 5분 동안 유효합니다.</p>
<div style="margin: 30px 0;">
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">로그인 완료하기</a>
</div>
<p style="font-size: 14px;">간편 코드: <strong>%s</strong></p>
<p style="font-size: 12px; color: #888;">만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.</p>
</div>
`, 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 {
slog.Info("[Poll] Success", "pendingRef", req.PendingRef)
return c.JSON(fiber.Map{
"sessionJwt": data["jwt"],
"status": "ok",
})
}
if data["status"] == "approved" {
loginID := data["loginId"]
if loginID == "" {
loginID = data["login_id"]
}
if loginID == "" {
slog.Warn("[Poll] Approved but missing loginId", "pendingRef", req.PendingRef)
return errorJSON(c, fiber.StatusBadRequest, "Invalid session reference")
}
if h.IdpProvider == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
}
loginStrategy := h.loadLoginStrategy(req.PendingRef)
if loginStrategy == "" {
loginStrategy = loginFlowLink
}
var authInfo *domain.AuthInfo
var err error
if loginStrategy == loginFlowCode {
code, _ := h.RedisService.Get(prefixLoginCodeValue + req.PendingRef)
code = normalizeLoginCode(code)
if code == "" {
slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", req.PendingRef)
return errorJSON(c, fiber.StatusBadRequest, "Login code expired")
}
flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
if flowID == "" {
return 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 errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
}
slog.Error("[Poll] IDP code verify failed", "error", err)
return 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 errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
}
slog.Error("[Poll] IDP session issue failed", "error", err)
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)
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
}
sessionDataJSON, _ := json.Marshal(sessionData)
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionDataJSON), defaultExpiration)
h.writeLinkAuditLog(loginID, req.PendingRef, authInfo.SessionToken, c)
h.clearLoginMeta(req.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 + req.PendingRef)
}
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")
}
sessionToken := authInfo.SessionToken.JWT
c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken)
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
}
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)
}
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
if !strings.Contains(loginID, "@") {
if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookupLoginID); mapped != "" {
effectiveLoginID = mapped
}
}
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
c.Locals("login_id", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken)
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
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,
})
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
h.RedisService.Delete(prefixLoginCodePending + lookupLoginID)
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
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
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,
})
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) 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.IdpProvider.SignIn(loginID, req.Password)
if err != nil {
if errors.Is(err, domain.ErrNotSupported) {
return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
}
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = err.Error()
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
return errorJSONCode(c, fiber.StatusNotFound, "not_found", "User not registered")
}
return errorJSONCode(c, fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials")
}
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
if resolveErr != nil || subject == "" {
slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve user identity")
}
authInfo.Subject = subject
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
setSessionIDLocal(c, authInfo.SessionToken)
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 && 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, 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)
return c.JSON(fiber.Map{
"redirectTo": acceptResp.RedirectTo,
"status": "ok",
"provider": h.IdpProvider.Name(),
})
}
// --- OIDC 로그인 흐름 처리 끝 ---
resp := fiber.Map{
"sessionJwt": 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)
}
// 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 := os.Getenv("USERFRONT_URL")
if userfrontURL == "" {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.ProviderError = "USERFRONT_URL is not set"
ale.Log(slog.LevelError, "USERFRONT_URL is not set")
return errorJSON(c, fiber.StatusInternalServerError, "USERFRONT_URL environment variable is not set")
}
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
ale.RedirectTo = redirectURL
// 내부 토큰 발급 + 우리 채널로 전송
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/reset-password?token=%s", userfrontURL, 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(`
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
<h2 style="color: #1A1F2C;">Baron 로그인 비밀번호 재설정</h2>
<p>아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.</p>
<div style="margin: 30px 0;">
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">비밀번호 재설정</a>
</div>
<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
</div>
`, 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 == "" {
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
}
// Simple HTML page with a form to trigger the POST request
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>Baron 로그인 - 비밀번호 재설정</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; }
.container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 100%%; }
h2 { color: #1A1F2C; margin-bottom: 1rem; }
p { color: #666; margin-bottom: 2rem; }
button { background-color: #1A1F2C; color: white; padding: 12px 24px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%%; transition: background 0.2s; }
button:hover { background-color: #333; }
</style>
</head>
<body>
<div class="container">
<h2>비밀번호 재설정</h2>
<p>아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.</p>
<form action="/api/v1/auth/password/reset/verify" method="POST">
<input type="hidden" name="token" value="%s">
<button type="submit">계속하기</button>
</form>
</div>
</body>
</html>
`, 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")
}
}
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 := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
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) == "" {
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)
}
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 := os.Getenv("USERFRONT_URL")
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
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]interface{} `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(`
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
<h2 style="color: #1A1F2C;">Baron 로그인</h2>
<p>아래 버튼을 클릭하여 로그인을 완료해 주세요.</p>
<div style="margin: 24px 0;">
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">로그인 완료하기</a>
</div>
<p style="font-size: 14px;">간편 코드: <strong>%s</strong></p>
<p style="font-size: 12px; color: #888;">링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.</p>
</div>
`, 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 i := 0; i < 10; i++ {
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]interface{}, 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
}
}
}
return ""
}
func sanitizePhoneForSms(phone string) string {
sanitized := strings.TrimSpace(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 {
phone = strings.ReplaceAll(phone, "-", "")
if strings.HasPrefix(phone, "010") && len(phone) == 11 {
return "+8210" + phone[3:]
}
return 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 {
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
}
return c.JSON(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"`
}
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
}
meta := qrMeta{
IPAddress: extractClientIPFromHeaders(c),
UserAgent: c.Get("User-Agent"),
}
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 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 ""
}
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
}
if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" {
return realIP
}
return c.IP()
}
type authTimelineItem struct {
EventID string `json:"event_id"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id"`
SessionID string `json:"session_id,omitempty"`
EventType string `json:"event_type"`
Status string `json:"status"`
AuthMethod string `json:"auth_method,omitempty"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Details string `json:"details,omitempty"`
Source string `json:"source,omitempty"`
ClientID string `json:"client_id,omitempty"`
AppName string `json:"app_name,omitempty"`
ParentSessionID string `json:"parent_session_id,omitempty"`
}
type consentClientInfo struct {
ClientID string
Name string
ConsentAt time.Time
}
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 := limit * 10
if fetchLimit < limit {
fetchLimit = 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 {
continue
}
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
}
items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
for i := range authLogs {
log := authLogs[i]
appName := "Baron 로그인"
clientID := ""
path := strings.ToLower(extractAuditPath(log))
if strings.Contains(path, "/api/v1/auth/oidc/login/accept") {
appName = "OIDC 로그인"
// 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강
if details, err := parseAuditDetails(log.Details); err == nil && details != nil {
if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" {
appName = strings.TrimSpace(name)
}
if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" {
clientID = strings.TrimSpace(cid)
if appName == "OIDC 로그인" {
appName = clientID
}
}
}
if appName == "OIDC 로그인" {
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
}
consent := consentMap[clientID]
appName := consent.Name
if appName == "" {
appName = clientID
}
details := map[string]any{
"path": log.Path,
"client_id": clientID,
"decision": log.Decision,
"status_code": log.Status,
}
detailsJSON, _ := json.Marshal(details)
status := "success"
if log.Status >= 400 {
status = "failure"
}
eventID := oathkeeperEventID(log)
item := authTimelineItem{
EventID: eventID,
Timestamp: log.Timestamp,
UserID: profile.ID,
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
Status: status,
AuthMethod: "세션 위임",
IPAddress: log.ClientIP,
UserAgent: log.UserAgent,
Details: string(detailsJSON),
Source: "oathkeeper",
ClientID: clientID,
AppName: appName,
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Timestamp.Equal(items[j].Timestamp) {
return items[i].EventID > items[j].EventID
}
return items[i].Timestamp.After(items[j].Timestamp)
})
nextCursor := ""
hasMore := len(authLogs) >= fetchLimit || len(oathkeeperLogs) >= fetchLimit
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
} else if hasMore && len(items) > 0 {
last := items[len(items)-1]
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
}
return c.JSON(fiber.Map{
"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"` // Added
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
}
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
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)
}
}
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)
}
existing := records[clientID]
if existing == nil {
records[clientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: clientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
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
}
existing.Scopes = mergeScopes(existing.Scopes, scopes)
if lastAuth.After(existing.lastAuth) {
existing.lastAuth = lastAuth
}
}
// [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 := 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)
}
}
records[dc.ClientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
ID: dc.ClientID,
Name: name,
Logo: extractHydraClientLogo(client.Metadata),
URL: clientURL,
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 interface{} `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.([]interface{}); 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
} 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")
}
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.AuditRepo != nil {
detailsMap := map[string]interface{}{
"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),
})
}
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")
}
// [DEBUG] Hydra 응답 상세 로깅
slog.Info("GetConsentRequest Debug",
"challenge", challenge,
"skip", consentRequest.Skip,
"subject", consentRequest.Subject,
"client_id", consentRequest.Client.ClientID,
"scopes", consentRequest.RequestedScope,
)
// 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)
// 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행
} else {
sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err != nil {
slog.Error("failed to auto-accept hydra consent request", "error", err)
// 자동 승인 실패 시 일반 흐름으로 진행
} 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)
}
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID)
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]interface{})
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
// 안전하게 처리
rawBytes, _ := json.Marshal(rawScopes)
var scopesList []map[string]interface{}
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]interface{}{
"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")
}
// 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
}
// 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)
}
sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope)
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]interface{}{
"client_id": consentRequest.Client.ClientID,
"scopes": consentRequest.RequestedScope,
"client_name": consentRequest.Client.ClientName,
}
detailsBytes, _ := json.Marshal(detailsMap)
_ = h.AuditRepo.Create(&domain.AuditLog{
EventID: GenerateSecureToken(16),
Timestamp: time.Now(),
UserID: consentRequest.Subject,
EventType: "consent.granted",
Status: "success",
IPAddress: c.IP(),
UserAgent: string(c.Request().Header.UserAgent()),
Details: string(detailsBytes),
})
}
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 {
// Audit 상세 정보 보강: OIDC 로그인 시점에 client 정보를 저장
clientID := strings.TrimSpace(loginReq.Client.ClientID)
if clientID != "" {
clientName := strings.TrimSpace(loginReq.Client.ClientName)
if clientName == "" {
clientName = clientID
}
c.Locals("audit_details_extra", map[string]any{
"client_id": clientID,
"client_name": clientName,
})
}
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.")
}
}
}
}
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
}
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) {
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 != "" {
cacheKey = "cache:profile:token:" + token
cached, _ := h.RedisService.Get(cacheKey)
if cached != "" {
if json.Unmarshal([]byte(cached), &profile) == nil {
// Fall through to role override check
}
}
}
if profile == nil {
// Fetch from Kratos (SoT)
if token != "" {
profile, err = h.getKratosProfile(token)
if err != nil && h.Hydra != nil {
// Fallback to Hydra introspection
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 != "" {
slog.Info("🔑 [AUTH_DEBUG] Overriding real profile role with mock role",
"email", profile.Email, "oldRole", profile.Role, "newRole", mockRole)
profile.Role = mockRole
}
} else if isDev && mockRole != "" && token == "" && cookie == "" {
slog.Info("🔑 [AUTH_DEBUG] No real session found, using full Mock Auth", "role", mockRole)
profile = &domain.UserProfileResponse{
ID: "00000000-0000-0000-0000-000000000000",
Email: "mock@hmac.kr",
Name: "Dev Mock User",
Role: mockRole,
}
if tid := c.Get("X-Tenant-ID"); tid != "" {
profile.TenantID = &tid
}
}
if profile == nil {
return nil, errors.New("invalid session (trace:resolve_profile)")
}
// 3. Post-Process (Defaults & Metadata Enrichment)
if profile.Role == "" {
profile.Role = domain.RoleUser
}
// Fetch Tenant Metadata if missing
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
profile.Tenant = tenant
}
}
if profile.Tenant == nil && profile.CompanyCode != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
profile.Tenant = tenant
if profile.TenantID == nil || *profile.TenantID == "" {
profile.TenantID = &tenant.ID
}
}
}
// 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" && err == nil {
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]interface{}) []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 := 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 parseAuditDetails(details string) (map[string]any, error) {
var payload map[string]any
if details == "" {
return nil, fmt.Errorf("empty details")
}
if err := json.Unmarshal([]byte(details), &payload); err != nil {
return nil, err
}
return payload, nil
}
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, _ := 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, _ := 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"):
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 := 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 parseClientIDFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
if id := strings.TrimSpace(parsed.Query().Get("client_id")); id != "" {
return id
}
if id := strings.TrimSpace(parsed.Query().Get("clientId")); id != "" {
return id
}
return ""
}
func parseClientIDFromRaw(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return ""
}
if id := readClientIDFromPayload(payload); id != "" {
return id
}
if request, ok := payload["request"].(map[string]any); ok {
if id := readClientIDFromPayload(request); id != "" {
return id
}
if urlRaw, ok := request["url"].(string); ok {
if id := parseClientIDFromURL(urlRaw); id != "" {
return id
}
}
if pathRaw, ok := request["path"].(string); ok {
if id := parseClientIDFromURL(pathRaw); id != "" {
return id
}
}
}
return ""
}
func readClientIDFromPayload(payload map[string]any) string {
if payload == nil {
return ""
}
if raw, ok := payload["client_id"].(string); ok && strings.TrimSpace(raw) != "" {
return strings.TrimSpace(raw)
}
if raw, ok := payload["clientId"].(string); ok && strings.TrimSpace(raw) != "" {
return strings.TrimSpace(raw)
}
return ""
}
func extractSessionIDFromAuditDetails(details string) string {
if details == "" {
return ""
}
var payload map[string]any
if err := json.Unmarshal([]byte(details), &payload); err != nil {
return ""
}
if raw, ok := payload["session_id"]; ok {
switch value := raw.(type) {
case string:
return value
default:
return fmt.Sprint(value)
}
}
return ""
}
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 (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]interface{}) 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]interface{}, 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 {
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
}
return result.Identity.ID, result.Identity.Traits, 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]interface{}{
"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]interface{}, 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 {
Identity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
} `json:"identity"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil, err
}
return result.Identity.ID, result.Identity.Traits, 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) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
if kratosAdminURL == "" {
kratosAdminURL = "http://kratos:4434"
}
payload := map[string]interface{}{
"schema_id": "default",
"traits": traits,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", kratosAdminURL, identityID), 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()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("kratos admin update failed status=%d body=%s", resp.StatusCode, string(respBody))
}
return 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")
}
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]interface{}) *domain.UserProfileResponse {
email, _ := traits["email"].(string)
name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string)
dept, _ := traits["department"].(string)
affType, _ := traits["affiliationType"].(string)
compCode, _ := traits["companyCode"].(string)
role, _ := traits["role"].(string)
tenantID, _ := traits["tenant_id"].(string)
profile := &domain.UserProfileResponse{
ID: identityID,
Email: email,
Name: name,
Phone: h.formatPhoneForDisplay(phone),
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
Role: role,
Metadata: make(map[string]any),
}
if tenantID != "" {
profile.TenantID = &tenantID
}
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true,
}
for k, v := range traits {
if !coreTraits[k] {
profile.Metadata[k] = v
}
}
return profile
}
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
}
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), 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]interface{}
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")
}
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
}
if err := h.updateKratosIdentity(identityID, traits); err != nil {
slog.Error("Failed to update profile in Kratos", "error", err)
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
}
// 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]interface{}) 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]interface{}) 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 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"`
}
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, _ := 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"].([]interface{}); 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})
}