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

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,240 @@
package logger
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
// AuditLogEntry holds common audit logging fields.
type AuditLogEntry struct {
RequestID string
Stage string
Operation string // e.g., "SendPasswordReset", "Verify"
Method string
Path string
Status int
LatencyMs time.Duration
IP string
UserAgent string
Origin string
Referer string
Query map[string]string
Headers map[string]string // 핵심 헤더(민감 키는 마스킹됨)
LoginIDs map[string]string // loginId and loginId_normalized
Token string // For reset tokens, magic link tokens
ProviderError string
ProviderStatus int // Provider HTTP status
ProviderBody string // Provider response body (full raw)
RefreshToken string
SessionJwt string
AccessJwt string
UserLoginId string
UserID string
Email string
Phone string
SetCookieName string
SetCookieValue string
SetCookieAttrs map[string]string
RedirectTo string
HasCookieDSRF bool
ParsedCookieDSRF string
// ... potentially more fields specific to different stages
}
// NewAuditLogEntry creates a new AuditLogEntry with a generated RequestID and initial common fields.
func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry {
reqID := uuid.New().String()
// Extract query parameters
queryParams := make(map[string]string)
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
k := string(key)
queryParams[k] = maskSensitiveByKey(k, string(value))
})
// Extract relevant headers
headers := make(map[string]string)
headers["Host"] = c.Get("Host")
headers["User-Agent"] = c.Get("User-Agent")
headers["Origin"] = c.Get("Origin")
headers["Referer"] = c.Get("Referer")
return &AuditLogEntry{
RequestID: reqID,
Stage: stage,
Method: c.Method(),
Path: c.Path(),
IP: c.IP(),
UserAgent: c.Get("User-Agent"),
Origin: c.Get("Origin"),
Referer: c.Get("Referer"),
Query: queryParams,
Headers: headers,
LoginIDs: make(map[string]string),
SetCookieAttrs: make(map[string]string),
}
}
// Log emits an audit log entry using slog.
// It includes common fields and allows for additional custom fields.
func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
attrs := []slog.Attr{
slog.String("req_id", ale.RequestID),
slog.String("stage", ale.Stage),
}
if ale.Operation != "" {
attrs = append(attrs, slog.String("op", ale.Operation))
}
if ale.Method != "" {
attrs = append(attrs, slog.String("method", ale.Method))
}
if ale.Path != "" {
attrs = append(attrs, slog.String("path", ale.Path))
}
if ale.Status != 0 {
attrs = append(attrs, slog.Int("status", ale.Status))
}
if ale.LatencyMs != 0 {
attrs = append(attrs, slog.Duration("latency_ms", ale.LatencyMs))
}
if ale.IP != "" {
attrs = append(attrs, slog.String("ip", ale.IP))
}
if ale.UserAgent != "" {
attrs = append(attrs, slog.String("user_agent", ale.UserAgent))
}
if ale.Origin != "" {
attrs = append(attrs, slog.String("origin", ale.Origin))
}
if ale.Referer != "" {
attrs = append(attrs, slog.String("referer", ale.Referer))
}
if len(ale.Query) > 0 {
queryGroupArgs := make([]any, 0, len(ale.Query))
for k, v := range ale.Query {
queryGroupArgs = append(queryGroupArgs, slog.String(k, maskSensitiveByKey(k, v)))
}
attrs = append(attrs, slog.Group("query", queryGroupArgs...))
}
if len(ale.Headers) > 0 {
headersGroupArgs := make([]any, 0, len(ale.Headers))
for k, v := range ale.Headers {
headersGroupArgs = append(headersGroupArgs, slog.String(k, maskSensitiveByKey(k, v)))
}
attrs = append(attrs, slog.Group("headers", headersGroupArgs...))
}
if len(ale.LoginIDs) > 0 {
loginIDGroupArgs := make([]any, 0, len(ale.LoginIDs))
for k, v := range ale.LoginIDs {
loginIDGroupArgs = append(loginIDGroupArgs, slog.String(k, v))
}
attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...))
}
if ale.Token != "" {
attrs = append(attrs, slog.Bool("has_token", true))
}
if ale.ProviderError != "" {
attrs = append(attrs, slog.String("provider_error", ale.ProviderError))
}
if ale.ProviderStatus != 0 {
attrs = append(attrs, slog.Int("provider_http_status", ale.ProviderStatus))
}
if ale.ProviderBody != "" {
attrs = append(attrs, slog.String("provider_response_body", ale.ProviderBody))
}
if ale.RefreshToken != "" {
attrs = append(attrs, slog.Bool("has_refresh_token", true))
}
if ale.SessionJwt != "" {
attrs = append(attrs, slog.Bool("has_session_jwt", true))
}
if ale.AccessJwt != "" {
attrs = append(attrs, slog.Bool("has_access_jwt", true))
}
if ale.UserLoginId != "" {
attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId))
}
if ale.UserID != "" {
attrs = append(attrs, slog.String("user_id", ale.UserID))
}
if ale.Email != "" {
attrs = append(attrs, slog.String("email", ale.Email))
}
if ale.Phone != "" {
attrs = append(attrs, slog.String("phone", ale.Phone))
}
if ale.SetCookieName != "" {
attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName))
if ale.SetCookieValue != "" {
attrs = append(attrs, slog.Bool("has_set_cookie_value", true))
}
if len(ale.SetCookieAttrs) > 0 {
cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
for k, v := range ale.SetCookieAttrs {
cookieAttrsGroupArgs = append(cookieAttrsGroupArgs, slog.String(k, v))
}
attrs = append(attrs, slog.Group("set_cookie_attrs", cookieAttrsGroupArgs...))
}
}
if ale.RedirectTo != "" {
attrs = append(attrs, slog.String("redirect_to", ale.RedirectTo))
}
if ale.HasCookieDSRF {
attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF))
}
if ale.ParsedCookieDSRF != "" {
attrs = append(attrs, slog.Bool("has_parsed_cookie_DSRF", true))
}
// Convert variadic args to slog.Attr before appending
for i := 0; i < len(args); i += 2 {
if i+1 < len(args) {
attrs = append(attrs, slog.Any(fmt.Sprintf("%v", args[i]), args[i+1]))
} else {
// Handle odd number of arguments - log the last one with a generic key
attrs = append(attrs, slog.Any(fmt.Sprintf("extra_arg_%d", i), args[i]))
}
}
slog.Default().LogAttrs(context.Background(), level, msg, attrs...)
}
var sensitiveAuditKeys = map[string]struct{}{
"password": {},
"currentpassword": {},
"newpassword": {},
"oldpassword": {},
"token": {},
"accesstoken": {},
"refreshtoken": {},
"authorization": {},
"cookie": {},
"setcookie": {},
"verificationcode": {},
"code": {},
"loginchallenge": {},
"loginverifier": {},
"sessionjwt": {},
"accessjwt": {},
"refreshjwt": {},
}
func maskSensitiveByKey(key, value string) string {
if value == "" {
return value
}
k := strings.ToLower(key)
k = strings.ReplaceAll(k, "-", "")
k = strings.ReplaceAll(k, "_", "")
if _, ok := sensitiveAuditKeys[k]; ok {
return "*****"
}
return value
}

