forked from baron/baron-sso
비밀번호 재설정 중복 완료 요청 문제 수정
This commit is contained in:
@@ -539,8 +539,12 @@ func main() {
|
|||||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||||
|
auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage)
|
||||||
|
auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage)
|
||||||
// [Added] Use POST for actual verification triggered by the user
|
// [Added] Use POST for actual verification triggered by the user
|
||||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken)
|
||||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||||
auth.Post("/sms", authHandler.SendSms)
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type NaverSmsRequest struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
CountryCode string `json:"countryCode"`
|
CountryCode string `json:"countryCode"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Messages []SmsMessage `json:"messages"`
|
Messages []SmsMessage `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,18 +66,20 @@ const (
|
|||||||
loginFlowLink = "link"
|
loginFlowLink = "link"
|
||||||
|
|
||||||
// Durations
|
// Durations
|
||||||
defaultExpiration = 5 * time.Minute
|
defaultExpiration = 5 * time.Minute
|
||||||
signupStateExpiration = 10 * time.Minute
|
signupStateExpiration = 10 * time.Minute
|
||||||
signupBlockDuration = 10 * time.Minute
|
signupBlockDuration = 10 * time.Minute
|
||||||
maxSignupFailures = 5
|
maxSignupFailures = 5
|
||||||
emailCodeTTL = 5 * time.Minute
|
emailCodeTTL = 5 * time.Minute
|
||||||
smsCodeTTL = 3 * time.Minute
|
smsCodeTTL = 3 * time.Minute
|
||||||
prefixPwdResetToken = "pwdreset_token:"
|
prefixPwdResetToken = "pwdreset_token:"
|
||||||
pwdResetExpiration = 15 * time.Minute
|
prefixPwdResetUsed = "pwdreset_used:"
|
||||||
minPollInterval = 2 * time.Second
|
pwdResetExpiration = 15 * time.Minute
|
||||||
loginCodeExpiration = 10 * time.Minute
|
pwdResetUsedExpiration = 2 * time.Minute
|
||||||
linkResendCooldown = 60 * time.Second
|
minPollInterval = 2 * time.Second
|
||||||
prefixDrySend = "dry_send:"
|
loginCodeExpiration = 10 * time.Minute
|
||||||
|
linkResendCooldown = 60 * time.Second
|
||||||
|
prefixDrySend = "dry_send:"
|
||||||
headlessJWKSFetchTTL = 5 * time.Second
|
headlessJWKSFetchTTL = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2368,9 +2370,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userfrontURL := h.resolveUserfrontURL(c)
|
userfrontURL := h.resolveUserfrontURL(c)
|
||||||
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
|
||||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
|
// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
|
||||||
ale.RedirectTo = redirectURL
|
verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
|
||||||
|
|
||||||
// 내부 토큰 발급 + 우리 채널로 전송
|
// 내부 토큰 발급 + 우리 채널로 전송
|
||||||
resetToken := GenerateSecureToken(32)
|
resetToken := GenerateSecureToken(32)
|
||||||
@@ -2390,7 +2392,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
||||||
}
|
}
|
||||||
|
|
||||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken)
|
resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
|
||||||
ale.RedirectTo = resetLink
|
ale.RedirectTo = resetLink
|
||||||
ale.Operation = "SendPasswordReset"
|
ale.Operation = "SendPasswordReset"
|
||||||
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
||||||
@@ -2456,6 +2458,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
token = c.Query("t")
|
token = c.Query("t")
|
||||||
}
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Params("token")
|
||||||
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||||
@@ -2509,6 +2514,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
|||||||
token = c.Query("t")
|
token = c.Query("t")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Params("token")
|
||||||
|
}
|
||||||
ale.Token = token
|
ale.Token = token
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -2583,6 +2591,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
if resetToken != "" {
|
if resetToken != "" {
|
||||||
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
||||||
if err != nil || strings.TrimSpace(val) == "" {
|
if err != nil || strings.TrimSpace(val) == "" {
|
||||||
|
if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
|
||||||
|
ale.Status = fiber.StatusOK
|
||||||
|
ale.LatencyMs = time.Since(startTime)
|
||||||
|
ale.Token = resetToken
|
||||||
|
ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
|
||||||
|
ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
|
||||||
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||||
|
}
|
||||||
ale.Status = fiber.StatusUnauthorized
|
ale.Status = fiber.StatusUnauthorized
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.ProviderError = "Invalid or expired reset token"
|
ale.ProviderError = "Invalid or expired reset token"
|
||||||
@@ -2652,6 +2668,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
||||||
if resetToken != "" {
|
if resetToken != "" {
|
||||||
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
||||||
|
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Mock services
|
// Mock services
|
||||||
type mockEmailService struct{}
|
type mockEmailService struct {
|
||||||
|
lastTo string
|
||||||
|
lastSubject string
|
||||||
|
lastBody string
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil }
|
func (m *mockEmailService) SendEmail(to, subject, body string) error {
|
||||||
|
m.lastTo = to
|
||||||
|
m.lastSubject = subject
|
||||||
|
m.lastBody = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockSmsService struct{}
|
type mockSmsService struct {
|
||||||
|
lastTo string
|
||||||
|
lastContent string
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockSmsService) SendSms(to, content string) error { return nil }
|
func (m *mockSmsService) SendSms(to, content string) error {
|
||||||
|
m.lastTo = to
|
||||||
|
m.lastContent = content
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
|
||||||
|
const resetToken = "dup-token"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
const newPassword = "StrongPass1!"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + resetToken: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idp := &mockIdpProvider{
|
||||||
|
userExists: true,
|
||||||
|
err: nil,
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
IdpProvider: idp,
|
||||||
|
}
|
||||||
|
app := newResetFlowTestApp(h)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"newPassword": newPassword,
|
||||||
|
})
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"/api/v1/auth/password/reset/complete?token=%s",
|
||||||
|
resetToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
firstReq.Header.Set("Content-Type", "application/json")
|
||||||
|
firstResp, err := app.Test(firstReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer firstResp.Body.Close()
|
||||||
|
|
||||||
|
if firstResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
|
||||||
|
}
|
||||||
|
if idp.updateCallCount != 1 {
|
||||||
|
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
secondReq.Header.Set("Content-Type", "application/json")
|
||||||
|
secondResp, err := app.Test(secondReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer secondResp.Body.Close()
|
||||||
|
|
||||||
|
if secondResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
|
||||||
|
}
|
||||||
|
if idp.updateCallCount != 1 {
|
||||||
|
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||||
const token = "tok-enc"
|
const token = "tok-enc"
|
||||||
const loginID = "user+alias@example.com"
|
const loginID = "user+alias@example.com"
|
||||||
@@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
|
||||||
|
const token = "tok-ve"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + token: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
|
||||||
|
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/auth/password/reset/ve?token="+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
getResp, err := app.Test(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/auth/password/reset/ve?token="+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
postResp, err := app.Test(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer postResp.Body.Close()
|
||||||
|
|
||||||
|
if postResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
|
||||||
|
const token = "tok-path"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + token: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
|
||||||
|
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/auth/password/reset/v/"+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
getResp, err := app.Test(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/auth/password/reset/v/"+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
postResp, err := app.Test(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer postResp.Body.Close()
|
||||||
|
|
||||||
|
if postResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
||||||
h := &AuthHandler{}
|
h := &AuthHandler{}
|
||||||
app := newResetInitAppWithErrorCodeEnricher(h)
|
app := newResetInitAppWithErrorCodeEnricher(h)
|
||||||
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
|
|||||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
|
||||||
|
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||||
|
|
||||||
|
redis := &testRedisRepo{values: map[string]string{}}
|
||||||
|
smsSvc := &mockSmsService{}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
IdpProvider: &mockIdpProvider{},
|
||||||
|
SmsService: smsSvc,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"loginId": "01012345678",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
|
||||||
|
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
|
||||||
|
}
|
||||||
|
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
|
||||||
|
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type mockIdpProvider struct {
|
|||||||
err error
|
err error
|
||||||
initiateLinkErr error
|
initiateLinkErr error
|
||||||
updateCalled bool
|
updateCalled bool
|
||||||
|
updateCallCount int
|
||||||
updatedLoginID string
|
updatedLoginID string
|
||||||
updatedPassword string
|
updatedPassword string
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
|
|||||||
|
|
||||||
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
m.updateCalled = true
|
m.updateCalled = true
|
||||||
|
m.updateCallCount++
|
||||||
m.updatedLoginID = loginID
|
m.updatedLoginID = loginID
|
||||||
m.updatedPassword = newPassword
|
m.updatedPassword = newPassword
|
||||||
return m.err
|
return m.err
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const naverSMSMaxBytes = 90
|
||||||
|
|
||||||
type SmsServiceImpl struct {
|
type SmsServiceImpl struct {
|
||||||
accessKey string
|
accessKey string
|
||||||
secretKey string
|
secretKey string
|
||||||
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
// Naver SENS API requires phone number without '+'
|
// Naver SENS API requires phone number without '+'
|
||||||
sanitizedTo := strings.Replace(to, "+", "", 1)
|
sanitizedTo := strings.Replace(to, "+", "", 1)
|
||||||
|
|
||||||
reqBody := domain.NaverSmsRequest{
|
reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
|
||||||
Type: "SMS",
|
if reqBody.Type == "LMS" {
|
||||||
ContentType: "COMM",
|
slog.Info("[SmsService] Upgrading message type to LMS due to content length",
|
||||||
CountryCode: "82",
|
"bytes", len([]byte(content)),
|
||||||
From: s.senderPhone,
|
)
|
||||||
Content: content,
|
|
||||||
Messages: []domain.SmsMessage{
|
|
||||||
{
|
|
||||||
To: sanitizedTo,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest {
|
||||||
|
requestType := "SMS"
|
||||||
|
subject := ""
|
||||||
|
if len([]byte(content)) > naverSMSMaxBytes {
|
||||||
|
requestType = "LMS"
|
||||||
|
subject = "[Baron 로그인]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.NaverSmsRequest{
|
||||||
|
Type: requestType,
|
||||||
|
ContentType: "COMM",
|
||||||
|
CountryCode: "82",
|
||||||
|
From: senderPhone,
|
||||||
|
Subject: subject,
|
||||||
|
Content: content,
|
||||||
|
Messages: []domain.SmsMessage{
|
||||||
|
{
|
||||||
|
To: sanitizedTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
||||||
space := " "
|
space := " "
|
||||||
newLine := "\n"
|
newLine := "\n"
|
||||||
|
|||||||
26
backend/internal/service/sms_service_test.go
Normal file
26
backend/internal/service/sms_service_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) {
|
||||||
|
req := buildNaverSmsRequest("0262857755", "821012345678", "123456")
|
||||||
|
|
||||||
|
if req.Type != "SMS" {
|
||||||
|
t.Fatalf("expected SMS, got %s", req.Type)
|
||||||
|
}
|
||||||
|
if req.Subject != "" {
|
||||||
|
t.Fatalf("expected empty subject for SMS, got %q", req.Subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) {
|
||||||
|
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||||
|
req := buildNaverSmsRequest("0262857755", "821012345678", content)
|
||||||
|
|
||||||
|
if req.Type != "LMS" {
|
||||||
|
t.Fatalf("expected LMS, got %s", req.Type)
|
||||||
|
}
|
||||||
|
if req.Subject == "" {
|
||||||
|
t.Fatal("expected LMS subject to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user