- 체인 nonce 경쟁 조건 수정 (operatorMu + per-user mutex) - 등록/SSAFY 원자적 트랜잭션 (wallet+profile 롤백 보장) - IdempotencyRequired 미들웨어 (SETNX 원자적 클레임) - 런치 티켓 API (JWT URL 노출 방지) - HttpOnly 쿠키 refresh token - SSAFY OAuth state 파라미터 (CSRF 방지) - Refresh 시 DB 조회로 최신 role 사용 - 공지사항/유저목록 페이지네이션 - BodyLimit 미들웨어 (1MB, upload 제외) - 입력 검증 강화 (닉네임, 게임데이터, 공지 길이) - 에러 메시지 내부 정보 노출 방지 - io.LimitReader (RPC 10MB, SSAFY 1MB) - RequestID 비출력 문자 제거 - 단위 테스트 (auth 11, announcement 9, bossraid 16) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
292 lines
8.1 KiB
Go
292 lines
8.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"a301_server/pkg/config"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Password hashing (bcrypt)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBcryptHashAndVerify(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
password string
|
|
wantMatch bool
|
|
}{
|
|
{"short password", "abc", true},
|
|
{"normal password", "myP@ssw0rd!", true},
|
|
{"unicode password", "비밀번호123", true},
|
|
{"empty password", "", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(tc.password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("GenerateFromPassword failed: %v", err)
|
|
}
|
|
|
|
err = bcrypt.CompareHashAndPassword(hash, []byte(tc.password))
|
|
if (err == nil) != tc.wantMatch {
|
|
t.Errorf("CompareHashAndPassword: got err=%v, wantMatch=%v", err, tc.wantMatch)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBcryptWrongPassword(t *testing.T) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("GenerateFromPassword failed: %v", err)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword(hash, []byte("wrong")); err == nil {
|
|
t.Error("expected error comparing wrong password, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
|
|
password := "samePassword"
|
|
hash1, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
hash2, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if string(hash1) == string(hash2) {
|
|
t.Error("expected different hashes for the same password (different salts)")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. JWT token generation and parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func setupTestConfig() {
|
|
config.C = config.Config{
|
|
JWTSecret: "test-jwt-secret-key-for-unit-tests",
|
|
RefreshSecret: "test-refresh-secret-key-for-unit-tests",
|
|
JWTExpiryHours: 1,
|
|
}
|
|
}
|
|
|
|
func TestIssueAndParseAccessToken(t *testing.T) {
|
|
setupTestConfig()
|
|
|
|
tests := []struct {
|
|
name string
|
|
userID uint
|
|
username string
|
|
role string
|
|
}{
|
|
{"admin user", 1, "admin", "admin"},
|
|
{"regular user", 42, "player1", "user"},
|
|
{"unicode username", 100, "유저", "user"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
|
|
claims := &Claims{
|
|
UserID: tc.userID,
|
|
Username: tc.username,
|
|
Role: tc.role,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
|
|
if err != nil {
|
|
t.Fatalf("SignedString failed: %v", err)
|
|
}
|
|
|
|
parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte(config.C.JWTSecret), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ParseWithClaims failed: %v", err)
|
|
}
|
|
if !parsed.Valid {
|
|
t.Fatal("parsed token is not valid")
|
|
}
|
|
|
|
got, ok := parsed.Claims.(*Claims)
|
|
if !ok {
|
|
t.Fatal("failed to cast claims")
|
|
}
|
|
if got.UserID != tc.userID {
|
|
t.Errorf("UserID = %d, want %d", got.UserID, tc.userID)
|
|
}
|
|
if got.Username != tc.username {
|
|
t.Errorf("Username = %q, want %q", got.Username, tc.username)
|
|
}
|
|
if got.Role != tc.role {
|
|
t.Errorf("Role = %q, want %q", got.Role, tc.role)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTokenWithWrongSecret(t *testing.T) {
|
|
setupTestConfig()
|
|
|
|
claims := &Claims{
|
|
UserID: 1,
|
|
Username: "test",
|
|
Role: "user",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, _ := token.SignedString([]byte(config.C.JWTSecret))
|
|
|
|
_, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte("wrong-secret"), nil
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error parsing token with wrong secret, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParseExpiredToken(t *testing.T) {
|
|
setupTestConfig()
|
|
|
|
claims := &Claims{
|
|
UserID: 1,
|
|
Username: "test",
|
|
Role: "user",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, _ := token.SignedString([]byte(config.C.JWTSecret))
|
|
|
|
_, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte(config.C.JWTSecret), nil
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error parsing expired token, got nil")
|
|
}
|
|
}
|
|
|
|
func TestRefreshTokenUsesDifferentSecret(t *testing.T) {
|
|
setupTestConfig()
|
|
|
|
claims := &Claims{
|
|
UserID: 1,
|
|
Username: "test",
|
|
Role: "user",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
|
|
// Sign with refresh secret
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, _ := token.SignedString([]byte(config.C.RefreshSecret))
|
|
|
|
// Should fail with JWT secret
|
|
_, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte(config.C.JWTSecret), nil
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error parsing refresh token with access secret")
|
|
}
|
|
|
|
// Should succeed with refresh secret
|
|
parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte(config.C.RefreshSecret), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected success with refresh secret, got: %v", err)
|
|
}
|
|
if !parsed.Valid {
|
|
t.Error("parsed refresh token is not valid")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Input validation helpers (sanitizeForUsername)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSanitizeForUsername(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"lowercase letters", "hello", "hello"},
|
|
{"uppercase converted", "HeLLo", "hello"},
|
|
{"digits kept", "user123", "user123"},
|
|
{"underscore kept", "user_name", "user_name"},
|
|
{"hyphen kept", "user-name", "user-name"},
|
|
{"special chars removed", "user@name!#$", "username"},
|
|
{"spaces removed", "user name", "username"},
|
|
{"unicode removed", "유저abc", "abc"},
|
|
{"mixed", "User-123_Test!", "user-123_test"},
|
|
{"empty input", "", ""},
|
|
{"all removed", "!!@@##", ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeForUsername(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeForUsername(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Claims struct fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestClaimsRoundTrip(t *testing.T) {
|
|
setupTestConfig()
|
|
|
|
original := &Claims{
|
|
UserID: 999,
|
|
Username: "testuser",
|
|
Role: "admin",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, original)
|
|
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
|
|
if err != nil {
|
|
t.Fatalf("signing failed: %v", err)
|
|
}
|
|
|
|
parsed, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
return []byte(config.C.JWTSecret), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("parsing failed: %v", err)
|
|
}
|
|
|
|
got := parsed.Claims.(*Claims)
|
|
|
|
if got.UserID != original.UserID {
|
|
t.Errorf("UserID: got %d, want %d", got.UserID, original.UserID)
|
|
}
|
|
if got.Username != original.Username {
|
|
t.Errorf("Username: got %q, want %q", got.Username, original.Username)
|
|
}
|
|
if got.Role != original.Role {
|
|
t.Errorf("Role: got %q, want %q", got.Role, original.Role)
|
|
}
|
|
}
|