1
0
forked from baron/baron-sso

비밀번호 재설정 중복 완료 요청 문제 수정

This commit is contained in:
2026-03-31 11:17:55 +09:00
parent df145b2957
commit 68114eea66
8 changed files with 309 additions and 31 deletions

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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."})
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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"

View 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")
}
}