forked from baron/baron-sso
- UpdateUser: Implement 'Preserve & Merge' logic to fetch existing joined tenants from Keto and merge them with UI requests, preventing the loss of multi-tenant affiliations. - Keto Sync: Expand the self-healing background job to iterate over all companyCodes, ensuring 'members' relations are created for every joined tenant (fixes #554). - AuthHandler: Update extractFirstString to gracefully handle numeric JSON types, fixing an issue where Kratos login codes were lost during Courier webhook processing.
7654 lines
234 KiB
Go
7654 lines
234 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/logger"
|
|
"baron-sso-backend/internal/repository"
|
|
"baron-sso-backend/internal/service"
|
|
"baron-sso-backend/internal/utils"
|
|
"bytes"
|
|
"context"
|
|
crand "crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/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
|
|
TenantService service.TenantService
|
|
KetoService service.KetoService
|
|
KetoOutboxRepo repository.KetoOutboxRepository
|
|
UserRepo repository.UserRepository
|
|
ConsentRepo repository.ClientConsentRepository
|
|
}
|
|
|
|
type signupState struct {
|
|
Code string `json:"code"`
|
|
Verified bool `json:"verified"`
|
|
FailCount int `json:"fail_count"`
|
|
ExpiresAt int64 `json:"expires_at"` // Unix timestamp
|
|
}
|
|
|
|
type headlessLinkState struct {
|
|
ClientID string `json:"clientId"`
|
|
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 {
|
|
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(),
|
|
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) map[string]any {
|
|
claims := map[string]any{}
|
|
if traits == nil {
|
|
return claims
|
|
}
|
|
|
|
scopeSet := map[string]struct{}{}
|
|
for _, scope := range scopes {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
continue
|
|
}
|
|
scopeSet[scope] = struct{}{}
|
|
}
|
|
|
|
getString := func(key string) string {
|
|
raw, ok := traits[key]
|
|
if !ok || raw == nil {
|
|
return ""
|
|
}
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(value)
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprint(value))
|
|
}
|
|
}
|
|
|
|
displayName := getString("displayname")
|
|
if displayName == "" {
|
|
displayName = getString("name")
|
|
}
|
|
if displayName != "" {
|
|
claims["name"] = displayName
|
|
}
|
|
primaryEmail := getString("primary_email")
|
|
if primaryEmail == "" {
|
|
primaryEmail = getString("email")
|
|
}
|
|
if primaryEmail != "" {
|
|
claims["email"] = primaryEmail
|
|
}
|
|
|
|
if _, ok := scopeSet["profile"]; ok {
|
|
profile := map[string]any{}
|
|
names := map[string]any{}
|
|
for _, key := range []string{
|
|
"name",
|
|
"displayname",
|
|
"preferred_username",
|
|
"given_name",
|
|
"family_name",
|
|
"middle_name",
|
|
"nickname",
|
|
} {
|
|
if value := getString(key); value != "" {
|
|
names[key] = value
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
profile["names"] = names
|
|
}
|
|
|
|
emails := collectEmailList(traits, primaryEmail)
|
|
if len(emails) > 0 {
|
|
profile["emails"] = emails
|
|
}
|
|
if len(profile) > 0 {
|
|
claims["profile"] = profile
|
|
}
|
|
|
|
for _, key := range []string{
|
|
"department",
|
|
"affiliationType",
|
|
"companyCode",
|
|
"displayname",
|
|
"team",
|
|
"grade",
|
|
"familyCompany",
|
|
"taxCode",
|
|
"familyUniqueKey",
|
|
"personal",
|
|
} {
|
|
if raw, ok := traits[key]; ok && raw != nil {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
if strings.TrimSpace(value) != "" {
|
|
claims[key] = strings.TrimSpace(value)
|
|
}
|
|
default:
|
|
claims[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, ok := scopeSet["phone"]; ok {
|
|
if phone := getString("phone_number"); phone != "" {
|
|
claims["phone_number"] = phone
|
|
}
|
|
}
|
|
|
|
return claims
|
|
}
|
|
|
|
func 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 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)
|
|
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
|
|
|
|
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
|
|
sessionData := map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": sessionToken,
|
|
}
|
|
if sessionID != "" {
|
|
sessionData["session_id"] = sessionID
|
|
}
|
|
sessionDataJSON, _ := json.Marshal(sessionData)
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration)
|
|
|
|
h.writeLinkAuditLog(loginID, pendingRef, authInfo.SessionToken, c)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"token": sessionToken,
|
|
"message": "Login successful",
|
|
})
|
|
}
|
|
|
|
// VerifyLoginCode - Verify Kratos login code and issue session.
|
|
func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|
var req struct {
|
|
LoginID string `json:"loginId"`
|
|
Code string `json:"code"`
|
|
PendingRef string `json:"pendingRef"`
|
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("[LoginCode] Body parse error", "error", err)
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
|
}
|
|
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
loginID = strings.ReplaceAll(loginID, " ", "+")
|
|
if loginID == "" || req.Code == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "loginId and code are required")
|
|
}
|
|
|
|
lookupLoginID := loginID
|
|
if !strings.Contains(loginID, "@") {
|
|
lookupLoginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable")
|
|
}
|
|
|
|
flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID)
|
|
if err != nil || flowID == "" {
|
|
return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired")
|
|
}
|
|
|
|
if req.VerifyOnly {
|
|
c.Locals("auth_timeline_skip", true)
|
|
effectiveLoginID := lookupLoginID
|
|
if !strings.Contains(loginID, "@") {
|
|
if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookupLoginID); mapped != "" {
|
|
effectiveLoginID = mapped
|
|
}
|
|
}
|
|
pendingRef := strings.TrimSpace(req.PendingRef)
|
|
storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
|
|
if pendingRef == "" {
|
|
pendingRef = storedRef
|
|
} else if storedRef != "" && pendingRef != storedRef {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference")
|
|
}
|
|
if pendingRef == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference")
|
|
}
|
|
expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
|
|
expectedCode = normalizeLoginCode(expectedCode)
|
|
inputCode := normalizeLoginCode(req.Code)
|
|
if expectedCode == "" || inputCode == "" || inputCode != expectedCode {
|
|
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code")
|
|
}
|
|
h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration)
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": "approved",
|
|
"loginId": effectiveLoginID,
|
|
})
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
|
return c.JSON(fiber.Map{
|
|
"status": "approved",
|
|
"pendingRef": pendingRef,
|
|
"message": "Login approved",
|
|
})
|
|
}
|
|
|
|
authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
|
|
}
|
|
slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err)
|
|
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code")
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session")
|
|
}
|
|
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID)
|
|
if resolveErr != nil || subject == "" {
|
|
slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr)
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
|
|
}
|
|
authInfo.Subject = subject
|
|
c.Locals("login_id", lookupLoginID)
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
|
|
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
|
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
|
|
|
|
pendingRef := strings.TrimSpace(req.PendingRef)
|
|
if pendingRef == "" {
|
|
storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
|
|
pendingRef = storedRef
|
|
}
|
|
if pendingRef != "" {
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": authInfo.SessionToken.JWT,
|
|
})
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
|
h.RedisService.Delete(prefixLoginCodePending + lookupLoginID)
|
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
|
|
return c.JSON(fiber.Map{
|
|
"status": "approved",
|
|
"pendingRef": pendingRef,
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": subject,
|
|
"message": "Login approved",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"token": authInfo.SessionToken.JWT,
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": subject,
|
|
"message": "Login successful",
|
|
})
|
|
}
|
|
|
|
// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
|
|
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
|
var req struct {
|
|
ShortCode string `json:"shortCode"`
|
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("[LoginShortCode] Body parse error", "error", err)
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "Invalid request body")
|
|
}
|
|
|
|
shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode))
|
|
if shortCode == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "shortCode is required")
|
|
}
|
|
|
|
val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode)
|
|
if val == "" {
|
|
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code")
|
|
}
|
|
|
|
var payload shortLoginCodePayload
|
|
if err := json.Unmarshal([]byte(val), &payload); err != nil {
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Invalid code payload")
|
|
}
|
|
if payload.LoginID == "" || payload.Code == "" {
|
|
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_or_expired_code", "Invalid or expired code")
|
|
}
|
|
|
|
if req.VerifyOnly {
|
|
c.Locals("auth_timeline_skip", true)
|
|
if payload.PendingRef == "" {
|
|
return errorJSONCode(c, fiber.StatusBadRequest, "invalid_session_reference", "Invalid session reference")
|
|
}
|
|
normalizedCode := normalizeLoginCode(payload.Code)
|
|
if normalizedCode != "" {
|
|
h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration)
|
|
}
|
|
h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration)
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": "approved",
|
|
"loginId": payload.LoginID,
|
|
})
|
|
h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
|
|
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
|
|
return c.JSON(fiber.Map{
|
|
"status": "approved",
|
|
"pendingRef": payload.PendingRef,
|
|
"message": "Login approved",
|
|
})
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return errorJSONCode(c, fiber.StatusServiceUnavailable, "service_unavailable", "Identity provider unavailable")
|
|
}
|
|
|
|
flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID)
|
|
if err != nil || flowID == "" {
|
|
return errorJSONCode(c, fiber.StatusNotFound, "not_found", "Login flow expired")
|
|
}
|
|
|
|
authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return errorJSONCode(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported")
|
|
}
|
|
slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err)
|
|
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_code", "Invalid code")
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to issue session")
|
|
}
|
|
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID)
|
|
if resolveErr != nil || subject == "" {
|
|
slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr)
|
|
return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
|
|
}
|
|
authInfo.Subject = subject
|
|
c.Locals("login_id", payload.LoginID)
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
|
|
h.RedisService.Delete(prefixLoginCode + payload.LoginID)
|
|
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
|
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID)
|
|
h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID)
|
|
|
|
if payload.PendingRef != "" {
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": authInfo.SessionToken.JWT,
|
|
})
|
|
h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
|
|
h.RedisService.Delete(prefixLoginCodePending + payload.LoginID)
|
|
return c.JSON(fiber.Map{
|
|
"status": "approved",
|
|
"pendingRef": payload.PendingRef,
|
|
"token": authInfo.SessionToken.JWT,
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": subject,
|
|
"message": "Login approved",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"token": authInfo.SessionToken.JWT,
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": subject,
|
|
"message": "Login successful",
|
|
})
|
|
}
|
|
|
|
// PasswordLogin - Authenticate a user with login ID and password.
|
|
func logOidcRedirectSummary(source, redirectTo string) {
|
|
parsed, err := url.Parse(redirectTo)
|
|
if err != nil {
|
|
slog.Warn(
|
|
"OIDC redirect parse failed",
|
|
"source", source,
|
|
"redirectToLength", len(redirectTo),
|
|
"error", err,
|
|
)
|
|
return
|
|
}
|
|
|
|
query := parsed.Query()
|
|
slog.Info(
|
|
"OIDC redirect summary",
|
|
"source", source,
|
|
"redirectToLength", len(redirectTo),
|
|
"redirectToHost", parsed.Host,
|
|
"redirectToPath", parsed.Path,
|
|
"redirectHasLoginVerifier", query.Has("login_verifier"),
|
|
"redirectHasRedirectURI", query.Has("redirect_uri"),
|
|
)
|
|
}
|
|
|
|
func (h *AuthHandler) 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)
|
|
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)
|
|
|
|
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,
|
|
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) 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)
|
|
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)
|
|
return c.JSON(fiber.Map{
|
|
"redirectTo": acceptResp.RedirectTo,
|
|
"status": "ok",
|
|
"provider": h.IdpProvider.Name(),
|
|
})
|
|
}
|
|
// --- OIDC 로그인 흐름 처리 끝 ---
|
|
|
|
resp := fiber.Map{
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"status": "ok",
|
|
"provider": h.IdpProvider.Name(),
|
|
}
|
|
if authInfo.RefreshToken != nil {
|
|
resp["refreshJwt"] = authInfo.RefreshToken.JWT
|
|
}
|
|
if authInfo.Subject != "" {
|
|
resp["subject"] = authInfo.Subject
|
|
}
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
c.Locals("audit_details_extra", map[string]any{
|
|
"client_id": clientID,
|
|
"client_name": clientName,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
|
|
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
|
return h.resolveCurrentProfile(c)
|
|
}
|
|
|
|
func looksLikeJWT(token string) bool {
|
|
return strings.Count(token, ".") == 2
|
|
}
|
|
|
|
func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) {
|
|
if c == nil || token == nil {
|
|
return
|
|
}
|
|
if sessionID := extractSessionIDFromToken(token); sessionID != "" {
|
|
c.Locals("session_id", sessionID)
|
|
}
|
|
}
|
|
|
|
func extractSessionIDFromToken(token *domain.Token) string {
|
|
if token == nil {
|
|
return ""
|
|
}
|
|
if token.SessionID != "" {
|
|
return token.SessionID
|
|
}
|
|
if token.JWT != "" {
|
|
return extractSessionIDFromJWT(token.JWT)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractSessionIDFromJWT(token string) string {
|
|
if !looksLikeJWT(token) {
|
|
return ""
|
|
}
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return ""
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
}
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"sid", "session_id", "sessionId", "jti"} {
|
|
if raw, ok := claims[key]; ok {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
if value != "" {
|
|
return value
|
|
}
|
|
default:
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type qrMeta struct {
|
|
IPAddress string `json:"ip_address"`
|
|
UserAgent string `json:"user_agent"`
|
|
}
|
|
|
|
func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) {
|
|
if h.RedisService == nil || pendingRef == "" || c == nil {
|
|
return
|
|
}
|
|
meta := qrMeta{
|
|
IPAddress: extractClientIPFromHeaders(c),
|
|
UserAgent: c.Get("User-Agent"),
|
|
}
|
|
raw, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = h.RedisService.Set(prefixQrMeta+pendingRef, string(raw), 5*time.Minute)
|
|
}
|
|
|
|
func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return qrMeta{}, false
|
|
}
|
|
val, err := h.RedisService.Get(prefixQrMeta + pendingRef)
|
|
if err != nil || val == "" {
|
|
return qrMeta{}, false
|
|
}
|
|
var meta qrMeta
|
|
if err := json.Unmarshal([]byte(val), &meta); err != nil {
|
|
return qrMeta{}, false
|
|
}
|
|
return meta, true
|
|
}
|
|
|
|
func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) {
|
|
if h.RedisService == nil || pendingRef == "" || c == nil {
|
|
return
|
|
}
|
|
meta := qrMeta{
|
|
IPAddress: extractClientIPFromHeaders(c),
|
|
UserAgent: c.Get("User-Agent"),
|
|
}
|
|
raw, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl)
|
|
}
|
|
|
|
func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return qrMeta{}, false
|
|
}
|
|
val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef)
|
|
if err != nil || val == "" {
|
|
return qrMeta{}, false
|
|
}
|
|
var meta qrMeta
|
|
if err := json.Unmarshal([]byte(val), &meta); err != nil {
|
|
return qrMeta{}, false
|
|
}
|
|
if meta.IPAddress == "" && meta.UserAgent == "" {
|
|
return qrMeta{}, false
|
|
}
|
|
return meta, true
|
|
}
|
|
|
|
func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
|
|
if h.RedisService == nil || pendingRef == "" || sessionID == "" {
|
|
return
|
|
}
|
|
_ = h.RedisService.Set(prefixQrApproverSession+pendingRef, sessionID, loginCodeExpiration)
|
|
}
|
|
|
|
func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return ""
|
|
}
|
|
val, err := h.RedisService.Get(prefixQrApproverSession + pendingRef)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(val)
|
|
}
|
|
|
|
func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return
|
|
}
|
|
method := resolveLoginMethod(rawMethod, loginID)
|
|
if method != "" {
|
|
_ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl)
|
|
}
|
|
if flow != "" {
|
|
_ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl)
|
|
}
|
|
if strategy != "" {
|
|
_ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl)
|
|
}
|
|
if strings.TrimSpace(loginID) != "" {
|
|
_ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return "", "", "", ""
|
|
}
|
|
method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef)
|
|
flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef)
|
|
strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef)
|
|
rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef)
|
|
return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID)
|
|
}
|
|
|
|
func (h *AuthHandler) loadLoginFlow(pendingRef string) string {
|
|
_, flow, _, _ := h.loadLoginMeta(pendingRef)
|
|
return flow
|
|
}
|
|
|
|
func (h *AuthHandler) loadLoginStrategy(pendingRef string) string {
|
|
_, _, strategy, _ := h.loadLoginMeta(pendingRef)
|
|
return strategy
|
|
}
|
|
|
|
func (h *AuthHandler) clearLoginMeta(pendingRef string) {
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
return
|
|
}
|
|
_ = h.RedisService.Delete(prefixLoginMethod + pendingRef)
|
|
_ = h.RedisService.Delete(prefixLoginFlow + pendingRef)
|
|
_ = h.RedisService.Delete(prefixLoginStrategy + pendingRef)
|
|
_ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef)
|
|
_ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef)
|
|
}
|
|
|
|
func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
|
|
if h.AuditRepo == nil || pendingRef == "" {
|
|
return
|
|
}
|
|
meta, ok := h.loadQrMeta(pendingRef)
|
|
if !ok {
|
|
meta = qrMeta{
|
|
IPAddress: "",
|
|
UserAgent: "",
|
|
}
|
|
}
|
|
if approvedSessionID == "" {
|
|
approvedSessionID = h.loadQrApproverSessionID(pendingRef)
|
|
}
|
|
sessionID := extractSessionIDFromToken(sessionToken)
|
|
details := map[string]any{
|
|
"path": "/api/v1/auth/qr/approve",
|
|
"login_id": loginID,
|
|
"pending_ref": pendingRef,
|
|
}
|
|
if sessionID != "" {
|
|
details["session_id"] = sessionID
|
|
}
|
|
if approvedSessionID != "" {
|
|
details["approved_session_id"] = approvedSessionID
|
|
}
|
|
detailsJSON, _ := json.Marshal(details)
|
|
|
|
log := &domain.AuditLog{
|
|
EventID: GenerateSecureToken(16),
|
|
Timestamp: time.Now(),
|
|
UserID: "",
|
|
SessionID: sessionID,
|
|
EventType: "POST /api/v1/auth/qr/approve",
|
|
Status: "success",
|
|
IPAddress: meta.IPAddress,
|
|
UserAgent: meta.UserAgent,
|
|
Details: string(detailsJSON),
|
|
AuthMethod: "QR",
|
|
}
|
|
_ = h.AuditRepo.Create(log)
|
|
}
|
|
|
|
func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken *domain.Token, c *fiber.Ctx) {
|
|
if h.AuditRepo == nil {
|
|
return
|
|
}
|
|
meta := qrMeta{
|
|
IPAddress: extractClientIPFromHeaders(c),
|
|
UserAgent: "",
|
|
}
|
|
if c != nil {
|
|
meta.UserAgent = c.Get("User-Agent")
|
|
}
|
|
sessionID := extractSessionIDFromToken(sessionToken)
|
|
loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef)
|
|
path := "/api/v1/auth/magic-link/verify"
|
|
authLabel := "링크"
|
|
if loginStrategy == loginFlowCode {
|
|
path = "/api/v1/auth/login/code/verify"
|
|
}
|
|
displayFlow := loginFlow
|
|
if displayFlow == "" {
|
|
displayFlow = loginStrategy
|
|
}
|
|
if displayFlow == loginFlowCode {
|
|
authLabel = "코드"
|
|
} else if displayFlow == loginFlowLink {
|
|
authLabel = "링크"
|
|
}
|
|
logLoginID := loginID
|
|
if rawLoginID != "" {
|
|
logLoginID = rawLoginID
|
|
}
|
|
details := map[string]any{
|
|
"path": path,
|
|
"login_id": logLoginID,
|
|
"pending_ref": pendingRef,
|
|
}
|
|
if sessionID != "" {
|
|
details["session_id"] = sessionID
|
|
}
|
|
if loginMethod != "" {
|
|
details["login_method"] = loginMethod
|
|
}
|
|
if loginFlow != "" {
|
|
details["login_flow"] = loginFlow
|
|
}
|
|
if loginStrategy != "" {
|
|
details["login_strategy"] = loginStrategy
|
|
}
|
|
if rawLoginID != "" && rawLoginID != loginID {
|
|
details["login_id_effective"] = loginID
|
|
}
|
|
if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
|
|
if approverMeta.IPAddress != "" {
|
|
details["approved_ip"] = approverMeta.IPAddress
|
|
}
|
|
if approverMeta.UserAgent != "" {
|
|
details["approved_user_agent"] = approverMeta.UserAgent
|
|
}
|
|
}
|
|
detailsJSON, _ := json.Marshal(details)
|
|
|
|
log := &domain.AuditLog{
|
|
EventID: GenerateSecureToken(16),
|
|
Timestamp: time.Now(),
|
|
UserID: "",
|
|
SessionID: sessionID,
|
|
EventType: fmt.Sprintf("POST %s", path),
|
|
Status: "success",
|
|
IPAddress: meta.IPAddress,
|
|
UserAgent: meta.UserAgent,
|
|
Details: string(detailsJSON),
|
|
AuthMethod: authLabel,
|
|
}
|
|
_ = h.AuditRepo.Create(log)
|
|
}
|
|
|
|
func extractClientIPFromHeaders(c *fiber.Ctx) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
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")
|
|
|
|
// 우선 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"`
|
|
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)
|
|
}
|
|
initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs)
|
|
|
|
existing := records[clientID]
|
|
if existing == nil {
|
|
records[clientID] = &linkedRpRecord{
|
|
linkedRpSummary: linkedRpSummary{
|
|
ID: clientID,
|
|
Name: name,
|
|
Logo: extractHydraClientLogo(client.Metadata),
|
|
URL: clientURL,
|
|
InitURL: initURL,
|
|
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
|
|
}
|
|
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,
|
|
record.Scopes,
|
|
client.RedirectURIs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// [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,
|
|
)
|
|
initURL := resolveLinkedRPInitURL(
|
|
client.ClientID,
|
|
dc.GrantedScopes,
|
|
client.RedirectURIs,
|
|
)
|
|
|
|
records[dc.ClientID] = &linkedRpRecord{
|
|
linkedRpSummary: linkedRpSummary{
|
|
ID: dc.ClientID,
|
|
Name: name,
|
|
Logo: extractHydraClientLogo(client.Metadata),
|
|
URL: clientURL,
|
|
InitURL: initURL,
|
|
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,
|
|
scopes,
|
|
client.RedirectURIs,
|
|
)
|
|
} else {
|
|
// Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체
|
|
if record.Name == "" {
|
|
record.Name = details.ClientID
|
|
}
|
|
}
|
|
|
|
records[details.ClientID] = record
|
|
}
|
|
}
|
|
}
|
|
|
|
ordered := make([]*linkedRpRecord, 0, len(records))
|
|
for _, record := range records {
|
|
ordered = append(ordered, record)
|
|
}
|
|
sort.Slice(ordered, func(i, j int) bool {
|
|
return ordered[i].lastAuth.After(ordered[j].lastAuth)
|
|
})
|
|
|
|
items := make([]linkedRpSummary, 0, len(ordered))
|
|
for i, record := range ordered {
|
|
if i >= 100 {
|
|
break
|
|
}
|
|
if !record.lastAuth.IsZero() {
|
|
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
|
}
|
|
items = append(items, record.linkedRpSummary)
|
|
}
|
|
|
|
return c.JSON(linkedRpListResponse{Items: items})
|
|
}
|
|
|
|
func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
|
clientID := c.Params("id")
|
|
if clientID == "" {
|
|
return fiber.NewError(fiber.StatusBadRequest, "client_id is required")
|
|
}
|
|
|
|
subject, err := h.resolveConsentSubject(c)
|
|
if err != nil || subject == "" {
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
|
}
|
|
|
|
slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)
|
|
|
|
if h.Hydra == nil {
|
|
return fiber.NewError(fiber.StatusServiceUnavailable, "hydra admin unavailable")
|
|
}
|
|
|
|
// Hydra에서 해당 사용자와 클라이언트의 모든 동의 세션을 삭제
|
|
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
|
slog.Error("failed to revoke hydra consent sessions", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
|
|
}
|
|
|
|
if h.AuditRepo != nil {
|
|
detailsMap := map[string]interface{}{
|
|
"client_id": clientID,
|
|
}
|
|
detailsBytes, _ := json.Marshal(detailsMap)
|
|
|
|
_ = h.AuditRepo.Create(&domain.AuditLog{
|
|
EventID: GenerateSecureToken(16),
|
|
Timestamp: time.Now(),
|
|
UserID: subject,
|
|
EventType: "consent.revoked",
|
|
Status: "success",
|
|
IPAddress: c.IP(),
|
|
UserAgent: string(c.Request().Header.UserAgent()),
|
|
Details: string(detailsBytes),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"status": "success",
|
|
"message": "Link revoked successfully",
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|
challenge := c.Query("consent_challenge")
|
|
if challenge == "" {
|
|
return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
|
|
}
|
|
|
|
consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), challenge)
|
|
if err != nil {
|
|
slog.Error("failed to get hydra consent request", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
|
}
|
|
|
|
// [DEBUG] Hydra 응답 상세 로깅
|
|
slog.Info("GetConsentRequest Debug",
|
|
"challenge", challenge,
|
|
"skip", consentRequest.Skip,
|
|
"subject", consentRequest.Subject,
|
|
"client_id", consentRequest.Client.ClientID,
|
|
"scopes", consentRequest.RequestedScope,
|
|
)
|
|
|
|
// Hydra가 이전에 동의한 이력이 있어 skip을 권장하는 경우, 즉시 승인 처리
|
|
if consentRequest.Skip {
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
|
|
if err != nil || identity == nil {
|
|
slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject)
|
|
// 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행
|
|
} else {
|
|
sessionClaims := withOidcSessionMetadata(
|
|
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope),
|
|
h.resolveCurrentSessionID(c),
|
|
)
|
|
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
|
if err != nil {
|
|
slog.Error("failed to auto-accept hydra consent request", "error", err)
|
|
// 자동 승인 실패 시 일반 흐름으로 진행
|
|
} else {
|
|
// [New] Sync to local DB even on auto-accept to ensure data consistency
|
|
if h.ConsentRepo != nil {
|
|
consent := &domain.ClientConsent{
|
|
ClientID: consentRequest.Client.ClientID,
|
|
Subject: consentRequest.Subject,
|
|
GrantedScopes: consentRequest.RequestedScope,
|
|
}
|
|
_ = h.ConsentRepo.Upsert(c.Context(), consent)
|
|
}
|
|
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID)
|
|
return c.JSON(acceptResp)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hydra 응답을 기본으로 하되, 메타데이터에서 커스텀 스코프 설명을 추출하여 추가
|
|
response := fiber.Map{
|
|
"challenge": consentRequest.Challenge,
|
|
"requested_scope": consentRequest.RequestedScope,
|
|
"requested_access_token_audience": consentRequest.RequestedAudience,
|
|
"skip": consentRequest.Skip,
|
|
"subject": consentRequest.Subject,
|
|
"client": consentRequest.Client,
|
|
}
|
|
|
|
// structured_scopes 파싱 및 scope_details 생성
|
|
if metadata := consentRequest.Client.Metadata; metadata != nil {
|
|
if rawScopes, ok := metadata["structured_scopes"]; ok {
|
|
scopeDetails := make(map[string]map[string]interface{})
|
|
|
|
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
|
|
// 안전하게 처리
|
|
rawBytes, _ := json.Marshal(rawScopes)
|
|
var scopesList []map[string]interface{}
|
|
if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
|
|
for _, item := range scopesList {
|
|
name, _ := item["name"].(string)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
desc, _ := item["description"].(string)
|
|
mandatory, _ := item["mandatory"].(bool)
|
|
|
|
scopeDetails[name] = map[string]interface{}{
|
|
"description": desc,
|
|
"mandatory": mandatory,
|
|
}
|
|
}
|
|
}
|
|
response["scope_details"] = scopeDetails
|
|
}
|
|
}
|
|
|
|
return c.JSON(response)
|
|
}
|
|
|
|
func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
|
var req struct {
|
|
ConsentChallenge string `json:"consent_challenge"`
|
|
GrantScope []string `json:"grant_scope"` // 사용자가 선택한 스코프
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
if reqJson, err := json.Marshal(req); err == nil {
|
|
slog.Info("AcceptConsentRequest: received request body", "body", string(reqJson))
|
|
} else {
|
|
slog.Error("AcceptConsentRequest: failed to marshal request for logging", "error", err)
|
|
}
|
|
|
|
if req.ConsentChallenge == "" {
|
|
return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
|
|
}
|
|
|
|
// 1. Hydra에서 원래 요청 정보 조회
|
|
consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), req.ConsentChallenge)
|
|
if err != nil {
|
|
slog.Error("failed to get hydra consent request before accepting", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
|
}
|
|
|
|
// 2. 스코프 필터링 (사용자가 선택한 것만 허용)
|
|
if len(req.GrantScope) > 0 {
|
|
allowedScopes := make(map[string]bool)
|
|
for _, s := range consentRequest.RequestedScope {
|
|
allowedScopes[s] = true
|
|
}
|
|
|
|
filteredScopes := make([]string, 0, len(req.GrantScope))
|
|
for _, s := range req.GrantScope {
|
|
if allowedScopes[s] {
|
|
filteredScopes = append(filteredScopes, s)
|
|
}
|
|
}
|
|
consentRequest.RequestedScope = filteredScopes
|
|
}
|
|
|
|
// 3. Hydra에 승인 요청
|
|
if consentRequest.Subject == "" {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing")
|
|
}
|
|
if h.KratosAdmin == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable")
|
|
}
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
|
|
if err != nil || identity == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity")
|
|
}
|
|
c.Locals("user_id", consentRequest.Subject)
|
|
if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" {
|
|
c.Locals("login_id", loginID)
|
|
}
|
|
currentSessionID := h.resolveCurrentSessionID(c)
|
|
sessionClaims := withOidcSessionMetadata(
|
|
buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope),
|
|
currentSessionID,
|
|
)
|
|
|
|
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,
|
|
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.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
subject, err := h.resolveConsentSubject(c)
|
|
if err != nil || subject == "" {
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
|
}
|
|
c.Locals("user_id", subject)
|
|
approvedSessionID := strings.TrimSpace(req.ApprovedSessionID)
|
|
if approvedSessionID == "" {
|
|
approvedSessionID = strings.TrimSpace(req.SessionID)
|
|
}
|
|
if approvedSessionID == "" {
|
|
if sessionID, ok := c.Locals("session_id").(string); ok && sessionID != "" {
|
|
approvedSessionID = sessionID
|
|
}
|
|
}
|
|
if approvedSessionID == "" {
|
|
if token := h.getBearerToken(c); token != "" {
|
|
if resolved, err := h.getKratosSessionID(token); err == nil {
|
|
approvedSessionID = resolved
|
|
}
|
|
}
|
|
}
|
|
if approvedSessionID == "" {
|
|
if cookie := c.Get("Cookie"); cookie != "" {
|
|
if derivedID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
|
|
approvedSessionID = derivedID
|
|
}
|
|
}
|
|
}
|
|
if approvedSessionID != "" {
|
|
c.Locals("approved_session_id", approvedSessionID)
|
|
}
|
|
if h.KratosAdmin != nil {
|
|
if identity, err := h.KratosAdmin.GetIdentity(c.Context(), subject); err == nil && identity != nil {
|
|
if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" {
|
|
c.Locals("login_id", loginID)
|
|
}
|
|
}
|
|
}
|
|
|
|
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
|
|
if err != nil {
|
|
slog.Error("failed to accept hydra login request", "error", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
|
}
|
|
logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo)
|
|
|
|
return c.JSON(acceptResp)
|
|
}
|
|
|
|
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
|
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
|
|
|
|
mockRole := c.Get("X-Test-Role")
|
|
if mockRole == "" {
|
|
mockRole = c.Get("X-Mock-Role")
|
|
}
|
|
|
|
token := h.getBearerToken(c)
|
|
cookie := c.Get("Cookie")
|
|
|
|
var profile *domain.UserProfileResponse
|
|
var err error
|
|
cacheKey := ""
|
|
|
|
// 1. Try to fetch real profile if token/cookie exists
|
|
if token != "" || cookie != "" {
|
|
// Try Redis Cache
|
|
if h.RedisService != nil && token == "" && 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
|
|
}
|
|
|
|
// Fetch Tenant Metadata if missing
|
|
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
|
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
|
|
profile.Tenant = tenant
|
|
}
|
|
}
|
|
if profile.Tenant == nil && profile.CompanyCode != "" {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
|
profile.Tenant = tenant
|
|
if profile.TenantID == nil || *profile.TenantID == "" {
|
|
profile.TenantID = &tenant.ID
|
|
}
|
|
}
|
|
}
|
|
|
|
// [New] Fetch manageable and joined tenants
|
|
if h.TenantService != nil {
|
|
if profile.Role == domain.RoleTenantAdmin {
|
|
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
|
|
if err == nil {
|
|
profile.ManageableTenants = manageable
|
|
}
|
|
}
|
|
|
|
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
|
|
if err == nil {
|
|
profile.JoinedTenants = joined
|
|
}
|
|
}
|
|
|
|
// 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"):
|
|
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
|
|
}
|
|
}
|
|
|
|
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 resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string {
|
|
clientID = strings.TrimSpace(clientID)
|
|
if clientID == "" {
|
|
return ""
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
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.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/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"):
|
|
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",
|
|
"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 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),
|
|
})
|
|
}
|