forked from baron/baron-sso
1054 lines
37 KiB
Go
1054 lines
37 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/service"
|
|
"context"
|
|
crand "crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand"
|
|
"os"
|
|
"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:"
|
|
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
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
ProjectID string
|
|
SmsService domain.SmsService
|
|
EmailService domain.EmailService
|
|
RedisService *service.RedisService
|
|
DescopeClient *client.DescopeClient
|
|
}
|
|
|
|
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 NewAuthHandler(redisService *service.RedisService) *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,
|
|
}
|
|
}
|
|
|
|
// --- Signup Flow Handlers ---
|
|
|
|
// CheckEmail - Checks if email is available (not registered in Descope)
|
|
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.DescopeClient == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
|
|
// Search in Descope
|
|
// Note: Descope doesn't have a direct "exists" check, we use Load or Search.
|
|
// Since we are checking availability for signup, we want "User not found".
|
|
exists, err := h.DescopeClient.Management.User().Load(context.Background(), req.Email)
|
|
|
|
// If err is nil and exists is not nil, user exists.
|
|
if err == nil && exists != nil {
|
|
return c.JSON(fiber.Map{"available": false, "message": "Email already registered"})
|
|
}
|
|
|
|
// Check if specific error is "not found" or just assume if Load fails it might be free.
|
|
// Typically Descope Load returns error if not found? Let's assume so or check error message.
|
|
// Actually, strictly speaking, we should handle specific errors, but for MVP:
|
|
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 SSO] 회원가입 인증코드"
|
|
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 SSO] 인증번호 [%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"})
|
|
}
|
|
|
|
// Password Validation
|
|
if len(req.Password) < 12 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 12 characters"})
|
|
}
|
|
// Check complexity (at least 2 types: lower, upper, digit, special)
|
|
types := 0
|
|
if strings.ContainsAny(req.Password, "abcdefghijklmnopqrstuvwxyz") { types++ }
|
|
if strings.ContainsAny(req.Password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { types++ }
|
|
if strings.ContainsAny(req.Password, "0123456789") { types++ }
|
|
if strings.ContainsAny(req.Password, "!@#$%^&*()_+-=[]{}|;:,.<>?") { types++ }
|
|
if types < 2 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least 2 types of characters (letters, numbers, symbols)"})
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
// 3. Create User in Descope
|
|
if h.DescopeClient == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
}
|
|
|
|
// Normalize Phone for Descope (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
|
|
}
|
|
|
|
descopeUser := &descope.UserRequest{}
|
|
descopeUser.Email = req.Email
|
|
descopeUser.Phone = normalizedPhone
|
|
descopeUser.Name = req.Name
|
|
descopeUser.CustomAttributes = map[string]any{
|
|
"affiliationType": req.AffiliationType,
|
|
"companyCode": req.CompanyCode,
|
|
"department": req.Department,
|
|
"termsAccepted": req.TermsAccepted,
|
|
"createdAt": time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
// Create user
|
|
// Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update`
|
|
// or use a specialized signup flow.
|
|
// `Management.User().Create` creates a user but doesn't set a password credential immediately unless specified?
|
|
// Actually `User().Create` creates the identity.
|
|
// To set password, we use `h.DescopeClient.Management.User().SetPassword(...)`
|
|
|
|
// Check if user exists (Double check)
|
|
exists, _ := h.DescopeClient.Management.User().Load(context.Background(), req.Email)
|
|
if exists != nil {
|
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
|
|
}
|
|
|
|
// Create
|
|
_, err := h.DescopeClient.Management.User().Create(context.Background(), req.Email, descopeUser)
|
|
if err != nil {
|
|
slog.Error("[Signup] Failed to create user in Descope", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"})
|
|
}
|
|
|
|
// Set Password
|
|
err = h.DescopeClient.Management.User().SetPassword(context.Background(), req.Email, req.Password)
|
|
if err != nil {
|
|
slog.Error("[Signup] Failed to set password", "error", err)
|
|
// Rollback? Delete user?
|
|
h.DescopeClient.Management.User().Delete(context.Background(), req.Email)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("Failed to set password: %v", err)})
|
|
}
|
|
|
|
// 4. Cleanup Redis
|
|
h.RedisService.Delete(emailKey)
|
|
h.RedisService.Delete(phoneKey)
|
|
|
|
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType)
|
|
|
|
return c.JSON(fiber.Map{"success": true, "message": "User registered successfully"})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
|
|
authHeader := c.Get("Authorization")
|
|
if authHeader == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
|
return ""
|
|
}
|
|
return parts[1]
|
|
}
|
|
|
|
func (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)
|
|
}
|
|
|
|
// 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 SSO] 인증번호: %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)
|
|
|
|
// Note: In a real scenario, you might want to generate a Descope JWT here too
|
|
// using the same logic as VerifyMagicLink, but for now returning a placeholder
|
|
// or you can call the Descope logic if needed.
|
|
token := "sms-verified-placeholder-token"
|
|
|
|
return c.JSON(fiber.Map{"token": token})
|
|
}
|
|
|
|
// 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, " ", "")
|
|
|
|
// Generate secure tokens
|
|
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, loginID), defaultExpiration)
|
|
|
|
// Generate Link
|
|
frontendURL := os.Getenv("FRONTEND_URL")
|
|
if frontendURL == "" {
|
|
frontendURL = "http://sso.hmac.kr"
|
|
}
|
|
link := fmt.Sprintf("%s/verify/%s", frontendURL, 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 SSO] 로그인 링크"
|
|
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: 12px; color: #888;">만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.</p>
|
|
</div>
|
|
`, link)
|
|
|
|
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 SSO] 로그인 링크: %s", link)
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
|
if err != nil || val == "" {
|
|
return c.JSON(fiber.Map{"status": statusPending})
|
|
}
|
|
|
|
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{"status": statusPending})
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 1. Generate Descope Session Directly (Management SDK)
|
|
if h.DescopeClient == nil {
|
|
slog.Error("[Verify] Descope Client is nil!")
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
|
|
}
|
|
|
|
// [Fix] Search for existing user by phone to prevent fragmentation
|
|
// Normalize Phone Number for Search (E.164)
|
|
searchPhone := loginID
|
|
if !strings.Contains(searchPhone, "@") {
|
|
// If it looks like a KR mobile number (010...), format to +8210...
|
|
if strings.HasPrefix(searchPhone, "010") {
|
|
searchPhone = "+82" + searchPhone[1:]
|
|
} else if strings.HasPrefix(searchPhone, "82") {
|
|
searchPhone = "+" + searchPhone
|
|
}
|
|
}
|
|
|
|
slog.Info("[Verify] Searching for user", "phone", searchPhone)
|
|
searchOptions := &descope.UserSearchOptions{
|
|
Phones: []string{searchPhone},
|
|
Limit: 1,
|
|
}
|
|
|
|
var targetLoginID string
|
|
users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions)
|
|
|
|
if errSearch == nil && len(users) > 0 {
|
|
if len(users[0].LoginIDs) > 0 {
|
|
targetLoginID = users[0].LoginIDs[0]
|
|
slog.Info("[Verify] User found", "existingLoginID", targetLoginID)
|
|
} else {
|
|
// Should not happen for a valid user, but fallback to UserID or searchPhone
|
|
slog.Warn("[Verify] User found but no LoginIDs, using UserID")
|
|
targetLoginID = users[0].UserID
|
|
}
|
|
} else {
|
|
// Not found, or search error. Fallback to using the phone as LoginID.
|
|
// Use the normalized phone number to ensure consistency (+82...)
|
|
targetLoginID = searchPhone
|
|
slog.Info("[Verify] User not found by phone, will use/create", "loginID", targetLoginID)
|
|
}
|
|
|
|
slog.Info("[Verify] Generating embedded link", "loginID", targetLoginID)
|
|
embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") {
|
|
slog.Info("[Verify] User not found, creating...", "loginID", targetLoginID)
|
|
|
|
// Create User with Explicit Phone Attribute
|
|
userObj := &descope.UserRequest{}
|
|
if strings.Contains(targetLoginID, "@") {
|
|
userObj.Email = targetLoginID
|
|
} else {
|
|
userObj.Phone = targetLoginID // Must be E.164
|
|
}
|
|
|
|
_, errCreate := h.DescopeClient.Management.User().Create(context.Background(), targetLoginID, userObj)
|
|
if errCreate != nil {
|
|
slog.Error("[Verify] Failed to create user", "error", errCreate)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"})
|
|
}
|
|
|
|
embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
|
|
if err != nil {
|
|
slog.Error("[Verify] Failed to generate token after creation", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"})
|
|
}
|
|
} else {
|
|
slog.Error("[Verify] Descope Error", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"})
|
|
}
|
|
}
|
|
|
|
slog.Info("[Verify] Exchanging embedded token for session JWT")
|
|
authInfo, err := h.DescopeClient.Auth.MagicLink().Verify(context.Background(), embeddedToken, nil)
|
|
if err != nil {
|
|
slog.Error("[Verify] Final verification failed", "error", err)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify upstream token"})
|
|
}
|
|
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",
|
|
})
|
|
}
|
|
|
|
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
|
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
|
pendingRef := GenerateSecureToken(16)
|
|
|
|
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
|
frontendURL := os.Getenv("FRONTEND_URL")
|
|
if frontendURL == "" {
|
|
frontendURL = "https://sso.hmac.kr"
|
|
}
|
|
qrPayload := fmt.Sprintf("%s/approve?ref=%s", frontendURL, pendingRef)
|
|
|
|
slog.Info("[QR] Init", "pendingRef", pendingRef, "url", qrPayload)
|
|
|
|
// Redis에 초기 상태 저장 (5분 만료)
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
|
|
"pendingRef": pendingRef,
|
|
"expiresIn": 300,
|
|
})
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
|
if err != nil || val == "" {
|
|
return c.JSON(fiber.Map{"status": "expired"})
|
|
}
|
|
|
|
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.JSON(fiber.Map{"status": statusPending})
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
slog.Info("[QR] Scan & Approve", "pendingRef", req.PendingRef)
|
|
|
|
// 1. Redis에서 세션 확인
|
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
|
if err != nil || val == "" {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"})
|
|
}
|
|
|
|
// 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달
|
|
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
"status": statusSuccess,
|
|
"jwt": req.Token,
|
|
})
|
|
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute)
|
|
|
|
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")
|
|
}
|
|
|
|
// 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 == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
|
}
|
|
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err != nil || !authorized {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// UpdateMe - Updates current user's profile with phone verification check
|
|
func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
|
|
token := h.getBearerToken(c)
|
|
if token == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
|
}
|
|
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err != nil || !authorized {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
}
|
|
|
|
var req domain.UpdateUserRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
// 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),
|
|
})
|
|
}
|
|
|
|
// SendUpdateCode - Sends OTP for phone number change
|
|
func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
|
|
token := h.getBearerToken(c)
|
|
if token == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
|
}
|
|
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err != nil || !authorized {
|
|
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:" + userToken.ID + ":" + phone
|
|
h.RedisService.Set(key, code, 5*time.Minute)
|
|
|
|
// Send SMS
|
|
content := fmt.Sprintf("[Baron SSO] 정보 수정 인증번호: [%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)
|
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
|
if err != nil || !authorized {
|
|
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:" + userToken.ID + ":" + 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:" + userToken.ID + ":" + phone
|
|
h.RedisService.Set(verifyKey, "verified", 10*time.Minute)
|
|
h.RedisService.Delete(key)
|
|
|
|
return c.JSON(fiber.Map{"success": true})
|
|
}
|