View File

@@ -0,0 +1,80 @@
package logger
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuditLogEntry_RedactsSensitiveFields(t *testing.T) {
buf := &bytes.Buffer{}
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil)))
defer slog.SetDefault(previous)
ale := &AuditLogEntry{
RequestID: "req-1",
Stage: "login",
Token: "tok-secret",
RefreshToken: "refresh-secret",
SessionJwt: "session-secret",
AccessJwt: "access-secret",
SetCookieName: "sid",
SetCookieValue: "cookie-secret",
ParsedCookieDSRF: "dsrf-secret",
LoginIDs: map[string]string{
"loginId": "user@example.com",
},
Query: map[string]string{
"token": "query-token",
"locale": "ko",
},
Headers: map[string]string{
"Authorization": "Bearer secret",
"Cookie": "session=secret",
},
}
ale.Log(slog.LevelInfo, "test")
line := strings.TrimSpace(buf.String())
require.NotEmpty(t, line)
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &payload))
assert.NotContains(t, payload, "token")
assert.NotContains(t, payload, "refresh_token")
assert.NotContains(t, payload, "session_jwt")
assert.NotContains(t, payload, "access_jwt")
assert.NotContains(t, payload, "set_cookie_value")
assert.NotContains(t, payload, "parsed_cookie_DSRF")
assert.NotContains(t, payload, "request_body")
assert.NotContains(t, payload, "new_password")
assert.Equal(t, true, payload["has_token"])
assert.Equal(t, true, payload["has_refresh_token"])
assert.Equal(t, true, payload["has_session_jwt"])
assert.Equal(t, true, payload["has_access_jwt"])
assert.Equal(t, true, payload["has_set_cookie_value"])
assert.Equal(t, true, payload["has_parsed_cookie_DSRF"])
loginIDs, ok := payload["login_ids"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "user@example.com", loginIDs["loginId"])
query, ok := payload["query"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "*****", query["token"])
assert.Equal(t, "ko", query["locale"])
headers, ok := payload["headers"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "*****", headers["Authorization"])
assert.Equal(t, "*****", headers["Cookie"])
}

