forked from baron/baron-sso
8361 lines
256 KiB
Go
8361 lines
256 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"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
const (
|
|
// Redis Key Prefixes
|
|
prefixSession = "enchanted_session:"
|
|
prefixToken = "enchanted_token:"
|
|
prefixLoginCode = "login_code_flow:"
|
|
prefixLoginCodePending = "login_code_pending:"
|
|
prefixLoginCodeSmsTarget = "login_code_sms_target:"
|
|
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
|
|
prefixLoginCodeShort = "login_code_short:"
|
|
prefixLoginCodeValue = "login_code_value:"
|
|
prefixLoginIDRaw = "login_id_raw:"
|
|
prefixLoginMethod = "login_method:"
|
|
prefixLoginFlow = "login_flow:"
|
|
prefixLoginStrategy = "login_strategy:"
|
|
prefixLoginApproverMeta = "login_approver_meta:"
|
|
prefixLoginCodeSmsOnly = "login_code_sms_only:"
|
|
prefixLoginCodeQrPending = "login_code_qr_pending:"
|
|
prefixLoginCodeQr = "login_code_qr:"
|
|
prefixHeadlessLinkState = "headless_link_state:"
|
|
prefixPollMeta = "poll_meta:"
|
|
prefixQrRef = "qr_ref:"
|
|
prefixQrMeta = "qr_meta:"
|
|
prefixQrApproverSession = "qr_approver_session:"
|
|
prefixQrPending = "qr_pending:"
|
|
prefixSignupEmail = "signup:email:"
|
|
prefixSignupPhone = "signup:phone:"
|
|
|
|
// Session Statuses
|
|
statusPending = "pending"
|
|
statusSuccess = "success"
|
|
|
|
// Login Flows
|
|
loginFlowCode = "code"
|
|
loginFlowLink = "link"
|
|
|
|
// Durations
|
|
defaultExpiration = 5 * time.Minute
|
|
signupStateExpiration = 10 * time.Minute
|
|
signupBlockDuration = 10 * time.Minute
|
|
maxSignupFailures = 5
|
|
emailCodeTTL = 5 * time.Minute
|
|
smsCodeTTL = 3 * time.Minute
|
|
prefixPwdResetToken = "pwdreset_token:"
|
|
prefixPwdResetUsed = "pwdreset_used:"
|
|
pwdResetExpiration = 15 * time.Minute
|
|
pwdResetUsedExpiration = 2 * time.Minute
|
|
minPollInterval = 2 * time.Second
|
|
loginCodeExpiration = 10 * time.Minute
|
|
linkResendCooldown = 60 * time.Second
|
|
prefixDrySend = "dry_send:"
|
|
headlessJWKSFetchTTL = 5 * time.Second
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
SmsService domain.SmsService
|
|
EmailService domain.EmailService
|
|
RedisService domain.RedisRepository
|
|
HeadlessJWKS *service.HeadlessJWKSCacheService
|
|
KratosAdmin service.KratosAdminService
|
|
IdpProvider domain.IdentityProvider
|
|
AuditRepo domain.AuditRepository
|
|
OathkeeperRepo domain.OathkeeperLogRepository
|
|
Hydra *service.HydraAdminService
|
|
BackchannelLogout *service.BackchannelLogoutService
|
|
TenantService service.TenantService
|
|
KetoService service.KetoService
|
|
KetoOutboxRepo repository.KetoOutboxRepository
|
|
UserRepo repository.UserRepository
|
|
ConsentRepo repository.ClientConsentRepository
|
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
|
}
|
|
|
|
type signupState struct {
|
|
Code string `json:"code"`
|
|
Verified bool `json:"verified"`
|
|
FailCount int `json:"fail_count"`
|
|
ExpiresAt int64 `json:"expires_at"` // Unix timestamp
|
|
}
|
|
|
|
type headlessLinkState struct {
|
|
ClientID string `json:"clientId"`
|
|
ClientName string `json:"clientName,omitempty"`
|
|
LoginChallenge string `json:"loginChallenge"`
|
|
LoginID string `json:"loginId"`
|
|
RedirectTo string `json:"redirectTo,omitempty"`
|
|
}
|
|
|
|
type headlessClientAssertionClaims struct {
|
|
Issuer string `json:"iss"`
|
|
Subject string `json:"sub"`
|
|
Audience headlessAssertionAud `json:"aud"`
|
|
ExpiresAt int64 `json:"exp"`
|
|
IssuedAt int64 `json:"iat,omitempty"`
|
|
NotBefore int64 `json:"nbf,omitempty"`
|
|
ID string `json:"jti,omitempty"`
|
|
}
|
|
|
|
type headlessAssertionAud []string
|
|
|
|
type headlessLoginFailure struct {
|
|
status int
|
|
code string
|
|
safeMessage string
|
|
logMessage string
|
|
debugFields map[string]any
|
|
}
|
|
|
|
func (e *headlessLoginFailure) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.code != "" {
|
|
return e.code
|
|
}
|
|
return e.safeMessage
|
|
}
|
|
|
|
func newHeadlessLoginFailure(status int, code, safeMessage, logMessage string, debugFields map[string]any) *headlessLoginFailure {
|
|
return &headlessLoginFailure{
|
|
status: status,
|
|
code: code,
|
|
safeMessage: safeMessage,
|
|
logMessage: logMessage,
|
|
debugFields: debugFields,
|
|
}
|
|
}
|
|
|
|
func (a *headlessAssertionAud) UnmarshalJSON(data []byte) error {
|
|
var single string
|
|
if err := json.Unmarshal(data, &single); err == nil {
|
|
*a = []string{single}
|
|
return nil
|
|
}
|
|
|
|
var list []string
|
|
if err := json.Unmarshal(data, &list); err != nil {
|
|
return err
|
|
}
|
|
*a = list
|
|
return nil
|
|
}
|
|
|
|
// GenerateSecureToken - Helper to generate secure random strings
|
|
func GenerateSecureToken(length int) string {
|
|
b := make([]byte, length)
|
|
if _, err := crand.Read(b); err != nil {
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func GenerateSecureAlnumToken(length int) string {
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
if length <= 0 {
|
|
return ""
|
|
}
|
|
buf := make([]byte, length)
|
|
if _, err := crand.Read(buf); err != nil {
|
|
return ""
|
|
}
|
|
for i := range buf {
|
|
buf[i] = charset[int(buf[i])%len(charset)]
|
|
}
|
|
return string(buf)
|
|
}
|
|
|
|
func GenerateUserCode() string {
|
|
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
|
return fmt.Sprintf("%c%c-%03d",
|
|
letters[rand.Intn(len(letters))],
|
|
letters[rand.Intn(len(letters))],
|
|
rand.Intn(1000),
|
|
)
|
|
}
|
|
|
|
func checkPollInterval(redis domain.RedisRepository, key string, interval time.Duration) (bool, int) {
|
|
now := time.Now().UnixMilli()
|
|
val, err := redis.Get(key)
|
|
if err == nil && val != "" {
|
|
if last, parseErr := strconv.ParseInt(val, 10, 64); parseErr == nil {
|
|
if now-last < interval.Milliseconds() {
|
|
_ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration)
|
|
return true, int(interval.Seconds()) + 1
|
|
}
|
|
}
|
|
}
|
|
_ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration)
|
|
return false, int(interval.Seconds())
|
|
}
|
|
|
|
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler {
|
|
backchannelLogout, err := service.NewBackchannelLogoutService()
|
|
if err != nil {
|
|
slog.Warn("failed to initialize backchannel logout service", "error", err)
|
|
}
|
|
return &AuthHandler{
|
|
SmsService: service.NewSmsService(),
|
|
EmailService: service.NewEmailService(),
|
|
RedisService: redisService,
|
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
|
KratosAdmin: kratos,
|
|
IdpProvider: idpProvider,
|
|
AuditRepo: auditRepo,
|
|
OathkeeperRepo: oathkeeperRepo,
|
|
Hydra: service.NewHydraAdminService(),
|
|
BackchannelLogout: backchannelLogout,
|
|
TenantService: tenantService,
|
|
KetoService: ketoService,
|
|
KetoOutboxRepo: ketoOutboxRepo,
|
|
UserRepo: userRepo,
|
|
ConsentRepo: consentRepo,
|
|
}
|
|
}
|
|
|
|
// --- Signup Flow Handlers ---
|
|
|
|
// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
|
|
func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error {
|
|
var req domain.CheckEmailRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
|
|
}
|
|
|
|
// Email Format Validation
|
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
|
|
exists, err := h.IdpProvider.UserExists(req.Email)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
if exists {
|
|
return c.JSON(fiber.Map{"available": false, "message": "Email already registered"})
|
|
}
|
|
return c.JSON(fiber.Map{"available": true})
|
|
}
|
|
|
|
// CheckLoginID - 로그인 ID 사용 가능 여부를 확인합니다.
|
|
func (h *AuthHandler) CheckLoginID(c *fiber.Ctx) error {
|
|
var req domain.CheckLoginIDRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
|
|
// Basic validation via our ValidateLoginID helper (without email/phone since we just check format & collision with reserved words)
|
|
if err := domain.ValidateLoginID(req.LoginID, "", ""); err != nil {
|
|
return c.JSON(fiber.Map{"available": false, "message": err.Error()})
|
|
}
|
|
|
|
// We don't prepend companyCode to Kratos lookup if traits.id is unique globally
|
|
// Assuming Kratos traits.id handles unique constraints per tenant or globally based on schema
|
|
exists, err := h.IdpProvider.UserExists(req.LoginID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
|
|
if !exists && h.UserRepo != nil {
|
|
// [New] Check local DB for custom login IDs (Plan A)
|
|
taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), req.LoginID)
|
|
if err != nil {
|
|
slog.Error("Failed to check login ID in local DB", "error", err)
|
|
} else if taken {
|
|
exists = true
|
|
}
|
|
}
|
|
|
|
if exists {
|
|
return c.JSON(fiber.Map{"available": false, "message": "ID already registered"})
|
|
}
|
|
return c.JSON(fiber.Map{"available": true})
|
|
}
|
|
|
|
// SendSignupEmailCode - Sends verification code to email
|
|
func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
|
|
var req domain.SendSignupCodeRequest
|
|
if err := c.BodyParser(&req); err != nil || req.Target == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid email")
|
|
}
|
|
req.Type = "email" // Enforce type
|
|
|
|
key := prefixSignupEmail + req.Target
|
|
|
|
// 1. Check existing state (Rate Limit / Block)
|
|
state, _ := h.getSignupState(key)
|
|
if state != nil && state.FailCount > maxSignupFailures {
|
|
// Check if block expired
|
|
// Simple block implementation: if FailCount > 5, user is blocked until TTL expires
|
|
// Since we refresh TTL on each update, we rely on Redis TTL.
|
|
return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts. Try again later.")
|
|
}
|
|
|
|
// 2. Generate Code
|
|
rand.Seed(time.Now().UnixNano())
|
|
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
|
|
|
// 3. Update State
|
|
newState := &signupState{
|
|
Code: code,
|
|
Verified: false,
|
|
FailCount: 0, // Reset fail count on new code generation? Or keep it?
|
|
// Requirement says "Auth fail > 5 -> block". New code usually resets or continues?
|
|
// Usually getting a new code doesn't reset verify failure count if we want strict blocking.
|
|
// But for simplicity let's say "fail count" applies to verification attempts.
|
|
// If we are issuing a new code, it's a new attempt cycle usually.
|
|
// However, spamming "send code" is also an attack.
|
|
// Let's keep FailCount if exists, or 0.
|
|
ExpiresAt: time.Now().Add(emailCodeTTL).Unix(),
|
|
}
|
|
if state != nil {
|
|
newState.FailCount = state.FailCount
|
|
}
|
|
|
|
h.saveSignupState(key, newState, signupStateExpiration)
|
|
|
|
// 4. Send Email
|
|
if h.EmailService == nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
|
|
}
|
|
|
|
subject := "[Baron 로그인] 회원가입 인증코드"
|
|
body := fmt.Sprintf(`
|
|
<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"})
|
|
}
|
|
|
|
var affiliateSlugs = map[string]bool{
|
|
"hanmac": true,
|
|
"saman": true,
|
|
"ptc": true,
|
|
"jangheon": true,
|
|
"baron": true,
|
|
"halla": true,
|
|
}
|
|
|
|
func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) {
|
|
if h.TenantService == nil {
|
|
return false, nil
|
|
}
|
|
tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName)
|
|
if err != nil || tenant == nil {
|
|
return false, nil
|
|
}
|
|
// [Strict] Check if the slug belongs to the predefined family company slugs
|
|
return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant
|
|
}
|
|
|
|
// VerifySignupCode - Verifies the code for email or phone
|
|
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
|
var req domain.VerifySignupCodeRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
|
|
}
|
|
|
|
var key string
|
|
if req.Type == "email" {
|
|
key = prefixSignupEmail + req.Target
|
|
} else if req.Type == "phone" {
|
|
phone := strings.ReplaceAll(req.Target, "-", "")
|
|
key = prefixSignupPhone + phone
|
|
} else {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid type")
|
|
}
|
|
|
|
state, err := h.getSignupState(key)
|
|
if err != nil || state == nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Verification session expired or not found")
|
|
}
|
|
|
|
// Check Verified
|
|
if state.Verified {
|
|
return c.JSON(fiber.Map{"success": true, "message": "Already verified"})
|
|
}
|
|
|
|
// Check Attempts
|
|
if state.FailCount > maxSignupFailures {
|
|
return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts")
|
|
}
|
|
|
|
// Check Code match (Allow magic code 000000 in non-production environments)
|
|
isMagicCodeAllowed := service.IsDryRunAllowed() && req.Code == "000000"
|
|
if state.Code != req.Code && !isMagicCodeAllowed {
|
|
state.FailCount++
|
|
h.saveSignupState(key, state, signupStateExpiration)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
|
"error": "Invalid code",
|
|
"code": "invalid_code",
|
|
"failCount": state.FailCount,
|
|
})
|
|
}
|
|
|
|
// Check Expiry (Logic time vs stored time)
|
|
if time.Now().Unix() > state.ExpiresAt {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Code expired")
|
|
}
|
|
|
|
// Success
|
|
state.Verified = true
|
|
h.saveSignupState(key, state, signupStateExpiration)
|
|
|
|
// [New] Check if this is a family affiliate domain to let frontend lock the choice
|
|
isAffiliate := false
|
|
parts := strings.Split(req.Target, "@")
|
|
if req.Type == "email" && len(parts) == 2 {
|
|
isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1])
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"isAffiliate": isAffiliate,
|
|
})
|
|
}
|
|
|
|
// Signup - Finalize registration
|
|
// GetActiveTenants - List active tenants ONLY if the email is verified in Redis
|
|
func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error {
|
|
if h.TenantService == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable")
|
|
}
|
|
|
|
email := c.Query("email")
|
|
if email == "" {
|
|
// No email provided, return empty list (Security policy)
|
|
return c.JSON([]interface{}{})
|
|
}
|
|
|
|
// 1. Verify Verification Status in Redis
|
|
emailKey := prefixSignupEmail + email
|
|
state, _ := h.getSignupState(emailKey)
|
|
if state == nil || !state.Verified {
|
|
slog.Warn("[GetActiveTenants] Unverified access attempt", "email", email)
|
|
return errorJSON(c, fiber.StatusForbidden, "Email verification is required before selecting an organization.")
|
|
}
|
|
|
|
// 2. Extract domain from verified email
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) != 2 {
|
|
return c.JSON([]interface{}{})
|
|
}
|
|
domainName := parts[1]
|
|
|
|
// [Policy] Verify if the email belongs to any family affiliate domain
|
|
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
|
if !isInternal {
|
|
// If not an affiliate email, do not show any tenants
|
|
return c.JSON([]interface{}{})
|
|
}
|
|
|
|
// 3. List and Filter Tenants
|
|
tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "")
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants")
|
|
}
|
|
|
|
type tenantResp struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Type string `json:"type"`
|
|
Domains []string `json:"domains"`
|
|
}
|
|
|
|
var results []tenantResp
|
|
for _, t := range tenants {
|
|
// [Strict] Only allow choosing defined family company slugs
|
|
if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] {
|
|
continue
|
|
}
|
|
|
|
var domains []string
|
|
for _, d := range t.Domains {
|
|
domains = append(domains, d.Domain)
|
|
}
|
|
results = append(results, tenantResp{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
Slug: t.Slug,
|
|
Type: t.Type,
|
|
Domains: domains,
|
|
})
|
|
}
|
|
|
|
return c.JSON(results)
|
|
}
|
|
|
|
func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|
var req domain.SignupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
// 1. Validate Fields (Simple validation)
|
|
if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Missing required fields")
|
|
}
|
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
|
|
}
|
|
if !req.TermsAccepted {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Terms must be accepted")
|
|
}
|
|
|
|
// 비밀번호 정책 검증
|
|
policy := h.resolvePasswordPolicy()
|
|
if err := validatePasswordWithPolicy(policy, req.Password); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
// 2. Verify Auth Status (Redis)
|
|
emailKey := prefixSignupEmail + req.Email
|
|
phoneKey := prefixSignupPhone + strings.ReplaceAll(req.Phone, "-", "")
|
|
|
|
emailState, _ := h.getSignupState(emailKey)
|
|
phoneState, _ := h.getSignupState(phoneKey)
|
|
|
|
if emailState == nil || !emailState.Verified {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Email not verified")
|
|
}
|
|
if phoneState == nil || !phoneState.Verified {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Phone not verified")
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable")
|
|
}
|
|
|
|
// [New Policy] Enforce Explicit Tenant Assignment (No Auto-Provisioning)
|
|
companyCode := ""
|
|
var tenantID *string
|
|
|
|
parts := strings.Split(req.Email, "@")
|
|
if len(parts) != 2 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
|
|
}
|
|
domainName := parts[1]
|
|
|
|
// Check if this domain belongs to a predefined family affiliate
|
|
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
|
|
|
// [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose)
|
|
if isInternal {
|
|
req.AffiliationType = "AFFILIATE"
|
|
slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email)
|
|
} else {
|
|
req.AffiliationType = "GENERAL"
|
|
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
|
|
}
|
|
|
|
// If user provided a CompanyCode, verify it exists and is a family affiliate
|
|
if req.CompanyCode != "" {
|
|
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
|
|
if !isInternal {
|
|
slog.Warn("[Signup] Security violation: non-internal email providing CompanyCode", "email", req.Email)
|
|
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
|
|
}
|
|
|
|
// Verify the selected company code exists and is indeed a family company
|
|
if !affiliateSlugs[strings.ToLower(req.CompanyCode)] {
|
|
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
|
|
}
|
|
|
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
|
|
if err == nil && tenant != nil {
|
|
if tenant.Status == domain.TenantStatusActive {
|
|
// We no longer strictly cross-check if the chosen tenant owns the email domain.
|
|
// Being an 'isInternal' (family) email is enough to join ANY family affiliate.
|
|
|
|
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 {
|
|
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email)
|
|
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
|
|
}
|
|
} else {
|
|
// If it's a family affiliate domain, they MUST select one of the family companies
|
|
if isInternal {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.")
|
|
}
|
|
}
|
|
|
|
if tenantID == nil && req.AffiliationType == "AFFILIATE" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
slog.Info("[Signup] Phone normalization", "raw", req.Phone, "normalized", normalizedPhone)
|
|
|
|
// IDP에 전달할 BrokerUser 스키마 구성
|
|
attributes := map[string]interface{}{
|
|
"department": req.Department,
|
|
"affiliationType": req.AffiliationType,
|
|
"companyCode": companyCode,
|
|
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
|
"grade": "member",
|
|
}
|
|
|
|
// Sync all custom login IDs based on tenant schemas
|
|
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
|
|
|
|
// Validate all collected LoginIDs
|
|
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
|
|
for _, lid := range collectedIDs {
|
|
if err := domain.ValidateLoginID(lid, req.Email, normalizedPhone); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
brokerUser := &domain.BrokerUser{
|
|
Email: req.Email,
|
|
Name: req.Name,
|
|
PhoneNumber: normalizedPhone,
|
|
Attributes: attributes,
|
|
}
|
|
|
|
providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return errorJSON(c, fiber.StatusNotImplemented, "Signup method not supported")
|
|
}
|
|
slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err)
|
|
if strings.Contains(err.Error(), "already exists") {
|
|
return errorJSON(c, fiber.StatusConflict, "User or login identifier already exists")
|
|
}
|
|
// Include the actual error message in the response for debugging
|
|
return errorJSON(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to create user: %v", err))
|
|
}
|
|
|
|
// 4. Cleanup Redis
|
|
h.RedisService.Delete(emailKey)
|
|
h.RedisService.Delete(phoneKey)
|
|
|
|
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
|
|
|
|
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
|
// 로컬 DB 저장이 실패하더라도 회원가입 프로세스는 성공으로 간주합니다.
|
|
localUser := &domain.User{
|
|
ID: providerID,
|
|
Email: req.Email,
|
|
Name: req.Name,
|
|
Phone: normalizedPhone,
|
|
Role: "user",
|
|
AffiliationType: req.AffiliationType,
|
|
CompanyCode: companyCode,
|
|
Department: req.Department,
|
|
Status: "active",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if tenantID != nil {
|
|
localUser.TenantID = tenantID
|
|
}
|
|
|
|
// Merge metadata
|
|
localUser.Metadata = make(domain.JSONMap)
|
|
for k, v := range req.Metadata {
|
|
localUser.Metadata[k] = v
|
|
}
|
|
|
|
if h.UserRepo != nil {
|
|
go func(u *domain.User, ids []domain.UserLoginID) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := h.UserRepo.Update(ctx, u); err != nil {
|
|
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
|
|
} else {
|
|
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
|
|
|
|
// Update User Login IDs
|
|
for i := range ids {
|
|
ids[i].UserID = u.ID
|
|
}
|
|
if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
|
|
slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
|
|
}
|
|
|
|
// [Keto] Sync user-tenant relationship via Outbox
|
|
if h.KetoOutboxRepo != nil && u.TenantID != nil {
|
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
Namespace: "Tenant",
|
|
Object: *u.TenantID,
|
|
Relation: "members",
|
|
Subject: "User:" + u.ID,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
}
|
|
}
|
|
}(localUser, loginIDRecords)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"message": "User registered successfully",
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": providerID,
|
|
})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
|
|
authHeader := c.Get("Authorization")
|
|
if authHeader == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
|
return ""
|
|
}
|
|
return parts[1]
|
|
}
|
|
|
|
func firstForwardedValue(raw string) string {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
value := strings.TrimSpace(part)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func forwardedDirective(raw, key string) string {
|
|
for _, group := range strings.Split(raw, ",") {
|
|
for _, directive := range strings.Split(group, ";") {
|
|
pair := strings.SplitN(strings.TrimSpace(directive), "=", 2)
|
|
if len(pair) != 2 {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(pair[0]), key) {
|
|
continue
|
|
}
|
|
return strings.Trim(strings.TrimSpace(pair[1]), "\"")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizedAbsoluteBaseURL(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return ""
|
|
}
|
|
|
|
parsed.RawQuery = ""
|
|
parsed.Fragment = ""
|
|
return strings.TrimRight(parsed.String(), "/")
|
|
}
|
|
|
|
func forwardedRequestHost(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if host := firstForwardedValue(c.Get("X-Forwarded-Host")); host != "" {
|
|
return host
|
|
}
|
|
if host := forwardedDirective(c.Get("Forwarded"), "host"); host != "" {
|
|
return host
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func forwardedRequestProto(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if proto := firstForwardedValue(c.Get("X-Forwarded-Proto")); proto != "" {
|
|
return strings.ToLower(proto)
|
|
}
|
|
if proto := forwardedDirective(c.Get("Forwarded"), "proto"); proto != "" {
|
|
return strings.ToLower(proto)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolvePublicRequestBaseURL(c *fiber.Ctx, configuredBaseURL string) string {
|
|
if base := normalizedAbsoluteBaseURL(configuredBaseURL); base != "" {
|
|
return base
|
|
}
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
|
|
host := forwardedRequestHost(c)
|
|
proto := forwardedRequestProto(c)
|
|
if host != "" && proto != "" {
|
|
return fmt.Sprintf("%s://%s", proto, host)
|
|
}
|
|
|
|
base := strings.TrimRight(strings.TrimSpace(c.BaseURL()), "/")
|
|
if base != "" {
|
|
return base
|
|
}
|
|
|
|
host = strings.TrimSpace(c.Get("Host"))
|
|
if host == "" {
|
|
host = strings.TrimSpace(c.Hostname())
|
|
}
|
|
proto = strings.ToLower(strings.TrimSpace(c.Protocol()))
|
|
if host == "" || proto == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s://%s", proto, host)
|
|
}
|
|
|
|
func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string {
|
|
envURL := os.Getenv("USERFRONT_URL")
|
|
if envURL == "" {
|
|
envURL = "http://sso.hmac.kr"
|
|
}
|
|
|
|
baseURL := resolvePublicRequestBaseURL(c, "")
|
|
host := strings.TrimSpace(forwardedRequestHost(c))
|
|
if host == "" {
|
|
host = strings.TrimSpace(c.Hostname())
|
|
}
|
|
|
|
if host == "" || (host == "localhost" && os.Getenv("APP_ENV") != "dev") {
|
|
return strings.TrimRight(envURL, "/")
|
|
}
|
|
|
|
if baseURL == "" {
|
|
return strings.TrimRight(envURL, "/")
|
|
}
|
|
|
|
envParsed, envErr := url.Parse(strings.TrimRight(envURL, "/"))
|
|
baseParsed, baseErr := url.Parse(strings.TrimRight(baseURL, "/"))
|
|
if envErr == nil && baseErr == nil &&
|
|
strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) &&
|
|
envParsed.Scheme == "https" && baseParsed.Scheme == "http" {
|
|
return strings.TrimRight(envURL, "/")
|
|
}
|
|
|
|
return baseURL
|
|
}
|
|
|
|
func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
|
tenantID, _ := c.Locals("tenant_id").(string)
|
|
if tenantID == "" {
|
|
return c.JSON(fiber.Map{
|
|
"isCentral": true,
|
|
})
|
|
}
|
|
|
|
tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusNotFound, "Tenant not found")
|
|
}
|
|
|
|
res := fiber.Map{
|
|
"isCentral": false,
|
|
"id": tenant.ID,
|
|
"name": tenant.Name,
|
|
"slug": tenant.Slug,
|
|
"description": tenant.Description,
|
|
"type": tenant.Type,
|
|
}
|
|
|
|
if loginIdField, ok := tenant.Config["loginIdField"].(string); ok && loginIdField != "" {
|
|
res["loginIdField"] = loginIdField
|
|
// Find label in userSchema
|
|
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
|
for _, field := range schema {
|
|
if f, ok := field.(map[string]interface{}); ok {
|
|
if f["key"] == loginIdField {
|
|
res["loginIdLabel"] = f["label"]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(res)
|
|
}
|
|
|
|
// 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, tenantID 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
|
|
}
|
|
}
|
|
|
|
// [New] Dynamic Claim Injection for Multi-tenancy
|
|
if tenantID != "" {
|
|
claims["tenant_id"] = tenantID
|
|
// Extract namespaced metadata if available
|
|
// The key in traits is expected to be the tenantID
|
|
if namespaced, ok := traits[tenantID].(map[string]any); ok {
|
|
for k, v := range namespaced {
|
|
claims[k] = v
|
|
}
|
|
} else if namespaced, ok := traits[tenantID].(map[string]interface{}); ok {
|
|
for k, v := range namespaced {
|
|
claims[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// [Update] Pass ALL tenants the user belongs to
|
|
allTenants := map[string]any{}
|
|
var joinedTenants []string
|
|
|
|
// Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant
|
|
for k, v := range traits {
|
|
if m, ok := v.(map[string]any); ok {
|
|
allTenants[k] = m
|
|
joinedTenants = append(joinedTenants, k)
|
|
} else if m, ok := v.(map[string]interface{}); ok {
|
|
allTenants[k] = m
|
|
joinedTenants = append(joinedTenants, k)
|
|
}
|
|
}
|
|
|
|
// [Fix] Include primary tenant_id in joined_tenants if it's not already there
|
|
if primaryTenantID := getString("tenant_id"); primaryTenantID != "" {
|
|
found := false
|
|
for _, id := range joinedTenants {
|
|
if id == primaryTenantID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
joinedTenants = append(joinedTenants, primaryTenantID)
|
|
}
|
|
}
|
|
|
|
if len(allTenants) > 0 || len(joinedTenants) > 0 {
|
|
claims["tenants"] = allTenants
|
|
claims["joined_tenants"] = joinedTenants
|
|
}
|
|
|
|
return claims
|
|
}
|
|
|
|
func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any {
|
|
if claims == nil {
|
|
claims = map[string]any{}
|
|
}
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if sessionID != "" {
|
|
claims["session_id"] = sessionID
|
|
claims["sid"] = sessionID
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
|
|
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
|
|
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
|
|
return withOidcSessionMetadata(claims, sessionID)
|
|
}
|
|
|
|
func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any {
|
|
if baseClaims == nil {
|
|
baseClaims = map[string]any{}
|
|
}
|
|
if metadata == nil {
|
|
return baseClaims
|
|
}
|
|
|
|
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
|
|
if !ok || rawClaims == nil {
|
|
return baseClaims
|
|
}
|
|
|
|
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
|
|
if err != nil {
|
|
slog.Warn("failed to normalize configured id token claims", "error", err)
|
|
return baseClaims
|
|
}
|
|
|
|
rpClaims, _ := baseClaims["rp_claims"].(map[string]any)
|
|
if rpClaims == nil {
|
|
rpClaims = map[string]any{}
|
|
}
|
|
|
|
for _, claim := range normalizedClaims {
|
|
value, err := parseConfiguredClaimValue(claim.Value, claim.ValueType)
|
|
if err != nil {
|
|
slog.Warn("failed to parse configured id token claim", "namespace", claim.Namespace, "key", claim.Key, "error", err)
|
|
continue
|
|
}
|
|
|
|
if claim.Namespace == "rp_claims" {
|
|
rpClaims[claim.Key] = value
|
|
continue
|
|
}
|
|
|
|
if _, exists := baseClaims[claim.Key]; exists {
|
|
continue
|
|
}
|
|
baseClaims[claim.Key] = value
|
|
}
|
|
|
|
if len(rpClaims) > 0 {
|
|
baseClaims["rp_claims"] = rpClaims
|
|
}
|
|
return baseClaims
|
|
}
|
|
|
|
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
|
|
if claims == nil {
|
|
claims = map[string]any{}
|
|
}
|
|
if h == nil || h.RPUserMetadataRepo == nil {
|
|
return claims
|
|
}
|
|
|
|
clientID := strings.TrimSpace(client.ClientID)
|
|
subject = strings.TrimSpace(subject)
|
|
if clientID == "" || subject == "" {
|
|
return claims
|
|
}
|
|
|
|
claimKeys := extractClaimEnabledCustomUserSchemaKeys(client.Metadata)
|
|
if len(claimKeys) == 0 {
|
|
return claims
|
|
}
|
|
|
|
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
|
|
if err != nil || row == nil || len(row.Metadata) == 0 {
|
|
return claims
|
|
}
|
|
|
|
fields := make(map[string]any)
|
|
for _, key := range claimKeys {
|
|
raw, ok := row.Metadata[key]
|
|
if !ok || raw == nil {
|
|
continue
|
|
}
|
|
if value, ok := raw.(string); ok {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
fields[key] = value
|
|
continue
|
|
}
|
|
fields[key] = raw
|
|
}
|
|
if len(fields) == 0 {
|
|
return claims
|
|
}
|
|
|
|
profile := map[string]any{
|
|
"client_id": clientID,
|
|
"fields": fields,
|
|
}
|
|
if existing, ok := claims["rp_profiles"].([]any); ok {
|
|
claims["rp_profiles"] = append(existing, profile)
|
|
return claims
|
|
}
|
|
if existing, ok := claims["rp_profiles"].([]interface{}); ok {
|
|
claims["rp_profiles"] = append(existing, profile)
|
|
return claims
|
|
}
|
|
claims["rp_profiles"] = []any{profile}
|
|
return claims
|
|
}
|
|
|
|
func extractClaimEnabledCustomUserSchemaKeys(metadata map[string]interface{}) []string {
|
|
if metadata == nil {
|
|
return nil
|
|
}
|
|
rawSchema, ok := metadata["customUserSchema"]
|
|
if !ok || rawSchema == nil {
|
|
return nil
|
|
}
|
|
|
|
var items []interface{}
|
|
switch schema := rawSchema.(type) {
|
|
case []interface{}:
|
|
items = schema
|
|
case []map[string]interface{}:
|
|
items = make([]interface{}, 0, len(schema))
|
|
for _, item := range schema {
|
|
items = append(items, item)
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(items))
|
|
seen := make(map[string]struct{})
|
|
for _, item := range items {
|
|
field, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
if typed, typedOK := item.(map[string]any); typedOK {
|
|
field = typed
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
enabled, _ := field["claimEnabled"].(bool)
|
|
if !enabled {
|
|
enabled, _ = field["claim_enabled"].(bool)
|
|
}
|
|
if !enabled {
|
|
continue
|
|
}
|
|
key, _ := field["key"].(string)
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
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 := h.resolveUserfrontURL(c)
|
|
if req.URI != "" {
|
|
userfrontURL = req.URI
|
|
}
|
|
|
|
drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed()
|
|
if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() {
|
|
slog.Warn("[Enchanted] DrySend ignored in production", "loginID", loginID)
|
|
}
|
|
if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
|
|
keyLoginID := lookupLoginID
|
|
if init.LoginID != "" {
|
|
keyLoginID = init.LoginID
|
|
}
|
|
if !strings.Contains(loginID, "@") && req.CodeOnly {
|
|
_ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration)
|
|
} else {
|
|
_ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID)
|
|
}
|
|
if init.FlowID != "" {
|
|
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
|
|
}
|
|
pendingRef := GenerateSecureToken(3)
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusPending,
|
|
"loginId": keyLoginID,
|
|
})
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
|
intent := loginFlowLink
|
|
if req.CodeOnly {
|
|
intent = loginFlowCode
|
|
}
|
|
h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration)
|
|
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
|
|
if drySend {
|
|
_ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration)
|
|
if keyLoginID != lookupLoginID {
|
|
_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, loginCodeExpiration)
|
|
}
|
|
}
|
|
if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID {
|
|
_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
|
|
_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
|
|
}
|
|
expiresIn := 0
|
|
if !init.ExpiresAt.IsZero() {
|
|
expiresIn = int(time.Until(init.ExpiresAt).Seconds())
|
|
}
|
|
if expiresIn <= 0 {
|
|
expiresIn = int(loginCodeExpiration.Seconds())
|
|
}
|
|
return c.JSON(fiber.Map{
|
|
"linkId": "Sent",
|
|
"pendingRef": pendingRef,
|
|
"maskedEmail": loginID,
|
|
"mode": init.Mode,
|
|
"provider": h.IdpProvider.Name(),
|
|
"expiresIn": expiresIn,
|
|
"interval": int(minPollInterval.Seconds()),
|
|
"resendAfter": int(linkResendCooldown.Seconds()),
|
|
})
|
|
} else if err != nil && !errors.Is(err, domain.ErrNotSupported) {
|
|
slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err)
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
|
|
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
|
|
userCode := GenerateUserCode()
|
|
token := GenerateSecureToken(3)
|
|
pendingRef := GenerateSecureToken(3)
|
|
|
|
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
|
|
|
// Store in Redis
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusPending,
|
|
"loginId": lookupLoginID,
|
|
})
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
|
h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
|
|
if drySend {
|
|
_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration)
|
|
}
|
|
intent := loginFlowLink
|
|
if req.CodeOnly {
|
|
intent = loginFlowCode
|
|
}
|
|
h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration)
|
|
|
|
// Generate Link
|
|
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
|
|
link := fmt.Sprintf("%s/verify/%s", userfrontURL, token)
|
|
|
|
// Route based on LoginID type
|
|
if strings.Contains(loginID, "@") {
|
|
// Send Email
|
|
if !drySend && h.EmailService == nil {
|
|
slog.Error("[Enchanted] Email Service not configured")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
|
|
}
|
|
|
|
subject := "[Baron 로그인] 링크"
|
|
body := fmt.Sprintf(`
|
|
<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" {
|
|
_, authInfo, err := h.completeApprovedLinkLogin(c, req.PendingRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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)
|
|
|
|
// Write Kratos session cookies to the response
|
|
h.writeAuthCookies(c, authInfo.SetCookies)
|
|
|
|
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
|
|
|
|
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
|
|
sessionData := map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": sessionToken,
|
|
}
|
|
if sessionID != "" {
|
|
sessionData["session_id"] = sessionID
|
|
}
|
|
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)
|
|
|
|
// Write Kratos session cookies to the response
|
|
h.writeAuthCookies(c, authInfo.SetCookies)
|
|
|
|
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) authenticatePasswordLogin(ctx context.Context, loginID, password string) (*domain.AuthInfo, error) {
|
|
if h.IdpProvider == nil {
|
|
return nil, fmt.Errorf("authentication service not configured")
|
|
}
|
|
|
|
authInfo, err := h.IdpProvider.SignIn(loginID, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(ctx, loginID)
|
|
if resolveErr != nil || subject == "" {
|
|
slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
|
|
return nil, fmt.Errorf("failed to resolve user identity")
|
|
}
|
|
|
|
authInfo.Subject = subject
|
|
return authInfo, nil
|
|
}
|
|
|
|
func passwordLoginErrorSpec(err error) (int, string, string) {
|
|
if err == nil {
|
|
return fiber.StatusOK, "", ""
|
|
}
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return fiber.StatusNotImplemented, "not_supported", "Login method not supported"
|
|
}
|
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
|
|
return fiber.StatusNotFound, "not_found", "User not registered"
|
|
}
|
|
if strings.Contains(err.Error(), "failed to resolve user identity") {
|
|
return fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity"
|
|
}
|
|
return fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials"
|
|
}
|
|
|
|
func headlessAssertionAudiences(c *fiber.Ctx) []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
path := strings.TrimSpace(c.Path())
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
base := resolvePublicRequestBaseURL(c, os.Getenv("BACKEND_PUBLIC_URL"))
|
|
if base == "" {
|
|
return []string{path}
|
|
}
|
|
|
|
return []string{base + path, path}
|
|
}
|
|
|
|
func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bool {
|
|
for _, audience := range actual {
|
|
for _, candidate := range expected {
|
|
if strings.TrimSpace(audience) == strings.TrimSpace(candidate) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func joinHeadlessAudiences(values []string) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
trimmed := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
trimmed = append(trimmed, value)
|
|
}
|
|
return strings.Join(trimmed, ", ")
|
|
}
|
|
|
|
func headlessRequestID(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
reqID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
|
|
if reqID != "" {
|
|
return reqID
|
|
}
|
|
return strings.TrimSpace(c.Get(fiber.HeaderXRequestID))
|
|
}
|
|
|
|
func isHeadlessDebugLoggingEnabled() bool {
|
|
return slog.Default().Enabled(context.Background(), slog.LevelDebug)
|
|
}
|
|
|
|
func truncateHeadlessLogValue(value string, limit int) string {
|
|
value = strings.TrimSpace(value)
|
|
if limit <= 0 || len(value) <= limit {
|
|
return value
|
|
}
|
|
return value[:limit]
|
|
}
|
|
|
|
func logHeadlessLoginFailure(c *fiber.Ctx, message string, failure *headlessLoginFailure, clientID, loginChallenge string) {
|
|
if failure == nil {
|
|
return
|
|
}
|
|
|
|
args := []any{
|
|
"reason_code", failure.code,
|
|
"client_id", strings.TrimSpace(clientID),
|
|
"path", c.Path(),
|
|
}
|
|
|
|
if reqID := headlessRequestID(c); reqID != "" {
|
|
args = append(args, "req_id", reqID)
|
|
}
|
|
|
|
if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" {
|
|
args = append(args, "login_challenge_prefix", trimmedChallenge)
|
|
}
|
|
|
|
if isHeadlessDebugLoggingEnabled() {
|
|
keys := make([]string, 0, len(failure.debugFields))
|
|
for key := range failure.debugFields {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
args = append(args, key, failure.debugFields[key])
|
|
}
|
|
}
|
|
|
|
level := slog.LevelWarn
|
|
if failure.status >= 500 {
|
|
level = slog.LevelError
|
|
}
|
|
slog.Log(context.Background(), level, message, args...)
|
|
}
|
|
|
|
func logHeadlessLoginSuccess(c *fiber.Ctx, clientID, loginChallenge, redirectTo string) {
|
|
args := []any{
|
|
"client_id", strings.TrimSpace(clientID),
|
|
"path", c.Path(),
|
|
"response_status", fiber.StatusOK,
|
|
}
|
|
|
|
if reqID := headlessRequestID(c); reqID != "" {
|
|
args = append(args, "req_id", reqID)
|
|
}
|
|
|
|
if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" {
|
|
args = append(args, "login_challenge_prefix", trimmedChallenge)
|
|
}
|
|
|
|
parsed, err := url.Parse(redirectTo)
|
|
if err != nil {
|
|
args = append(args, "redirect_to_length", len(redirectTo), "redirect_parse_error", err.Error())
|
|
slog.Info("headless password login succeeded", args...)
|
|
return
|
|
}
|
|
|
|
query := parsed.Query()
|
|
args = append(
|
|
args,
|
|
"redirect_to_length", len(redirectTo),
|
|
"redirect_to_host", parsed.Host,
|
|
"redirect_to_path", parsed.Path,
|
|
"redirect_has_login_verifier", query.Has("login_verifier"),
|
|
"redirect_has_redirect_uri", query.Has("redirect_uri"),
|
|
)
|
|
slog.Info("headless password login succeeded", args...)
|
|
}
|
|
|
|
func respondHeadlessLoginFailure(c *fiber.Ctx, failure *headlessLoginFailure) error {
|
|
if failure == nil {
|
|
return nil
|
|
}
|
|
return errorJSONCode(c, failure.status, failure.code, failure.safeMessage)
|
|
}
|
|
|
|
func newHeadlessCredentialFailure(status int, code, safeMessage string) *headlessLoginFailure {
|
|
return newHeadlessLoginFailure(
|
|
status,
|
|
code,
|
|
safeMessage,
|
|
"headless password login credential authentication failed",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, bool, error) {
|
|
if h.HeadlessJWKS == nil {
|
|
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.RedisService, nil)
|
|
}
|
|
keySet, _, refreshed, err := h.HeadlessJWKS.EnsureFreshKeySet(ctx, client, expectedKid)
|
|
if err != nil {
|
|
return nil, refreshed, err
|
|
}
|
|
return keySet, refreshed, nil
|
|
}
|
|
|
|
func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) *headlessLoginFailure {
|
|
now := time.Now().Unix()
|
|
expectedAudiences := headlessAssertionAudiences(c)
|
|
receivedAudiences := []string(claims.Audience)
|
|
debugFields := map[string]any{
|
|
"claim_issuer": claims.Issuer,
|
|
"claim_subject": claims.Subject,
|
|
"claim_expires_at": claims.ExpiresAt,
|
|
"claim_not_before": claims.NotBefore,
|
|
"claim_issued_at": claims.IssuedAt,
|
|
"received_audiences": receivedAudiences,
|
|
"expected_audiences": expectedAudiences,
|
|
"received_audiences_text": joinHeadlessAudiences(receivedAudiences),
|
|
"expected_audiences_text": joinHeadlessAudiences(expectedAudiences),
|
|
}
|
|
if claims.Issuer != clientID || claims.Subject != clientID {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_iss_sub",
|
|
"Client assertion issuer or subject mismatch",
|
|
"headless password login client assertion claims mismatch",
|
|
debugFields,
|
|
)
|
|
}
|
|
if claims.ExpiresAt == 0 || claims.ExpiresAt <= now {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_expired",
|
|
"Client assertion has expired",
|
|
"headless password login client assertion expired",
|
|
debugFields,
|
|
)
|
|
}
|
|
if claims.NotBefore != 0 && claims.NotBefore > now {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_not_before",
|
|
"Client assertion is not active yet",
|
|
"headless password login client assertion not active yet",
|
|
debugFields,
|
|
)
|
|
}
|
|
if claims.IssuedAt != 0 && claims.IssuedAt > now+60 {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_iat_future",
|
|
"Client assertion issued-at time is invalid",
|
|
"headless password login client assertion issued in the future",
|
|
debugFields,
|
|
)
|
|
}
|
|
if !containsHeadlessAudience(expectedAudiences, claims.Audience) {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_audience",
|
|
"Client assertion audience mismatch",
|
|
"headless password login client assertion audience mismatch",
|
|
debugFields,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.HydraClient, clientID, clientAssertion string) *headlessLoginFailure {
|
|
assertion := strings.TrimSpace(clientAssertion)
|
|
if assertion == "" {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusBadRequest,
|
|
"bad_request",
|
|
"client_assertion is required",
|
|
"headless password login client assertion missing",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
token, err := josejwt.ParseSigned(assertion, []jose.SignatureAlgorithm{
|
|
jose.RS256, jose.RS384, jose.RS512,
|
|
jose.PS256, jose.PS384, jose.PS512,
|
|
jose.ES256, jose.ES384, jose.ES512,
|
|
jose.EdDSA,
|
|
})
|
|
if err != nil {
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_parse",
|
|
"Client assertion format is invalid",
|
|
"headless password login client assertion parse failed",
|
|
nil,
|
|
)
|
|
}
|
|
|
|
expectedKid := ""
|
|
if len(token.Headers) > 0 {
|
|
expectedKid = strings.TrimSpace(token.Headers[0].KeyID)
|
|
}
|
|
|
|
keySet, refreshed, err := h.loadHeadlessJWKS(c.Context(), client, expectedKid)
|
|
if err != nil {
|
|
slog.Error("failed to load jwks for headless client assertion", "clientID", clientID, "error", err)
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_jwks_load",
|
|
headlessClientAssertionErrorMessage(err),
|
|
"headless password login client assertion jwks load failed",
|
|
map[string]any{
|
|
"received_kid": expectedKid,
|
|
},
|
|
)
|
|
}
|
|
|
|
matchingKidPresent := expectedKid != "" && containsHeadlessKeyID(keySet, expectedKid)
|
|
for _, key := range keySet.Keys {
|
|
if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid {
|
|
continue
|
|
}
|
|
|
|
var claims headlessClientAssertionClaims
|
|
if err := token.Claims(key.Key, &claims); err != nil {
|
|
continue
|
|
}
|
|
if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil {
|
|
if failure.debugFields == nil {
|
|
failure.debugFields = map[string]any{}
|
|
}
|
|
failure.debugFields["received_kid"] = expectedKid
|
|
failure.debugFields["jwks_refreshed"] = refreshed
|
|
return failure
|
|
}
|
|
_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
|
|
return nil
|
|
}
|
|
|
|
if matchingKidPresent && !refreshed && h.HeadlessJWKS != nil {
|
|
refreshedKeySet, _, refreshErr := h.HeadlessJWKS.ForceRefreshKeySet(c.Context(), client, "signature_verification_failed")
|
|
if refreshErr == nil && refreshedKeySet != nil {
|
|
for _, key := range refreshedKeySet.Keys {
|
|
if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid {
|
|
continue
|
|
}
|
|
|
|
var claims headlessClientAssertionClaims
|
|
if err := token.Claims(key.Key, &claims); err != nil {
|
|
continue
|
|
}
|
|
if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil {
|
|
if failure.debugFields == nil {
|
|
failure.debugFields = map[string]any{}
|
|
}
|
|
failure.debugFields["received_kid"] = expectedKid
|
|
failure.debugFields["jwks_refreshed"] = true
|
|
return failure
|
|
}
|
|
_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return newHeadlessLoginFailure(
|
|
fiber.StatusUnauthorized,
|
|
"invalid_client_assertion_signature",
|
|
"Client assertion signature verification failed",
|
|
"headless password login client assertion signature verification failed",
|
|
map[string]any{
|
|
"received_kid": expectedKid,
|
|
"jwks_refreshed": refreshed,
|
|
},
|
|
)
|
|
}
|
|
|
|
func headlessClientAssertionErrorMessage(err error) string {
|
|
if err == nil {
|
|
return "Failed to verify client assertion"
|
|
}
|
|
message := strings.TrimSpace(err.Error())
|
|
switch {
|
|
case strings.Contains(message, "requires jwksUri"):
|
|
return "Headless login requires jwksUri. Inline jwks is not supported."
|
|
case strings.Contains(message, "no keys"):
|
|
return "Configured jwksUri returned no keys for headless login."
|
|
case strings.Contains(message, "failed to fetch jwksUri"):
|
|
return "Failed to refresh headless login jwks from jwksUri."
|
|
case strings.Contains(message, "failed to decode jwks"):
|
|
return "Configured jwksUri returned an invalid jwks document."
|
|
default:
|
|
return "Failed to verify client assertion"
|
|
}
|
|
}
|
|
|
|
func containsHeadlessKeyID(keySet *jose.JSONWebKeySet, expectedKid string) bool {
|
|
if keySet == nil {
|
|
return false
|
|
}
|
|
for _, key := range keySet.Keys {
|
|
if strings.TrimSpace(key.KeyID) == strings.TrimSpace(expectedKid) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *AuthHandler) storeHeadlessLinkState(pendingRef string, state headlessLinkState, ttl time.Duration) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return
|
|
}
|
|
raw, err := json.Marshal(state)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = h.RedisService.Set(prefixHeadlessLinkState+pendingRef, string(raw), ttl)
|
|
}
|
|
|
|
func (h *AuthHandler) loadHeadlessLinkState(pendingRef string) (headlessLinkState, bool) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return headlessLinkState{}, false
|
|
}
|
|
raw, err := h.RedisService.Get(prefixHeadlessLinkState + pendingRef)
|
|
if err != nil || raw == "" {
|
|
return headlessLinkState{}, false
|
|
}
|
|
var state headlessLinkState
|
|
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
|
return headlessLinkState{}, false
|
|
}
|
|
return state, true
|
|
}
|
|
|
|
func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) (string, *domain.AuthInfo, error) {
|
|
val, err := h.RedisService.Get(prefixSession + pendingRef)
|
|
if err != nil || val == "" {
|
|
return "", nil, errorJSON(c, fiber.StatusBadRequest, "Invalid session reference")
|
|
}
|
|
|
|
var data map[string]string
|
|
_ = json.Unmarshal([]byte(val), &data)
|
|
loginID := data["loginId"]
|
|
if loginID == "" {
|
|
loginID = data["login_id"]
|
|
}
|
|
if loginID == "" {
|
|
slog.Warn("[Poll] Approved but missing loginId", "pendingRef", pendingRef)
|
|
return "", nil, errorJSON(c, fiber.StatusBadRequest, "Invalid session reference")
|
|
}
|
|
if h.IdpProvider == nil {
|
|
return "", nil, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
|
|
loginStrategy := h.loadLoginStrategy(pendingRef)
|
|
if loginStrategy == "" {
|
|
loginStrategy = loginFlowLink
|
|
}
|
|
|
|
var authInfo *domain.AuthInfo
|
|
if loginStrategy == loginFlowCode {
|
|
code, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
|
|
code = normalizeLoginCode(code)
|
|
if code == "" {
|
|
slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", pendingRef)
|
|
return "", nil, errorJSON(c, fiber.StatusBadRequest, "Login code expired")
|
|
}
|
|
flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
|
|
if flowID == "" {
|
|
return "", nil, errorJSON(c, fiber.StatusNotFound, "Login flow expired")
|
|
}
|
|
authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return "", nil, errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
|
|
}
|
|
slog.Error("[Poll] IDP code verify failed", "error", err)
|
|
return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to verify login code")
|
|
}
|
|
} else {
|
|
authInfo, err = h.IdpProvider.IssueSession(loginID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return "", nil, errorJSON(c, fiber.StatusNotImplemented, "Login method not supported")
|
|
}
|
|
slog.Error("[Poll] IDP session issue failed", "error", err)
|
|
return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
|
|
}
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session")
|
|
}
|
|
|
|
c.Locals("login_id", loginID)
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
|
|
// Write Kratos session cookies to the response
|
|
h.writeAuthCookies(c, authInfo.SetCookies)
|
|
|
|
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
|
|
if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
|
|
if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" {
|
|
sessionID = resolved
|
|
authInfo.SessionToken.SessionID = resolved
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
}
|
|
}
|
|
|
|
sessionData := map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": authInfo.SessionToken.JWT,
|
|
}
|
|
if sessionID != "" {
|
|
sessionData["session_id"] = sessionID
|
|
}
|
|
sessionDataJSON, _ := json.Marshal(sessionData)
|
|
_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration)
|
|
|
|
h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c)
|
|
h.clearLoginMeta(pendingRef)
|
|
if loginStrategy == loginFlowCode {
|
|
_ = h.RedisService.Delete(prefixLoginCode + loginID)
|
|
_ = h.RedisService.Delete(prefixLoginCodePending + loginID)
|
|
_ = h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
|
|
_ = h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
|
|
_ = h.RedisService.Delete(prefixLoginCodeValue + pendingRef)
|
|
}
|
|
|
|
return loginID, authInfo, nil
|
|
}
|
|
|
|
func (h *AuthHandler) validateHeadlessPasswordLoginClient(loginReq *domain.HydraLoginRequest, clientID string) error {
|
|
if loginReq == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
|
|
}
|
|
|
|
if strings.TrimSpace(loginReq.Client.ClientID) != strings.TrimSpace(clientID) {
|
|
return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use this login request.")
|
|
}
|
|
|
|
if metadata := loginReq.Client.Metadata; metadata != nil {
|
|
if status, ok := metadata["status"].(string); ok && strings.ToLower(status) == "inactive" {
|
|
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
|
}
|
|
}
|
|
|
|
if !loginReq.Client.IsHeadlessLoginEnabled() {
|
|
return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use headless password login.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error {
|
|
var req struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientAssertion string `json:"client_assertion"`
|
|
LoginID string `json:"loginId"`
|
|
Password string `json:"password"`
|
|
LoginChallenge string `json:"login_challenge"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
|
}
|
|
|
|
clientID := strings.TrimSpace(req.ClientID)
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
loginChallenge := strings.TrimSpace(req.LoginChallenge)
|
|
if clientID == "" || loginID == "" || strings.TrimSpace(req.Password) == "" || loginChallenge == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, loginId, password and login_challenge are required")
|
|
}
|
|
|
|
if h.IdpProvider == nil || h.Hydra == nil {
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
|
|
}
|
|
|
|
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), loginChallenge)
|
|
if err != nil {
|
|
slog.Error("failed to get hydra login request for headless password login", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
|
|
}
|
|
if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
|
|
return err
|
|
}
|
|
if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
|
|
logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge)
|
|
return respondHeadlessLoginFailure(c, failure)
|
|
}
|
|
|
|
authInfo, authErr := h.authenticatePasswordLogin(c.Context(), loginID, req.Password)
|
|
if authErr != nil {
|
|
status, code, message := passwordLoginErrorSpec(authErr)
|
|
logHeadlessLoginFailure(
|
|
c,
|
|
"headless password login credential authentication failed",
|
|
newHeadlessCredentialFailure(status, code, message),
|
|
clientID,
|
|
loginChallenge,
|
|
)
|
|
return errorJSONCode(c, status, code, message)
|
|
}
|
|
|
|
c.Locals("user_id", authInfo.Subject)
|
|
c.Locals("login_id", loginID)
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
attachAuditClientDetails(c, loginReq.Client)
|
|
appendAuditDetail(c, "login_challenge", loginChallenge)
|
|
|
|
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject)
|
|
if err != nil {
|
|
slog.Error("failed to accept hydra login request in headless password login", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
|
}
|
|
|
|
logOidcRedirectSummary("headless_password_login", acceptResp.RedirectTo)
|
|
if err := c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"redirectTo": acceptResp.RedirectTo,
|
|
"status": "ok",
|
|
"provider": h.IdpProvider.Name(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
logHeadlessLoginSuccess(c, clientID, loginChallenge, acceptResp.RedirectTo)
|
|
return nil
|
|
}
|
|
|
|
func (h *AuthHandler) startHeadlessPhoneLink(c *fiber.Ctx, loginID string) (fiber.Map, string, string, time.Duration, error) {
|
|
rawLoginID := strings.ReplaceAll(loginID, "-", "")
|
|
rawLoginID = strings.ReplaceAll(rawLoginID, " ", "")
|
|
if rawLoginID == "" || strings.Contains(rawLoginID, "@") {
|
|
return nil, "", "", 0, errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "phone-based loginId is required")
|
|
}
|
|
|
|
lookupLoginID := normalizePhoneForLoginID(rawLoginID)
|
|
if h.IdpProvider == nil {
|
|
return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
exists, err := h.IdpProvider.UserExists(lookupLoginID)
|
|
if err != nil {
|
|
slog.Warn("[HeadlessLink] IDP user lookup failed", "loginID", rawLoginID, "error", err)
|
|
return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
if !exists {
|
|
slog.Warn("[HeadlessLink] User not found", "loginID", rawLoginID)
|
|
return nil, "", "", 0, errorJSON(c, fiber.StatusNotFound, "User not registered")
|
|
}
|
|
|
|
userfrontURL := h.resolveUserfrontURL(c)
|
|
if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
|
|
keyLoginID := lookupLoginID
|
|
if init.LoginID != "" {
|
|
keyLoginID = init.LoginID
|
|
}
|
|
if init.FlowID != "" {
|
|
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
|
|
}
|
|
pendingRef := GenerateSecureToken(3)
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusPending,
|
|
"loginId": keyLoginID,
|
|
})
|
|
_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
|
h.storeLoginMeta(pendingRef, rawLoginID, "sms", loginFlowLink, loginFlowCode, loginCodeExpiration)
|
|
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
|
|
if keyLoginID != lookupLoginID {
|
|
_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
|
|
_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
|
|
}
|
|
expiresIn := int(loginCodeExpiration.Seconds())
|
|
if !init.ExpiresAt.IsZero() {
|
|
if seconds := int(time.Until(init.ExpiresAt).Seconds()); seconds > 0 {
|
|
expiresIn = seconds
|
|
}
|
|
}
|
|
return fiber.Map{
|
|
"pendingRef": pendingRef,
|
|
"status": "pending",
|
|
"mode": init.Mode,
|
|
"provider": h.IdpProvider.Name(),
|
|
"expiresIn": expiresIn,
|
|
"interval": int(minPollInterval.Seconds()),
|
|
"resendAfter": int(linkResendCooldown.Seconds()),
|
|
}, pendingRef, keyLoginID, loginCodeExpiration, nil
|
|
} else if err != nil && !errors.Is(err, domain.ErrNotSupported) {
|
|
slog.Error("[HeadlessLink] Link login init failed", "provider", h.IdpProvider.Name(), "error", err)
|
|
return nil, "", "", 0, errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
|
|
}
|
|
|
|
if h.SmsService == nil {
|
|
return nil, "", "", 0, errorJSON(c, fiber.StatusInternalServerError, "SMS service not configured")
|
|
}
|
|
|
|
token := GenerateSecureToken(3)
|
|
pendingRef := GenerateSecureToken(3)
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusPending,
|
|
"loginId": lookupLoginID,
|
|
})
|
|
_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
|
_ = h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
|
|
h.storeLoginMeta(pendingRef, rawLoginID, "sms", loginFlowLink, loginFlowLink, defaultExpiration)
|
|
|
|
link := fmt.Sprintf("%s/verify/%s", strings.TrimRight(userfrontURL, "/"), token)
|
|
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s", link)
|
|
if err := h.SmsService.SendSms(rawLoginID, content); err != nil {
|
|
slog.Error("[HeadlessLink] SMS send failed", "error", err)
|
|
return nil, "", "", 0, errorJSON(c, fiber.StatusInternalServerError, "Failed to send SMS")
|
|
}
|
|
|
|
return fiber.Map{
|
|
"pendingRef": pendingRef,
|
|
"status": "pending",
|
|
"provider": h.IdpProvider.Name(),
|
|
"expiresIn": int(defaultExpiration.Seconds()),
|
|
"interval": int(minPollInterval.Seconds()),
|
|
"resendAfter": int(linkResendCooldown.Seconds()),
|
|
}, pendingRef, lookupLoginID, defaultExpiration, nil
|
|
}
|
|
|
|
func (h *AuthHandler) HeadlessLinkInit(c *fiber.Ctx) error {
|
|
var req struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientAssertion string `json:"client_assertion"`
|
|
LoginID string `json:"loginId"`
|
|
LoginChallenge string `json:"login_challenge"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
|
}
|
|
|
|
clientID := strings.TrimSpace(req.ClientID)
|
|
loginChallenge := strings.TrimSpace(req.LoginChallenge)
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
if clientID == "" || loginChallenge == "" || loginID == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, client_assertion, loginId and login_challenge are required")
|
|
}
|
|
if h.Hydra == nil {
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
|
|
}
|
|
|
|
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), loginChallenge)
|
|
if err != nil {
|
|
slog.Error("failed to get hydra login request for headless link init", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
|
|
}
|
|
if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
|
|
return err
|
|
}
|
|
if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
|
|
logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge)
|
|
return respondHeadlessLoginFailure(c, failure)
|
|
}
|
|
|
|
resp, pendingRef, resolvedLoginID, ttl, err := h.startHeadlessPhoneLink(c, loginID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.storeHeadlessLinkState(pendingRef, headlessLinkState{
|
|
ClientID: clientID,
|
|
ClientName: strings.TrimSpace(loginReq.Client.ClientName),
|
|
LoginChallenge: loginChallenge,
|
|
LoginID: resolvedLoginID,
|
|
}, ttl)
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error {
|
|
var req struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientAssertion string `json:"client_assertion"`
|
|
PendingRef string `json:"pendingRef"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
|
}
|
|
|
|
clientID := strings.TrimSpace(req.ClientID)
|
|
pendingRef := strings.TrimSpace(req.PendingRef)
|
|
if clientID == "" || pendingRef == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_id, client_assertion and pendingRef are required")
|
|
}
|
|
|
|
state, ok := h.loadHeadlessLinkState(pendingRef)
|
|
if !ok {
|
|
return c.JSON(fiber.Map{
|
|
"error": "expired_token",
|
|
"code": "expired_token",
|
|
})
|
|
}
|
|
if state.ClientID != clientID {
|
|
return fiber.NewError(fiber.StatusForbidden, "The client application is not allowed to use this pending login.")
|
|
}
|
|
if state.RedirectTo != "" {
|
|
return c.JSON(fiber.Map{
|
|
"redirectTo": state.RedirectTo,
|
|
"status": "ok",
|
|
})
|
|
}
|
|
if h.Hydra == nil {
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
|
|
}
|
|
|
|
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), state.LoginChallenge)
|
|
if err != nil {
|
|
slog.Error("failed to get hydra login request for headless link poll", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load OIDC login request")
|
|
}
|
|
if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
|
|
return err
|
|
}
|
|
if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
|
|
logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, state.LoginChallenge)
|
|
return respondHeadlessLoginFailure(c, failure)
|
|
}
|
|
|
|
val, err := h.RedisService.Get(prefixSession + pendingRef)
|
|
if err != nil || val == "" {
|
|
return c.JSON(fiber.Map{
|
|
"error": "expired_token",
|
|
"code": "expired_token",
|
|
})
|
|
}
|
|
|
|
var session map[string]string
|
|
_ = json.Unmarshal([]byte(val), &session)
|
|
if session["status"] == statusPending {
|
|
return c.JSON(fiber.Map{
|
|
"error": "authorization_pending",
|
|
"code": "authorization_pending",
|
|
"interval": int(minPollInterval.Seconds()),
|
|
})
|
|
}
|
|
|
|
loginID := state.LoginID
|
|
if session["status"] == "approved" {
|
|
completedLoginID, _, err := h.completeApprovedLinkLogin(c, pendingRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
loginID = completedLoginID
|
|
}
|
|
|
|
if loginID == "" {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve approved user identity")
|
|
}
|
|
subject, err := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
|
|
if err != nil || subject == "" {
|
|
slog.Error("failed to resolve kratos identity for headless link poll", "loginID", loginID, "error", err)
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve user identity")
|
|
}
|
|
|
|
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), state.LoginChallenge, subject)
|
|
if err != nil {
|
|
slog.Error("failed to accept hydra login request in headless link poll", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
|
}
|
|
|
|
state.RedirectTo = acceptResp.RedirectTo
|
|
h.storeHeadlessLinkState(pendingRef, state, defaultExpiration)
|
|
logOidcRedirectSummary("headless_link_poll", acceptResp.RedirectTo)
|
|
return c.JSON(fiber.Map{
|
|
"redirectTo": acceptResp.RedirectTo,
|
|
"status": "ok",
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) writeAuthCookies(c *fiber.Ctx, cookies []*http.Cookie) {
|
|
if len(cookies) == 0 {
|
|
return
|
|
}
|
|
|
|
host := c.Hostname()
|
|
domain := ""
|
|
|
|
// IP address or localhost check
|
|
if ip := net.ParseIP(host); ip != nil || host == "localhost" {
|
|
domain = host
|
|
} else {
|
|
// Extract root domain (e.g., .hmac.kr from sso.hmac.kr)
|
|
parts := strings.Split(host, ".")
|
|
if len(parts) >= 2 {
|
|
domain = "." + strings.Join(parts[len(parts)-2:], ".")
|
|
}
|
|
}
|
|
|
|
for _, cookie := range cookies {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: cookie.Name,
|
|
Value: cookie.Value,
|
|
Path: "/",
|
|
Domain: domain,
|
|
MaxAge: cookie.MaxAge,
|
|
Expires: cookie.Expires,
|
|
Secure: true,
|
|
HTTPOnly: true,
|
|
SameSite: "Lax",
|
|
})
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|
startTime := time.Now()
|
|
ale := logger.NewAuditLogEntry(c, "login")
|
|
ale.Operation = "Auth.Password().SignIn"
|
|
|
|
var req struct {
|
|
LoginID string `json:"loginId"`
|
|
Password string `json:"password"`
|
|
LoginChallenge string `json:"login_challenge,omitempty"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = err.Error()
|
|
ale.Log(slog.LevelError, "Body parse error")
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
|
}
|
|
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
|
ale.LoginIDs["loginId_normalized"] = loginID
|
|
|
|
ale.Log(slog.LevelInfo, "Attempting to login")
|
|
|
|
if h.IdpProvider == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "IDP Provider is nil"
|
|
ale.Log(slog.LevelError, "IDP Provider is nil")
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured")
|
|
}
|
|
|
|
authInfo, err := h.authenticatePasswordLogin(c.Context(), loginID, req.Password)
|
|
if err != nil {
|
|
status, code, message := passwordLoginErrorSpec(err)
|
|
ale.Status = status
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = err.Error()
|
|
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
|
|
return errorJSONCode(c, status, code, message)
|
|
}
|
|
|
|
ale.Status = fiber.StatusOK
|
|
ale.LatencyMs = time.Since(startTime)
|
|
c.Locals("user_id", authInfo.Subject)
|
|
c.Locals("login_id", loginID)
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
|
|
// Write Kratos session cookies to the response
|
|
h.writeAuthCookies(c, authInfo.SetCookies)
|
|
|
|
if req.LoginChallenge == "" {
|
|
attachAuditClientDetails(c, domain.HydraClient{
|
|
ClientID: "userfront",
|
|
ClientName: "UserFront",
|
|
})
|
|
}
|
|
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
|
|
|
// --- OIDC 로그인 흐름 처리 ---
|
|
if req.LoginChallenge != "" {
|
|
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
|
|
|
|
// Check if the client is active
|
|
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
|
|
if err == nil && loginReq != nil {
|
|
attachAuditClientDetails(c, loginReq.Client)
|
|
if loginReq.Client.Metadata != nil {
|
|
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
|
|
if strings.ToLower(status) == "inactive" {
|
|
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
|
|
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject)
|
|
if err != nil {
|
|
slog.Error("failed to accept hydra login request", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
|
}
|
|
logOidcRedirectSummary("password_login", acceptResp.RedirectTo)
|
|
|
|
// IMPORTANT: Also return sessionJwt and token during OIDC flow to ensure portal session.
|
|
return c.JSON(fiber.Map{
|
|
"redirectTo": acceptResp.RedirectTo,
|
|
"status": "ok",
|
|
"provider": h.IdpProvider.Name(),
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"token": authInfo.SessionToken.JWT,
|
|
"subject": authInfo.Subject,
|
|
})
|
|
}
|
|
// --- OIDC 로그인 흐름 처리 끝 ---
|
|
|
|
resp := fiber.Map{
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"token": authInfo.SessionToken.JWT,
|
|
"status": "ok",
|
|
"provider": h.IdpProvider.Name(),
|
|
}
|
|
if authInfo.RefreshToken != nil {
|
|
resp["refreshJwt"] = authInfo.RefreshToken.JWT
|
|
}
|
|
if authInfo.Subject != "" {
|
|
resp["subject"] = authInfo.Subject
|
|
}
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
|
|
clientID := strings.TrimSpace(client.ClientID)
|
|
if clientID == "" {
|
|
return
|
|
}
|
|
|
|
clientName := strings.TrimSpace(client.ClientName)
|
|
if clientName == "" {
|
|
clientName = clientID
|
|
}
|
|
|
|
if client.Metadata != nil {
|
|
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid != "" {
|
|
c.Locals("tenant_id", tid)
|
|
}
|
|
}
|
|
|
|
c.Locals("audit_details_extra", map[string]any{
|
|
"client_id": clientID,
|
|
"client_name": clientName,
|
|
})
|
|
}
|
|
|
|
func appendAuditDetail(c *fiber.Ctx, key string, value any) {
|
|
if c == nil || strings.TrimSpace(key) == "" || value == nil {
|
|
return
|
|
}
|
|
|
|
extra, _ := c.Locals("audit_details_extra").(map[string]any)
|
|
if extra == nil {
|
|
extra = make(map[string]any)
|
|
}
|
|
extra[key] = value
|
|
c.Locals("audit_details_extra", extra)
|
|
}
|
|
|
|
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다.
|
|
func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|
startTime := time.Now()
|
|
ale := logger.NewAuditLogEntry(c, "initiate")
|
|
|
|
var req domain.PasswordResetInitiateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = err.Error()
|
|
ale.Log(slog.LevelError, "Body parse error")
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
|
ale.LoginIDs["loginId_normalized"] = loginID
|
|
|
|
if loginID == "" {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Login ID is required"
|
|
ale.Log(slog.LevelWarn, "Login ID missing")
|
|
return errorJSON(c, fiber.StatusBadRequest, "Login ID is required")
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "IDP Provider is not initialized"
|
|
ale.Log(slog.LevelError, "IDP Provider is not initialized")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
|
|
}
|
|
|
|
userfrontURL := h.resolveUserfrontURL(c)
|
|
// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
|
|
// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
|
|
verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
|
|
|
|
// 내부 토큰 발급 + 우리 채널로 전송
|
|
resetToken := GenerateSecureToken(32)
|
|
if resetToken == "" {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Failed to generate reset token"
|
|
ale.Log(slog.LevelError, "Failed to generate reset token")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to generate reset token")
|
|
}
|
|
|
|
if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = err.Error()
|
|
ale.Log(slog.LevelError, "Failed to store reset token in Redis")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
|
}
|
|
|
|
resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
|
|
ale.RedirectTo = resetLink
|
|
ale.Operation = "SendPasswordReset"
|
|
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
|
drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed()
|
|
if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() {
|
|
ale.Log(slog.LevelWarn, "DrySend ignored in production")
|
|
}
|
|
|
|
if strings.Contains(loginID, "@") {
|
|
if !drySend && h.EmailService == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Email service not configured"
|
|
ale.Log(slog.LevelError, "Email service not configured")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Email service not configured")
|
|
}
|
|
subject := "[Baron 로그인] 비밀번호 재설정"
|
|
body := fmt.Sprintf(`
|
|
<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 == "" {
|
|
token = c.Params("token")
|
|
}
|
|
|
|
if token == "" {
|
|
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
|
}
|
|
|
|
// Simple HTML page with a form to trigger the POST request
|
|
html := fmt.Sprintf(`
|
|
<!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")
|
|
}
|
|
}
|
|
if token == "" {
|
|
token = c.Params("token")
|
|
}
|
|
ale.Token = token
|
|
|
|
if token == "" {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Missing token"
|
|
ale.Log(slog.LevelWarn, "Missing token in request")
|
|
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
|
}
|
|
|
|
loginID, err := h.RedisService.Get(prefixPwdResetToken + token)
|
|
if err != nil || loginID == "" {
|
|
ale.Status = fiber.StatusUnauthorized
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Invalid or expired reset token"
|
|
ale.Log(slog.LevelWarn, "Reset token invalid or expired")
|
|
return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired token")
|
|
}
|
|
|
|
ale.LoginIDs["loginId"] = loginID
|
|
ale.LoginIDs["loginId_normalized"] = loginID
|
|
|
|
userfrontURL := h.resolveUserfrontURL(c)
|
|
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) == "" {
|
|
if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
|
|
ale.Status = fiber.StatusOK
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.Token = resetToken
|
|
ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
|
|
ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
|
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
|
}
|
|
ale.Status = fiber.StatusUnauthorized
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Invalid or expired reset token"
|
|
ale.Token = resetToken
|
|
ale.Log(slog.LevelWarn, "Reset token invalid or expired")
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid or expired reset token")
|
|
}
|
|
loginID = strings.TrimSpace(val)
|
|
ale.Token = resetToken
|
|
}
|
|
if loginID != "" && !strings.Contains(loginID, "@") {
|
|
loginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
|
|
ale.LoginIDs["loginId"] = loginID
|
|
|
|
// 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다.
|
|
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
|
|
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
|
|
ale.ParsedCookieDSRF = dsrfCookie
|
|
ale.HasCookieDSRF = true
|
|
} else {
|
|
ale.HasCookieDSRF = false
|
|
}
|
|
}
|
|
|
|
if loginID == "" || req.NewPassword == "" {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "Login ID and new password are required"
|
|
ale.Log(slog.LevelWarn, "Login ID or new password missing")
|
|
return errorJSON(c, fiber.StatusBadRequest, "Login ID and new password are required")
|
|
}
|
|
|
|
// 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다.
|
|
ale.Log(slog.LevelInfo, "Received new password for reset")
|
|
|
|
policy := h.resolvePasswordPolicy()
|
|
if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = err.Error()
|
|
ale.Log(slog.LevelWarn, "Validation failed: "+err.Error())
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName))
|
|
|
|
if h.IdpProvider == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = "IDP Provider is nil"
|
|
ale.Log(slog.LevelError, "IDP Provider is nil")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
|
|
}
|
|
|
|
if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.ProviderError = err.Error()
|
|
ale.Log(slog.LevelError, "Failed to update password via IDP")
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password")
|
|
}
|
|
|
|
ale.Status = fiber.StatusOK
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
|
if resetToken != "" {
|
|
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
|
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
|
|
}
|
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
|
}
|
|
|
|
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
|
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
|
pendingRef := GenerateSecureToken(16)
|
|
qrRef := GenerateSecureAlnumToken(64)
|
|
if qrRef == "" {
|
|
qrRef = GenerateSecureToken(16)
|
|
}
|
|
|
|
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
|
userfrontURL := h.resolveUserfrontURL(c)
|
|
qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef)
|
|
|
|
slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload)
|
|
|
|
// Redis에 초기 상태 저장 (5분 만료)
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
|
h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
|
|
h.storeQrMeta(pendingRef, c)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
|
|
"pendingRef": pendingRef,
|
|
"expiresIn": 300,
|
|
"interval": int(minPollInterval.Seconds()),
|
|
})
|
|
}
|
|
|
|
// PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다.
|
|
func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error {
|
|
var req struct {
|
|
PendingRef string `json:"pendingRef"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid body")
|
|
}
|
|
|
|
pollKey := prefixPollMeta + "qr:" + req.PendingRef
|
|
if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": "slow_down",
|
|
"code": "slow_down",
|
|
"interval": interval,
|
|
})
|
|
}
|
|
|
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
|
if err != nil || val == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "expired_token")
|
|
}
|
|
|
|
var data map[string]string
|
|
json.Unmarshal([]byte(val), &data)
|
|
|
|
if data["status"] == statusSuccess {
|
|
slog.Info("[QR] Poll Success", "pendingRef", req.PendingRef)
|
|
return c.JSON(fiber.Map{
|
|
"status": "ok",
|
|
"sessionJwt": data["jwt"],
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": "authorization_pending",
|
|
"code": "authorization_pending",
|
|
"interval": int(minPollInterval.Seconds()),
|
|
})
|
|
}
|
|
|
|
// ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다.
|
|
// (이미 로그인된 세션이 필요함)
|
|
func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
|
|
var req struct {
|
|
PendingRef string `json:"pendingRef"`
|
|
Token string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용)
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("[QR] Scan body parse error", "error", err)
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid body")
|
|
}
|
|
|
|
rawRef := strings.TrimSpace(req.PendingRef)
|
|
pendingRef, err := h.resolveQrPendingRef(rawRef)
|
|
if err != nil || pendingRef == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid pendingRef")
|
|
}
|
|
|
|
slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef)
|
|
|
|
// 1. Redis에서 세션 확인
|
|
val, err := h.RedisService.Get(prefixSession + pendingRef)
|
|
if err != nil || val == "" {
|
|
return errorJSON(c, fiber.StatusNotFound, "Session expired or not found")
|
|
}
|
|
|
|
if req.Token == "" {
|
|
cookie := c.Get(fiber.HeaderCookie)
|
|
if cookie == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Missing session token")
|
|
}
|
|
_, traits, _, err := h.getKratosIdentityWithCookie(cookie)
|
|
if err != nil {
|
|
slog.Warn("[QR] Cookie session invalid", "error", err)
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" {
|
|
h.storeQrApproverSessionID(pendingRef, sessionID)
|
|
}
|
|
loginID := pickLoginIDFromTraits(traits)
|
|
if loginID == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
if !strings.Contains(loginID, "@") {
|
|
loginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
|
|
slog.Error("[QR] Start code login failed", "error", err)
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session")
|
|
}
|
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
|
}
|
|
|
|
// 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
|
|
approvedSessionID := ""
|
|
if req.Token != "" {
|
|
if sessionID, err := h.getKratosSessionID(req.Token); err == nil {
|
|
approvedSessionID = sessionID
|
|
}
|
|
}
|
|
if approvedSessionID != "" {
|
|
h.storeQrApproverSessionID(pendingRef, approvedSessionID)
|
|
}
|
|
|
|
loginID, err := h.resolveKratosLoginID(req.Token)
|
|
if err != nil {
|
|
slog.Warn("[QR] Invalid token", "error", err)
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
|
|
slog.Error("[QR] Start code login failed", "error", err)
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue web session")
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
|
}
|
|
|
|
type kratosCourierRequest struct {
|
|
Recipient string `json:"recipient"`
|
|
TemplateType string `json:"template_type"`
|
|
TemplateData map[string]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
|
|
}
|
|
// Handle numeric types by converting to string
|
|
if num, ok := val.(float64); ok {
|
|
return fmt.Sprint(num)
|
|
}
|
|
if num, ok := val.(int); ok {
|
|
return fmt.Sprint(num)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sanitizePhoneForSms(phone string) string {
|
|
sanitized := 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)
|
|
}
|
|
|
|
func (h *AuthHandler) resolveProfileForSubject(ctx context.Context, subject string) (*domain.UserProfileResponse, error) {
|
|
subject = strings.TrimSpace(subject)
|
|
if subject == "" || h.KratosAdmin == nil {
|
|
return nil, fmt.Errorf("subject profile unavailable")
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(ctx, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if identity == nil {
|
|
return nil, fmt.Errorf("identity not found")
|
|
}
|
|
|
|
profile := h.mapKratosIdentityToProfile(identity.ID, identity.Traits)
|
|
if profile == nil {
|
|
return nil, fmt.Errorf("failed to map identity profile")
|
|
}
|
|
return h.hydrateResolvedProfile(ctx, profile), nil
|
|
}
|
|
|
|
func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domain.UserProfileResponse) *domain.UserProfileResponse {
|
|
if profile == nil {
|
|
return nil
|
|
}
|
|
|
|
profile.Role = domain.NormalizeRole(profile.Role)
|
|
if profile.Role == "" {
|
|
profile.Role = domain.RoleUser
|
|
}
|
|
|
|
if h.TenantService != nil {
|
|
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
|
if tenant, err := h.TenantService.GetTenant(ctx, *profile.TenantID); err == nil {
|
|
profile.Tenant = tenant
|
|
}
|
|
}
|
|
if profile.Tenant == nil && profile.CompanyCode != "" {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(ctx, profile.CompanyCode); err == nil && tenant != nil {
|
|
profile.Tenant = tenant
|
|
if profile.TenantID == nil || *profile.TenantID == "" {
|
|
profile.TenantID = &tenant.ID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if h.TenantService != nil {
|
|
if profile.Role == domain.RoleTenantAdmin {
|
|
manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
|
|
if err == nil {
|
|
profile.ManageableTenants = manageable
|
|
}
|
|
}
|
|
|
|
joined, err := h.TenantService.ListJoinedTenants(ctx, profile.ID)
|
|
if err == nil {
|
|
profile.JoinedTenants = joined
|
|
}
|
|
}
|
|
|
|
return profile
|
|
}
|
|
|
|
// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
|
|
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
|
return h.resolveCurrentProfile(c)
|
|
}
|
|
|
|
func looksLikeJWT(token string) bool {
|
|
return strings.Count(token, ".") == 2
|
|
}
|
|
|
|
func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) {
|
|
if c == nil || token == nil {
|
|
return
|
|
}
|
|
if sessionID := extractSessionIDFromToken(token); sessionID != "" {
|
|
c.Locals("session_id", sessionID)
|
|
}
|
|
}
|
|
|
|
func extractSessionIDFromToken(token *domain.Token) string {
|
|
if token == nil {
|
|
return ""
|
|
}
|
|
if token.SessionID != "" {
|
|
return token.SessionID
|
|
}
|
|
if token.JWT != "" {
|
|
return extractSessionIDFromJWT(token.JWT)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractSessionIDFromJWT(token string) string {
|
|
if !looksLikeJWT(token) {
|
|
return ""
|
|
}
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return ""
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
}
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"sid", "session_id", "sessionId", "jti"} {
|
|
if raw, ok := claims[key]; ok {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
if value != "" {
|
|
return value
|
|
}
|
|
default:
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type qrMeta struct {
|
|
IPAddress string `json:"ip_address"`
|
|
UserAgent string `json:"user_agent"`
|
|
}
|
|
|
|
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 state, ok := h.loadHeadlessLinkState(pendingRef); ok {
|
|
if strings.TrimSpace(state.ClientID) != "" {
|
|
details["client_id"] = strings.TrimSpace(state.ClientID)
|
|
}
|
|
clientName := strings.TrimSpace(state.ClientName)
|
|
if clientName == "" && strings.TrimSpace(state.ClientID) != "" {
|
|
clientName = strings.TrimSpace(state.ClientID)
|
|
}
|
|
if clientName != "" {
|
|
details["client_name"] = clientName
|
|
}
|
|
if strings.TrimSpace(state.LoginChallenge) != "" {
|
|
details["login_challenge"] = strings.TrimSpace(state.LoginChallenge)
|
|
}
|
|
}
|
|
if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
|
|
if approverMeta.IPAddress != "" {
|
|
details["approved_ip"] = approverMeta.IPAddress
|
|
}
|
|
if approverMeta.UserAgent != "" {
|
|
details["approved_user_agent"] = approverMeta.UserAgent
|
|
}
|
|
}
|
|
detailsJSON, _ := json.Marshal(details)
|
|
|
|
log := &domain.AuditLog{
|
|
EventID: GenerateSecureToken(16),
|
|
Timestamp: time.Now(),
|
|
UserID: "",
|
|
SessionID: sessionID,
|
|
EventType: fmt.Sprintf("POST %s", path),
|
|
Status: "success",
|
|
IPAddress: meta.IPAddress,
|
|
UserAgent: meta.UserAgent,
|
|
Details: string(detailsJSON),
|
|
AuthMethod: authLabel,
|
|
}
|
|
_ = h.AuditRepo.Create(log)
|
|
}
|
|
|
|
func extractClientIPFromHeaders(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
|
|
}
|
|
|
|
type authTimelineItem struct {
|
|
EventID string `json:"event_id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
UserID string `json:"user_id"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
EventType string `json:"event_type"`
|
|
Status string `json:"status"`
|
|
AuthMethod string `json:"auth_method,omitempty"`
|
|
IPAddress string `json:"ip_address"`
|
|
UserAgent string `json:"user_agent"`
|
|
Details string `json:"details,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
ClientID string `json:"client_id,omitempty"`
|
|
AppName string `json:"app_name,omitempty"`
|
|
ParentSessionID string `json:"parent_session_id,omitempty"`
|
|
}
|
|
|
|
type consentClientInfo struct {
|
|
ClientID string
|
|
Name string
|
|
ConsentAt time.Time
|
|
}
|
|
|
|
type loginClientInfo struct {
|
|
ClientID string
|
|
Name string
|
|
}
|
|
|
|
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|
if h.AuditRepo == nil && h.OathkeeperRepo == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
|
}
|
|
|
|
limit := c.QueryInt("limit", 20)
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
|
var cursor *domain.AuditCursor
|
|
if cursorRaw != "" {
|
|
var err error
|
|
cursor, err = parseAuditCursor(cursorRaw)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor")
|
|
}
|
|
}
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
currentSessionID := ""
|
|
if token := h.getBearerToken(c); token != "" {
|
|
currentSessionID = extractSessionIDFromJWT(token)
|
|
}
|
|
if currentSessionID == "" {
|
|
if cookie := c.Get("Cookie"); cookie != "" {
|
|
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
|
|
currentSessionID = sessionID
|
|
}
|
|
}
|
|
}
|
|
|
|
subject := ""
|
|
if h.OathkeeperRepo != nil {
|
|
if value, err := h.resolveConsentSubject(c); err == nil {
|
|
subject = value
|
|
}
|
|
}
|
|
|
|
consentMap := make(map[string]consentClientInfo)
|
|
if subject != "" && h.Hydra != nil {
|
|
if sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, ""); err == nil {
|
|
for _, session := range sessions {
|
|
client := session.Client
|
|
if client.ClientID == "" && session.ConsentRequest != nil {
|
|
client = session.ConsentRequest.Client
|
|
}
|
|
clientID := strings.TrimSpace(client.ClientID)
|
|
if clientID == "" {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(client.ClientName)
|
|
if name == "" {
|
|
name = clientID
|
|
}
|
|
consentAt := time.Time{}
|
|
if session.AuthenticatedAt != nil {
|
|
consentAt = *session.AuthenticatedAt
|
|
} else if session.RequestedAt != nil {
|
|
consentAt = *session.RequestedAt
|
|
} else if session.HandledAt != nil {
|
|
consentAt = *session.HandledAt
|
|
}
|
|
if existing, ok := consentMap[clientID]; ok {
|
|
if !consentAt.IsZero() && (existing.ConsentAt.IsZero() || consentAt.Before(existing.ConsentAt)) {
|
|
existing.ConsentAt = consentAt
|
|
consentMap[clientID] = existing
|
|
}
|
|
if existing.Name == "" {
|
|
existing.Name = name
|
|
consentMap[clientID] = existing
|
|
}
|
|
continue
|
|
}
|
|
consentMap[clientID] = consentClientInfo{
|
|
ClientID: clientID,
|
|
Name: name,
|
|
ConsentAt: consentAt,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
candidates := buildLoginCandidates(profile)
|
|
fetchLimit := 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 {
|
|
if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) {
|
|
continue
|
|
}
|
|
}
|
|
oathkeeperLogs = append(oathkeeperLogs, log)
|
|
if len(oathkeeperLogs) >= fetchLimit {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(logs) < fetchLimit || lastScanned == nil {
|
|
break
|
|
}
|
|
currentCursor = &domain.AuditCursor{
|
|
Timestamp: lastScanned.Timestamp,
|
|
EventID: oathkeeperEventID(*lastScanned),
|
|
}
|
|
}
|
|
}
|
|
|
|
loginChallengeCache := make(map[string]loginClientInfo)
|
|
resolveLoginClient := func(challenge string) (loginClientInfo, bool) {
|
|
challenge = strings.TrimSpace(challenge)
|
|
if challenge == "" || h.Hydra == nil {
|
|
return loginClientInfo{}, false
|
|
}
|
|
if cached, ok := loginChallengeCache[challenge]; ok {
|
|
return cached, cached.ClientID != ""
|
|
}
|
|
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), challenge)
|
|
if err != nil || loginReq == nil {
|
|
loginChallengeCache[challenge] = loginClientInfo{}
|
|
return loginClientInfo{}, false
|
|
}
|
|
clientID := strings.TrimSpace(loginReq.Client.ClientID)
|
|
if clientID == "" {
|
|
loginChallengeCache[challenge] = loginClientInfo{}
|
|
return loginClientInfo{}, false
|
|
}
|
|
name := strings.TrimSpace(loginReq.Client.ClientName)
|
|
if name == "" {
|
|
name = clientID
|
|
}
|
|
info := loginClientInfo{
|
|
ClientID: clientID,
|
|
Name: name,
|
|
}
|
|
loginChallengeCache[challenge] = info
|
|
return info, true
|
|
}
|
|
|
|
clientCache := make(map[string]loginClientInfo)
|
|
resolveClientByID := func(cid string) (loginClientInfo, bool) {
|
|
cid = strings.TrimSpace(cid)
|
|
if cid == "" || h.Hydra == nil {
|
|
return loginClientInfo{}, false
|
|
}
|
|
if cached, ok := clientCache[cid]; ok {
|
|
return cached, cached.ClientID != ""
|
|
}
|
|
client, err := h.Hydra.GetClient(c.Context(), cid)
|
|
if err != nil || client == nil {
|
|
clientCache[cid] = loginClientInfo{}
|
|
return loginClientInfo{}, false
|
|
}
|
|
name := strings.TrimSpace(client.ClientName)
|
|
if name == "" {
|
|
name = cid
|
|
}
|
|
info := loginClientInfo{
|
|
ClientID: cid,
|
|
Name: name,
|
|
}
|
|
clientCache[cid] = info
|
|
return info, true
|
|
}
|
|
|
|
items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
|
|
for i := range authLogs {
|
|
log := authLogs[i]
|
|
appName := "Baron 로그인"
|
|
clientID := ""
|
|
path := strings.ToLower(extractAuditPath(log))
|
|
|
|
isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept")
|
|
isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") ||
|
|
strings.Contains(path, "/api/v1/auth/headless/password/login")
|
|
|
|
// 우선 audit details의 client 정보를 사용
|
|
if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
|
|
if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" {
|
|
clientID = strings.TrimSpace(cid)
|
|
}
|
|
if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" {
|
|
appName = strings.TrimSpace(name)
|
|
}
|
|
}
|
|
|
|
// 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강
|
|
if appName == "Baron 로그인" || appName == "" {
|
|
if isOidcAccept {
|
|
appName = "OIDC 로그인"
|
|
}
|
|
if clientID != "" {
|
|
appName = clientID
|
|
if info, ok := resolveClientByID(clientID); ok {
|
|
appName = info.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isOidcAccept || isPasswordLogin) && (appName == "OIDC 로그인" || appName == "Baron 로그인" || appName == clientID) {
|
|
loginChallenge := extractLoginChallengeFromAuditDetails(log.Details)
|
|
if loginChallenge != "" {
|
|
if info, ok := resolveLoginClient(loginChallenge); ok {
|
|
appName = info.Name
|
|
clientID = info.ClientID
|
|
}
|
|
}
|
|
}
|
|
|
|
item := authTimelineItem{
|
|
EventID: log.EventID,
|
|
Timestamp: log.Timestamp,
|
|
UserID: log.UserID,
|
|
SessionID: log.SessionID,
|
|
EventType: log.EventType,
|
|
Status: log.Status,
|
|
AuthMethod: log.AuthMethod,
|
|
IPAddress: log.IPAddress,
|
|
UserAgent: log.UserAgent,
|
|
Details: log.Details,
|
|
Source: "backend",
|
|
AppName: appName,
|
|
ClientID: clientID,
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
for i := range oathkeeperLogs {
|
|
log := oathkeeperLogs[i]
|
|
clientID := extractClientIDFromOathkeeperLog(log)
|
|
if clientID == "" {
|
|
continue
|
|
}
|
|
|
|
appName := clientID
|
|
if consent, ok := consentMap[clientID]; ok {
|
|
appName = consent.Name
|
|
}
|
|
if appName == "" || appName == clientID {
|
|
if info, ok := resolveClientByID(clientID); ok {
|
|
appName = info.Name
|
|
}
|
|
}
|
|
|
|
details := map[string]any{
|
|
"path": log.Path,
|
|
"client_id": clientID,
|
|
"decision": log.Decision,
|
|
"status_code": log.Status,
|
|
}
|
|
detailsJSON, _ := json.Marshal(details)
|
|
status := "success"
|
|
if log.Status >= 400 {
|
|
status = "failure"
|
|
}
|
|
eventID := oathkeeperEventID(log)
|
|
item := authTimelineItem{
|
|
EventID: eventID,
|
|
Timestamp: log.Timestamp,
|
|
UserID: profile.ID,
|
|
SessionID: extractSessionIDFromOathkeeperLog(log),
|
|
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
|
|
Status: status,
|
|
AuthMethod: "세션 위임",
|
|
IPAddress: log.ClientIP,
|
|
UserAgent: log.UserAgent,
|
|
Details: string(detailsJSON),
|
|
Source: "oathkeeper",
|
|
ClientID: clientID,
|
|
AppName: appName,
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].Timestamp.Equal(items[j].Timestamp) {
|
|
return items[i].EventID > items[j].EventID
|
|
}
|
|
return items[i].Timestamp.After(items[j].Timestamp)
|
|
})
|
|
|
|
nextCursor := ""
|
|
hasMore := len(authLogs) >= fetchLimit || len(oathkeeperLogs) >= fetchLimit
|
|
if len(items) > limit {
|
|
items = items[:limit]
|
|
last := items[len(items)-1]
|
|
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
|
|
} else if hasMore && len(items) > 0 {
|
|
last := items[len(items)-1]
|
|
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"items": items,
|
|
"limit": limit,
|
|
"cursor": cursorRaw,
|
|
"next_cursor": nextCursor,
|
|
})
|
|
}
|
|
|
|
func encodeTimelineCursor(timestamp time.Time, eventID string) string {
|
|
if eventID == "" {
|
|
eventID = fmt.Sprintf("%d", timestamp.UnixNano())
|
|
}
|
|
payload := timestamp.UTC().Format(time.RFC3339Nano) + "|" + eventID
|
|
return base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
}
|
|
|
|
func oathkeeperEventID(log domain.OathkeeperAccessLog) string {
|
|
if log.RequestID != "" {
|
|
return log.RequestID
|
|
}
|
|
if log.TraceID != "" {
|
|
return log.TraceID
|
|
}
|
|
if log.SpanID != "" {
|
|
return log.SpanID
|
|
}
|
|
return fmt.Sprintf("%d", log.Timestamp.UnixNano())
|
|
}
|
|
|
|
type linkedRpSummary struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Logo string `json:"logo,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
InitURL string `json:"init_url,omitempty"`
|
|
AutoLoginSupported bool `json:"auto_login_supported"`
|
|
AutoLoginURL string `json:"auto_login_url,omitempty"`
|
|
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
|
Status string `json:"status"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
}
|
|
|
|
type linkedRpListResponse struct {
|
|
Items []linkedRpSummary `json:"items"`
|
|
}
|
|
|
|
type linkedRpRecord struct {
|
|
linkedRpSummary
|
|
lastAuth time.Time
|
|
}
|
|
|
|
func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
|
if h.Hydra == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "hydra admin unavailable")
|
|
}
|
|
|
|
subjects, err := h.resolveConsentSubjects(c)
|
|
if err != nil || len(subjects) == 0 {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
|
|
var sessions []domain.HydraConsentSession
|
|
|
|
var lastErr error
|
|
hasSuccess := false
|
|
for _, subject := range subjects {
|
|
subject = strings.TrimSpace(subject)
|
|
if subject == "" {
|
|
continue
|
|
}
|
|
linked, listErr := h.Hydra.ListConsentSessions(c.Context(), subject, "")
|
|
if listErr != nil {
|
|
lastErr = listErr
|
|
continue
|
|
}
|
|
hasSuccess = true
|
|
sessions = append(sessions, linked...)
|
|
}
|
|
if !hasSuccess && lastErr != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, lastErr.Error())
|
|
}
|
|
|
|
records := make(map[string]*linkedRpRecord)
|
|
for _, session := range sessions {
|
|
client := session.Client
|
|
if client.ClientID == "" && session.ConsentRequest != nil {
|
|
client = session.ConsentRequest.Client
|
|
}
|
|
clientID := strings.TrimSpace(client.ClientID)
|
|
if clientID == "" {
|
|
continue
|
|
}
|
|
|
|
name := strings.TrimSpace(client.ClientName)
|
|
if name == "" {
|
|
name = clientID
|
|
}
|
|
|
|
clientURL := resolveLinkedRPURL(
|
|
client.ClientID,
|
|
client.ClientURI,
|
|
client.RedirectURIs,
|
|
)
|
|
|
|
lastAuth := time.Time{}
|
|
if session.AuthenticatedAt != nil {
|
|
lastAuth = *session.AuthenticatedAt
|
|
} else if session.RequestedAt != nil {
|
|
lastAuth = *session.RequestedAt
|
|
} else if session.HandledAt != nil {
|
|
lastAuth = *session.HandledAt
|
|
}
|
|
|
|
scopes := session.GrantedScope
|
|
if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
|
|
scopes = strings.Fields(client.Scope)
|
|
}
|
|
autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
|
autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
|
initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
|
|
|
existing := records[clientID]
|
|
if existing == nil {
|
|
records[clientID] = &linkedRpRecord{
|
|
linkedRpSummary: linkedRpSummary{
|
|
ID: clientID,
|
|
Name: name,
|
|
Logo: extractHydraClientLogo(client.Metadata),
|
|
URL: clientURL,
|
|
InitURL: initURL,
|
|
AutoLoginSupported: autoLoginSupported,
|
|
AutoLoginURL: autoLoginURL,
|
|
Status: "active", // Hydra 세션이 있으면 활성
|
|
Scopes: scopes,
|
|
},
|
|
lastAuth: lastAuth,
|
|
}
|
|
continue
|
|
}
|
|
|
|
if existing.Name == "" {
|
|
existing.Name = name
|
|
}
|
|
if existing.Logo == "" {
|
|
existing.Logo = extractHydraClientLogo(client.Metadata)
|
|
}
|
|
if existing.URL == "" {
|
|
existing.URL = clientURL
|
|
}
|
|
if existing.InitURL == "" {
|
|
existing.InitURL = initURL
|
|
}
|
|
if !existing.AutoLoginSupported {
|
|
existing.AutoLoginSupported = autoLoginSupported
|
|
}
|
|
if existing.AutoLoginURL == "" {
|
|
existing.AutoLoginURL = autoLoginURL
|
|
}
|
|
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
|
if lastAuth.After(existing.lastAuth) {
|
|
existing.lastAuth = lastAuth
|
|
}
|
|
}
|
|
|
|
// Consent session payload may omit metadata fields such as logo_url.
|
|
// Rehydrate missing display fields from the full Hydra client object.
|
|
for clientID, record := range records {
|
|
if record == nil {
|
|
continue
|
|
}
|
|
needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == ""
|
|
if !needsHydraLookup {
|
|
continue
|
|
}
|
|
|
|
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if record.Name == "" {
|
|
name := strings.TrimSpace(client.ClientName)
|
|
if name == "" {
|
|
name = client.ClientID
|
|
}
|
|
record.Name = name
|
|
}
|
|
if record.Logo == "" {
|
|
record.Logo = extractHydraClientLogo(client.Metadata)
|
|
}
|
|
if record.URL == "" {
|
|
record.URL = resolveLinkedRPURL(
|
|
client.ClientID,
|
|
client.ClientURI,
|
|
client.RedirectURIs,
|
|
)
|
|
}
|
|
if record.InitURL == "" {
|
|
record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
|
}
|
|
if !record.AutoLoginSupported {
|
|
record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
|
}
|
|
if record.AutoLoginURL == "" {
|
|
record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
|
}
|
|
}
|
|
|
|
// [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함)
|
|
if h.ConsentRepo != nil {
|
|
for _, subject := range subjects {
|
|
dbConsents, err := h.ConsentRepo.ListBySubject(c.Context(), subject)
|
|
if err != nil {
|
|
slog.Error("failed to list db consents for subject", "subject", subject, "error", err)
|
|
continue
|
|
}
|
|
|
|
for _, dc := range dbConsents {
|
|
if _, exists := records[dc.ClientID]; exists {
|
|
// 이미 Hydra 세션으로 존재하면 skip (active 우선)
|
|
continue
|
|
}
|
|
|
|
// 삭제된 권한일 경우
|
|
status := "inactive"
|
|
if dc.DeletedAt.Valid {
|
|
status = "revoked"
|
|
}
|
|
|
|
// Hydra에서 클라이언트 정보 조회 (메타데이터용)
|
|
client, err := h.Hydra.GetClient(c.Context(), dc.ClientID)
|
|
if err != nil {
|
|
slog.Error("failed to get client info from hydra for inactive rp", "client_id", dc.ClientID, "error", err)
|
|
// Hydra에 정보가 없더라도 기본 정보로 추가
|
|
records[dc.ClientID] = &linkedRpRecord{
|
|
linkedRpSummary: linkedRpSummary{
|
|
ID: dc.ClientID,
|
|
Name: dc.ClientID,
|
|
Status: status,
|
|
Scopes: dc.GrantedScopes,
|
|
},
|
|
lastAuth: dc.UpdatedAt,
|
|
}
|
|
continue
|
|
}
|
|
|
|
name := strings.TrimSpace(client.ClientName)
|
|
if name == "" {
|
|
name = client.ClientID
|
|
}
|
|
|
|
clientURL := resolveLinkedRPURL(
|
|
client.ClientID,
|
|
client.ClientURI,
|
|
client.RedirectURIs,
|
|
)
|
|
autoLoginSupported := resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
|
autoLoginURL := resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
|
initURL := resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
|
|
|
records[dc.ClientID] = &linkedRpRecord{
|
|
linkedRpSummary: linkedRpSummary{
|
|
ID: dc.ClientID,
|
|
Name: name,
|
|
Logo: extractHydraClientLogo(client.Metadata),
|
|
URL: clientURL,
|
|
InitURL: initURL,
|
|
AutoLoginSupported: autoLoginSupported,
|
|
AutoLoginURL: autoLoginURL,
|
|
Status: status,
|
|
Scopes: dc.GrantedScopes,
|
|
},
|
|
lastAuth: dc.UpdatedAt,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// [New] Audit Log Scan for recent history fallback (timeline 200 items)
|
|
// Hydra 세션이나 로컬 DB(ConsentRepo)에 없지만 최근 활동 이력이 있는 앱을 보강
|
|
if h.AuditRepo != nil {
|
|
for _, subject := range subjects {
|
|
auditLogs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 200)
|
|
if err != nil {
|
|
slog.Error("failed to scan audit logs for linked rps", "error", err, "subject", subject)
|
|
continue
|
|
}
|
|
for _, log := range auditLogs {
|
|
var details struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientName string `json:"client_name"`
|
|
Scopes 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
|
|
record.InitURL = resolveLinkedRPInitURL(client.ClientID, client.Metadata)
|
|
record.AutoLoginSupported = resolveLinkedRPAutoLoginSupported(client.ClientID, client.Metadata)
|
|
record.AutoLoginURL = resolveLinkedRPAutoLoginURL(client.ClientID, client.Metadata)
|
|
} else {
|
|
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
|
if record.Name == "" {
|
|
record.Name = details.ClientID
|
|
}
|
|
}
|
|
|
|
records[details.ClientID] = record
|
|
}
|
|
}
|
|
}
|
|
|
|
ordered := make([]*linkedRpRecord, 0, len(records))
|
|
for _, record := range records {
|
|
ordered = append(ordered, record)
|
|
}
|
|
sort.Slice(ordered, func(i, j int) bool {
|
|
return ordered[i].lastAuth.After(ordered[j].lastAuth)
|
|
})
|
|
|
|
items := make([]linkedRpSummary, 0, len(ordered))
|
|
for i, record := range ordered {
|
|
if i >= 100 {
|
|
break
|
|
}
|
|
if !record.lastAuth.IsZero() {
|
|
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
|
}
|
|
items = append(items, record.linkedRpSummary)
|
|
}
|
|
|
|
return c.JSON(linkedRpListResponse{Items: items})
|
|
}
|
|
|
|
func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
|
clientID := c.Params("id")
|
|
if clientID == "" {
|
|
return fiber.NewError(fiber.StatusBadRequest, "client_id is required")
|
|
}
|
|
|
|
subject, err := h.resolveConsentSubject(c)
|
|
if err != nil || subject == "" {
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|
|
|
|
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"status": "success",
|
|
"message": "Link revoked successfully",
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|
challenge := c.Query("consent_challenge")
|
|
if challenge == "" {
|
|
return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
|
|
}
|
|
|
|
consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), challenge)
|
|
if err != nil {
|
|
slog.Error("failed to get hydra consent request", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
|
}
|
|
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
|
|
|
|
// [DEBUG] Hydra 응답 상세 로깅
|
|
slog.Info("GetConsentRequest Debug",
|
|
"challenge", challenge,
|
|
"skip", consentRequest.Skip,
|
|
"subject", consentRequest.Subject,
|
|
"client_id", consentRequest.Client.ClientID,
|
|
"scopes", consentRequest.RequestedScope,
|
|
)
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
if (err != nil || profile == nil) && consentRequest.Subject != "" {
|
|
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
|
|
profile = fallbackProfile
|
|
err = nil
|
|
}
|
|
}
|
|
if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
|
|
return nil
|
|
}
|
|
|
|
// [New] 로컬 DB에서 기존 동의 내역 확인 (강제 자동 승인 전략)
|
|
// Hydra가 skip을 주지 않더라도, 우리 DB에 이미 기록이 있다면 승인 처리함
|
|
if !consentRequest.Skip && h.ConsentRepo != nil && consentRequest.Subject != "" {
|
|
existingConsent, err := h.ConsentRepo.Find(c.Context(), consentRequest.Client.ClientID, consentRequest.Subject)
|
|
if err == nil && existingConsent != nil {
|
|
// 요청된 스코프가 이미 동의된 스코프 내에 있는지 확인
|
|
allGranted := true
|
|
grantedMap := make(map[string]bool)
|
|
for _, s := range existingConsent.GrantedScopes {
|
|
grantedMap[s] = true
|
|
}
|
|
for _, s := range consentRequest.RequestedScope {
|
|
if !grantedMap[s] {
|
|
allGranted = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if allGranted {
|
|
slog.Info("Auto-approving based on local DB consent record", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID)
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
|
|
if err == nil && identity != nil {
|
|
currentSessionID := h.resolveCurrentSessionID(c)
|
|
var tenantID string
|
|
if consentRequest.Client.Metadata != nil {
|
|
if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok {
|
|
tenantID = tid
|
|
}
|
|
}
|
|
sessionClaims := composeOIDCSessionClaims(
|
|
consentRequest.Client,
|
|
identity.Traits,
|
|
consentRequest.RequestedScope,
|
|
tenantID,
|
|
currentSessionID,
|
|
)
|
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
|
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
|
if err == nil {
|
|
return c.JSON(acceptResp)
|
|
}
|
|
slog.Error("failed to force auto-accept based on local DB", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hydra가 이전에 동의한 이력이 있어 skip을 권장하는 경우, 즉시 승인 처리
|
|
if consentRequest.Skip {
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
|
|
if err != nil || identity == nil {
|
|
slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject, "client_id", consentRequest.Client.ClientID)
|
|
// 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행
|
|
} else {
|
|
currentSessionID := h.resolveCurrentSessionID(c)
|
|
var tenantID string
|
|
if consentRequest.Client.Metadata != nil {
|
|
if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok {
|
|
tenantID = tid
|
|
}
|
|
}
|
|
|
|
sessionClaims := composeOIDCSessionClaims(
|
|
consentRequest.Client,
|
|
identity.Traits,
|
|
consentRequest.RequestedScope,
|
|
tenantID,
|
|
currentSessionID,
|
|
)
|
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
|
|
|
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
|
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
|
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
|
|
if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil {
|
|
slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS (SKIP) ===", "claims", string(debugClaimsJSON))
|
|
}
|
|
}
|
|
|
|
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
|
if err != nil {
|
|
slog.Error("failed to auto-accept hydra consent request", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
|
|
// 자동 승인 실패 시 일반 흐름으로 진행
|
|
} else {
|
|
// [New] Sync to local DB even on auto-accept to ensure data consistency
|
|
if h.ConsentRepo != nil {
|
|
consent := &domain.ClientConsent{
|
|
ClientID: consentRequest.Client.ClientID,
|
|
Subject: consentRequest.Subject,
|
|
GrantedScopes: consentRequest.RequestedScope,
|
|
}
|
|
_ = h.ConsentRepo.Upsert(c.Context(), consent)
|
|
}
|
|
|
|
if h.AuditRepo != nil {
|
|
detailsMap := map[string]interface{}{
|
|
"client_id": consentRequest.Client.ClientID,
|
|
"scopes": consentRequest.RequestedScope,
|
|
"client_name": consentRequest.Client.ClientName,
|
|
}
|
|
if currentSessionID != "" {
|
|
detailsMap["session_id"] = currentSessionID
|
|
detailsMap["approved_session_id"] = currentSessionID
|
|
}
|
|
detailsMap["auto_accepted"] = true
|
|
detailsBytes, _ := json.Marshal(detailsMap)
|
|
|
|
_ = h.AuditRepo.Create(&domain.AuditLog{
|
|
EventID: GenerateSecureToken(16),
|
|
Timestamp: time.Now(),
|
|
UserID: consentRequest.Subject,
|
|
TenantID: tenantID, // Uses the tenantID extracted earlier
|
|
SessionID: currentSessionID,
|
|
EventType: "consent.granted",
|
|
Status: "success",
|
|
IPAddress: c.IP(),
|
|
UserAgent: string(c.Request().Header.UserAgent()),
|
|
Details: string(detailsBytes),
|
|
})
|
|
}
|
|
|
|
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID)
|
|
return c.JSON(acceptResp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hydra 응답을 기본으로 하되, 메타데이터에서 커스텀 스코프 설명을 추출하여 추가
|
|
response := fiber.Map{
|
|
"challenge": consentRequest.Challenge,
|
|
"requested_scope": consentRequest.RequestedScope,
|
|
"requested_access_token_audience": consentRequest.RequestedAudience,
|
|
"skip": consentRequest.Skip,
|
|
"subject": consentRequest.Subject,
|
|
"client": consentRequest.Client,
|
|
}
|
|
|
|
// structured_scopes 파싱 및 scope_details 생성
|
|
if metadata := consentRequest.Client.Metadata; metadata != nil {
|
|
if rawScopes, ok := metadata["structured_scopes"]; ok {
|
|
scopeDetails := make(map[string]map[string]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")
|
|
}
|
|
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
|
|
|
|
// 2. 스코프 필터링 (사용자가 선택한 것만 허용)
|
|
if len(req.GrantScope) > 0 {
|
|
allowedScopes := make(map[string]bool)
|
|
for _, s := range consentRequest.RequestedScope {
|
|
allowedScopes[s] = true
|
|
}
|
|
|
|
filteredScopes := make([]string, 0, len(req.GrantScope))
|
|
for _, s := range req.GrantScope {
|
|
if allowedScopes[s] {
|
|
filteredScopes = append(filteredScopes, s)
|
|
}
|
|
}
|
|
consentRequest.RequestedScope = filteredScopes
|
|
}
|
|
consentRequest.RequestedScope = mergeRequestedScopesWithClientRequirements(consentRequest.Client, consentRequest.RequestedScope)
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
if (err != nil || profile == nil) && consentRequest.Subject != "" {
|
|
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), consentRequest.Subject); fallbackErr == nil {
|
|
profile = fallbackProfile
|
|
err = nil
|
|
}
|
|
}
|
|
if enforceClientTenantAccess(c, h.TenantService, consentRequest.Client, profile, err) {
|
|
return nil
|
|
}
|
|
|
|
// 3. Hydra에 승인 요청
|
|
if consentRequest.Subject == "" {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing")
|
|
}
|
|
if h.KratosAdmin == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable")
|
|
}
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
|
|
if err != nil || identity == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity")
|
|
}
|
|
c.Locals("user_id", consentRequest.Subject)
|
|
if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" {
|
|
c.Locals("login_id", loginID)
|
|
}
|
|
currentSessionID := h.resolveCurrentSessionID(c)
|
|
|
|
var tenantID string
|
|
if consentRequest.Client.Metadata != nil {
|
|
if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok {
|
|
tenantID = tid
|
|
}
|
|
}
|
|
|
|
sessionClaims := composeOIDCSessionClaims(
|
|
consentRequest.Client,
|
|
identity.Traits,
|
|
consentRequest.RequestedScope,
|
|
tenantID,
|
|
currentSessionID,
|
|
)
|
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
|
|
|
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)
|
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
|
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
|
|
if debugClaimsJSON, err := json.MarshalIndent(sessionClaims, "", " "); err == nil {
|
|
slog.Info("=== [ACTUAL DATA] GENERATED OIDC CLAIMS ===", "claims", string(debugClaimsJSON))
|
|
}
|
|
}
|
|
|
|
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims)
|
|
if err != nil {
|
|
slog.Error("failed to accept hydra consent request", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request")
|
|
}
|
|
|
|
// [New] Sync to local DB for "List All Consents" feature
|
|
if h.ConsentRepo != nil {
|
|
consent := &domain.ClientConsent{
|
|
ClientID: consentRequest.Client.ClientID,
|
|
Subject: consentRequest.Subject,
|
|
GrantedScopes: consentRequest.RequestedScope,
|
|
}
|
|
if err := h.ConsentRepo.Upsert(c.Context(), consent); err != nil {
|
|
slog.Error("failed to sync consent to local DB", "error", err, "subject", consent.Subject, "client", consent.ClientID)
|
|
// Don't fail the whole request, but log it
|
|
}
|
|
}
|
|
|
|
if h.AuditRepo != nil {
|
|
detailsMap := map[string]interface{}{
|
|
"client_id": consentRequest.Client.ClientID,
|
|
"scopes": consentRequest.RequestedScope,
|
|
"client_name": consentRequest.Client.ClientName,
|
|
}
|
|
if currentSessionID != "" {
|
|
detailsMap["session_id"] = currentSessionID
|
|
detailsMap["approved_session_id"] = currentSessionID
|
|
}
|
|
detailsBytes, _ := json.Marshal(detailsMap)
|
|
|
|
_ = h.AuditRepo.Create(&domain.AuditLog{
|
|
EventID: GenerateSecureToken(16),
|
|
Timestamp: time.Now(),
|
|
UserID: consentRequest.Subject,
|
|
TenantID: tenantID, // [New] Add TenantID to AuditLog
|
|
SessionID: currentSessionID,
|
|
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 {
|
|
attachAuditClientDetails(c, loginReq.Client)
|
|
|
|
if loginReq.Client.Metadata != nil {
|
|
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
|
|
if strings.ToLower(status) == "inactive" {
|
|
slog.Warn("Login rejected for inactive client in AcceptOidcLoginRequest", "client_id", loginReq.Client.ClientID)
|
|
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
if (err != nil || profile == nil) && loginReq != nil && strings.TrimSpace(loginReq.Subject) != "" {
|
|
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), loginReq.Subject); fallbackErr == nil {
|
|
profile = fallbackProfile
|
|
err = nil
|
|
}
|
|
}
|
|
if loginReq != nil {
|
|
if enforceClientTenantAccess(c, h.TenantService, loginReq.Client, profile, err) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
subject, err := h.resolveConsentSubject(c)
|
|
if err != nil || subject == "" {
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
|
}
|
|
c.Locals("user_id", subject)
|
|
approvedSessionID := strings.TrimSpace(req.ApprovedSessionID)
|
|
if approvedSessionID == "" {
|
|
approvedSessionID = strings.TrimSpace(req.SessionID)
|
|
}
|
|
if approvedSessionID == "" {
|
|
if sessionID, ok := c.Locals("session_id").(string); ok && sessionID != "" {
|
|
approvedSessionID = sessionID
|
|
}
|
|
}
|
|
if approvedSessionID == "" {
|
|
if token := h.getBearerToken(c); token != "" {
|
|
if resolved, err := h.getKratosSessionID(token); err == nil {
|
|
approvedSessionID = resolved
|
|
}
|
|
}
|
|
}
|
|
if approvedSessionID == "" {
|
|
if cookie := c.Get("Cookie"); cookie != "" {
|
|
if derivedID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
|
|
approvedSessionID = derivedID
|
|
}
|
|
}
|
|
}
|
|
if approvedSessionID != "" {
|
|
c.Locals("approved_session_id", approvedSessionID)
|
|
}
|
|
if h.KratosAdmin != nil {
|
|
if identity, err := h.KratosAdmin.GetIdentity(c.Context(), subject); err == nil && identity != nil {
|
|
if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" {
|
|
c.Locals("login_id", loginID)
|
|
}
|
|
}
|
|
}
|
|
|
|
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
|
|
if err != nil {
|
|
slog.Error("failed to accept hydra login request", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
|
}
|
|
logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo)
|
|
|
|
return c.JSON(acceptResp)
|
|
}
|
|
|
|
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
return profile, nil
|
|
}
|
|
|
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
|
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
|
|
|
|
mockRole := c.Get("X-Test-Role")
|
|
if mockRole == "" {
|
|
mockRole = c.Get("X-Mock-Role")
|
|
}
|
|
|
|
token := h.getBearerToken(c)
|
|
cookie := c.Get("Cookie")
|
|
|
|
var profile *domain.UserProfileResponse
|
|
var err error
|
|
cacheKey := ""
|
|
|
|
// 1. Try to fetch real profile if token/cookie exists
|
|
if token != "" || cookie != "" {
|
|
// Try Redis Cache
|
|
if h.RedisService != nil && token == "" && cookie != "" {
|
|
cacheKey = "cache:profile:cookie:" + cookie
|
|
cached, _ := h.RedisService.Get(cacheKey)
|
|
if cached != "" {
|
|
if json.Unmarshal([]byte(cached), &profile) == nil {
|
|
slog.Debug("Profile loaded from cache", "role", profile.Role)
|
|
}
|
|
}
|
|
}
|
|
|
|
if profile == nil {
|
|
// Fetch from Kratos (SoT)
|
|
if token != "" {
|
|
profile, err = h.getKratosProfile(token)
|
|
if err != nil && h.Hydra != nil {
|
|
// Fallback to Hydra introspection. This is expected for API calls using Bearer tokens.
|
|
slog.Debug("Kratos cookie session absent, falling back to Hydra token", "error", err.Error())
|
|
profile, err = h.getHydraProfile(c.Context(), token)
|
|
}
|
|
} else if cookie != "" {
|
|
profile, err = h.getKratosProfileWithCookie(cookie)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Role Override for real profile or fallback to Mock Profile
|
|
if profile != nil {
|
|
if isDev && mockRole != "" {
|
|
normalizedMockRole := domain.NormalizeRole(mockRole)
|
|
if profile.Role != normalizedMockRole {
|
|
slog.Info("🔑 [AUTH] Overriding real profile role",
|
|
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", normalizedMockRole)
|
|
profile.Role = normalizedMockRole
|
|
}
|
|
}
|
|
} else if isDev && mockRole != "" && token == "" && cookie == "" {
|
|
normalizedMockRole := domain.NormalizeRole(mockRole)
|
|
slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole)
|
|
profile = &domain.UserProfileResponse{
|
|
ID: "00000000-0000-0000-0000-000000000000",
|
|
Email: "mock@hmac.kr",
|
|
Name: "Dev Mock User",
|
|
Role: normalizedMockRole,
|
|
}
|
|
if tid := c.Get("X-Tenant-ID"); tid != "" {
|
|
profile.TenantID = &tid
|
|
}
|
|
}
|
|
|
|
if profile == nil {
|
|
slog.Warn("No profile resolved", "token_len", len(token), "cookie_len", len(cookie), "mockRole", mockRole)
|
|
return nil, errors.New("invalid session (trace:resolve_profile)")
|
|
}
|
|
|
|
// 3. Post-Process (Defaults & Metadata Enrichment)
|
|
profile.Role = domain.NormalizeRole(profile.Role)
|
|
if profile.Role == "" {
|
|
profile.Role = domain.RoleUser
|
|
}
|
|
|
|
// [New] Backtracking Logic for Session Tenant (Plan A)
|
|
if usedID, ok := profile.Metadata["_used_identifier"].(string); ok && usedID != "" && h.UserRepo != nil {
|
|
if tid, err := h.UserRepo.FindTenantIDByLoginID(c.Context(), usedID); err == nil && tid != "" {
|
|
profile.SessionTenantID = &tid
|
|
slog.Debug("Auto-assigned session tenant via backtracking", "loginID", usedID, "tenantID", tid)
|
|
}
|
|
delete(profile.Metadata, "_used_identifier") // Cleanup
|
|
}
|
|
|
|
profile = h.hydrateResolvedProfile(c.Context(), profile)
|
|
|
|
// 4. Save to Redis Cache (Short TTL)
|
|
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key
|
|
// or we should include the mock role in the cache key.
|
|
// For simplicity, let's skip caching if mockRole is present in dev.
|
|
if h.RedisService != nil && token == "" && cacheKey != "" && err == nil && !(isDev && mockRole != "") {
|
|
if data, err := json.Marshal(profile); err == nil {
|
|
ttlStr := os.Getenv("PROFILE_CACHE_TTL")
|
|
ttl := 30 * time.Minute // Default TTL
|
|
if ttlStr != "" {
|
|
if parsed, err := time.ParseDuration(ttlStr); err == nil {
|
|
ttl = parsed
|
|
}
|
|
}
|
|
_ = h.RedisService.Set(cacheKey, string(data), ttl)
|
|
}
|
|
}
|
|
|
|
return profile, nil
|
|
}
|
|
|
|
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
|
token := h.getBearerToken(c)
|
|
if token != "" {
|
|
identityID, resolveErr := h.resolveIdentityID(c, token)
|
|
if resolveErr == nil && identityID != "" {
|
|
return identityID, nil
|
|
}
|
|
if cookie := c.Get("Cookie"); cookie != "" {
|
|
cookieID, _, _, cookieErr := h.getKratosIdentityWithCookie(cookie)
|
|
if cookieErr == nil && cookieID != "" {
|
|
return cookieID, nil
|
|
}
|
|
}
|
|
return "", resolveErr
|
|
}
|
|
cookie := c.Get("Cookie")
|
|
if cookie == "" {
|
|
return "", fmt.Errorf("missing authorization token")
|
|
}
|
|
identityID, _, _, err := h.getKratosIdentityWithCookie(cookie)
|
|
return identityID, err
|
|
}
|
|
|
|
func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) {
|
|
token := h.getBearerToken(c)
|
|
if token != "" {
|
|
identityID, traits, _, err := h.getKratosIdentity(token)
|
|
if err == nil && identityID != "" {
|
|
subjects := []string{identityID}
|
|
subjects = appendLoginIDsFromTraits(subjects, traits)
|
|
return uniqueStrings(subjects), nil
|
|
}
|
|
}
|
|
|
|
cookie := c.Get("Cookie")
|
|
if cookie == "" {
|
|
return nil, fmt.Errorf("missing authorization token")
|
|
}
|
|
identityID, traits, _, err := h.getKratosIdentityWithCookie(cookie)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subjects := []string{identityID}
|
|
subjects = appendLoginIDsFromTraits(subjects, traits)
|
|
return uniqueStrings(subjects), nil
|
|
}
|
|
|
|
func uniqueStrings(items []string) []string {
|
|
seen := make(map[string]struct{}, len(items))
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
item = strings.TrimSpace(item)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[item]; ok {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
result = append(result, item)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func appendLoginIDsFromValues(subjects []string, email string, phone string) []string {
|
|
if strings.TrimSpace(email) != "" {
|
|
subjects = append(subjects, strings.TrimSpace(email))
|
|
}
|
|
if strings.TrimSpace(phone) != "" {
|
|
subjects = append(subjects, normalizePhoneForLoginID(phone))
|
|
}
|
|
return subjects
|
|
}
|
|
|
|
func appendLoginIDsFromTraits(subjects []string, traits map[string]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 := utils.ParseAuditDetails(log.Details); err == nil {
|
|
if path, ok := payload["path"].(string); ok && path != "" {
|
|
return path
|
|
}
|
|
}
|
|
}
|
|
parts := strings.SplitN(log.EventType, " ", 2)
|
|
if len(parts) == 2 {
|
|
return strings.TrimSpace(parts[1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractRequestBody(details map[string]any) map[string]any {
|
|
if details == nil {
|
|
return nil
|
|
}
|
|
raw, ok := details["request_body"].(string)
|
|
if !ok || raw == "" {
|
|
return nil
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
|
return nil
|
|
}
|
|
return body
|
|
}
|
|
|
|
func shouldSkipAuthTimeline(log domain.AuditLog) bool {
|
|
details, _ := utils.ParseAuditDetails(log.Details)
|
|
path := strings.ToLower(extractAuditPath(log))
|
|
if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") {
|
|
return true
|
|
}
|
|
if path != "" && strings.Contains(path, "/api/v1/auth/consent/accept") {
|
|
return true
|
|
}
|
|
if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") ||
|
|
strings.Contains(path, "/api/v1/auth/login/code/verify")) {
|
|
sessionID := log.SessionID
|
|
if sessionID == "" {
|
|
sessionID = extractSessionIDFromAuditDetails(log.Details)
|
|
}
|
|
if sessionID == "" {
|
|
return true
|
|
}
|
|
}
|
|
if details != nil {
|
|
if raw, ok := details["auth_timeline_skip"]; ok {
|
|
switch value := raw.(type) {
|
|
case bool:
|
|
if value {
|
|
return true
|
|
}
|
|
case string:
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
requestBody := extractRequestBody(details)
|
|
if requestBody != nil {
|
|
if raw, ok := requestBody["verifyOnly"]; ok {
|
|
switch value := raw.(type) {
|
|
case bool:
|
|
if value {
|
|
return true
|
|
}
|
|
case string:
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func loginIDKind(loginID string) string {
|
|
normalized := strings.TrimSpace(loginID)
|
|
if normalized == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(normalized, "@") {
|
|
return "email"
|
|
}
|
|
return "phone"
|
|
}
|
|
|
|
func resolveLoginMethod(rawMethod, loginID string) string {
|
|
method := strings.ToLower(strings.TrimSpace(rawMethod))
|
|
if method == "sms" || method == "email" {
|
|
return method
|
|
}
|
|
if strings.TrimSpace(loginID) == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(loginID, "@") {
|
|
return "email"
|
|
}
|
|
return "sms"
|
|
}
|
|
|
|
func loginMethodLabel(method string) string {
|
|
switch strings.ToLower(strings.TrimSpace(method)) {
|
|
case "sms":
|
|
return "SMS"
|
|
case "email":
|
|
return "Email"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func deriveAuthMethod(log domain.AuditLog) string {
|
|
path := strings.ToLower(extractAuditPath(log))
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
|
|
loginID := extractLoginIDFromAuditDetails(log.Details)
|
|
kind := loginIDKind(loginID)
|
|
details, _ := utils.ParseAuditDetails(log.Details)
|
|
requestBody := extractRequestBody(details)
|
|
if details != nil {
|
|
if raw, ok := details["auth_timeline_skip"]; ok {
|
|
switch value := raw.(type) {
|
|
case bool:
|
|
if value {
|
|
return ""
|
|
}
|
|
case string:
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if requestBody != nil {
|
|
if raw, ok := requestBody["verifyOnly"]; ok {
|
|
switch value := raw.(type) {
|
|
case bool:
|
|
if value {
|
|
return ""
|
|
}
|
|
case string:
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") ||
|
|
strings.Contains(path, "/api/v1/auth/qr/poll") ||
|
|
strings.Contains(path, "/api/v1/auth/qr/approve")) {
|
|
return "QR"
|
|
}
|
|
if details != nil {
|
|
rawFlow, _ := details["login_flow"].(string)
|
|
rawMethod, _ := details["login_method"].(string)
|
|
flow := strings.ToLower(strings.TrimSpace(rawFlow))
|
|
methodLabel := loginMethodLabel(rawMethod)
|
|
switch flow {
|
|
case loginFlowCode:
|
|
if methodLabel != "" {
|
|
return fmt.Sprintf("코드(%s)", methodLabel)
|
|
}
|
|
return "코드"
|
|
case loginFlowLink:
|
|
if methodLabel != "" {
|
|
return fmt.Sprintf("링크(%s)", methodLabel)
|
|
}
|
|
return "링크"
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case strings.Contains(path, "/api/v1/auth/password/login"),
|
|
strings.Contains(path, "/api/v1/auth/headless/password/login"):
|
|
if kind == "email" {
|
|
return "비밀번호(Email)"
|
|
}
|
|
if kind == "phone" {
|
|
return "비밀번호(전화번호)"
|
|
}
|
|
return "비밀번호"
|
|
case strings.Contains(path, "/api/v1/auth/enchanted-link/init"):
|
|
if requestBody != nil {
|
|
if raw, ok := requestBody["codeOnly"]; ok {
|
|
if value, ok := raw.(bool); ok && value {
|
|
if kind == "phone" {
|
|
return "코드(SMS)"
|
|
}
|
|
if kind == "email" {
|
|
return "코드(Email)"
|
|
}
|
|
return "코드"
|
|
}
|
|
}
|
|
}
|
|
if requestBody != nil {
|
|
if raw, ok := requestBody["method"].(string); ok {
|
|
method := strings.ToLower(strings.TrimSpace(raw))
|
|
if method == "sms" {
|
|
return "링크(SMS)"
|
|
}
|
|
if method == "email" {
|
|
return "링크(Email)"
|
|
}
|
|
}
|
|
}
|
|
if kind == "phone" {
|
|
return "링크(SMS)"
|
|
}
|
|
if kind == "email" {
|
|
return "링크(Email)"
|
|
}
|
|
return "링크"
|
|
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
|
|
if kind == "phone" {
|
|
return "링크(SMS)"
|
|
}
|
|
if kind == "email" {
|
|
return "링크(Email)"
|
|
}
|
|
return "링크"
|
|
case strings.Contains(path, "/api/v1/auth/login/code/verify"):
|
|
if kind == "phone" {
|
|
return "코드(SMS)"
|
|
}
|
|
if kind == "email" {
|
|
return "코드(Email)"
|
|
}
|
|
return "코드"
|
|
case strings.Contains(path, "/api/v1/auth/login/code/verify-short"):
|
|
return "코드(간편)"
|
|
case strings.Contains(path, "/api/v1/auth/verify-sms"):
|
|
return "코드(SMS)"
|
|
case strings.Contains(path, "/api/v1/auth/qr/approve"):
|
|
return "QR"
|
|
case strings.Contains(path, "/api/v1/auth/qr/init"):
|
|
return "QR"
|
|
case strings.Contains(path, "/api/v1/auth/qr/poll"):
|
|
return "QR"
|
|
case strings.Contains(path, "/api/v1/auth/oidc/login/accept"):
|
|
return "OIDC 로그인"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} {
|
|
candidates := make(map[string]struct{})
|
|
if profile == nil {
|
|
return candidates
|
|
}
|
|
for _, raw := range []string{profile.Email, profile.Phone, normalizePhoneForLoginID(profile.Phone)} {
|
|
if normalized := normalizeLoginIdentifier(raw); normalized != "" {
|
|
candidates[normalized] = struct{}{}
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func normalizeLoginIdentifier(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(trimmed, "@") {
|
|
return strings.ToLower(trimmed)
|
|
}
|
|
return normalizePhoneForLoginID(trimmed)
|
|
}
|
|
|
|
func matchesAuthTimelineUser(log domain.AuditLog, profile *domain.UserProfileResponse, candidates map[string]struct{}, sessionID string) bool {
|
|
if profile == nil {
|
|
return false
|
|
}
|
|
if profile.ID != "" && log.UserID == profile.ID {
|
|
return true
|
|
}
|
|
loginID := extractLoginIDFromAuditDetails(log.Details)
|
|
normalized := normalizeLoginIdentifier(loginID)
|
|
if normalized != "" {
|
|
if _, ok := candidates[normalized]; ok {
|
|
return true
|
|
}
|
|
}
|
|
if sessionID == "" {
|
|
return false
|
|
}
|
|
if log.SessionID != "" && log.SessionID == sessionID {
|
|
return true
|
|
}
|
|
if extracted := extractSessionIDFromAuditDetails(log.Details); extracted != "" && extracted == sessionID {
|
|
return true
|
|
}
|
|
if approved := extractApprovedSessionIDFromAuditDetails(log.Details); approved != "" && approved == sessionID {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func extractLoginIDFromAuditDetails(details string) string {
|
|
if details == "" {
|
|
return ""
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
|
return ""
|
|
}
|
|
if raw, ok := payload["login_id"]; ok {
|
|
if value, ok := raw.(string); ok && value != "" {
|
|
return value
|
|
}
|
|
}
|
|
if raw, ok := payload["loginId"]; ok {
|
|
if value, ok := raw.(string); ok && value != "" {
|
|
return value
|
|
}
|
|
}
|
|
if raw, ok := payload["request_body"]; ok {
|
|
if value, ok := raw.(string); ok && value != "" {
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(value), &body); err == nil {
|
|
if loginID := extractLoginIDFromClaims(body); loginID != "" {
|
|
return loginID
|
|
}
|
|
if target, ok := body["target"].(string); ok && target != "" {
|
|
return target
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractLoginChallengeFromAuditDetails(details string) string {
|
|
if details == "" {
|
|
return ""
|
|
}
|
|
payload, err := utils.ParseAuditDetails(details)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if raw, ok := payload["login_challenge"].(string); ok && raw != "" {
|
|
return raw
|
|
}
|
|
if raw, ok := payload["loginChallenge"].(string); ok && raw != "" {
|
|
return raw
|
|
}
|
|
body := extractRequestBody(payload)
|
|
if body == nil {
|
|
return ""
|
|
}
|
|
if raw, ok := body["login_challenge"].(string); ok && raw != "" {
|
|
return raw
|
|
}
|
|
if raw, ok := body["loginChallenge"].(string); ok && raw != "" {
|
|
return raw
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
|
|
if value := strings.TrimSpace(log.ClientID); value != "" {
|
|
return value
|
|
}
|
|
if value := strings.TrimSpace(log.RP); value != "" {
|
|
return value
|
|
}
|
|
if value := parseClientIDFromURL(log.Target); value != "" {
|
|
return value
|
|
}
|
|
if value := parseClientIDFromURL(log.Path); value != "" {
|
|
return value
|
|
}
|
|
return parseClientIDFromRaw(log.Raw)
|
|
}
|
|
|
|
func extractSessionIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
|
|
if value := parseSessionIDFromURL(log.Target); value != "" {
|
|
return value
|
|
}
|
|
if value := parseSessionIDFromURL(log.Path); value != "" {
|
|
return value
|
|
}
|
|
return parseSessionIDFromRaw(log.Raw)
|
|
}
|
|
|
|
func parseClientIDFromURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if id := strings.TrimSpace(parsed.Query().Get("client_id")); id != "" {
|
|
return id
|
|
}
|
|
if id := strings.TrimSpace(parsed.Query().Get("clientId")); id != "" {
|
|
return id
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseSessionIDFromURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} {
|
|
if id := strings.TrimSpace(parsed.Query().Get(key)); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseClientIDFromRaw(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
return ""
|
|
}
|
|
if id := readClientIDFromPayload(payload); id != "" {
|
|
return id
|
|
}
|
|
if request, ok := payload["request"].(map[string]any); ok {
|
|
if id := readClientIDFromPayload(request); id != "" {
|
|
return id
|
|
}
|
|
if urlRaw, ok := request["url"].(string); ok {
|
|
if id := parseClientIDFromURL(urlRaw); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
if pathRaw, ok := request["path"].(string); ok {
|
|
if id := parseClientIDFromURL(pathRaw); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readClientIDFromPayload(payload map[string]any) string {
|
|
if payload == nil {
|
|
return ""
|
|
}
|
|
if raw, ok := payload["client_id"].(string); ok && strings.TrimSpace(raw) != "" {
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
if raw, ok := payload["clientId"].(string); ok && strings.TrimSpace(raw) != "" {
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractSessionIDFromAuditDetails(details string) string {
|
|
if details == "" {
|
|
return ""
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
|
return ""
|
|
}
|
|
return readSessionIDFromAny(payload)
|
|
}
|
|
|
|
func extractApprovedSessionIDFromAuditDetails(details string) string {
|
|
if details == "" {
|
|
return ""
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
|
return ""
|
|
}
|
|
if raw, ok := payload["approved_session_id"]; ok {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return value
|
|
default:
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
if raw, ok := payload["approvedSessionId"]; ok {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return value
|
|
default:
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseSessionIDFromRaw(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
var payload any
|
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
return ""
|
|
}
|
|
return readSessionIDFromAny(payload)
|
|
}
|
|
|
|
func readSessionIDFromAny(payload any) string {
|
|
switch value := payload.(type) {
|
|
case map[string]any:
|
|
for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} {
|
|
if raw, ok := value[key]; ok {
|
|
switch sid := raw.(type) {
|
|
case string:
|
|
if strings.TrimSpace(sid) != "" {
|
|
return strings.TrimSpace(sid)
|
|
}
|
|
default:
|
|
rendered := strings.TrimSpace(fmt.Sprint(sid))
|
|
if rendered != "" && rendered != "<nil>" {
|
|
return rendered
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, nested := range value {
|
|
if sid := readSessionIDFromAny(nested); sid != "" {
|
|
return sid
|
|
}
|
|
}
|
|
case []any:
|
|
for _, nested := range value {
|
|
if sid := readSessionIDFromAny(nested); sid != "" {
|
|
return sid
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
|
id, _, _, err := h.getKratosIdentity(token)
|
|
return id, err
|
|
}
|
|
|
|
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
|
|
_, traits, _, err := h.getKratosIdentity(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
loginID := pickLoginIDFromTraits(traits)
|
|
if loginID == "" {
|
|
return "", fmt.Errorf("kratos login id missing")
|
|
}
|
|
if !strings.Contains(loginID, "@") {
|
|
loginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
return loginID, nil
|
|
}
|
|
|
|
func pickLoginIDFromTraits(traits map[string]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{}, string, error) {
|
|
identityID, traits, _, usedID, err := h.getKratosIdentityWithSession(sessionToken)
|
|
return identityID, traits, usedID, err
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, string, error) {
|
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
|
if kratosURL == "" {
|
|
kratosURL = "http://kratos:4433"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
|
if err != nil {
|
|
return "", nil, "", "", err
|
|
}
|
|
req.Header.Set("X-Session-Token", sessionToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", nil, "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
AuthenticatedAt string `json:"authenticated_at"`
|
|
AuthenticationMethods []struct {
|
|
Method string `json:"method"`
|
|
Identifier string `json:"identifier"`
|
|
} `json:"authentication_methods"`
|
|
Identity struct {
|
|
ID string `json:"id"`
|
|
Traits map[string]interface{} `json:"traits"`
|
|
} `json:"identity"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", nil, "", "", err
|
|
}
|
|
|
|
usedIdentifier := ""
|
|
for _, m := range result.AuthenticationMethods {
|
|
if m.Identifier != "" {
|
|
usedIdentifier = m.Identifier
|
|
break
|
|
}
|
|
}
|
|
|
|
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
|
if kratosURL == "" {
|
|
kratosURL = "http://kratos:4433"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("X-Session-Token", sessionToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", err
|
|
}
|
|
return result.ID, nil
|
|
}
|
|
|
|
func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
|
|
if identityID == "" {
|
|
return "", fmt.Errorf("kratos identity id is empty")
|
|
}
|
|
|
|
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
|
if kratosAdminURL == "" {
|
|
kratosAdminURL = "http://kratos:4434"
|
|
}
|
|
|
|
payload := map[string]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{}, string, error) {
|
|
identityID, traits, _, usedID, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
|
return identityID, traits, usedID, err
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, string, error) {
|
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
|
if kratosURL == "" {
|
|
kratosURL = "http://kratos:4433"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
|
if err != nil {
|
|
return "", nil, "", "", err
|
|
}
|
|
req.Header.Set("Cookie", cookie)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", nil, "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", nil, "", "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
AuthenticatedAt string `json:"authenticated_at"`
|
|
AuthenticationMethods []struct {
|
|
Method string `json:"method"`
|
|
Identifier string `json:"identifier"`
|
|
} `json:"authentication_methods"`
|
|
Identity struct {
|
|
ID string `json:"id"`
|
|
Traits map[string]interface{} `json:"traits"`
|
|
} `json:"identity"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", nil, "", "", err
|
|
}
|
|
|
|
usedIdentifier := ""
|
|
for _, m := range result.AuthenticationMethods {
|
|
if m.Identifier != "" {
|
|
usedIdentifier = m.Identifier
|
|
break
|
|
}
|
|
}
|
|
|
|
return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, usedIdentifier, nil
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
|
if kratosURL == "" {
|
|
kratosURL = "http://kratos:4433"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Cookie", cookie)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", err
|
|
}
|
|
return result.ID, nil
|
|
}
|
|
|
|
func (h *AuthHandler) 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")
|
|
}
|
|
if err := h.validateHydraTokenSession(ctx, intro); err != nil {
|
|
slog.Warn("Hydra token session validation failed", "error", err)
|
|
return nil, err
|
|
}
|
|
|
|
slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)
|
|
|
|
// Fetch identity details from Kratos by subject (identityID)
|
|
identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject)
|
|
if err != nil || identity == nil {
|
|
slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject)
|
|
// Fallback to minimal profile if Kratos identity not found
|
|
return &domain.UserProfileResponse{
|
|
ID: intro.Subject,
|
|
Email: "unknown@hydra.local",
|
|
Name: "Hydra User",
|
|
Role: domain.RoleUser,
|
|
}, nil
|
|
}
|
|
|
|
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
|
|
}
|
|
|
|
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]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)
|
|
relyingPartyID, _ := traits["relying_party_id"].(string)
|
|
|
|
profile := &domain.UserProfileResponse{
|
|
ID: identityID,
|
|
Email: email,
|
|
Name: name,
|
|
Phone: h.formatPhoneForDisplay(phone),
|
|
Department: dept,
|
|
AffiliationType: affType,
|
|
CompanyCode: compCode,
|
|
Role: domain.NormalizeRole(role),
|
|
Metadata: make(map[string]any),
|
|
}
|
|
|
|
if tenantID != "" {
|
|
profile.TenantID = &tenantID
|
|
}
|
|
if strings.TrimSpace(relyingPartyID) != "" {
|
|
rpID := strings.TrimSpace(relyingPartyID)
|
|
profile.RelyingPartyID = &rpID
|
|
}
|
|
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"affiliationType": true, "role": true, "tenant_id": true, "relying_party_id": true,
|
|
}
|
|
for k, v := range traits {
|
|
if !coreTraits[k] {
|
|
profile.Metadata[k] = v
|
|
}
|
|
}
|
|
return profile
|
|
}
|
|
|
|
func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse {
|
|
if profile == nil {
|
|
return nil
|
|
}
|
|
profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt)
|
|
if usedIdentifier != "" {
|
|
if profile.Metadata == nil {
|
|
profile.Metadata = make(map[string]any)
|
|
}
|
|
profile.Metadata["_used_identifier"] = usedIdentifier
|
|
}
|
|
return profile
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
|
identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithSession(sessionToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return h.applySessionInfoFromWhoami(
|
|
h.mapKratosIdentityToProfile(identityID, traits),
|
|
authenticatedAt,
|
|
usedIdentifier,
|
|
), nil
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
|
identityID, traits, authenticatedAt, usedIdentifier, err := h.getKratosIdentityWithCookieAndSession(cookie)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return h.applySessionInfoFromWhoami(
|
|
h.mapKratosIdentityToProfile(identityID, traits),
|
|
authenticatedAt,
|
|
usedIdentifier,
|
|
), nil
|
|
}
|
|
|
|
// UpdateMe - Updates current user's profile with phone verification check
|
|
func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
|
token := h.getBearerToken(c)
|
|
var req domain.UpdateUserRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
var (
|
|
identityID string
|
|
traits map[string]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
|
|
}
|
|
|
|
// Merge custom metadata into traits
|
|
if len(req.Metadata) > 0 {
|
|
for k, v := range req.Metadata {
|
|
// Do not overwrite core fields
|
|
if _, isCore := map[string]bool{"email": true, "phone_number": true, "name": true, "department": true, "grade": true, "companyCode": true, "affiliationType": true, "id": true, "role": true, "tenant_id": true}[k]; !isCore {
|
|
// [Fix] Support merging namespaced metadata maps
|
|
if incomingMap, ok := v.(map[string]any); ok {
|
|
if existingMap, ok := traits[k].(map[string]interface{}); ok {
|
|
for subK, subV := range incomingMap {
|
|
existingMap[subK] = subV
|
|
}
|
|
traits[k] = existingMap
|
|
} else {
|
|
traits[k] = incomingMap
|
|
}
|
|
} else {
|
|
traits[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// [LoginID Sync based on Tenant Settings]
|
|
// Perform sync AFTER metadata merge to ensure traits contains current values
|
|
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, identityID)
|
|
|
|
// Validate all collected LoginIDs
|
|
userEmail := extractTraitString(traits, "email")
|
|
userPhone := extractTraitString(traits, "phone_number")
|
|
if collectedIDs, ok := traits["custom_login_ids"].([]string); ok {
|
|
for _, lid := range collectedIDs {
|
|
if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.updateKratosIdentity(identityID, traits); err != nil {
|
|
slog.Error("Failed to update profile in Kratos", "error", err)
|
|
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")
|
|
}
|
|
|
|
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency
|
|
if h.UserRepo != nil {
|
|
ctx := context.Background()
|
|
// Also update local User record (read-model)
|
|
// We can fetch updated identity or just map current traits
|
|
// Since mapKratosIdentityToProfile is for UI, let's just use UpdateUserLoginIDs first
|
|
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
|
|
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Invalidate token-based profile cache so refreshed /user/me returns latest traits.
|
|
if h.RedisService != nil && token != "" {
|
|
cacheKey := "cache:profile:token:" + token
|
|
_ = h.RedisService.Delete(cacheKey)
|
|
}
|
|
|
|
slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID)
|
|
return c.JSON(fiber.Map{
|
|
"status": "success",
|
|
"updatedAt": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// ChangeMyPassword - 로그인 상태에서 현재 비밀번호를 확인한 뒤 변경합니다.
|
|
func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error {
|
|
var req domain.PasswordChangeRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
currentPassword := strings.TrimSpace(req.CurrentPassword)
|
|
newPassword := strings.TrimSpace(req.NewPassword)
|
|
if currentPassword == "" || newPassword == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Current password and new password are required")
|
|
}
|
|
|
|
policy := h.resolvePasswordPolicy()
|
|
if err := validatePasswordWithPolicy(policy, newPassword); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
loginID := ""
|
|
token := h.getBearerToken(c)
|
|
if token != "" {
|
|
if resolved, err := h.resolveKratosLoginID(token); err == nil {
|
|
loginID = resolved
|
|
}
|
|
}
|
|
|
|
if loginID == "" {
|
|
cookie := c.Get("Cookie")
|
|
if cookie == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Missing authorization token")
|
|
}
|
|
_, traits, _, err := h.getKratosIdentityWithCookie(cookie)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
loginID = pickLoginIDFromTraits(traits)
|
|
if loginID == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Login ID not found")
|
|
}
|
|
if !strings.Contains(loginID, "@") {
|
|
loginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
}
|
|
|
|
if _, err := h.IdpProvider.SignIn(loginID, currentPassword); err != nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Current password is invalid")
|
|
}
|
|
|
|
if err := h.IdpProvider.UpdateUserPassword(loginID, newPassword, nil); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to update password")
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "Password updated"})
|
|
}
|
|
|
|
// SendUpdateCode - Sends OTP for phone number change
|
|
func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
|
|
token := h.getBearerToken(c)
|
|
var (
|
|
userID string
|
|
err error
|
|
)
|
|
if token != "" {
|
|
userID, err = h.resolveIdentityID(c, token)
|
|
} else {
|
|
cookie := c.Get("Cookie")
|
|
if cookie == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Unauthorized")
|
|
}
|
|
userID, _, _, err = h.getKratosIdentityWithCookie(cookie)
|
|
}
|
|
if err != nil || userID == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
|
|
var req struct {
|
|
Phone string `json:"phone"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid phone")
|
|
}
|
|
|
|
phone := h.formatPhoneForStorage(req.Phone)
|
|
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
|
|
|
// Store code in Redis
|
|
key := "otp_update_phone:" + userID + ":" + phone
|
|
h.RedisService.Set(key, code, 5*time.Minute)
|
|
|
|
// Send SMS
|
|
content := fmt.Sprintf("[Baron 로그인] 정보 수정 인증번호: [%s]", code)
|
|
go h.SmsService.SendSms(phone, content)
|
|
|
|
return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."})
|
|
}
|
|
|
|
// VerifyUpdateCode - Verifies OTP for phone number change
|
|
func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
|
|
token := h.getBearerToken(c)
|
|
var (
|
|
userID string
|
|
err error
|
|
)
|
|
if token != "" {
|
|
userID, err = h.resolveIdentityID(c, token)
|
|
} else {
|
|
cookie := c.Get("Cookie")
|
|
if cookie == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
userID, _, _, err = h.getKratosIdentityWithCookie(cookie)
|
|
}
|
|
if err != nil || userID == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
|
|
var req struct {
|
|
Phone string `json:"phone"`
|
|
Code string `json:"code"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
|
|
}
|
|
|
|
phone := h.formatPhoneForStorage(req.Phone)
|
|
key := "otp_update_phone:" + userID + ":" + phone
|
|
storedCode, _ := h.RedisService.Get(key)
|
|
|
|
if storedCode == "" || storedCode != req.Code {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "인증번호가 일치하지 않거나 만료되었습니다.")
|
|
}
|
|
|
|
// Mark as verified for 10 minutes
|
|
verifyKey := "verify_update_phone:" + userID + ":" + phone
|
|
h.RedisService.Set(verifyKey, "verified", 10*time.Minute)
|
|
h.RedisService.Delete(key)
|
|
|
|
return c.JSON(fiber.Map{"success": true})
|
|
}
|
|
|
|
func hydraClientStatus(metadata map[string]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 resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string {
|
|
switch strings.TrimSpace(clientID) {
|
|
case "adminfront":
|
|
if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" {
|
|
return value
|
|
}
|
|
case "devfront":
|
|
if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" {
|
|
return value
|
|
}
|
|
case "orgfront":
|
|
if value := strings.TrimSpace(os.Getenv("ORGFRONT_URL")); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
|
|
clientURL := strings.TrimSpace(clientURI)
|
|
if clientURL != "" {
|
|
return clientURL
|
|
}
|
|
|
|
if len(redirectURIs) > 0 {
|
|
if parsed, err := url.Parse(redirectURIs[0]); err == nil {
|
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]interface{}) bool {
|
|
if readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) {
|
|
return true
|
|
}
|
|
switch strings.TrimSpace(clientID) {
|
|
case "adminfront", "devfront", "orgfront":
|
|
return resolveLinkedRPAutoLoginURL(clientID, nil) != ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string {
|
|
clientID = strings.TrimSpace(clientID)
|
|
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
|
|
if clientID == "orgfront" {
|
|
return ensureOrgfrontAutoLoginURL(metadataURL)
|
|
}
|
|
return metadataURL
|
|
}
|
|
|
|
switch clientID {
|
|
case "adminfront":
|
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" {
|
|
return value + "/login?auto=1"
|
|
}
|
|
case "devfront":
|
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" {
|
|
return value + "/login?auto=1&returnTo=%2Fclients"
|
|
}
|
|
case "orgfront":
|
|
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" {
|
|
return value + "/login?auto=1"
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func ensureOrgfrontAutoLoginURL(rawURL string) string {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return rawURL
|
|
}
|
|
if strings.TrimRight(parsed.Path, "/") != "/login" {
|
|
return rawURL
|
|
}
|
|
query := parsed.Query()
|
|
if query.Get("auto") != "1" {
|
|
query.Set("auto", "1")
|
|
parsed.RawQuery = query.Encode()
|
|
}
|
|
return parsed.String()
|
|
}
|
|
|
|
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string {
|
|
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
|
|
return ""
|
|
}
|
|
return resolveLinkedRPAutoLoginURL(clientID, metadata)
|
|
}
|
|
|
|
func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs []string) string {
|
|
hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/")
|
|
if hydraPublicURL == "" {
|
|
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
|
if userfrontURL == "" {
|
|
userfrontURL = "https://sso.hmac.kr"
|
|
}
|
|
hydraPublicURL = userfrontURL + "/oidc"
|
|
}
|
|
|
|
redirectURI := ""
|
|
if len(redirectURIs) > 0 {
|
|
redirectURI = strings.TrimSpace(redirectURIs[0])
|
|
}
|
|
|
|
mergedScopes := make([]string, 0, len(scopes)+1)
|
|
seen := map[string]struct{}{}
|
|
for _, scope := range append([]string{"openid"}, scopes...) {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[scope]; ok {
|
|
continue
|
|
}
|
|
seen[scope] = struct{}{}
|
|
mergedScopes = append(mergedScopes, scope)
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("client_id", clientID)
|
|
params.Set("response_type", "code")
|
|
params.Set("scope", strings.Join(mergedScopes, " "))
|
|
params.Set("state", GenerateSecureAlnumToken(16))
|
|
if redirectURI != "" {
|
|
params.Set("redirect_uri", redirectURI)
|
|
}
|
|
|
|
return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode())
|
|
}
|
|
|
|
func mergeScopes(current []string, next []string) []string {
|
|
if len(next) == 0 {
|
|
return current
|
|
}
|
|
seen := make(map[string]struct{}, len(current)+len(next))
|
|
for _, scope := range current {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
continue
|
|
}
|
|
seen[scope] = struct{}{}
|
|
}
|
|
for _, scope := range next {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[scope]; ok {
|
|
continue
|
|
}
|
|
seen[scope] = struct{}{}
|
|
current = append(current, scope)
|
|
}
|
|
return current
|
|
}
|
|
|
|
type rpHistoryItem struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientName string `json:"client_name"`
|
|
Scopes []string `json:"scopes"`
|
|
LastApprovedAt *time.Time `json:"last_approved_at"`
|
|
LastRevokedAt *time.Time `json:"last_revoked_at"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type userSessionItem struct {
|
|
SessionID string `json:"session_id"`
|
|
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
IssuedAt *time.Time `json:"issued_at,omitempty"`
|
|
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
ClientID string `json:"client_id,omitempty"`
|
|
AppName string `json:"app_name,omitempty"`
|
|
IsCurrent bool `json:"is_current"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
type userSessionListResponse struct {
|
|
Items []userSessionItem `json:"items"`
|
|
}
|
|
|
|
func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
|
|
}
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
if strings.TrimSpace(profile.ID) == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
|
|
sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
|
|
}
|
|
|
|
currentSessionID := h.resolveCurrentSessionID(c)
|
|
auditHints := h.loadSessionAuditHints(c.Context(), profile.ID)
|
|
|
|
items := make([]userSessionItem, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
if !session.Active {
|
|
continue
|
|
}
|
|
item := userSessionItem{
|
|
SessionID: session.ID,
|
|
IsCurrent: session.ID != "" && session.ID == currentSessionID,
|
|
IsActive: session.Active,
|
|
}
|
|
if !session.AuthenticatedAt.IsZero() {
|
|
ts := session.AuthenticatedAt
|
|
item.AuthenticatedAt = &ts
|
|
item.LastSeenAt = &ts
|
|
}
|
|
if !session.ExpiresAt.IsZero() {
|
|
ts := session.ExpiresAt
|
|
item.ExpiresAt = &ts
|
|
}
|
|
if !session.IssuedAt.IsZero() {
|
|
ts := session.IssuedAt
|
|
item.IssuedAt = &ts
|
|
if item.AuthenticatedAt == nil {
|
|
item.AuthenticatedAt = &ts
|
|
}
|
|
if item.LastSeenAt == nil {
|
|
item.LastSeenAt = &ts
|
|
}
|
|
}
|
|
if hint, ok := auditHints[session.ID]; ok {
|
|
if item.IPAddress == "" {
|
|
item.IPAddress = hint.IPAddress
|
|
}
|
|
if item.UserAgent == "" {
|
|
item.UserAgent = hint.UserAgent
|
|
}
|
|
if item.ClientID == "" {
|
|
item.ClientID = hint.ClientID
|
|
}
|
|
if item.AppName == "" {
|
|
item.AppName = hint.AppName
|
|
}
|
|
if hint.Timestamp != nil {
|
|
item.LastSeenAt = hint.Timestamp
|
|
}
|
|
}
|
|
if item.UserAgent == "" && len(session.Devices) > 0 {
|
|
deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent)
|
|
if !looksLikeInternalUserAgent(deviceUserAgent) {
|
|
item.UserAgent = deviceUserAgent
|
|
}
|
|
}
|
|
if item.IPAddress == "" && len(session.Devices) > 0 {
|
|
item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress)
|
|
}
|
|
if item.IsCurrent {
|
|
applyCurrentSessionRequestHints(c, &item)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].IsCurrent != items[j].IsCurrent {
|
|
return items[i].IsCurrent
|
|
}
|
|
iTime := latestSessionTimestamp(items[i])
|
|
jTime := latestSessionTimestamp(items[j])
|
|
if iTime.Equal(jTime) {
|
|
return items[i].SessionID < items[j].SessionID
|
|
}
|
|
return iTime.After(jTime)
|
|
})
|
|
|
|
return c.JSON(userSessionListResponse{Items: items})
|
|
}
|
|
|
|
func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
|
|
}
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
targetSessionID := strings.TrimSpace(c.Params("id"))
|
|
if targetSessionID == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "session id is required")
|
|
}
|
|
|
|
mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
|
|
}
|
|
ownedSession := false
|
|
for _, candidate := range mySessions {
|
|
if strings.TrimSpace(candidate.ID) == targetSessionID {
|
|
ownedSession = true
|
|
break
|
|
}
|
|
}
|
|
if !ownedSession {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
|
}
|
|
|
|
session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session")
|
|
}
|
|
if session == nil {
|
|
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing")
|
|
return c.JSON(fiber.Map{"status": "ok"})
|
|
}
|
|
|
|
result := "revoked"
|
|
if !session.Active {
|
|
result = "already_inactive"
|
|
} else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session")
|
|
}
|
|
if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
|
|
}
|
|
h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID)
|
|
|
|
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
|
|
return c.JSON(fiber.Map{"status": "ok"})
|
|
}
|
|
|
|
func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
|
|
subject, err := h.resolveConsentSubject(c)
|
|
if err != nil || subject == "" {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
|
|
}
|
|
|
|
if h.AuditRepo == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
|
}
|
|
|
|
logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 100)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history")
|
|
}
|
|
|
|
historyMap := make(map[string]*rpHistoryItem)
|
|
|
|
// Logs are DESC (newest first). Iterate in reverse (oldest first) to build state.
|
|
for i := len(logs) - 1; i >= 0; i-- {
|
|
log := logs[i]
|
|
details, _ := utils.ParseAuditDetails(log.Details)
|
|
clientID, _ := details["client_id"].(string)
|
|
if clientID == "" {
|
|
continue
|
|
}
|
|
|
|
item, ok := historyMap[clientID]
|
|
if !ok {
|
|
item = &rpHistoryItem{
|
|
ClientID: clientID,
|
|
Status: "unknown",
|
|
}
|
|
historyMap[clientID] = item
|
|
}
|
|
|
|
if name, ok := details["client_name"].(string); ok && name != "" {
|
|
item.ClientName = name
|
|
}
|
|
|
|
if log.EventType == "consent.granted" {
|
|
item.Status = "active"
|
|
ts := log.Timestamp
|
|
item.LastApprovedAt = &ts
|
|
|
|
if scopesRaw, ok := details["scopes"].([]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})
|
|
}
|
|
|
|
type sessionAuditHint struct {
|
|
Timestamp *time.Time
|
|
IPAddress string
|
|
UserAgent string
|
|
ClientID string
|
|
AppName string
|
|
}
|
|
|
|
func latestSessionTimestamp(item userSessionItem) time.Time {
|
|
for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} {
|
|
if candidate != nil {
|
|
return *candidate
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if token := h.getBearerToken(c); token != "" {
|
|
if sessionID := extractSessionIDFromJWT(token); sessionID != "" {
|
|
return sessionID
|
|
}
|
|
if sessionID, err := h.getKratosSessionID(token); err == nil {
|
|
return sessionID
|
|
}
|
|
}
|
|
if cookie := c.Get("Cookie"); cookie != "" {
|
|
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
|
|
return sessionID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func applyCurrentSessionRequestHints(c *fiber.Ctx, item *userSessionItem) {
|
|
if c == nil || item == nil || !item.IsCurrent {
|
|
return
|
|
}
|
|
|
|
if item.IPAddress == "" {
|
|
item.IPAddress = strings.TrimSpace(resolveRequestClientIP(c))
|
|
}
|
|
if item.UserAgent == "" {
|
|
userAgent := strings.TrimSpace(c.Get("User-Agent"))
|
|
if !looksLikeInternalUserAgent(userAgent) {
|
|
item.UserAgent = userAgent
|
|
}
|
|
}
|
|
if strings.TrimSpace(item.ClientID) == "" {
|
|
item.ClientID = "userfront"
|
|
}
|
|
if strings.TrimSpace(item.AppName) == "" {
|
|
item.AppName = "UserFront"
|
|
}
|
|
}
|
|
|
|
func resolveRequestClientIP(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
|
|
}
|
|
|
|
func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint {
|
|
hints := make(map[string]sessionAuditHint)
|
|
if h.AuditRepo == nil || strings.TrimSpace(userID) == "" {
|
|
return hints
|
|
}
|
|
|
|
logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{
|
|
"login_success",
|
|
"qr_login_success",
|
|
"link_login_success",
|
|
"code_login_success",
|
|
"password_login_success",
|
|
"consent.granted",
|
|
"POST /api/v1/auth/oidc/login/accept",
|
|
"POST /api/v1/auth/password/login",
|
|
"POST /api/v1/auth/headless/password/login",
|
|
"POST /api/v1/auth/magic-link/verify",
|
|
"POST /api/v1/auth/login/code/verify",
|
|
"POST /api/v1/auth/qr/approve",
|
|
"session.revoked",
|
|
}, 200)
|
|
if err != nil {
|
|
return hints
|
|
}
|
|
|
|
for _, log := range logs {
|
|
sessionID := strings.TrimSpace(log.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details))
|
|
}
|
|
if sessionID == "" {
|
|
sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details))
|
|
}
|
|
if sessionID == "" {
|
|
continue
|
|
}
|
|
|
|
ts := log.Timestamp
|
|
ipAddress := strings.TrimSpace(log.IPAddress)
|
|
userAgent := strings.TrimSpace(log.UserAgent)
|
|
clientID, appName := deriveSessionClientInfo(log)
|
|
if details, err := parseAuditDetails(log.Details); err == nil {
|
|
if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" {
|
|
ipAddress = strings.TrimSpace(approvedIP)
|
|
}
|
|
if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" {
|
|
userAgent = strings.TrimSpace(approvedUserAgent)
|
|
}
|
|
}
|
|
if looksLikeInternalUserAgent(userAgent) {
|
|
userAgent = ""
|
|
}
|
|
hints[sessionID] = mergeSessionAuditHint(hints[sessionID], sessionAuditHint{
|
|
Timestamp: &ts,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
ClientID: clientID,
|
|
AppName: appName,
|
|
})
|
|
}
|
|
return hints
|
|
}
|
|
|
|
func mergeSessionAuditHint(existing sessionAuditHint, candidate sessionAuditHint) sessionAuditHint {
|
|
if candidate.Timestamp != nil &&
|
|
(existing.Timestamp == nil || candidate.Timestamp.After(*existing.Timestamp)) {
|
|
existing.Timestamp = candidate.Timestamp
|
|
}
|
|
if shouldReplaceSessionIP(existing.IPAddress, candidate.IPAddress) {
|
|
existing.IPAddress = candidate.IPAddress
|
|
}
|
|
if existing.UserAgent == "" && candidate.UserAgent != "" {
|
|
existing.UserAgent = candidate.UserAgent
|
|
}
|
|
if existing.ClientID == "" && candidate.ClientID != "" {
|
|
existing.ClientID = candidate.ClientID
|
|
}
|
|
if existing.AppName == "" && candidate.AppName != "" {
|
|
existing.AppName = candidate.AppName
|
|
}
|
|
return existing
|
|
}
|
|
|
|
func shouldReplaceSessionIP(existing string, candidate string) bool {
|
|
existing = strings.TrimSpace(existing)
|
|
candidate = strings.TrimSpace(candidate)
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
if existing == "" {
|
|
return true
|
|
}
|
|
if isPrivateIPAddress(existing) && !isPrivateIPAddress(candidate) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isPrivateIPAddress(raw string) bool {
|
|
return utils.IsPrivateOrReservedIP(raw)
|
|
}
|
|
|
|
func parseAuditDetails(details string) (map[string]any, error) {
|
|
return utils.ParseAuditDetails(details)
|
|
}
|
|
|
|
func deriveSessionClientInfo(log domain.AuditLog) (string, string) {
|
|
details, _ := parseAuditDetails(log.Details)
|
|
clientID := ""
|
|
appName := ""
|
|
if details != nil {
|
|
if value, ok := details["client_id"].(string); ok {
|
|
clientID = strings.TrimSpace(value)
|
|
}
|
|
if value, ok := details["client_name"].(string); ok {
|
|
appName = strings.TrimSpace(value)
|
|
}
|
|
}
|
|
path := strings.ToLower(extractAuditPath(log))
|
|
if appName == "" {
|
|
switch {
|
|
case strings.Contains(path, "/api/v1/auth/oidc/login/accept"):
|
|
appName = "OIDC 로그인"
|
|
case strings.Contains(path, "/api/v1/auth/qr/approve"):
|
|
appName = "QR 로그인"
|
|
case strings.Contains(path, "/api/v1/auth/login/code/verify"):
|
|
appName = "코드 로그인"
|
|
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
|
|
appName = "링크 로그인"
|
|
case strings.Contains(path, "/api/v1/auth/password/login"),
|
|
strings.Contains(path, "/api/v1/auth/headless/password/login"):
|
|
appName = "비밀번호 로그인"
|
|
}
|
|
}
|
|
if appName == "" && clientID != "" {
|
|
appName = clientID
|
|
}
|
|
return clientID, appName
|
|
}
|
|
|
|
func extractStringLikeValue(raw any) string {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(value)
|
|
default:
|
|
text := strings.TrimSpace(fmt.Sprint(value))
|
|
if text == "" || text == "<nil>" {
|
|
return ""
|
|
}
|
|
return text
|
|
}
|
|
}
|
|
|
|
func extractHydraSessionID(ext map[string]interface{}) string {
|
|
if len(ext) == 0 {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"session_id", "sid", "sessionId"} {
|
|
if value := extractStringLikeValue(ext[key]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *AuthHandler) validateHydraTokenSession(ctx context.Context, intro *service.HydraIntrospectionResponse) error {
|
|
if h == nil || h.KratosAdmin == nil || intro == nil {
|
|
return nil
|
|
}
|
|
|
|
sessionID := extractHydraSessionID(intro.Ext)
|
|
if sessionID == "" {
|
|
return nil
|
|
}
|
|
|
|
session, err := h.KratosAdmin.GetSession(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("kratos session lookup failed: %w", err)
|
|
}
|
|
if session == nil {
|
|
return errors.New("linked session not found")
|
|
}
|
|
if !session.Active {
|
|
return errors.New("linked session is inactive")
|
|
}
|
|
if identityID := strings.TrimSpace(session.Identity.ID); identityID != "" && strings.TrimSpace(intro.Subject) != "" && identityID != strings.TrimSpace(intro.Subject) {
|
|
return errors.New("linked session subject mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID string) map[string][]string {
|
|
bindings := make(map[string][]string)
|
|
if h == nil || h.AuditRepo == nil || strings.TrimSpace(userID) == "" {
|
|
return bindings
|
|
}
|
|
|
|
logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{
|
|
"consent.granted",
|
|
"POST /api/v1/auth/oidc/login/accept",
|
|
"POST /api/v1/auth/password/login",
|
|
"POST /api/v1/auth/headless/password/login",
|
|
"password_login_success",
|
|
"login_success",
|
|
}, 200)
|
|
if err != nil {
|
|
return bindings
|
|
}
|
|
|
|
for _, log := range logs {
|
|
sessionID := strings.TrimSpace(log.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details))
|
|
}
|
|
if sessionID == "" {
|
|
sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details))
|
|
}
|
|
if sessionID == "" {
|
|
continue
|
|
}
|
|
|
|
clientID, _ := deriveSessionClientInfo(log)
|
|
clientID = strings.TrimSpace(clientID)
|
|
if clientID == "" {
|
|
continue
|
|
}
|
|
|
|
existing := bindings[sessionID]
|
|
seen := false
|
|
for _, candidate := range existing {
|
|
if candidate == clientID {
|
|
seen = true
|
|
break
|
|
}
|
|
}
|
|
if !seen {
|
|
bindings[sessionID] = append(existing, clientID)
|
|
}
|
|
}
|
|
|
|
return bindings
|
|
}
|
|
|
|
func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID string, sessionID string) error {
|
|
if h == nil || h.Hydra == nil {
|
|
return nil
|
|
}
|
|
|
|
clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
|
|
if len(clientIDs) == 0 {
|
|
return nil
|
|
}
|
|
for _, clientID := range clientIDs {
|
|
if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) {
|
|
if h == nil || h.Hydra == nil {
|
|
return
|
|
}
|
|
|
|
clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)]
|
|
for _, clientID := range clientIDs {
|
|
h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) {
|
|
if h == nil || h.Hydra == nil || h.BackchannelLogout == nil {
|
|
return
|
|
}
|
|
|
|
clientID = strings.TrimSpace(clientID)
|
|
userID = strings.TrimSpace(userID)
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if clientID == "" || userID == "" {
|
|
return
|
|
}
|
|
|
|
client, err := h.Hydra.GetClient(ctx, clientID)
|
|
if err != nil {
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed")
|
|
return
|
|
}
|
|
if client == nil {
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found")
|
|
return
|
|
}
|
|
|
|
endpoint := client.BackchannelLogoutURI()
|
|
if endpoint == "" {
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured")
|
|
return
|
|
}
|
|
if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" {
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required")
|
|
return
|
|
}
|
|
|
|
logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID)
|
|
if err != nil {
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed")
|
|
return
|
|
}
|
|
|
|
statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken)
|
|
if err != nil {
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed")
|
|
return
|
|
}
|
|
|
|
h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "")
|
|
}
|
|
|
|
func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) {
|
|
if h == nil || h.AuditRepo == nil {
|
|
return
|
|
}
|
|
|
|
endpointHost := ""
|
|
if endpoint != "" {
|
|
if parsed, err := url.Parse(endpoint); err == nil {
|
|
endpointHost = parsed.Host
|
|
}
|
|
}
|
|
|
|
details := map[string]any{
|
|
"client_id": strings.TrimSpace(clientID),
|
|
"session_id": strings.TrimSpace(sessionID),
|
|
"endpoint_host": strings.TrimSpace(endpointHost),
|
|
"status_code": statusCode,
|
|
"retry_count": 0,
|
|
"logout_issuer": h.BackchannelLogout.Issuer(),
|
|
}
|
|
if reason != "" {
|
|
details["reason"] = reason
|
|
}
|
|
|
|
raw, err := json.Marshal(details)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
status := "success"
|
|
if strings.HasSuffix(eventType, ".failed") {
|
|
status = "failure"
|
|
} else if strings.HasSuffix(eventType, ".skipped") {
|
|
status = "skipped"
|
|
}
|
|
|
|
ipAddress := ""
|
|
userAgent := ""
|
|
if c != nil {
|
|
ipAddress = extractClientIPFromHeaders(c)
|
|
userAgent = strings.TrimSpace(c.Get("User-Agent"))
|
|
}
|
|
|
|
_ = h.AuditRepo.Create(&domain.AuditLog{
|
|
EventID: fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()),
|
|
Timestamp: time.Now().UTC(),
|
|
UserID: strings.TrimSpace(userID),
|
|
SessionID: strings.TrimSpace(sessionID),
|
|
EventType: eventType,
|
|
Status: status,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
Details: string(raw),
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error {
|
|
if h == nil || h.BackchannelLogout == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable")
|
|
}
|
|
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
|
|
c.Set(fiber.HeaderCacheControl, "no-store")
|
|
return c.JSON(h.BackchannelLogout.PublicJWKS())
|
|
}
|
|
|
|
func looksLikeInternalUserAgent(userAgent string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(userAgent))
|
|
if normalized == "" {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(normalized, "go-http-client/") ||
|
|
strings.HasPrefix(normalized, "fasthttp") ||
|
|
strings.HasPrefix(normalized, "fiber")
|
|
}
|
|
|
|
func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) {
|
|
if h.AuditRepo == nil {
|
|
return
|
|
}
|
|
|
|
details := map[string]any{
|
|
"target_session_id": strings.TrimSpace(targetSessionID),
|
|
"revoke_result": strings.TrimSpace(result),
|
|
}
|
|
if strings.TrimSpace(actorSessionID) != "" {
|
|
details["actor_session_id"] = strings.TrimSpace(actorSessionID)
|
|
}
|
|
raw, err := json.Marshal(details)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_ = h.AuditRepo.Create(&domain.AuditLog{
|
|
EventID: fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()),
|
|
Timestamp: time.Now().UTC(),
|
|
UserID: strings.TrimSpace(actorIdentityID),
|
|
SessionID: strings.TrimSpace(actorSessionID),
|
|
EventType: "session.revoked",
|
|
Status: "success",
|
|
IPAddress: extractClientIPFromHeaders(c),
|
|
UserAgent: strings.TrimSpace(c.Get("User-Agent")),
|
|
Details: string(raw),
|
|
})
|
|
}
|