1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/auth_handler.go

333 lines
9.6 KiB
Go

package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v4"
)
type AuthHandler struct {
ProjectID string
SmsService domain.SmsService
RedisService *service.RedisService
}
func NewAuthHandler() *AuthHandler {
pid := os.Getenv("DESCOPE_PROJECT_ID")
if pid == "" {
// Fallback for dev if not set
pid = "P37DsGepBT6uDWb5TYYpb5RxUPuq"
}
redisService, err := service.NewRedisService()
if err != nil {
log.Fatalf("Failed to connect to Redis: %v", err)
}
return &AuthHandler{
ProjectID: pid,
SmsService: service.NewSmsService(),
RedisService: redisService,
}
}
// SendSms sends a verification code via SMS.
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"})
}
log.Printf("[SMS 발송 시작] 요청된 번호: %s", req.PhoneNumber)
// Sanitize phone number: remove dashes
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
log.Printf("[SMS 발송] 번호 정제 완료: %s", sanitizedPhone)
// Generate a 6-digit verification code
rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))
content := fmt.Sprintf("[Baron SSO] Your verification code is %s", code)
log.Printf("[SMS 발송] 인증 코드 생성 완료: %s", code)
// Store the code in Redis before sending
if err := h.RedisService.StoreVerificationCode(sanitizedPhone, code); err != nil {
log.Printf("[SMS 발송 실패] Redis에 코드 저장 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to process request"})
}
log.Printf("[SMS 발송] Redis에 인증 코드 저장 성공 (키: sms_verify:%s)", sanitizedPhone)
if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil {
log.Printf("[SMS 발송 실패] SENS API 호출 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
}
log.Printf("[SMS 발송 성공] SENS API를 통해 SMS 발송 완료")
return c.JSON(fiber.Map{"message": "SMS sent successfully"})
}
// VerifySms verifies the provided SMS code.
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"})
}
log.Printf("[SMS 검증 시작] 요청된 번호: %s, 코드: %s", req.PhoneNumber, req.Code)
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
log.Printf("[SMS 검증] 번호 정제 완료: %s", sanitizedPhone)
storedCode, err := h.RedisService.GetVerificationCode(sanitizedPhone)
if err != nil {
log.Printf("[SMS 검증 실패] Redis에서 코드 조회 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"})
}
log.Printf("[SMS 검증] Redis에서 코드 조회 완료. 저장된 코드: '%s'", storedCode)
if storedCode == "" || storedCode != req.Code {
log.Printf("[SMS 검증 실패] 코드가 일치하지 않거나 만료됨 (요청된 코드: %s, 저장된 코드: %s)", req.Code, storedCode)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
}
log.Printf("[SMS 검증] 코드 일치 확인")
// Code is correct, delete it to prevent reuse
if err := h.RedisService.DeleteVerificationCode(sanitizedPhone); err != nil {
// Log the error but don't fail the request as the code was already verified
log.Printf("[SMS 검증] 경고: Redis에서 코드 삭제 실패 (하지만 검증은 성공으로 처리됨): %v", err)
} else {
log.Printf("[SMS 검증] Redis에서 사용된 코드 삭제 완료")
}
// Generate JWT token
claims := jwt.MapClaims{
"sub": sanitizedPhone, // Subject (user identifier)
"exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time (24 hours)
"iat": time.Now().Unix(), // Issued at
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
log.Printf("[SMS 검증] JWT 클레임 생성 완료")
// Sign the token with the secret key
secretKey := os.Getenv("COOKIE_SECRET")
if secretKey == "" {
log.Println("Warning: COOKIE_SECRET is not set. Using a default, insecure key.")
secretKey = "default-insecure-secret-key-for-dev"
}
signedToken, err := token.SignedString([]byte(secretKey))
if err != nil {
log.Printf("[SMS 검증 실패] JWT 토큰 서명 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate token"})
}
log.Printf("[SMS 검증 성공] JWT 토큰 발급 완료")
return c.JSON(fiber.Map{"token": signedToken})
}
// getBaseURL extracts the region code from Project ID if present (e.g., P37... -> api.37ds.descope.com)
// Default is api.descope.com
func (h *AuthHandler) getBaseURL() string {
if len(h.ProjectID) >= 32 {
// Heuristic: Descope project IDs usually start with 'P'
// If it's a region-specific project, the URL changes.
// For P37DsGepBT6uDWb5TYYpb5RxUPuq, the region is likely '37ds'.
// Actually, the safest bet is to use the standard API or check the logic.
// The error log showed 'api.37ds.descope.com'.
// Let's implement dynamic extraction or just use the standard one which redirects?
// No, standard is safer if region is unsure, but let's try to match the error URL.
// Region code is usually the first 4 chars after P? No.
// Let's rely on standard logic: https://api.descope.com usually works and routes.
// BUT the user specifically saw api.37ds.descope.com.
// Let's try the generic endpoint first.
return "https://api.descope.com"
}
return "https://api.descope.com"
}
// InitEnchantedLink proxies the sign-up/in request
func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
var req domain.EnchantedLinkInitRequest
if err := c.BodyParser(&req); err != nil {
fmt.Printf("[DEBUG] BodyParser failed: %v\n", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
fmt.Printf("[DEBUG] InitEnchantedLink - Received LoginID: '%s', URI: '%s'\n", req.LoginID, req.URI)
// Prepare Descope Request
// Note: We are using the public API endpoint which expects Bearer <ProjectID>
// Determine endpoint type (email vs sms)
// Default to Enchanted Link Email
apiPath := "enchantedlink/signup-in/email"
if req.Method == "sms" {
apiPath = "magiclink/signup-in/sms"
} else if len(req.LoginID) > 0 && req.LoginID[0] == '+' {
// Auto-detect if starts with +
apiPath = "magiclink/signup-in/sms"
}
url := fmt.Sprintf("%s/v1/auth/%s", h.getBaseURL(), apiPath)
payload := map[string]string{
"loginId": req.LoginID,
// "redirectUrl": req.URI, // Let Descope use default from console configuration
}
body, _ := json.Marshal(payload)
r, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+h.ProjectID)
client := &http.Client{}
resp, err := client.Do(r)
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString(err.Error())
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return c.Status(resp.StatusCode).Send(respBody)
}
return c.Send(respBody)
}
// PollEnchantedLink proxies the polling request
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"})
}
url := fmt.Sprintf("%s/v1/auth/enchantedlink/pending-session", h.getBaseURL())
payload := map[string]string{
"pendingRef": req.PendingRef,
}
body, _ := json.Marshal(payload)
r, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+h.ProjectID)
client := &http.Client{}
resp, err := client.Do(r)
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString(err.Error())
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return c.Status(resp.StatusCode).Send(respBody)
}
return c.Send(respBody)
}
// VerifyMagicLink verifies the token (t) from the email link
func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
var req domain.MagicLinkVerifyRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
// Use Magic Link Verify API
url := fmt.Sprintf("%s/v1/auth/magiclink/verify", h.getBaseURL())
payload := map[string]string{
"token": req.Token,
}
body, _ := json.Marshal(payload)
r, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+h.ProjectID)
client := &http.Client{}
resp, err := client.Do(r)
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString(err.Error())
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return c.Status(resp.StatusCode).Send(respBody)
}
return c.Send(respBody)
}