forked from baron/baron-sso
2698 lines
93 KiB
Go
2698 lines
93 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/logger"
|
|
"baron-sso-backend/internal/service"
|
|
"bytes"
|
|
"context"
|
|
crand "crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/descope/go-sdk/descope"
|
|
"github.com/descope/go-sdk/descope/client"
|
|
"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:"
|
|
prefixLoginCodeSmsOnly = "login_code_sms_only:"
|
|
prefixLoginCodeQrPending = "login_code_qr_pending:"
|
|
prefixLoginCodeQr = "login_code_qr:"
|
|
prefixPollMeta = "poll_meta:"
|
|
prefixQrRef = "qr_ref:"
|
|
prefixQrPending = "qr_pending:"
|
|
prefixSignupEmail = "signup:email:"
|
|
prefixSignupPhone = "signup:phone:"
|
|
|
|
// Session Statuses
|
|
statusPending = "pending"
|
|
statusSuccess = "success"
|
|
|
|
// Durations
|
|
defaultExpiration = 5 * time.Minute
|
|
signupStateExpiration = 10 * time.Minute
|
|
signupBlockDuration = 10 * time.Minute
|
|
maxSignupFailures = 5
|
|
emailCodeTTL = 5 * time.Minute
|
|
smsCodeTTL = 3 * time.Minute
|
|
prefixPwdResetToken = "pwdreset_token:"
|
|
pwdResetExpiration = 15 * time.Minute
|
|
minPollInterval = 2 * time.Second
|
|
loginCodeExpiration = 10 * time.Minute
|
|
linkResendCooldown = 60 * time.Second
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
ProjectID string
|
|
SmsService domain.SmsService
|
|
EmailService domain.EmailService
|
|
RedisService *service.RedisService
|
|
DescopeClient *client.DescopeClient
|
|
IdpProvider domain.IdentityProvider
|
|
}
|
|
|
|
type signupState struct {
|
|
Code string `json:"code"`
|
|
Verified bool `json:"verified"`
|
|
FailCount int `json:"fail_count"`
|
|
ExpiresAt int64 `json:"expires_at"` // Unix timestamp
|
|
}
|
|
|
|
// GenerateSecureToken - Helper to generate secure random strings
|
|
func GenerateSecureToken(length int) string {
|
|
b := make([]byte, length)
|
|
if _, err := crand.Read(b); err != nil {
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func GenerateSecureAlnumToken(length int) string {
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
if length <= 0 {
|
|
return ""
|
|
}
|
|
buf := make([]byte, length)
|
|
if _, err := crand.Read(buf); err != nil {
|
|
return ""
|
|
}
|
|
for i := range buf {
|
|
buf[i] = charset[int(buf[i])%len(charset)]
|
|
}
|
|
return string(buf)
|
|
}
|
|
|
|
func GenerateUserCode() string {
|
|
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
|
return fmt.Sprintf("%c%c-%03d",
|
|
letters[rand.Intn(len(letters))],
|
|
letters[rand.Intn(len(letters))],
|
|
rand.Intn(1000),
|
|
)
|
|
}
|
|
|
|
func checkPollInterval(redis *service.RedisService, 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 *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler {
|
|
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
|
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
|
|
|
var descopeClient *client.DescopeClient
|
|
var err error
|
|
if projectID != "" {
|
|
descopeClient, err = client.NewWithConfig(&client.Config{
|
|
ProjectID: projectID,
|
|
ManagementKey: managementKey,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("Failed to initialize Descope Client", "error", err)
|
|
}
|
|
}
|
|
|
|
return &AuthHandler{
|
|
ProjectID: projectID,
|
|
SmsService: service.NewSmsService(),
|
|
EmailService: service.NewEmailService(),
|
|
RedisService: redisService,
|
|
DescopeClient: descopeClient,
|
|
IdpProvider: idpProvider,
|
|
}
|
|
}
|
|
|
|
// --- Signup Flow Handlers ---
|
|
|
|
// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
|
|
func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error {
|
|
var req domain.CheckEmailRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
// Email Format Validation
|
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"})
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
|
|
exists, err := h.IdpProvider.UserExists(req.Email)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
if exists {
|
|
return c.JSON(fiber.Map{"available": false, "message": "Email already registered"})
|
|
}
|
|
return c.JSON(fiber.Map{"available": true})
|
|
}
|
|
|
|
// SendSignupEmailCode - Sends verification code to email
|
|
func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
|
|
var req domain.SendSignupCodeRequest
|
|
if err := c.BodyParser(&req); err != nil || req.Target == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts. Try again later."})
|
|
}
|
|
|
|
// 2. Generate Code
|
|
rand.Seed(time.Now().UnixNano())
|
|
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
|
|
|
// 3. Save State
|
|
newState := &signupState{
|
|
Code: code,
|
|
Verified: false,
|
|
FailCount: 0,
|
|
ExpiresAt: time.Now().Add(smsCodeTTL).Unix(),
|
|
}
|
|
if state != nil {
|
|
newState.FailCount = state.FailCount
|
|
}
|
|
h.saveSignupState(key, newState, signupStateExpiration)
|
|
|
|
// 4. Send SMS
|
|
content := fmt.Sprintf("[Baron 통합로그인] 인증번호 [%s]를 입력해주세요.", code)
|
|
go h.SmsService.SendSms(phone, content)
|
|
|
|
return c.JSON(fiber.Map{"message": "Verification code sent"})
|
|
}
|
|
|
|
// VerifySignupCode - Verifies the code for email or phone
|
|
func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
|
|
var req domain.VerifySignupCodeRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid type"})
|
|
}
|
|
|
|
state, err := h.getSignupState(key)
|
|
if err != nil || state == nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "Too many failed attempts"})
|
|
}
|
|
|
|
// Check Code match
|
|
if state.Code != req.Code {
|
|
state.FailCount++
|
|
h.saveSignupState(key, state, signupStateExpiration)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code", "failCount": state.FailCount})
|
|
}
|
|
|
|
// Check Expiry (Logic time vs stored time)
|
|
if time.Now().Unix() > state.ExpiresAt {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Code expired"})
|
|
}
|
|
|
|
// Success
|
|
state.Verified = true
|
|
h.saveSignupState(key, state, signupStateExpiration)
|
|
|
|
return c.JSON(fiber.Map{"success": true})
|
|
}
|
|
|
|
// Signup - Finalize registration
|
|
func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
|
var req domain.SignupRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
// 1. Validate Fields (Simple validation)
|
|
if req.Email == "" || req.Password == "" || req.Name == "" || req.Phone == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing required fields"})
|
|
}
|
|
if !req.TermsAccepted {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"})
|
|
}
|
|
|
|
// 비밀번호 정책 검증
|
|
policy := h.resolvePasswordPolicy()
|
|
if err := validatePasswordWithPolicy(policy, req.Password); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": 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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Email not verified"})
|
|
}
|
|
if phoneState == nil || !phoneState.Verified {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"})
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
|
|
// Normalize Phone (E.164 형태로 보관)
|
|
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
|
|
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
|
|
if strings.HasPrefix(normalizedPhone, "010") {
|
|
normalizedPhone = "+82" + normalizedPhone[1:]
|
|
} else if strings.HasPrefix(normalizedPhone, "82") {
|
|
normalizedPhone = "+" + normalizedPhone
|
|
}
|
|
|
|
// IDP에 전달할 BrokerUser 스키마 구성
|
|
attributes := map[string]interface{}{
|
|
"department": req.Department,
|
|
"affiliationType": req.AffiliationType,
|
|
"companyCode": req.CompanyCode,
|
|
// grade는 기존 스키마 필수 키이므로 기본값을 설정
|
|
"grade": "member",
|
|
}
|
|
brokerUser := &domain.BrokerUser{
|
|
Email: req.Email,
|
|
Name: req.Name,
|
|
PhoneNumber: normalizedPhone,
|
|
Attributes: attributes,
|
|
}
|
|
|
|
providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"})
|
|
}
|
|
|
|
// 4. Cleanup Redis
|
|
h.RedisService.Delete(emailKey)
|
|
h.RedisService.Delete(phoneKey)
|
|
|
|
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"success": true,
|
|
"message": "User registered successfully",
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": providerID,
|
|
})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
|
|
authHeader := c.Get("Authorization")
|
|
if authHeader == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
|
return ""
|
|
}
|
|
return parts[1]
|
|
}
|
|
|
|
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
|
|
func normalizePhoneForLoginID(phone string) string {
|
|
normalized := strings.ReplaceAll(phone, "-", "")
|
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
|
if strings.HasPrefix(normalized, "010") {
|
|
return "+82" + normalized[1:]
|
|
}
|
|
if strings.HasPrefix(normalized, "82") {
|
|
return "+" + normalized
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func (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 {
|
|
if policy == nil {
|
|
return nil
|
|
}
|
|
if policy.MinLength > 0 && len(password) < policy.MinLength {
|
|
return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
|
|
}
|
|
|
|
types := 0
|
|
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
|
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
|
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
|
hasSymbol := regexp.MustCompile(`[\W_]`).MatchString(password)
|
|
if hasLower {
|
|
types++
|
|
}
|
|
if hasUpper {
|
|
types++
|
|
}
|
|
if hasNumber {
|
|
types++
|
|
}
|
|
if hasSymbol {
|
|
types++
|
|
}
|
|
|
|
if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
|
|
return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
|
|
}
|
|
|
|
if policy.Lowercase && !hasLower {
|
|
return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
|
|
}
|
|
if policy.Uppercase && !hasUpper {
|
|
return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
|
|
}
|
|
if policy.Number && !hasNumber {
|
|
return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
|
|
}
|
|
if policy.NonAlphanumeric && !hasSymbol {
|
|
return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
|
|
storedCode, _ := h.RedisService.GetVerificationCode(sanitizedPhone)
|
|
|
|
if storedCode == "" || storedCode != req.Code {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
|
|
}
|
|
|
|
h.RedisService.DeleteVerificationCode(sanitizedPhone)
|
|
|
|
if h.IdpProvider == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Authentication service not configured"})
|
|
}
|
|
|
|
loginID := normalizePhoneForLoginID(req.PhoneNumber)
|
|
authInfo, err := h.IdpProvider.IssueSession(loginID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
exists, err := h.IdpProvider.UserExists(lookupLoginID)
|
|
if err != nil {
|
|
slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err)
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
if !exists {
|
|
slog.Warn("[Enchanted] User not found", "loginID", loginID)
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
|
|
}
|
|
|
|
userfrontURL := os.Getenv("USERFRONT_URL")
|
|
if userfrontURL == "" {
|
|
userfrontURL = "http://sso.hmac.kr"
|
|
}
|
|
if req.URI != "" {
|
|
userfrontURL = req.URI
|
|
}
|
|
|
|
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)
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
|
|
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, 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 c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "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
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
|
|
h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), 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 h.EmailService == nil {
|
|
slog.Error("[Enchanted] Email Service not configured")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 SSO 로그인</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)
|
|
|
|
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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"})
|
|
}
|
|
} else {
|
|
// Send SMS
|
|
content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
|
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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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",
|
|
"interval": interval,
|
|
})
|
|
}
|
|
|
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
|
if err != nil || val == "" {
|
|
return c.JSON(fiber.Map{"error": "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",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"error": "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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "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 h.IdpProvider == nil {
|
|
slog.Error("[Verify] IDP Provider is nil")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
}
|
|
slog.Error("[Verify] IDP session issue failed", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
slog.Error("[Verify] IDP returned empty session")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
}
|
|
sessionToken := authInfo.SessionToken.JWT
|
|
|
|
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": sessionToken,
|
|
})
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
|
|
|
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"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("[LoginCode] Body parse error", "error", err)
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
loginID = strings.ReplaceAll(loginID, " ", "+")
|
|
if loginID == "" || req.Code == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "loginId and code are required"})
|
|
}
|
|
|
|
lookupLoginID := loginID
|
|
if !strings.Contains(loginID, "@") {
|
|
lookupLoginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
|
|
flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID)
|
|
if err != nil || flowID == "" {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
|
|
}
|
|
|
|
authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
}
|
|
slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
}
|
|
|
|
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": authInfo.Subject,
|
|
"message": "Login approved",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"token": authInfo.SessionToken.JWT,
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": authInfo.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"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("[LoginShortCode] Body parse error", "error", err)
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode))
|
|
if shortCode == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "shortCode is required"})
|
|
}
|
|
|
|
val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode)
|
|
if val == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
|
|
}
|
|
|
|
var payload shortLoginCodePayload
|
|
if err := json.Unmarshal([]byte(val), &payload); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Invalid code payload"})
|
|
}
|
|
if payload.LoginID == "" || payload.Code == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
|
|
flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID)
|
|
if err != nil || flowID == "" {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
|
|
}
|
|
|
|
authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
}
|
|
slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
}
|
|
|
|
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": authInfo.Subject,
|
|
"message": "Login approved",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"token": authInfo.SessionToken.JWT,
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"provider": h.IdpProvider.Name(),
|
|
"subject": authInfo.Subject,
|
|
"message": "Login successful",
|
|
})
|
|
}
|
|
|
|
// PasswordLogin - Authenticate a user with login ID and password.
|
|
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"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Body parse error")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
|
ale.LoginIDs["loginId_normalized"] = loginID
|
|
ale.NewPassword = req.Password // For test only, logging password (sensitive)
|
|
|
|
ale.Log(slog.LevelInfo, "Attempting to login")
|
|
|
|
if h.IdpProvider == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = "IDP Provider is nil"
|
|
ale.Log(slog.LevelError, "IDP Provider is nil")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
|
}
|
|
|
|
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
}
|
|
ale.Status = fiber.StatusUnauthorized
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = err.Error()
|
|
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
|
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
|
|
}
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
|
}
|
|
|
|
ale.Status = fiber.StatusOK
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.SessionJwt = authInfo.SessionToken.JWT
|
|
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
|
|
|
resp := fiber.Map{
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
"status": "ok",
|
|
"provider": h.IdpProvider.Name(),
|
|
}
|
|
if authInfo.RefreshToken != nil {
|
|
resp["refreshJwt"] = authInfo.RefreshToken.JWT
|
|
}
|
|
if authInfo.Subject != "" {
|
|
resp["subject"] = authInfo.Subject
|
|
}
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 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.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Body parse error")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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.DescopeError = "Login ID is required"
|
|
ale.Log(slog.LevelWarn, "Login ID missing")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID is required"})
|
|
}
|
|
|
|
if h.IdpProvider == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = "IDP Provider is not initialized"
|
|
ale.Log(slog.LevelError, "IDP Provider is not initialized")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
|
}
|
|
|
|
userfrontURL := os.Getenv("USERFRONT_URL")
|
|
if userfrontURL == "" {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = "USERFRONT_URL is not set"
|
|
ale.Log(slog.LevelError, "USERFRONT_URL is not set")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "USERFRONT_URL environment variable is not set"})
|
|
}
|
|
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
|
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
|
|
ale.RedirectTo = redirectURL
|
|
|
|
// 내부 토큰 발급 + 우리 채널로 전송
|
|
resetToken := GenerateSecureToken(32)
|
|
if resetToken == "" {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = "Failed to generate reset token"
|
|
ale.Log(slog.LevelError, "Failed to generate reset token")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Failed to store reset token in Redis")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store reset token"})
|
|
}
|
|
|
|
resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken)
|
|
ale.RedirectTo = resetLink
|
|
ale.Operation = "SendPasswordReset"
|
|
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
|
|
|
if strings.Contains(loginID, "@") {
|
|
if h.EmailService == nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = "Email service not configured"
|
|
ale.Log(slog.LevelError, "Email service not configured")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 SSO 비밀번호 재설정</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 err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
|
|
}
|
|
} else {
|
|
if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
|
|
ale.Status = fiber.StatusInternalServerError
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"})
|
|
}
|
|
}
|
|
|
|
ale.Status = fiber.StatusOK
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.Log(slog.LevelInfo, "Password reset link sent successfully (internal token)")
|
|
return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."})
|
|
}
|
|
|
|
// VerifyPasswordResetPage - Serves an interstitial page to prevent link scanners from consuming the token.
|
|
func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
token = c.Query("t")
|
|
}
|
|
|
|
if token == "" {
|
|
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
|
}
|
|
|
|
// Simple HTML page with a form to trigger the POST request
|
|
html := fmt.Sprintf(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Baron SSO - 비밀번호 재설정</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; }
|
|
.container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 100%%; }
|
|
h2 { color: #1A1F2C; margin-bottom: 1rem; }
|
|
p { color: #666; margin-bottom: 2rem; }
|
|
button { background-color: #1A1F2C; color: white; padding: 12px 24px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%%; transition: background 0.2s; }
|
|
button:hover { background-color: #333; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h2>비밀번호 재설정</h2>
|
|
<p>아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.</p>
|
|
<form action="/api/v1/auth/password/reset/verify" method="POST">
|
|
<input type="hidden" name="token" value="%s">
|
|
<button type="submit">계속하기</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`, token)
|
|
|
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
return c.SendString(html)
|
|
}
|
|
|
|
// ProcessPasswordResetToken - Handles the POST request from the interstitial page.
|
|
// Verifies the token, sets the refresh token cookie, and redirects to the userfront.
|
|
func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
|
startTime := time.Now()
|
|
ale := logger.NewAuditLogEntry(c, "verify")
|
|
ale.Operation = "Verify"
|
|
|
|
// Token comes from Form Body in POST or query
|
|
token := c.FormValue("token")
|
|
if token == "" {
|
|
token = c.Query("token")
|
|
if token == "" {
|
|
token = c.Query("t")
|
|
}
|
|
}
|
|
ale.Token = token
|
|
|
|
if token == "" {
|
|
ale.Status = fiber.StatusBadRequest
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.DescopeError = "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.DescopeError = "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
|
|
|
|
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
|
|
os.Getenv("USERFRONT_URL"),
|
|
loginID,
|
|
token,
|
|
)
|
|
|
|
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.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Body parse error")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
|
|
loginID := c.Query("loginId")
|
|
resetToken := c.Query("token")
|
|
if loginID == "" && resetToken != "" {
|
|
if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
|
|
loginID = val
|
|
ale.Token = resetToken
|
|
}
|
|
}
|
|
|
|
ale.LoginIDs["loginId"] = loginID
|
|
ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only)
|
|
ale.NewPassword = req.NewPassword // Log new password (for test only)
|
|
|
|
// Request cookie logging (minimal)
|
|
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
|
|
ale.Headers["Request-Cookie-Header"] = 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.DescopeError = "Login ID and new password are required"
|
|
ale.Log(slog.LevelWarn, "Login ID or new password missing")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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.DescopeError = err.Error()
|
|
ale.Log(slog.LevelWarn, "Validation failed: "+err.Error())
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": 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.DescopeError = "IDP Provider is nil"
|
|
ale.Log(slog.LevelError, "IDP Provider is nil")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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.DescopeError = err.Error()
|
|
ale.Log(slog.LevelError, "Failed to update password via IDP")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"})
|
|
}
|
|
|
|
ale.Status = fiber.StatusOK
|
|
ale.LatencyMs = time.Since(startTime)
|
|
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
|
if resetToken != "" {
|
|
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
|
}
|
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
|
}
|
|
|
|
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
|
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
|
pendingRef := GenerateSecureToken(16)
|
|
qrRef := GenerateSecureAlnumToken(64)
|
|
if qrRef == "" {
|
|
qrRef = GenerateSecureToken(16)
|
|
}
|
|
|
|
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
|
userfrontURL := os.Getenv("USERFRONT_URL")
|
|
if userfrontURL == "" {
|
|
userfrontURL = "https://sso.hmac.kr"
|
|
}
|
|
qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef)
|
|
|
|
slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload)
|
|
|
|
// Redis에 초기 상태 저장 (5분 만료)
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
|
h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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",
|
|
"interval": interval,
|
|
})
|
|
}
|
|
|
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
|
if err != nil || val == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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",
|
|
"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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
|
}
|
|
|
|
rawRef := strings.TrimSpace(req.PendingRef)
|
|
pendingRef, err := h.resolveQrPendingRef(rawRef)
|
|
if err != nil || pendingRef == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid pendingRef"})
|
|
}
|
|
|
|
slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef)
|
|
|
|
// 1. Redis에서 세션 확인
|
|
val, err := h.RedisService.Get(prefixSession + pendingRef)
|
|
if err != nil || val == "" {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"})
|
|
}
|
|
|
|
if req.Token == "" {
|
|
cookie := c.Get(fiber.HeaderCookie)
|
|
if cookie == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"})
|
|
}
|
|
_, traits, err := h.getKratosIdentityWithCookie(cookie)
|
|
if err != nil {
|
|
slog.Warn("[QR] Cookie session invalid", "error", err)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
loginID := pickLoginIDFromTraits(traits)
|
|
if loginID == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
|
|
}
|
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
|
}
|
|
|
|
// 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
|
|
if sessionToken, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil {
|
|
slog.Error("[QR] Issue web session failed", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
|
|
} else if sessionToken != "" {
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": sessionToken,
|
|
})
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute)
|
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
|
}
|
|
|
|
loginID, err := h.resolveKratosLoginID(req.Token)
|
|
if err != nil {
|
|
slog.Warn("[QR] Invalid token", "error", err)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil {
|
|
slog.Error("[QR] Start code login failed", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
|
}
|
|
|
|
// ProxyToDescope (Placeholder)
|
|
func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error {
|
|
return c.Status(501).SendString("Descope Proxy Disabled")
|
|
}
|
|
|
|
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
if req.Recipient == "" {
|
|
slog.Warn("[Kratos Courier] Missing recipient")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient"})
|
|
}
|
|
|
|
loginID := req.Recipient
|
|
if !strings.Contains(loginID, "@") {
|
|
loginID = normalizePhoneForLoginID(loginID)
|
|
}
|
|
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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"})
|
|
}
|
|
flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
|
|
if flowID == "" {
|
|
slog.Error("[QR] Missing login flow for code verify", "loginID", loginID)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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.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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"})
|
|
}
|
|
flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
|
|
if flowID == "" {
|
|
slog.Error("[QR] Missing login flow for code verify", "loginID", loginID)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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.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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 err := h.SmsService.SendSms(phone, smsBody); err != nil {
|
|
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 h.EmailService == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
|
}
|
|
if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" {
|
|
subject = shortSubject
|
|
body = shortBody
|
|
}
|
|
if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil {
|
|
slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"})
|
|
}
|
|
slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType)
|
|
return c.JSON(fiber.Map{"status": "ok"})
|
|
}
|
|
|
|
if h.SmsService == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 err := h.SmsService.SendSms(phone, smsBody); err != nil {
|
|
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 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 SSO 로그인</h2>
|
|
<p>아래 버튼을 클릭하여 로그인을 완료해 주세요.</p>
|
|
<div style="margin: 24px 0;">
|
|
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">로그인 완료하기</a>
|
|
</div>
|
|
<p style="font-size: 14px;">간편 코드: <strong>%s</strong></p>
|
|
<p style="font-size: 12px; color: #888;">링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.</p>
|
|
</div>
|
|
`, link, shortCode)
|
|
return subject, body
|
|
}
|
|
|
|
func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID string) (string, string, bool) {
|
|
if req == nil || loginID == "" {
|
|
return "", "", false
|
|
}
|
|
code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
|
|
if code == "" {
|
|
return "", "", false
|
|
}
|
|
shortCode := h.generateShortCode(code)
|
|
if shortCode == "" {
|
|
return "", "", false
|
|
}
|
|
|
|
pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
|
|
payload := shortLoginCodePayload{
|
|
LoginID: loginID,
|
|
Code: code,
|
|
PendingRef: pendingRef,
|
|
}
|
|
raw, _ := json.Marshal(payload)
|
|
_ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration)
|
|
|
|
baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:5000"
|
|
}
|
|
|
|
link := fmt.Sprintf("%s/l/%s", baseURL, shortCode)
|
|
return shortCode, link, true
|
|
}
|
|
|
|
func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
|
|
if loginID == "" {
|
|
return false
|
|
}
|
|
val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID)
|
|
return val != ""
|
|
}
|
|
|
|
func (h *AuthHandler) generateShortCode(code string) string {
|
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
for i := 0; i < 10; i++ {
|
|
b := make([]byte, 2)
|
|
if _, err := crand.Read(b); err != nil {
|
|
break
|
|
}
|
|
prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)])
|
|
shortCode := prefix + code
|
|
if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" {
|
|
return shortCode
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeLoginCode(code string) string {
|
|
if code == "" {
|
|
return ""
|
|
}
|
|
digits := make([]rune, 0, len(code))
|
|
for _, ch := range code {
|
|
if ch >= '0' && ch <= '9' {
|
|
digits = append(digits, ch)
|
|
}
|
|
}
|
|
if len(digits) < 6 {
|
|
return ""
|
|
}
|
|
if len(digits) > 6 {
|
|
digits = digits[:6]
|
|
}
|
|
return string(digits)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractFirstString(data map[string]interface{}, keys ...string) string {
|
|
if data == nil {
|
|
return ""
|
|
}
|
|
for _, key := range keys {
|
|
if val, ok := data[key]; ok {
|
|
if str, ok := val.(string); ok && str != "" {
|
|
return str
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sanitizePhoneForSms(phone string) string {
|
|
sanitized := strings.TrimSpace(phone)
|
|
if strings.HasPrefix(sanitized, "+82") {
|
|
sanitized = "0" + sanitized[3:]
|
|
}
|
|
sanitized = strings.ReplaceAll(sanitized, "-", "")
|
|
sanitized = strings.ReplaceAll(sanitized, " ", "")
|
|
return sanitized
|
|
}
|
|
|
|
// HandleDescopeSmsRelay
|
|
func (h *AuthHandler) HandleDescopeSmsRelay(c *fiber.Ctx) error {
|
|
var req struct {
|
|
Recipient string `json:"recipient"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("Webhook Body parsing failed", "error", err)
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
if req.Recipient == "" || req.Body == "" {
|
|
slog.Warn("Webhook missing recipient or body")
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient or body"})
|
|
}
|
|
|
|
slog.Info("Received SMS request", "recipient", req.Recipient)
|
|
|
|
phone := req.Recipient
|
|
if strings.HasPrefix(phone, "+82") {
|
|
phone = "0" + phone[3:]
|
|
}
|
|
phone = strings.ReplaceAll(phone, "-", "")
|
|
phone = strings.ReplaceAll(phone, " ", "")
|
|
|
|
if err := h.SmsService.SendSms(phone, req.Body); err != nil {
|
|
slog.Error("Failed to forward SMS to Naver", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS via Naver"})
|
|
}
|
|
|
|
slog.Info("Successfully forwarded SMS", "phone", phone)
|
|
return c.JSON(fiber.Map{"status": "ok"})
|
|
}
|
|
|
|
// HandleDescopeEmailRelay - Webhook for Descope Generic Email Gateway
|
|
// Used for "Fake Email Strategy" to support Polling with SMS.
|
|
func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error {
|
|
var req struct {
|
|
To string `json:"to"` // e.g., 01012345678@sms.baron
|
|
Subject string `json:"subject"`
|
|
Text string `json:"text"` // Body containing the link
|
|
}
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
slog.Error("[Email Webhook] Body parsing failed", "error", err)
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
slog.Info("[Email Webhook] Received email request", "to", req.To)
|
|
|
|
// Check if it's a Fake Email for SMS
|
|
if strings.HasSuffix(req.To, "@sms.baron") {
|
|
phone := strings.Split(req.To, "@")[0]
|
|
|
|
// Sanitize Phone (Descope might sanitize or not, but let's be safe)
|
|
if strings.HasPrefix(phone, "+82") {
|
|
phone = "0" + phone[3:]
|
|
}
|
|
|
|
// Send SMS with the text body (Descope template should be optimized for SMS)
|
|
if err := h.SmsService.SendSms(phone, req.Text); err != nil {
|
|
slog.Error("[Email Webhook] Failed to forward Email-as-SMS", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
|
}
|
|
|
|
slog.Info("[Email Webhook] Successfully converted Email to SMS", "phone", phone)
|
|
return c.JSON(fiber.Map{"status": "ok"})
|
|
}
|
|
|
|
// Real Email Handling (Not implemented in this Relay)
|
|
// You would need an SMTP service here if you route ALL emails through this relay.
|
|
slog.Warn("[Email Webhook] Real email skipped (Not implemented)", "to", req.To)
|
|
return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"})
|
|
}
|
|
|
|
// --- 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 010 phone format
|
|
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
|
token := h.getBearerToken(c)
|
|
if token != "" {
|
|
if looksLikeJWT(token) && h.DescopeClient != nil {
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err == nil && authorized {
|
|
userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
|
|
}
|
|
|
|
dept, _ := userResponse.CustomAttributes["department"].(string)
|
|
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
|
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
|
|
|
resp := domain.UserProfileResponse{
|
|
ID: userResponse.UserID,
|
|
Email: userResponse.Email,
|
|
Name: userResponse.Name,
|
|
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
|
Department: dept,
|
|
AffiliationType: affType,
|
|
CompanyCode: compCode,
|
|
}
|
|
return c.JSON(resp)
|
|
}
|
|
}
|
|
|
|
profile, err := h.getKratosProfile(token)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
return c.JSON(profile)
|
|
}
|
|
|
|
cookie := c.Get("Cookie")
|
|
if cookie == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
|
}
|
|
profile, err := h.getKratosProfileWithCookie(cookie)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
return c.JSON(profile)
|
|
}
|
|
|
|
func looksLikeJWT(token string) bool {
|
|
return strings.Count(token, ".") == 2
|
|
}
|
|
|
|
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
|
if looksLikeJWT(token) && h.DescopeClient != nil {
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err == nil && authorized {
|
|
return userToken.ID, nil
|
|
}
|
|
}
|
|
id, _, err := h.getKratosIdentity(token)
|
|
return id, err
|
|
}
|
|
|
|
func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (string, error) {
|
|
if !looksLikeJWT(token) || h.DescopeClient == nil {
|
|
return "", nil
|
|
}
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err != nil || !authorized {
|
|
return "", nil
|
|
}
|
|
loginID, err := h.resolveDescopeLoginID(c.Context(), userToken)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
authInfo, err := h.IdpProvider.IssueSession(loginID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
return "", fmt.Errorf("descope issue session returned empty token")
|
|
}
|
|
return authInfo.SessionToken.JWT, nil
|
|
}
|
|
|
|
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 (h *AuthHandler) resolveDescopeLoginID(ctx context.Context, token *descope.Token) (string, error) {
|
|
if token == nil {
|
|
return "", fmt.Errorf("descope token is nil")
|
|
}
|
|
|
|
if loginID := extractLoginIDFromClaims(token.Claims); loginID != "" {
|
|
return loginID, nil
|
|
}
|
|
|
|
if h.DescopeClient == nil {
|
|
return "", fmt.Errorf("descope client is nil")
|
|
}
|
|
|
|
user, err := h.DescopeClient.Management.User().Load(ctx, token.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if user == nil {
|
|
return "", fmt.Errorf("descope user not found")
|
|
}
|
|
if loginID := pickPrimaryLoginID(user.LoginIDs); loginID != "" {
|
|
return loginID, nil
|
|
}
|
|
if user.Email != "" {
|
|
return user.Email, nil
|
|
}
|
|
if user.Phone != "" {
|
|
return user.Phone, nil
|
|
}
|
|
return "", fmt.Errorf("descope login id not found")
|
|
}
|
|
|
|
func pickPrimaryLoginID(loginIDs []string) string {
|
|
for _, id := range loginIDs {
|
|
if strings.Contains(id, "@") {
|
|
return id
|
|
}
|
|
}
|
|
if len(loginIDs) > 0 {
|
|
return loginIDs[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractLoginIDFromClaims(claims map[string]any) string {
|
|
if claims == nil {
|
|
return ""
|
|
}
|
|
|
|
candidateKeys := []string{"loginId", "login_id", "email", "phone_number", "phone", "phoneNumber"}
|
|
for _, key := range candidateKeys {
|
|
if raw, ok := claims[key]; ok {
|
|
if value, ok := raw.(string); ok && value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
|
|
if raw, ok := claims["loginIds"]; ok {
|
|
switch ids := raw.(type) {
|
|
case []string:
|
|
return pickPrimaryLoginID(ids)
|
|
case []any:
|
|
casted := make([]string, 0, len(ids))
|
|
for _, item := range ids {
|
|
if value, ok := item.(string); ok && value != "" {
|
|
casted = append(casted, value)
|
|
}
|
|
}
|
|
return pickPrimaryLoginID(casted)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
|
if kratosURL == "" {
|
|
kratosURL = "http://kratos:4433"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
req.Header.Set("X-Session-Token", sessionToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Identity struct {
|
|
ID string `json:"id"`
|
|
Traits map[string]interface{} `json:"traits"`
|
|
} `json:"identity"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return result.Identity.ID, result.Identity.Traits, nil
|
|
}
|
|
|
|
func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
|
|
if identityID == "" {
|
|
return "", fmt.Errorf("kratos identity id is empty")
|
|
}
|
|
|
|
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
|
if kratosAdminURL == "" {
|
|
kratosAdminURL = "http://kratos:4434"
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"identity_id": identityID,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, kratosAdminURL+"/admin/sessions", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("kratos admin create session failed status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var parsed struct {
|
|
SessionToken string `json:"session_token"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return "", err
|
|
}
|
|
if parsed.SessionToken == "" {
|
|
return "", fmt.Errorf("kratos admin session token missing: %s", string(respBody))
|
|
}
|
|
return parsed.SessionToken, nil
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) {
|
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
|
if kratosURL == "" {
|
|
kratosURL = "http://kratos:4433"
|
|
}
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
req.Header.Set("Cookie", cookie)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Identity struct {
|
|
ID string `json:"id"`
|
|
Traits map[string]interface{} `json:"traits"`
|
|
} `json:"identity"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return result.Identity.ID, result.Identity.Traits, nil
|
|
}
|
|
|
|
func (h *AuthHandler) 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) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
|
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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)
|
|
|
|
profile := &domain.UserProfileResponse{
|
|
ID: identityID,
|
|
Email: email,
|
|
Name: name,
|
|
Phone: h.formatPhoneForDisplay(phone),
|
|
Department: dept,
|
|
AffiliationType: affType,
|
|
CompanyCode: compCode,
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
|
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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)
|
|
|
|
profile := &domain.UserProfileResponse{
|
|
ID: identityID,
|
|
Email: email,
|
|
Name: name,
|
|
Phone: h.formatPhoneForDisplay(phone),
|
|
Department: dept,
|
|
AffiliationType: affType,
|
|
CompanyCode: compCode,
|
|
}
|
|
return profile, 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
if token != "" && looksLikeJWT(token) && h.DescopeClient != nil {
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err == nil && authorized {
|
|
// 1. Load current user to check changes
|
|
currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"})
|
|
}
|
|
|
|
newPhoneStorage := h.formatPhoneForStorage(req.Phone)
|
|
oldPhoneStorage := currentUser.Phone
|
|
|
|
slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name)
|
|
|
|
// 2. Handle Phone Number Change
|
|
if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage {
|
|
// Check verification status in Redis
|
|
verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage
|
|
val, _ := h.RedisService.Get(verifyKey)
|
|
if val != "verified" {
|
|
slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey)
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."})
|
|
}
|
|
|
|
// Update Phone in Descope and mark as verified
|
|
slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage)
|
|
_, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false)
|
|
if err != nil {
|
|
slog.Error("Failed to update phone in Descope", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."})
|
|
}
|
|
|
|
// If the old phone was used as a LoginID, replace it with the new one
|
|
for _, loginID := range currentUser.LoginIDs {
|
|
// Normalize for comparison
|
|
normID := strings.ReplaceAll(loginID, "+82", "0")
|
|
normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0")
|
|
|
|
if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) {
|
|
slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage)
|
|
_, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage)
|
|
if err != nil {
|
|
slog.Warn("Failed to update LoginID", "error", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Clear verification after successful update
|
|
h.RedisService.Delete(verifyKey)
|
|
}
|
|
|
|
// 3. Update Name if changed
|
|
if req.Name != "" && req.Name != currentUser.Name {
|
|
slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name)
|
|
_, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name)
|
|
if err != nil {
|
|
slog.Error("Failed to update user name", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."})
|
|
}
|
|
}
|
|
|
|
// 4. Update Custom Attributes (Department)
|
|
if req.Department != "" {
|
|
slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department)
|
|
if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil {
|
|
slog.Error("Failed to update department", "error", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"status": "success",
|
|
"updatedAt": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
|
}
|
|
identityID, traits, err = h.getKratosIdentityWithCookie(cookie)
|
|
}
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."})
|
|
}
|
|
traits["phone_number"] = newPhoneStorage
|
|
h.RedisService.Delete(verifyKey)
|
|
}
|
|
|
|
if req.Name != "" {
|
|
traits["name"] = req.Name
|
|
}
|
|
if req.Department != "" {
|
|
traits["department"] = req.Department
|
|
}
|
|
|
|
if err := h.updateKratosIdentity(identityID, traits); err != nil {
|
|
slog.Error("Failed to update profile in Kratos", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "프로필 업데이트에 실패했습니다."})
|
|
}
|
|
|
|
slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID)
|
|
return c.JSON(fiber.Map{
|
|
"status": "success",
|
|
"updatedAt": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// 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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
|
}
|
|
userID, _, err = h.getKratosIdentityWithCookie(cookie)
|
|
}
|
|
if err != nil || userID == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
|
|
var req struct {
|
|
Phone string `json:"phone"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
userID, _, err = h.getKratosIdentityWithCookie(cookie)
|
|
}
|
|
if err != nil || userID == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
|
|
var req struct {
|
|
Phone string `json:"phone"`
|
|
Code string `json:"code"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
|
|
}
|
|
|
|
phone := h.formatPhoneForStorage(req.Phone)
|
|
key := "otp_update_phone:" + userID + ":" + phone
|
|
storedCode, _ := h.RedisService.Get(key)
|
|
|
|
if storedCode == "" || storedCode != req.Code {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증번호가 일치하지 않거나 만료되었습니다."})
|
|
}
|
|
|
|
// 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})
|
|
}
|