첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
17
baron-sso/backend/internal/utils/audit.go
Normal file
17
baron-sso/backend/internal/utils/audit.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func ParseAuditDetails(details string) (map[string]any, error) {
|
||||
var payload map[string]any
|
||||
if details == "" {
|
||||
return nil, fmt.Errorf("empty details")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
27
baron-sso/backend/internal/utils/audit_test.go
Normal file
27
baron-sso/backend/internal/utils/audit_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAuditDetails(t *testing.T) {
|
||||
t.Run("empty details returns error", func(t *testing.T) {
|
||||
if _, err := ParseAuditDetails(""); err == nil {
|
||||
t.Fatalf("expected empty details error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||
if _, err := ParseAuditDetails("{invalid"); err == nil {
|
||||
t.Fatalf("expected invalid JSON error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid JSON returns payload", func(t *testing.T) {
|
||||
payload, err := ParseAuditDetails(`{"actor":"admin","count":2}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload["actor"] != "admin" || payload["count"] != float64(2) {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
87
baron-sso/backend/internal/utils/client_ip.go
Normal file
87
baron-sso/backend/internal/utils/client_ip.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveClientIP selects the best client IP from proxy headers and the remote address.
|
||||
// It prefers a public IP from X-Forwarded-For, then X-Real-IP, and finally the remote IP.
|
||||
func ResolveClientIP(forwardedFor, realIP, remoteIP string) string {
|
||||
forwardedCandidates := splitClientIPs(forwardedFor)
|
||||
if ip := firstPublicIP(forwardedCandidates); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := normalizeIP(realIP); ip != "" && !IsPrivateOrReservedIP(ip) {
|
||||
return ip
|
||||
}
|
||||
if ip := normalizeIP(remoteIP); ip != "" && !IsPrivateOrReservedIP(ip) {
|
||||
return ip
|
||||
}
|
||||
if len(forwardedCandidates) > 0 {
|
||||
return forwardedCandidates[0]
|
||||
}
|
||||
if ip := normalizeIP(realIP); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return normalizeIP(remoteIP)
|
||||
}
|
||||
|
||||
// IsPrivateOrReservedIP reports whether the IP is private or from a non-public network range.
|
||||
func IsPrivateOrReservedIP(raw string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
for _, cidr := range []string{
|
||||
"100.64.0.0/10",
|
||||
"fc00::/7",
|
||||
} {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err == nil && network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitClientIPs(forwardedFor string) []string {
|
||||
if strings.TrimSpace(forwardedFor) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(forwardedFor, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if ip := normalizeIP(part); ip != "" {
|
||||
result = append(result, ip)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstPublicIP(candidates []string) string {
|
||||
for _, candidate := range candidates {
|
||||
if !IsPrivateOrReservedIP(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeIP(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = host
|
||||
}
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
69
baron-sso/backend/internal/utils/client_ip_test.go
Normal file
69
baron-sso/backend/internal/utils/client_ip_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveClientIP_PrefersPublicForwardedIP(t *testing.T) {
|
||||
got := ResolveClientIP("100.100.100.1, 203.0.113.25, 10.0.0.2", "", "172.18.0.5")
|
||||
if got != "203.0.113.25" {
|
||||
t.Fatalf("expected public forwarded IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_FallsBackToFirstForwardedWhenAllPrivate(t *testing.T) {
|
||||
got := ResolveClientIP("100.100.100.1, 10.0.0.2", "192.168.0.10", "172.18.0.5")
|
||||
if got != "100.100.100.1" {
|
||||
t.Fatalf("expected first forwarded private IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_PrefersPublicRealIPOverPrivateForwarded(t *testing.T) {
|
||||
got := ResolveClientIP("100.100.100.1, 10.0.0.2", "198.51.100.7", "172.18.0.5")
|
||||
if got != "198.51.100.7" {
|
||||
t.Fatalf("expected public real IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_PrefersPublicRemoteIPWhenHeadersArePrivate(t *testing.T) {
|
||||
got := ResolveClientIP("10.0.0.2", "192.168.0.10", "203.0.113.8:12345")
|
||||
if got != "203.0.113.8" {
|
||||
t.Fatalf("expected public remote IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_FallsBackToRealIPWhenNoForwardedCandidates(t *testing.T) {
|
||||
got := ResolveClientIP("invalid", "192.168.0.10", "bad-remote")
|
||||
if got != "192.168.0.10" {
|
||||
t.Fatalf("expected normalized real IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_ReturnsEmptyForInvalidInputs(t *testing.T) {
|
||||
got := ResolveClientIP("", "bad-real", "bad-remote")
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty IP, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateOrReservedIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{name: "invalid", ip: "not-an-ip", expected: false},
|
||||
{name: "public", ip: "203.0.113.8", expected: false},
|
||||
{name: "private ipv4", ip: "10.0.0.1", expected: true},
|
||||
{name: "loopback", ip: "127.0.0.1", expected: true},
|
||||
{name: "link local", ip: "169.254.1.1", expected: true},
|
||||
{name: "carrier grade nat", ip: "100.64.0.1", expected: true},
|
||||
{name: "unique local ipv6", ip: "fc00::1", expected: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsPrivateOrReservedIP(tc.ip); got != tc.expected {
|
||||
t.Fatalf("unexpected private state for %s: got=%v expected=%v", tc.ip, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
80
baron-sso/backend/internal/utils/masking.go
Normal file
80
baron-sso/backend/internal/utils/masking.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var sensitiveKeys = map[string]struct{}{
|
||||
"password": {},
|
||||
"currentpassword": {},
|
||||
"newpassword": {},
|
||||
"oldpassword": {},
|
||||
"token": {},
|
||||
"accesstoken": {},
|
||||
"access_token": {},
|
||||
"refreshtoken": {},
|
||||
"refresh_token": {},
|
||||
"secret": {},
|
||||
"clientsecret": {},
|
||||
"client_secret": {},
|
||||
"authorization": {},
|
||||
"cookie": {},
|
||||
"set-cookie": {},
|
||||
"verificationcode": {},
|
||||
"verification_code": {},
|
||||
"code": {}, // Auth code (sensitive)
|
||||
}
|
||||
|
||||
// MaskSensitiveJSON parses a JSON byte slice and masks values of sensitive keys.
|
||||
// Returns the original data if it's not valid JSON.
|
||||
func MaskSensitiveJSON(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
var obj any
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
// Not a JSON object/array, return as is
|
||||
return data
|
||||
}
|
||||
|
||||
masked := maskValue(obj)
|
||||
|
||||
result, err := json.Marshal(masked)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func maskValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
newMap := make(map[string]any, len(val))
|
||||
for k, v := range val {
|
||||
if isSensitive(k) {
|
||||
newMap[k] = "*****"
|
||||
} else {
|
||||
newMap[k] = maskValue(v)
|
||||
}
|
||||
}
|
||||
return newMap
|
||||
case []any:
|
||||
newArr := make([]any, len(val))
|
||||
for i, v := range val {
|
||||
newArr[i] = maskValue(v)
|
||||
}
|
||||
return newArr
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitive(key string) bool {
|
||||
// Check case-insensitive
|
||||
// Remove common separators for looser matching? No, stick to lowercase check for now.
|
||||
k := strings.ToLower(key)
|
||||
_, ok := sensitiveKeys[k]
|
||||
return ok
|
||||
}
|
||||
59
baron-sso/backend/internal/utils/masking_test.go
Normal file
59
baron-sso/backend/internal/utils/masking_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMaskSensitiveJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string // We'll check containment or specific structure
|
||||
}{
|
||||
{
|
||||
name: "Flat object with password",
|
||||
input: `{"username": "user", "password": "secret123"}`,
|
||||
expected: `{"password":"*****","username":"user"}`,
|
||||
},
|
||||
{
|
||||
name: "Nested object with token",
|
||||
input: `{"data": {"token": "abc-def", "id": 123}}`,
|
||||
expected: `{"data":{"id":123,"token":"*****"}}`,
|
||||
},
|
||||
{
|
||||
name: "Case insensitive key",
|
||||
input: `{"NewPassword": "changed"}`,
|
||||
expected: `{"NewPassword":"*****"}`,
|
||||
},
|
||||
{
|
||||
name: "Array of objects",
|
||||
input: `[{"secret": "s1"}, {"secret": "s2"}]`,
|
||||
expected: `[{"secret":"*****"},{"secret":"*****"}]`,
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
input: `not-json`,
|
||||
expected: `not-json`,
|
||||
},
|
||||
{
|
||||
name: "Empty JSON",
|
||||
input: ``,
|
||||
expected: ``,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := MaskSensitiveJSON([]byte(tt.input))
|
||||
// Since JSON map order is undefined, exact string match might fail if keys are reordered.
|
||||
// Ideally we should unmarshal and compare maps, or use assert.JSONEq
|
||||
if tt.name == "Invalid JSON" || tt.name == "Empty JSON" {
|
||||
assert.Equal(t, tt.expected, string(result))
|
||||
} else {
|
||||
assert.JSONEq(t, tt.expected, string(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
200
baron-sso/backend/internal/utils/password_policy.go
Normal file
200
baron-sso/backend/internal/utils/password_policy.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
lowercaseChars = "abcdefghijklmnopqrstuvwxyz"
|
||||
uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
numberChars = "0123456789"
|
||||
symbolChars = "!@#$%^&*()-_=+[]{}<>?/,.~"
|
||||
)
|
||||
|
||||
// ValidatePasswordWithPolicy validates a password against the given policy.
|
||||
func ValidatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
if len(password) < policy.MinLength {
|
||||
return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
|
||||
}
|
||||
|
||||
hasLower := false
|
||||
hasUpper := false
|
||||
hasNumber := false
|
||||
hasSymbol := false
|
||||
types := 0
|
||||
|
||||
for _, ch := range password {
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
hasLower = true
|
||||
case ch >= 'A' && ch <= 'Z':
|
||||
hasUpper = true
|
||||
case ch >= '0' && ch <= '9':
|
||||
hasNumber = true
|
||||
default:
|
||||
hasSymbol = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasLower {
|
||||
types++
|
||||
}
|
||||
if hasUpper {
|
||||
types++
|
||||
}
|
||||
if hasNumber {
|
||||
types++
|
||||
}
|
||||
if hasSymbol {
|
||||
types++
|
||||
}
|
||||
|
||||
if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
|
||||
return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePasswordWithPolicy creates a random password that satisfies the policy.
|
||||
func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
|
||||
if policy == nil {
|
||||
policy = &domain.PasswordPolicy{}
|
||||
}
|
||||
|
||||
categories := []struct {
|
||||
name string
|
||||
required bool
|
||||
chars string
|
||||
}{
|
||||
{name: "lower", required: policy.Lowercase, chars: lowercaseChars},
|
||||
{name: "upper", required: policy.Uppercase, chars: uppercaseChars},
|
||||
{name: "number", required: policy.Number, chars: numberChars},
|
||||
{name: "symbol", required: policy.NonAlphanumeric, chars: symbolChars},
|
||||
}
|
||||
|
||||
selected := make([]string, 0, len(categories))
|
||||
required := make([]string, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
if cat.chars == "" {
|
||||
continue
|
||||
}
|
||||
if cat.required {
|
||||
required = append(required, cat.chars)
|
||||
}
|
||||
selected = append(selected, cat.chars)
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
selected = []string{lowercaseChars, uppercaseChars, numberChars, symbolChars}
|
||||
}
|
||||
|
||||
additionalTypes := policy.MinCharacterTypes - len(required)
|
||||
if additionalTypes > 0 {
|
||||
pool := make([]string, 0, len(selected))
|
||||
for _, cat := range selected {
|
||||
isRequired := slices.Contains(required, cat)
|
||||
if !isRequired {
|
||||
pool = append(pool, cat)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < additionalTypes && len(pool) > 0; i++ {
|
||||
idx, err := randomIndex(len(pool))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
required = append(required, pool[idx])
|
||||
pool = append(pool[:idx], pool[idx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
minLength := policy.MinLength
|
||||
if minLength <= 0 {
|
||||
minLength = 12
|
||||
}
|
||||
if minLength < len(required) {
|
||||
minLength = len(required)
|
||||
}
|
||||
|
||||
passwordRunes := make([]rune, 0, minLength)
|
||||
for _, charset := range required {
|
||||
ch, err := randomChar(charset)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
passwordRunes = append(passwordRunes, ch)
|
||||
}
|
||||
|
||||
var combined strings.Builder
|
||||
for _, charset := range selected {
|
||||
combined.WriteString(charset)
|
||||
}
|
||||
for len(passwordRunes) < minLength {
|
||||
ch, err := randomChar(combined.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
passwordRunes = append(passwordRunes, ch)
|
||||
}
|
||||
|
||||
if err := shuffleRunes(passwordRunes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(passwordRunes), nil
|
||||
}
|
||||
|
||||
func randomIndex(max int) (int, error) {
|
||||
if max <= 0 {
|
||||
return 0, fmt.Errorf("invalid max")
|
||||
}
|
||||
b := make([]byte, 1)
|
||||
for {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int(b[0]) < max*(256/max) {
|
||||
return int(b[0]) % max, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomChar(chars string) (rune, error) {
|
||||
idx, err := randomIndex(len(chars))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rune(chars[idx]), nil
|
||||
}
|
||||
|
||||
func shuffleRunes(values []rune) error {
|
||||
for i := len(values) - 1; i > 0; i-- {
|
||||
j, err := randomIndex(i + 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values[i], values[j] = values[j], values[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
128
baron-sso/backend/internal/utils/password_policy_test.go
Normal file
128
baron-sso/backend/internal/utils/password_policy_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidatePasswordWithPolicy(t *testing.T) {
|
||||
policy := &domain.PasswordPolicy{
|
||||
MinLength: 8,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
MinCharacterTypes: 3,
|
||||
}
|
||||
|
||||
t.Run("Valid Password", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(policy, "Pass1234!")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Nil Policy", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(nil, "")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Too Short", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(policy, "P123!")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "최소 8자")
|
||||
})
|
||||
|
||||
t.Run("Missing Lowercase", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(policy, "PASS1234!")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "소문자")
|
||||
})
|
||||
|
||||
t.Run("Missing Uppercase", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(policy, "pass1234!")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "대문자")
|
||||
})
|
||||
|
||||
t.Run("Missing Number", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(policy, "Password!")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "숫자")
|
||||
})
|
||||
|
||||
t.Run("Missing Symbol", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(policy, "Pass1234")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "특수문자")
|
||||
})
|
||||
|
||||
t.Run("Missing Minimum Character Types", func(t *testing.T) {
|
||||
err := ValidatePasswordWithPolicy(&domain.PasswordPolicy{MinLength: 4, MinCharacterTypes: 4}, "abcd")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "4가지")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGeneratePasswordWithPolicy(t *testing.T) {
|
||||
policy := &domain.PasswordPolicy{
|
||||
MinLength: 16,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
}
|
||||
|
||||
t.Run("Generate and Validate", func(t *testing.T) {
|
||||
password, err := GeneratePasswordWithPolicy(policy)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, password, 16)
|
||||
|
||||
err = ValidatePasswordWithPolicy(policy, password)
|
||||
assert.NoError(t, err, "Generated password '%s' does not satisfy policy", password)
|
||||
})
|
||||
|
||||
t.Run("Nil Policy Uses Default Length", func(t *testing.T) {
|
||||
password, err := GeneratePasswordWithPolicy(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, password, 12)
|
||||
})
|
||||
|
||||
t.Run("Minimum Character Types Adds Optional Categories", func(t *testing.T) {
|
||||
policy := &domain.PasswordPolicy{
|
||||
MinLength: 4,
|
||||
Lowercase: true,
|
||||
MinCharacterTypes: 4,
|
||||
}
|
||||
|
||||
password, err := GeneratePasswordWithPolicy(policy)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, password, 4)
|
||||
assert.NoError(t, ValidatePasswordWithPolicy(policy, password))
|
||||
})
|
||||
|
||||
t.Run("Required Categories Raise Short Minimum Length", func(t *testing.T) {
|
||||
policy := &domain.PasswordPolicy{
|
||||
MinLength: 1,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
}
|
||||
|
||||
password, err := GeneratePasswordWithPolicy(policy)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, password, 4)
|
||||
assert.NoError(t, ValidatePasswordWithPolicy(policy, password))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordPolicyRandomHelpersRejectInvalidInput(t *testing.T) {
|
||||
_, err := randomIndex(0)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = randomChar("")
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.NoError(t, shuffleRunes([]rune("a")))
|
||||
}
|
||||
121
baron-sso/backend/internal/utils/slug.go
Normal file
121
baron-sso/backend/internal/utils/slug.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
reservedSlugs = map[string]bool{
|
||||
"admin": true,
|
||||
"api": true,
|
||||
"auth": true,
|
||||
"system": true,
|
||||
"root": true,
|
||||
"super": true,
|
||||
"public": true,
|
||||
"internal": true,
|
||||
"baron": true,
|
||||
"sso": true,
|
||||
"login": true,
|
||||
"logout": true,
|
||||
"signup": true,
|
||||
"register": true,
|
||||
"tenant": true,
|
||||
"user": true,
|
||||
"dev": true,
|
||||
"stage": true,
|
||||
"prod": true,
|
||||
"test": true,
|
||||
"static": true,
|
||||
"assets": true,
|
||||
"image": true,
|
||||
"img": true,
|
||||
"mail": true,
|
||||
"smtp": true,
|
||||
"pop": true,
|
||||
"imap": true,
|
||||
"ns": true,
|
||||
"mx": true,
|
||||
"webmaster": true,
|
||||
"security": true,
|
||||
"support": true,
|
||||
"help": true,
|
||||
"billing": true,
|
||||
"account": true,
|
||||
"config": true,
|
||||
"status": true,
|
||||
"health": true,
|
||||
"metrics": true,
|
||||
"grafana": true,
|
||||
"prometheus": true,
|
||||
}
|
||||
)
|
||||
|
||||
// ValidateSlug checks if a slug meets requirements and is not reserved.
|
||||
func ValidateSlug(slug string) (bool, string) {
|
||||
s := strings.ToLower(strings.TrimSpace(slug))
|
||||
|
||||
if len(s) < 3 || len(s) > 32 {
|
||||
return false, "slug must be between 3 and 32 characters"
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(s) {
|
||||
return false, "slug can only contain lowercase letters, numbers, and hyphens"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") {
|
||||
return false, "slug cannot start or end with a hyphen"
|
||||
}
|
||||
|
||||
if reservedSlugs[s] {
|
||||
return false, "slug is a reserved keyword"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// GenerateSlug generates a base slug from a given string.
|
||||
// It removes special characters, replaces spaces with hyphens, and converts to lowercase.
|
||||
func GenerateSlug(name string) string {
|
||||
// Convert to lowercase
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
|
||||
// Replace non-alphanumeric characters (including spaces) with a hyphen
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
s = re.ReplaceAllString(s, "-")
|
||||
|
||||
// Remove leading and trailing hyphens
|
||||
s = strings.Trim(s, "-")
|
||||
|
||||
// Handle empty slug
|
||||
if s == "" {
|
||||
s = "tenant"
|
||||
}
|
||||
|
||||
// Truncate to maximum length of 32 (reserving space for suffixes)
|
||||
if len(s) > 25 {
|
||||
s = s[:25]
|
||||
s = strings.TrimSuffix(s, "-")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// GenerateUniqueSlug generates a unique slug by appending a suffix if the base slug exists.
|
||||
// It takes the base name and a checker function that returns true if the slug already exists.
|
||||
func GenerateUniqueSlug(name string, exists func(string) bool) string {
|
||||
baseSlug := GenerateSlug(name)
|
||||
|
||||
slug := baseSlug
|
||||
counter := 1
|
||||
|
||||
for reservedSlugs[slug] || exists(slug) {
|
||||
slug = fmt.Sprintf("%s-%d", baseSlug, counter)
|
||||
counter++
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
115
baron-sso/backend/internal/utils/slug_test.go
Normal file
115
baron-sso/backend/internal/utils/slug_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateSlug_ReservedKeywords(t *testing.T) {
|
||||
tests := []struct {
|
||||
slug string
|
||||
valid bool
|
||||
}{
|
||||
{"my-tenant", true},
|
||||
{"admin", false},
|
||||
{"api", false},
|
||||
{"static", false},
|
||||
{"security", false},
|
||||
{"billing", false},
|
||||
{"ns", false},
|
||||
{"mx", false},
|
||||
{"webmaster", false},
|
||||
{"status", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.slug, func(t *testing.T) {
|
||||
valid, msg := ValidateSlug(tt.slug)
|
||||
assert.Equal(t, tt.valid, valid, "Slug: "+tt.slug+" - "+msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSlug_Format(t *testing.T) {
|
||||
tests := []struct {
|
||||
slug string
|
||||
valid bool
|
||||
}{
|
||||
{"abc", true},
|
||||
{"a-b-c", true},
|
||||
{"123", true},
|
||||
{"ab", false}, // Too short
|
||||
{"-abc", false}, // Starts with hyphen
|
||||
{"abc-", false}, // Ends with hyphen
|
||||
{"Abc", true}, // Case insensitive check (converted to lower)
|
||||
{"invalid_slug", false}, // Contains underscore
|
||||
{"too-long-slug-name-that-exceeds-thirty-two-chars", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.slug, func(t *testing.T) {
|
||||
valid, _ := ValidateSlug(tt.slug)
|
||||
assert.Equal(t, tt.valid, valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"Hello World", "hello-world"},
|
||||
{"My Company!@#", "my-company"},
|
||||
{"---Test---", "test"},
|
||||
{" Spaces ", "spaces"},
|
||||
{"A VERY LONG NAME THAT EXCEEDS THIRTY TWO CHARACTERS", "a-very-long-name-that-exc"},
|
||||
{"한글 테스트", "tenant"}, // Non-ascii characters will be replaced by hyphens and trimmed to empty, then fallback to "tenant"
|
||||
{"Test 한글 Mix", "test-mix"},
|
||||
{"", "tenant"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
slug := GenerateSlug(tt.name)
|
||||
assert.Equal(t, tt.expected, slug)
|
||||
// Ensure generated slug is valid (unless it's reserved like "slug" wasn't reserved, but let's check format)
|
||||
if !reservedSlugs[slug] {
|
||||
valid, _ := ValidateSlug(slug)
|
||||
assert.True(t, valid, "Generated slug should be valid format")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUniqueSlug(t *testing.T) {
|
||||
existingSlugs := map[string]bool{
|
||||
"my-company": true,
|
||||
"my-company-1": true,
|
||||
"test": true,
|
||||
}
|
||||
|
||||
existsFunc := func(slug string) bool {
|
||||
return existingSlugs[slug]
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"My Company", "my-company-2"},
|
||||
{"Test", "test-1"},
|
||||
{"New Company", "new-company"},
|
||||
{"admin", "admin-1"}, // "admin" is reserved, so it should append suffix
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
slug := GenerateUniqueSlug(tt.name, existsFunc)
|
||||
assert.Equal(t, tt.expected, slug)
|
||||
valid, _ := ValidateSlug(slug)
|
||||
assert.True(t, valid, "Generated unique slug should be valid")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user