diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 838db5ae..e7b70f32 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -75,6 +75,7 @@ func main() { auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/sms", authHandler.SendSms) + auth.Post("/verify-sms", authHandler.VerifySms) // Client Logging Route (For Debugging) api.Post("/client-log", func(c *fiber.Ctx) error { diff --git a/backend/go.mod b/backend/go.mod index fe2b5b53..5d81e6a0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,8 +11,11 @@ require ( github.com/ClickHouse/ch-go v0.69.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/backend/go.sum b/backend/go.sum index a67fab7f..11e12c27 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -9,13 +9,19 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/backend/internal/domain/sms_models.go b/backend/internal/domain/sms_models.go index d1329642..d020db78 100644 --- a/backend/internal/domain/sms_models.go +++ b/backend/internal/domain/sms_models.go @@ -33,3 +33,9 @@ type NaverSmsResponse struct { type SmsRequest struct { PhoneNumber string `json:"phoneNumber"` } + +// SmsVerifyRequest represents the request body for verifying an SMS code. +type SmsVerifyRequest struct { + PhoneNumber string `json:"phoneNumber"` + Code string `json:"code"` +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 2266d9c5..b8f50508 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -15,11 +15,13 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" ) type AuthHandler struct { - ProjectID string - SmsService domain.SmsService + ProjectID string + SmsService domain.SmsService + RedisService *service.RedisService } func NewAuthHandler() *AuthHandler { @@ -28,9 +30,15 @@ func NewAuthHandler() *AuthHandler { // 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(), + ProjectID: pid, + SmsService: service.NewSmsService(), + RedisService: redisService, } } @@ -41,24 +49,94 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error { 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("Error sending SMS: %v", err) + log.Printf("[SMS 발송 실패] SENS API 호출 실패: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } - - // TODO: Store the verification code for later verification + 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 { diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go new file mode 100644 index 00000000..4a6b0ead --- /dev/null +++ b/backend/internal/service/redis_service.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "os" + "time" + + "github.com/go-redis/redis/v8" +) + +var ctx = context.Background() + +type RedisService struct { + Client *redis.Client +} + +// NewRedisService creates and returns a new RedisService +func NewRedisService() (*RedisService, error) { + redisAddr := os.Getenv("REDIS_ADDR") + if redisAddr == "" { + redisAddr = "localhost:6379" // Fallback for local dev without Docker + } + + rdb := redis.NewClient(&redis.Options{ + Addr: redisAddr, + }) + + // Ping the server to check the connection + if _, err := rdb.Ping(ctx).Result(); err != nil { + return nil, err + } + + return &RedisService{Client: rdb}, nil +} + +// StoreVerificationCode saves the SMS verification code with a 3-minute expiration +func (s *RedisService) StoreVerificationCode(phone, code string) error { + // Key format: "sms_verify:01012345678" + key := "sms_verify:" + phone + expiration := 3 * time.Minute + err := s.Client.Set(ctx, key, code, expiration).Err() + return err +} + +// GetVerificationCode retrieves the SMS verification code +func (s *RedisService) GetVerificationCode(phone string) (string, error) { + key := "sms_verify:" + phone + code, err := s.Client.Get(ctx, key).Result() + if err == redis.Nil { + // Key does not exist (expired or incorrect phone number) + return "", nil + } else if err != nil { + return "", err + } + return code, nil +} + +// DeleteVerificationCode removes the verification code after successful verification +func (s *RedisService) DeleteVerificationCode(phone string) error { + key := "sms_verify:" + phone + return s.Client.Del(ctx, key).Err() +} diff --git a/backend/internal/service/sms_service.go b/backend/internal/service/sms_service.go index 20ea5d2e..26aab0a1 100644 --- a/backend/internal/service/sms_service.go +++ b/backend/internal/service/sms_service.go @@ -12,11 +12,11 @@ import ( "net/http" "os" "strconv" + "strings" "time" "baron-sso-backend/internal/domain" ) - type SmsServiceImpl struct { accessKey string secretKey string @@ -25,11 +25,16 @@ type SmsServiceImpl struct { } func NewSmsService() domain.SmsService { + // Sanitize sender phone number right after reading from env + rawSenderPhone := os.Getenv("NAVER_SENDER_PHONE_NUMBER") + sanitizedSenderPhone := strings.ReplaceAll(rawSenderPhone, "-", "") + log.Printf("[서비스 초기화] 발신자 번호 처리: 원본='%s', 정제 후='%s'", rawSenderPhone, sanitizedSenderPhone) + return &SmsServiceImpl{ accessKey: os.Getenv("NAVER_CLOUD_ACCESS_KEY"), secretKey: os.Getenv("NAVER_CLOUD_SECRET_KEY"), serviceID: os.Getenv("NAVER_CLOUD_SERVICE_ID"), - senderPhone: os.Getenv("NAVER_SENDER_PHONE_NUMBER"), + senderPhone: sanitizedSenderPhone, } }