View File

@@ -0,0 +1,147 @@
package logger
import (
"log/slog"
"regexp"
"strings"
)
var sensitiveClientLogKeys = map[string]struct{}{
"password": {},
"currentpassword": {},
"newpassword": {},
"oldpassword": {},
"token": {},
"accesstoken": {},
"refreshtoken": {},
"secret": {},
"clientsecret": {},
"authorization": {},
"cookie": {},
"setcookie": {},
"verificationcode": {},
"code": {},
"loginchallenge": {},
"loginverifier": {},
"sessionjwt": {},
"accessjwt": {},
"refreshjwt": {},
}
var (
logJSONSensitivePattern = regexp.MustCompile(`(?i)"(password|currentpassword|newpassword|oldpassword|token|accesstoken|refreshtoken|secret|clientsecret|authorization|cookie|setcookie|verificationcode|code|loginchallenge|loginverifier|sessionjwt|accessjwt|refreshjwt)"\s*:\s*"[^"]*"`)
logKVPattern = regexp.MustCompile(`(?i)\b(password|current_password|currentpassword|new_password|newpassword|old_password|oldpassword|token|access_token|accesstoken|refresh_token|refreshtoken|authorization|cookie|session_jwt|sessionjwt|access_jwt|accessjwt|refresh_jwt|refreshjwt)\b\s*[:=]\s*([^\s,;]+)`)
)
func IsProductionEnv(appEnv string) bool {
return IsProductionLikeEnv(appEnv)
}
func parseOptionalBoolFlag(raw string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y", "on":
return true, true
case "0", "false", "no", "n", "off":
return false, true
default:
return false, false
}
}
func ClientDebugEnabled(appEnv, debugFlag string) bool {
if enabled, ok := parseOptionalBoolFlag(debugFlag); ok {
return enabled
}
if !IsProductionEnv(appEnv) {
return true
}
return false
}
func NormalizeClientLogLevel(level string) slog.Level {
switch strings.ToUpper(strings.TrimSpace(level)) {
case "SEVERE", "ERROR":
return slog.LevelError
case "WARNING", "WARN":
return slog.LevelWarn
case "DEBUG", "FINE", "TRACE":
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
func ShouldAcceptClientLog(appEnv, productionDebugFlag, level string) bool {
if ClientDebugEnabled(appEnv, productionDebugFlag) {
return true
}
return NormalizeClientLogLevel(level) >= slog.LevelWarn
}
func ShouldFilterNoisyClientInfo(appEnv, productionDebugFlag, message string) bool {
if ClientDebugEnabled(appEnv, productionDebugFlag) {
return false
}
msg := strings.ToLower(message)
return strings.Contains(msg, "navigating to") ||
strings.Contains(msg, "going to") ||
strings.Contains(msg, "redirecting to") ||
strings.Contains(msg, "full paths for routes")
}
func SanitizeClientLogMessage(message string) string {
if strings.TrimSpace(message) == "" {
return message
}
sanitized := logJSONSensitivePattern.ReplaceAllStringFunc(message, func(segment string) string {
parts := strings.SplitN(segment, ":", 2)
if len(parts) != 2 {
return segment
}
return parts[0] + `:"*****"`
})
sanitized = logKVPattern.ReplaceAllString(sanitized, `$1=*****`)
return sanitized
}
func SanitizeClientLogData(data map[string]any) map[string]any {
if len(data) == 0 {
return data
}
out := make(map[string]any, len(data))
for k, v := range data {
if isSensitiveClientLogKey(k) {
out[k] = "*****"
continue
}
out[k] = sanitizeClientLogValue(v)
}
return out
}
func sanitizeClientLogValue(v any) any {
switch val := v.(type) {
case map[string]any:
return SanitizeClientLogData(val)
case []any:
next := make([]any, len(val))
for i := range val {
next[i] = sanitizeClientLogValue(val[i])
}
return next
case string:
return SanitizeClientLogMessage(val)
default:
return val
}
}
func isSensitiveClientLogKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, "_", "")
normalized = strings.ReplaceAll(normalized, ".", "")
normalized = strings.ReplaceAll(normalized, " ", "")
_, ok := sensitiveClientLogKeys[normalized]
return ok
}

