From 68114eea661ef0ecc52b3b8b9d1ddbdc49cdb797 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 11:17:55 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=EC=A4=91=EB=B3=B5=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9A=94=EC=B2=AD=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 4 + backend/internal/domain/sms_models.go | 1 + backend/internal/handler/auth_handler.go | 49 +++-- .../handler/auth_handler_link_test.go | 24 ++- backend/internal/handler/auth_handler_test.go | 193 ++++++++++++++++++ backend/internal/handler/common_test.go | 2 + backend/internal/service/sms_service.go | 41 +++- backend/internal/service/sms_service_test.go | 26 +++ 8 files changed, 309 insertions(+), 31 deletions(-) create mode 100644 backend/internal/service/sms_service_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 61ff20d2..3610600b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -539,8 +539,12 @@ func main() { auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption 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 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.Get("/password/policy", authHandler.GetPasswordPolicy) auth.Post("/sms", authHandler.SendSms) diff --git a/backend/internal/domain/sms_models.go b/backend/internal/domain/sms_models.go index 53956273..c12fcd45 100644 --- a/backend/internal/domain/sms_models.go +++ b/backend/internal/domain/sms_models.go @@ -11,6 +11,7 @@ type NaverSmsRequest struct { ContentType string `json:"contentType"` CountryCode string `json:"countryCode"` From string `json:"from"` + Subject string `json:"subject,omitempty"` Content string `json:"content"` Messages []SmsMessage `json:"messages"` } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 62943600..f2653faa 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -66,18 +66,20 @@ const ( loginFlowLink = "link" // Durations - defaultExpiration = 5 * time.Minute - signupStateExpiration = 10 * time.Minute - signupBlockDuration = 10 * time.Minute - maxSignupFailures = 5 - emailCodeTTL = 5 * time.Minute - smsCodeTTL = 3 * time.Minute - prefixPwdResetToken = "pwdreset_token:" - pwdResetExpiration = 15 * time.Minute - minPollInterval = 2 * time.Second - loginCodeExpiration = 10 * time.Minute - linkResendCooldown = 60 * time.Second - prefixDrySend = "dry_send:" + defaultExpiration = 5 * time.Minute + signupStateExpiration = 10 * time.Minute + signupBlockDuration = 10 * time.Minute + maxSignupFailures = 5 + emailCodeTTL = 5 * time.Minute + smsCodeTTL = 3 * time.Minute + prefixPwdResetToken = "pwdreset_token:" + prefixPwdResetUsed = "pwdreset_used:" + pwdResetExpiration = 15 * time.Minute + pwdResetUsedExpiration = 2 * time.Minute + minPollInterval = 2 * time.Second + loginCodeExpiration = 10 * time.Minute + linkResendCooldown = 60 * time.Second + prefixDrySend = "dry_send:" headlessJWKSFetchTTL = 5 * time.Second ) @@ -2368,9 +2370,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { } userfrontURL := h.resolveUserfrontURL(c) - // [Changed] Point to Backend API for verification (which then redirects to Frontend) - redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) - ale.RedirectTo = redirectURL + // 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다. + // 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다. + verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL) // 내부 토큰 발급 + 우리 채널로 전송 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") } - resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken) + resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken) ale.RedirectTo = resetLink ale.Operation = "SendPasswordReset" ale.Log(slog.LevelInfo, "Initiating password reset via internal token") @@ -2456,6 +2458,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error { if token == "" { token = c.Query("t") } + if token == "" { + token = c.Params("token") + } if 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") } } + if token == "" { + token = c.Params("token") + } ale.Token = token if token == "" { @@ -2583,6 +2591,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { if resetToken != "" { val, err := h.RedisService.Get(prefixPwdResetToken + resetToken) 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.LatencyMs = time.Since(startTime) 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)) if resetToken != "" { _ = h.RedisService.Delete(prefixPwdResetToken + resetToken) + _ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration) } return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) } diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 39c28c6d..69387188 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -16,13 +16,29 @@ import ( ) // 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 { app := fiber.New() diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index 87a10b64..11ddbf63 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "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) { const token = "tok-enc" 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) { h := &AuthHandler{} app := newResetInitAppWithErrorCodeEnricher(h) @@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) 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) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 73cbdc73..32bd1d21 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -21,6 +21,7 @@ type mockIdpProvider struct { err error initiateLinkErr error updateCalled bool + updateCallCount int updatedLoginID 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 { m.updateCalled = true + m.updateCallCount++ m.updatedLoginID = loginID m.updatedPassword = newPassword return m.err diff --git a/backend/internal/service/sms_service.go b/backend/internal/service/sms_service.go index c50a739a..986a1706 100644 --- a/backend/internal/service/sms_service.go +++ b/backend/internal/service/sms_service.go @@ -17,6 +17,8 @@ import ( "time" ) +const naverSMSMaxBytes = 90 + type SmsServiceImpl struct { accessKey string secretKey string @@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error { // Naver SENS API requires phone number without '+' sanitizedTo := strings.Replace(to, "+", "", 1) - reqBody := domain.NaverSmsRequest{ - Type: "SMS", - ContentType: "COMM", - CountryCode: "82", - From: s.senderPhone, - Content: content, - Messages: []domain.SmsMessage{ - { - To: sanitizedTo, - }, - }, + reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content) + if reqBody.Type == "LMS" { + slog.Info("[SmsService] Upgrading message type to LMS due to content length", + "bytes", len([]byte(content)), + ) } jsonBody, err := json.Marshal(reqBody) @@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error { 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) { space := " " newLine := "\n" diff --git a/backend/internal/service/sms_service_test.go b/backend/internal/service/sms_service_test.go new file mode 100644 index 00000000..c9b2f1b9 --- /dev/null +++ b/backend/internal/service/sms_service_test.go @@ -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") + } +}