첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
240
baron-sso/backend/internal/logger/audit_logger.go
Normal file
240
baron-sso/backend/internal/logger/audit_logger.go
Normal 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
|
||||
}
|
||||
80
baron-sso/backend/internal/logger/audit_logger_test.go
Normal file
80
baron-sso/backend/internal/logger/audit_logger_test.go
Normal 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"])
|
||||
}
|
||||
147
baron-sso/backend/internal/logger/client_log_policy.go
Normal file
147
baron-sso/backend/internal/logger/client_log_policy.go
Normal 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
|
||||
}
|
||||
96
baron-sso/backend/internal/logger/client_log_policy_test.go
Normal file
96
baron-sso/backend/internal/logger/client_log_policy_test.go
Normal 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=*****")
|
||||
}
|
||||
78
baron-sso/backend/internal/logger/logger.go
Normal file
78
baron-sso/backend/internal/logger/logger.go
Normal 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
|
||||
}
|
||||
68
baron-sso/backend/internal/logger/logger_test.go
Normal file
68
baron-sso/backend/internal/logger/logger_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user