View File

@@ -0,0 +1,96 @@
package logger
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestClientDebugEnabled(t *testing.T) {
t.Run("non production enables debug by default", func(t *testing.T) {
assert.True(t, ClientDebugEnabled("dev", ""))
assert.True(t, ClientDebugEnabled("local", ""))
})
t.Run("explicit debug flag applies in non production", func(t *testing.T) {
assert.True(t, ClientDebugEnabled("dev", "true"))
assert.True(t, ClientDebugEnabled("local", "1"))
assert.False(t, ClientDebugEnabled("dev", "false"))
assert.False(t, ClientDebugEnabled("local", "0"))
})
t.Run("production disables debug by default", func(t *testing.T) {
assert.False(t, ClientDebugEnabled("production", ""))
assert.False(t, ClientDebugEnabled("prod", "false"))
assert.False(t, ClientDebugEnabled("stage", ""))
assert.False(t, ClientDebugEnabled("staging", "false"))
})
t.Run("production accepts explicit debug override", func(t *testing.T) {
assert.True(t, ClientDebugEnabled("production", "true"))
assert.True(t, ClientDebugEnabled("production", "1"))
assert.True(t, ClientDebugEnabled("prod", "on"))
})
}
func TestShouldAcceptClientLog(t *testing.T) {
assert.False(t, ShouldAcceptClientLog("production", "", "INFO"))
assert.False(t, ShouldAcceptClientLog("production", "", "DEBUG"))
assert.False(t, ShouldAcceptClientLog("stage", "", "INFO"))
assert.False(t, ShouldAcceptClientLog("stage", "", "DEBUG"))
assert.True(t, ShouldAcceptClientLog("production", "", "WARN"))
assert.True(t, ShouldAcceptClientLog("production", "", "ERROR"))
assert.True(t, ShouldAcceptClientLog("stage", "", "WARN"))
assert.True(t, ShouldAcceptClientLog("stage", "", "ERROR"))
assert.True(t, ShouldAcceptClientLog("production", "true", "INFO"))
assert.True(t, ShouldAcceptClientLog("dev", "", "INFO"))
assert.False(t, ShouldAcceptClientLog("dev", "false", "INFO"))
assert.True(t, ShouldAcceptClientLog("dev", "false", "WARN"))
}
func TestShouldFilterNoisyClientInfo(t *testing.T) {
assert.True(t, ShouldFilterNoisyClientInfo("production", "", "Navigating to /ko/signin"))
assert.True(t, ShouldFilterNoisyClientInfo("stage", "", "Navigating to /ko/signin"))
assert.False(t, ShouldFilterNoisyClientInfo("production", "true", "Navigating to /ko/signin"))
assert.False(t, ShouldFilterNoisyClientInfo("dev", "", "Navigating to /ko/signin"))
assert.True(t, ShouldFilterNoisyClientInfo("dev", "false", "Navigating to /ko/signin"))
}
func TestSanitizeClientLogData(t *testing.T) {
input := map[string]any{
"token": "raw-token",
"safe": "ok",
"nested": map[string]any{
"new_password": "secret-1",
"path": "/ko/profile",
},
"arr": []any{
map[string]any{"authorization": "Bearer abc"},
"token=abc123",
},
}
result := SanitizeClientLogData(input)
assert.Equal(t, "*****", result["token"])
assert.Equal(t, "ok", result["safe"])
nested := result["nested"].(map[string]any)
assert.Equal(t, "*****", nested["new_password"])
assert.Equal(t, "/ko/profile", nested["path"])
arr := result["arr"].([]any)
first := arr[0].(map[string]any)
assert.Equal(t, "*****", first["authorization"])
assert.Equal(t, "token=*****", arr[1])
}
func TestSanitizeClientLogMessage(t *testing.T) {
msg := `FLUTTER_ERROR token=abc123 payload={"password":"hello","safe":"ok"} authorization:BearerX`
sanitized := SanitizeClientLogMessage(msg)
assert.NotContains(t, sanitized, "abc123")
assert.NotContains(t, sanitized, `"password":"hello"`)
assert.Contains(t, sanitized, `"password":"*****"`)
assert.Contains(t, sanitized, "token=*****")
assert.Contains(t, sanitized, "authorization=*****")
}

View File

@@ -0,0 +1,78 @@
package logger
import (
"io"
"log/slog"
"os"
"strings"
)
// Config holds the logger configuration
type Config struct {
ServiceName string
Environment string // APP_ENV 기준
LevelOverride string
Output io.Writer
}
func IsProductionLikeEnv(appEnv string) bool {
env := strings.ToLower(strings.TrimSpace(appEnv))
return env == "prod" || env == "production" || env == "stage" || env == "staging"
}
// Init initializes the global logger with slog.
// It detects the environment to switch between TextHandler (dev) and JSONHandler (prod).
func Init(cfg Config) {
var handler slog.Handler
output := cfg.Output
if output == nil {
output = os.Stdout
}
opts := &slog.HandlerOptions{
Level: ResolveBackendLogLevel(cfg.Environment, cfg.LevelOverride),
// Customize attributes (Time format)
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.String(a.Key, a.Value.Time().Format("2006-01-02 15:04:05"))
}
return a
},
}
// Adjust level and format based on environment
env := strings.ToLower(cfg.Environment)
if env == "dev" || env == "local" || env == "development" {
handler = slog.NewTextHandler(output, opts)
} else {
// Production defaults to JSON
handler = slog.NewJSONHandler(output, opts)
}
// Create logger with common attributes
logger := slog.New(handler).With(
slog.String("svc", cfg.ServiceName),
)
// Set as global default logger
slog.SetDefault(logger)
}
func ResolveBackendLogLevel(appEnv, override string) slog.Level {
switch strings.ToLower(strings.TrimSpace(override)) {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
}
env := strings.ToLower(strings.TrimSpace(appEnv))
if env == "dev" || env == "local" || env == "development" {
return slog.LevelDebug
}
return slog.LevelInfo
}

View File

@@ -0,0 +1,68 @@
package logger
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestResolveBackendLogLevel_DefaultsByAppEnv(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
appEnv string
wantLevel slog.Level
}{
{name: "dev uses debug", appEnv: "dev", wantLevel: slog.LevelDebug},
{name: "local uses debug", appEnv: "local", wantLevel: slog.LevelDebug},
{name: "development uses debug", appEnv: "development", wantLevel: slog.LevelDebug},
{name: "stage uses info", appEnv: "stage", wantLevel: slog.LevelInfo},
{name: "production uses info", appEnv: "production", wantLevel: slog.LevelInfo},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := ResolveBackendLogLevel(tc.appEnv, "")
if got != tc.wantLevel {
t.Fatalf("expected level %v, got %v", tc.wantLevel, got)
}
})
}
}
func TestResolveBackendLogLevel_OverrideWins(t *testing.T) {
t.Parallel()
got := ResolveBackendLogLevel("production", "debug")
if got != slog.LevelDebug {
t.Fatalf("expected debug override, got %v", got)
}
got = ResolveBackendLogLevel("dev", "warn")
if got != slog.LevelWarn {
t.Fatalf("expected warn override, got %v", got)
}
}
func TestInit_UsesResolvedBackendLogLevel(t *testing.T) {
var buf bytes.Buffer
previous := slog.Default()
defer slog.SetDefault(previous)
Init(Config{
ServiceName: "baron-sso",
Environment: "stage",
LevelOverride: "debug",
Output: &buf,
})
slog.Debug("debug message should be visible")
if !strings.Contains(buf.String(), "debug message should be visible") {
t.Fatalf("expected debug log to be written, got=%s", buf.String())
}
}