1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/auth_handler.go
2026-01-21 12:51:00 +09:00

501 lines
17 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:"
// Session Statuses
statusPending = "pending"
statusSuccess = "success"
// Durations
defaultExpiration = 5 * time.Minute
)
type AuthHandler struct {
ProjectID string
SmsService domain.SmsService
EmailService domain.EmailService
RedisService *service.RedisService
DescopeClient *client.DescopeClient
}
// 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,
}
}
// 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://ssologin.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://ssologin.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"})
}