diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 55ebabb6..838db5ae 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -74,6 +74,7 @@ func main() { auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) + auth.Post("/sms", authHandler.SendSms) // Client Logging Route (For Debugging) api.Post("/client-log", func(c *fiber.Ctx) error { diff --git a/backend/internal/domain/sms_models.go b/backend/internal/domain/sms_models.go new file mode 100644 index 00000000..d1329642 --- /dev/null +++ b/backend/internal/domain/sms_models.go @@ -0,0 +1,35 @@ +package domain + +// SmsService defines the interface for sending SMS messages. +type SmsService interface { + SendSms(to, content string) error +} + +// NaverSmsRequest represents the request body for the Naver Cloud SMS API. +type NaverSmsRequest struct { + Type string `json:"type"` + ContentType string `json:"contentType"` + CountryCode string `json:"countryCode"` + From string `json:"from"` + Content string `json:"content"` + Messages []SmsMessage `json:"messages"` +} + +// SmsMessage represents a single message to be sent. +type SmsMessage struct { + To string `json:"to"` + Content string `json:"content,omitempty"` +} + +// NaverSmsResponse represents the response from the Naver Cloud SMS API. +type NaverSmsResponse struct { + RequestID string `json:"requestId"` + RequestTime string `json:"requestTime"` + StatusCode string `json:"statusCode"` + StatusName string `json:"statusName"` +} + +// SmsRequest represents the request body for sending an SMS. +type SmsRequest struct { + PhoneNumber string `json:"phoneNumber"` +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a765c51e..2266d9c5 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2,27 +2,61 @@ 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" ) type AuthHandler struct { - ProjectID string + ProjectID string + SmsService domain.SmsService } func NewAuthHandler() *AuthHandler { pid := os.Getenv("DESCOPE_PROJECT_ID") if pid == "" { // Fallback for dev if not set - pid = "P37DsGepBT6uDWb5TYYpb5RxUPuq" + pid = "P37DsGepBT6uDWb5TYYpb5RxUPuq" } - return &AuthHandler{ProjectID: pid} + return &AuthHandler{ + ProjectID: pid, + SmsService: service.NewSmsService(), + } +} + +// 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"}) + } + + // Sanitize phone number: remove dashes + sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") + + // 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) + + if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { + log.Printf("Error sending SMS: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } + + // TODO: Store the verification code for later verification + + return c.JSON(fiber.Map{"message": "SMS sent successfully"}) } // getBaseURL extracts the region code from Project ID if present (e.g., P37... -> api.37ds.descope.com) diff --git a/backend/internal/service/sms_service.go b/backend/internal/service/sms_service.go new file mode 100644 index 00000000..20ea5d2e --- /dev/null +++ b/backend/internal/service/sms_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + "time" + + "baron-sso-backend/internal/domain" +) + +type SmsServiceImpl struct { + accessKey string + secretKey string + serviceID string + senderPhone string +} + +func NewSmsService() domain.SmsService { + 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"), + } +} + +func (s *SmsServiceImpl) SendSms(to, content string) error { + timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10) + apiURL := fmt.Sprintf("https://sens.apigw.ntruss.com/sms/v2/services/%s/messages", s.serviceID) + log.Printf("Requesting SENS API URL: %s", apiURL) + + reqBody := domain.NaverSmsRequest{ + Type: "SMS", + ContentType: "COMM", + CountryCode: "82", + From: s.senderPhone, + Content: content, + Messages: []domain.SmsMessage{ + { + To: to, + }, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("error marshalling request body: %w", err) + } + + signature, err := s.makeSignature("POST", fmt.Sprintf("/sms/v2/services/%s/messages", s.serviceID), timestamp) + if err != nil { + return fmt.Errorf("error creating signature: %w", err) + } + + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-ncp-apigw-timestamp", timestamp) + req.Header.Set("x-ncp-iam-access-key", s.accessKey) + req.Header.Set("x-ncp-apigw-signature-v2", signature) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error sending request: %w", err) + } + defer resp.Body.Close() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode >= 300 { + log.Printf("error response from naver cloud sms api: %s", string(respBody)) + return fmt.Errorf("error sending sms: status code %d", resp.StatusCode) + } + + log.Printf("sms sent successfully: %s", string(respBody)) + return nil +} + +func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) { + space := " " + newLine := "\n" + message := method + space + url + newLine + timestamp + newLine + s.accessKey + + h := hmac.New(sha256.New, []byte(s.secretKey)) + _, err := h.Write([]byte(message)) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +}