첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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