forked from baron/baron-sso
Refactor password reset flow
This commit is contained in:
232
backend/internal/handler/password_policy_test.go
Normal file
232
backend/internal/handler/password_policy_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/descope/go-sdk/descope"
|
||||
"github.com/descope/go-sdk/descope/client"
|
||||
mocksauth "github.com/descope/go-sdk/descope/tests/mocks/auth"
|
||||
)
|
||||
|
||||
// 정책을 받아 필수 요구사항을 모두 포함하는 비밀번호를 생성한다.
|
||||
func generatePasswordFromPolicy(policy *descope.PasswordPolicy) string {
|
||||
minLen := int(policy.MinLength)
|
||||
if minLen < 8 {
|
||||
minLen = 12 // 안전한 기본값
|
||||
}
|
||||
|
||||
pwd := make([]rune, 0, minLen)
|
||||
|
||||
if policy.Lowercase {
|
||||
pwd = append(pwd, 'a')
|
||||
}
|
||||
if policy.Uppercase {
|
||||
pwd = append(pwd, 'B')
|
||||
}
|
||||
if policy.Number {
|
||||
pwd = append(pwd, '3')
|
||||
}
|
||||
if policy.NonAlphanumeric {
|
||||
pwd = append(pwd, '!')
|
||||
}
|
||||
|
||||
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
for len(pwd) < minLen {
|
||||
pwd = append(pwd, rune(charset[randomInt(len(charset))]))
|
||||
}
|
||||
|
||||
// 섞어서 예측 가능성을 낮춘다.
|
||||
for i := range pwd {
|
||||
j := randomInt(len(pwd))
|
||||
pwd[i], pwd[j] = pwd[j], pwd[i]
|
||||
}
|
||||
return string(pwd)
|
||||
}
|
||||
|
||||
func randomInt(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(binary.BigEndian.Uint64(b[:]) % uint64(n))
|
||||
}
|
||||
|
||||
func TestGeneratePasswordUsesNonAlphanumericRequirement(t *testing.T) {
|
||||
mockAuth := &mocksauth.MockAuthentication{
|
||||
MockPassword: &mocksauth.MockPassword{
|
||||
PolicyResponse: &descope.PasswordPolicy{
|
||||
MinLength: 8,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
policy, err := mockAuth.Password().GetPasswordPolicy(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("정책 조회 실패: %v", err)
|
||||
}
|
||||
if !policy.NonAlphanumeric {
|
||||
t.Fatalf("정책에 비영문자 요구사항이 표시되지 않음")
|
||||
}
|
||||
|
||||
pwd := generatePasswordFromPolicy(policy)
|
||||
|
||||
if len(pwd) < int(policy.MinLength) {
|
||||
t.Fatalf("비밀번호 길이가 정책 최소 길이 미만: got %d, want >= %d", len(pwd), policy.MinLength)
|
||||
}
|
||||
|
||||
var hasLower, hasUpper, hasNumber, hasSymbol bool
|
||||
for _, r := range pwd {
|
||||
switch {
|
||||
case unicode.IsLower(r):
|
||||
hasLower = true
|
||||
case unicode.IsUpper(r):
|
||||
hasUpper = true
|
||||
case unicode.IsNumber(r):
|
||||
hasNumber = true
|
||||
case !unicode.IsLetter(r) && !unicode.IsNumber(r):
|
||||
hasSymbol = true
|
||||
}
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
t.Fatalf("소문자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
t.Fatalf("대문자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
t.Fatalf("숫자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
t.Fatalf("비영문자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 테스트: 실제 Descope 정책으로 비밀번호를 생성하고 교체 플로우를 검증한다.
|
||||
// 필요 env:
|
||||
// DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, TEST_DESCOPE_LOGIN_ID, TEST_DESCOPE_CURRENT_PASSWORD
|
||||
func TestDescopePasswordPolicyAndChange(t *testing.T) {
|
||||
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||
loginID := os.Getenv("DESCOPE_TEST_ACCOUNT")
|
||||
|
||||
if projectID == "" || managementKey == "" || loginID == "" {
|
||||
t.Skip("환경변수(DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, DESCOPE_TEST_ACCOUNT) 미설정으로 통합 테스트 건너뜀")
|
||||
}
|
||||
|
||||
logf := func(format string, args ...any) {
|
||||
t.Logf(format, args...)
|
||||
fmt.Printf(format+"\n", args...)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cl, err := client.NewWithConfig(&client.Config{
|
||||
ProjectID: projectID,
|
||||
ManagementKey: managementKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Descope 클라이언트 초기화 실패: %v", err)
|
||||
}
|
||||
|
||||
policy, err := cl.Auth.Password().GetPasswordPolicy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("비밀번호 정책 조회 실패: %v", err)
|
||||
}
|
||||
logf("정책: min=%d lower=%v upper=%v number=%v nonAlpha=%v", policy.MinLength, policy.Lowercase, policy.Uppercase, policy.Number, policy.NonAlphanumeric)
|
||||
|
||||
// 테스트 계정이 없으면 생성
|
||||
users, _, err := cl.Management.User().SearchAll(ctx, &descope.UserSearchOptions{
|
||||
LoginIDs: []string{loginID},
|
||||
Limit: 1,
|
||||
Page: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("테스트 계정 검색 실패: %v", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
logf("테스트 계정 미존재, 생성 시도: %s", loginID)
|
||||
if _, err := cl.Management.User().CreateTestUser(ctx, loginID, &descope.UserRequest{
|
||||
User: descope.User{
|
||||
Email: loginID,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("테스트 계정 생성 실패: %v", err)
|
||||
}
|
||||
} else {
|
||||
logf("테스트 계정 존재 확인: %s", loginID)
|
||||
}
|
||||
|
||||
// 1) 기초 비밀번호 설정 (알려진 값으로 초기화)
|
||||
basePassword := generatePasswordFromPolicy(policy)
|
||||
if err := cl.Management.User().SetActivePassword(ctx, loginID, basePassword); err != nil {
|
||||
logf("초기 비밀번호 설정 실패: status=%d err=%v", statusFromError(err), err)
|
||||
t.Fatalf("초기 비밀번호 설정 실패: %v", err)
|
||||
}
|
||||
logf("초기 비밀번호 설정 완료: %s", basePassword)
|
||||
|
||||
// 2) 초기 비밀번호 로그인 검증
|
||||
wOld := httptest.NewRecorder()
|
||||
_, err = cl.Auth.Password().SignIn(ctx, loginID, basePassword, wOld)
|
||||
logf("기초 비밀번호 로그인: status=%d err=%v", statusFromError(err), err)
|
||||
if err != nil {
|
||||
t.Fatalf("기초 비밀번호 로그인 실패: %v", err)
|
||||
}
|
||||
|
||||
// 3) 새 비밀번호 생성 및 변경
|
||||
newPassword := generatePasswordFromPolicy(policy)
|
||||
if newPassword == basePassword {
|
||||
newPassword = newPassword + "Z9!"
|
||||
}
|
||||
logf("새 비밀번호 생성: %s", newPassword)
|
||||
|
||||
if err := cl.Management.User().SetActivePassword(ctx, loginID, newPassword); err != nil {
|
||||
logf("비밀번호 변경 실패: status=%d err=%v", statusFromError(err), err)
|
||||
t.Fatalf("비밀번호 변경 실패: %v", err)
|
||||
}
|
||||
logf("비밀번호 변경 성공(status=200)")
|
||||
|
||||
// 4) 새 비밀번호로 로그인 확인
|
||||
wNew := httptest.NewRecorder()
|
||||
_, err = cl.Auth.Password().SignIn(ctx, loginID, newPassword, wNew)
|
||||
logf("새 비밀번호 로그인: status=%d err=%v", statusFromError(err), err)
|
||||
if err != nil {
|
||||
t.Fatalf("새 비밀번호 로그인 실패: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromError(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusOK
|
||||
}
|
||||
var de *descope.Error
|
||||
if errors.As(err, &de) {
|
||||
if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
|
||||
switch v := statusRaw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case string:
|
||||
if n, convErr := strconv.Atoi(v); convErr == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
Reference in New Issue
Block a user