[MCP&A2A] 14. 인증과 보안
[MCP&A2A] 14. 인증과 보안
보안 아키텍처 개요
AI 에이전트 시스템은 민감한 데이터를 처리하므로 다층 보안 전략이 필수입니다. 인증(Authentication), 인가(Authorization), 데이터 보호, 감사(Audit)를 조합하여 포괄적인 보안을 구현합니다.
보안 계층
1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────┐
│ 1. 네트워크 보안 (TLS, Firewall) │
├─────────────────────────────────────┤
│ 2. 인증 (JWT, OAuth2) │
├─────────────────────────────────────┤
│ 3. 인가 (RBAC, Tenant Isolation) │
├─────────────────────────────────────┤
│ 4. 데이터 보호 (Encryption, RLS) │
├─────────────────────────────────────┤
│ 5. 감사 (Audit Log, Monitoring) │
└─────────────────────────────────────┘
JWT (JSON Web Token) 인증
JWT 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
JWT = Header.Payload.Signature
Header (Base64):
{
"alg": "RS256",
"typ": "JWT"
}
Payload (Base64):
{
"sub": "user_123",
"tenant_id": "tenant_abc",
"roles": ["admin", "user"],
"exp": 1735689600,
"iat": 1735686000
}
Signature:
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
왜 RS256인가?
| 알고리즘 | 키 타입 | 장점 | 단점 | 용도 |
|---|---|---|---|---|
| HS256 | 대칭키 | 빠름, 간단 | 서버만 검증 가능 | 단일 서비스 |
| RS256 | 비대칭키 | 공개키로 검증 | 느림, 복잡 | ✅ 마이크로서비스 |
RS256 선택 이유:
- MCP/A2A 서버는 공개키만 보유
- 인증 서버만 개인키 보유
- 키 유출 시 영향 최소화
- 서비스 간 신뢰 불필요
JWT 생성 (Auth 서버)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// internal/auth/jwt_generator.go
package auth
import (
"crypto/rsa"
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTGenerator struct {
privateKey *rsa.PrivateKey
issuer string
}
type Claims struct {
UserID string `json:"sub"`
TenantID string `json:"tenant_id"`
Email string `json:"email"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
func NewJWTGenerator(privateKeyPEM string, issuer string) (*JWTGenerator, error) {
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyPEM))
if err != nil {
return nil, err
}
return &JWTGenerator{
privateKey: privateKey,
issuer: issuer,
}, nil
}
func (g *JWTGenerator) Generate(userID, tenantID, email string, roles []string) (string, error) {
now := time.Now()
claims := &Claims{
UserID: userID,
TenantID: tenantID,
Email: email,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: g.issuer,
Subject: userID,
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(g.privateKey)
if err != nil {
return "", err
}
return signedToken, nil
}
// Refresh Token (긴 유효기간)
func (g *JWTGenerator) GenerateRefreshToken(userID, tenantID string) (string, error) {
now := time.Now()
claims := &jwt.RegisteredClaims{
Issuer: g.issuer,
Subject: userID,
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)), // 30일
IssuedAt: jwt.NewNumericDate(now),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(g.privateKey)
}
JWT 검증 (MCP/A2A 서버)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// internal/auth/jwt_validator.go
package auth
import (
"crypto/rsa"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
type JWTValidator struct {
publicKey *rsa.PublicKey
issuer string
}
func NewJWTValidator(publicKeyPEM string, issuer string) (*JWTValidator, error) {
publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKeyPEM))
if err != nil {
return nil, err
}
return &JWTValidator{
publicKey: publicKey,
issuer: issuer,
}, nil
}
func (v *JWTValidator) Validate(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(token *jwt.Token) (interface{}, error) {
// 알고리즘 확인
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return v.publicKey, nil
},
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
// Issuer 확인
if claims.Issuer != v.issuer {
return nil, fmt.Errorf("invalid issuer")
}
return claims, nil
}
Auth Middleware
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// internal/middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"mcp-server/internal/auth"
)
type AuthMiddleware struct {
validator *auth.JWTValidator
}
func NewAuthMiddleware(validator *auth.JWTValidator) *AuthMiddleware {
return &AuthMiddleware{
validator: validator,
}
}
func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Authorization 헤더 추출
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return
}
// Bearer 토큰 파싱
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
return
}
tokenString := parts[1]
// JWT 검증
claims, err := m.validator.Validate(tokenString)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid token: %v", err), http.StatusUnauthorized)
return
}
// 컨텍스트에 사용자 정보 추가
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "tenant_id", claims.TenantID)
ctx = context.WithValue(ctx, "email", claims.Email)
ctx = context.WithValue(ctx, "roles", claims.Roles)
// 다음 핸들러 호출
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Optional Auth (토큰 없어도 통과, 있으면 파싱)
func (m *AuthMiddleware) OptionalAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
claims, err := m.validator.Validate(parts[1])
if err == nil {
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
ctx = context.WithValue(ctx, "tenant_id", claims.TenantID)
r = r.WithContext(ctx)
}
}
}
next.ServeHTTP(w, r)
})
}
Role-Based Access Control (RBAC)
역할 정의
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// internal/auth/roles.go
package auth
type Role string
const (
RoleAdmin Role = "admin" // 전체 권한
RoleUser Role = "user" // 기본 사용자
RoleViewer Role = "viewer" // 읽기 전용
RoleAPIClient Role = "api_client" // API 전용 (UI 접근 불가)
)
type Permission string
const (
// 문서 권한
PermDocumentRead Permission = "document:read"
PermDocumentWrite Permission = "document:write"
PermDocumentDelete Permission = "document:delete"
// 태스크 권한
PermTaskCreate Permission = "task:create"
PermTaskRead Permission = "task:read"
PermTaskCancel Permission = "task:cancel"
// 설정 권한
PermSettingsRead Permission = "settings:read"
PermSettingsWrite Permission = "settings:write"
// 관리 권한
PermUserManage Permission = "user:manage"
PermTenantManage Permission = "tenant:manage"
)
// 역할별 권한 매핑
var RolePermissions = map[Role][]Permission{
RoleAdmin: {
PermDocumentRead, PermDocumentWrite, PermDocumentDelete,
PermTaskCreate, PermTaskRead, PermTaskCancel,
PermSettingsRead, PermSettingsWrite,
PermUserManage, PermTenantManage,
},
RoleUser: {
PermDocumentRead, PermDocumentWrite,
PermTaskCreate, PermTaskRead, PermTaskCancel,
PermSettingsRead,
},
RoleViewer: {
PermDocumentRead,
PermTaskRead,
PermSettingsRead,
},
RoleAPIClient: {
PermDocumentRead, PermDocumentWrite,
PermTaskCreate, PermTaskRead,
},
}
// HasPermission 권한 확인
func HasPermission(roles []string, permission Permission) bool {
for _, roleStr := range roles {
role := Role(roleStr)
permissions, ok := RolePermissions[role]
if !ok {
continue
}
for _, p := range permissions {
if p == permission {
return true
}
}
}
return false
}
RBAC Middleware
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// internal/middleware/rbac.go
package middleware
import (
"net/http"
"mcp-server/internal/auth"
)
type RBACMiddleware struct{}
func NewRBACMiddleware() *RBACMiddleware {
return &RBACMiddleware{}
}
func (m *RBACMiddleware) RequirePermission(permission auth.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 컨텍스트에서 역할 추출
roles, ok := r.Context().Value("roles").([]string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 권한 확인
if !auth.HasPermission(roles, permission) {
http.Error(w, "Forbidden: insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
func (m *RBACMiddleware) RequireRole(requiredRole auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roles, ok := r.Context().Value("roles").([]string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 역할 확인
hasRole := false
for _, role := range roles {
if auth.Role(role) == requiredRole {
hasRole = true
break
}
}
if !hasRole {
http.Error(w, "Forbidden: required role not found", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
라우터에 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// cmd/server/main.go
func main() {
// 미들웨어
authMiddleware := middleware.NewAuthMiddleware(jwtValidator)
rbacMiddleware := middleware.NewRBACMiddleware()
r := chi.NewRouter()
// 공개 엔드포인트
r.Get("/health", healthHandler)
r.Get("/.well-known/agent-card.json", agentCardHandler)
// 인증 필요
r.Group(func(r chi.Router) {
r.Use(authMiddleware.Authenticate)
// 기본 사용자 권한
r.Get("/mcp/documents", listDocumentsHandler)
// 쓰기 권한 필요
r.Group(func(r chi.Router) {
r.Use(rbacMiddleware.RequirePermission(auth.PermDocumentWrite))
r.Post("/mcp/documents", createDocumentHandler)
r.Put("/mcp/documents/{id}", updateDocumentHandler)
})
// 삭제 권한 필요 (관리자만)
r.Group(func(r chi.Router) {
r.Use(rbacMiddleware.RequirePermission(auth.PermDocumentDelete))
r.Delete("/mcp/documents/{id}", deleteDocumentHandler)
})
// 관리자 전용
r.Group(func(r chi.Router) {
r.Use(rbacMiddleware.RequireRole(auth.RoleAdmin))
r.Get("/admin/users", listUsersHandler)
r.Post("/admin/users", createUserHandler)
})
})
}
Rate Limiting
Token Bucket 알고리즘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// internal/middleware/rate_limit.go
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
type RateLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit // requests per second
burst int // bucket size
}
func NewRateLimiter(rps float64, burst int) *RateLimiter {
return &RateLimiter{
limiters: make(map[string]*rate.Limiter),
rate: rate.Limit(rps),
burst: burst,
}
}
func (rl *RateLimiter) getLimiter(key string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
limiter, exists := rl.limiters[key]
if !exists {
limiter = rate.NewLimiter(rl.rate, rl.burst)
rl.limiters[key] = limiter
}
return limiter
}
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 테넌트별 제한
tenantID := r.Context().Value("tenant_id")
if tenantID == nil {
tenantID = "anonymous"
}
key := tenantID.(string)
limiter := rl.getLimiter(key)
if !limiter.Allow() {
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%.0f", rl.rate))
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("Retry-After", "1")
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// Cleanup 주기적으로 사용하지 않는 limiter 제거
func (rl *RateLimiter) Cleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for key, limiter := range rl.limiters {
// 1시간 동안 사용 안 한 limiter 제거
if limiter.Tokens() == float64(rl.burst) {
delete(rl.limiters, key)
}
}
rl.mu.Unlock()
}
}
계층적 Rate Limiting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// internal/middleware/tiered_rate_limit.go
package middleware
type TieredRateLimiter struct {
tiers map[string]*RateLimiter
}
func NewTieredRateLimiter() *TieredRateLimiter {
return &TieredRateLimiter{
tiers: map[string]*RateLimiter{
"free": NewRateLimiter(10, 20), // 10 req/s, burst 20
"basic": NewRateLimiter(50, 100), // 50 req/s, burst 100
"pro": NewRateLimiter(100, 200), // 100 req/s, burst 200
"enterprise": NewRateLimiter(500, 1000), // 500 req/s, burst 1000
},
}
}
func (trl *TieredRateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 사용자의 플랜 확인
plan := r.Context().Value("plan")
if plan == nil {
plan = "free"
}
limiter, ok := trl.tiers[plan.(string)]
if !ok {
limiter = trl.tiers["free"]
}
limiter.Limit(next).ServeHTTP(w, r)
})
}
데이터 암호화
저장 데이터 암호화 (Encryption at Rest)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// internal/crypto/encryption.go
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
type Encryptor struct {
key []byte // 32 bytes for AES-256
}
func NewEncryptor(key string) (*Encryptor, error) {
// 키는 32바이트여야 함 (AES-256)
keyBytes := []byte(key)
if len(keyBytes) != 32 {
return nil, errors.New("key must be 32 bytes")
}
return &Encryptor{key: keyBytes}, nil
}
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
// GCM (Galois/Counter Mode)
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Nonce 생성
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// 암호화
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
// Base64 인코딩
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
// Base64 디코딩
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
// 복호화
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
필드 레벨 암호화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// internal/models/user.go
package models
import (
"mcp-server/internal/crypto"
)
type User struct {
ID string
Email string
Name string
// 암호화된 필드
APIKey string `json:"-"` // JSON 응답에서 제외
SecretKey string `json:"-"`
}
func (u *User) EncryptSecrets(encryptor *crypto.Encryptor) error {
if u.APIKey != "" {
encrypted, err := encryptor.Encrypt(u.APIKey)
if err != nil {
return err
}
u.APIKey = encrypted
}
if u.SecretKey != "" {
encrypted, err := encryptor.Encrypt(u.SecretKey)
if err != nil {
return err
}
u.SecretKey = encrypted
}
return nil
}
func (u *User) DecryptSecrets(encryptor *crypto.Encryptor) error {
if u.APIKey != "" {
decrypted, err := encryptor.Decrypt(u.APIKey)
if err != nil {
return err
}
u.APIKey = decrypted
}
if u.SecretKey != "" {
decrypted, err := encryptor.Decrypt(u.SecretKey)
if err != nil {
return err
}
u.SecretKey = decrypted
}
return nil
}
감사 로그 (Audit Log)
Audit Log 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// internal/audit/logger.go
package audit
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
type EventType string
const (
EventLogin EventType = "auth.login"
EventLogout EventType = "auth.logout"
EventDocumentCreate EventType = "document.create"
EventDocumentRead EventType = "document.read"
EventDocumentUpdate EventType = "document.update"
EventDocumentDelete EventType = "document.delete"
EventTaskCreate EventType = "task.create"
EventTaskCancel EventType = "task.cancel"
EventSettingsChange EventType = "settings.change"
)
type AuditEvent struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Type EventType `json:"type"`
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Resource string `json:"resource"`
Action string `json:"action"`
Result string `json:"result"` // success, failure
Metadata map[string]interface{} `json:"metadata"`
}
type AuditLogger struct {
store AuditStore
}
func NewAuditLogger(store AuditStore) *AuditLogger {
return &AuditLogger{store: store}
}
func (al *AuditLogger) Log(ctx context.Context, eventType EventType, resource, action, result string, metadata map[string]interface{}) error {
event := &AuditEvent{
ID: uuid.New().String(),
Timestamp: time.Now(),
Type: eventType,
TenantID: getStringFromContext(ctx, "tenant_id"),
UserID: getStringFromContext(ctx, "user_id"),
IP: getStringFromContext(ctx, "ip"),
UserAgent: getStringFromContext(ctx, "user_agent"),
Resource: resource,
Action: action,
Result: result,
Metadata: metadata,
}
return al.store.Save(event)
}
func getStringFromContext(ctx context.Context, key string) string {
val := ctx.Value(key)
if val == nil {
return ""
}
str, _ := val.(string)
return str
}
Audit Middleware
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// internal/middleware/audit.go
package middleware
import (
"bytes"
"io"
"net/http"
"time"
"mcp-server/internal/audit"
)
type AuditMiddleware struct {
logger *audit.AuditLogger
}
func NewAuditMiddleware(logger *audit.AuditLogger) *AuditMiddleware {
return &AuditMiddleware{logger: logger}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
body *bytes.Buffer
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
rw.body.Write(b)
return rw.ResponseWriter.Write(b)
}
func (am *AuditMiddleware) Log(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// IP 추출
ip := r.Header.Get("X-Real-IP")
if ip == "" {
ip = r.Header.Get("X-Forwarded-For")
}
if ip == "" {
ip = r.RemoteAddr
}
// 컨텍스트에 IP, User-Agent 추가
ctx := context.WithValue(r.Context(), "ip", ip)
ctx = context.WithValue(ctx, "user_agent", r.Header.Get("User-Agent"))
// Response writer 래핑
rw := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
body: &bytes.Buffer{},
}
// 다음 핸들러 실행
next.ServeHTTP(rw, r.WithContext(ctx))
// 감사 로그 기록
duration := time.Since(start)
result := "success"
if rw.statusCode >= 400 {
result = "failure"
}
am.logger.Log(ctx, mapMethodToEvent(r.Method, r.URL.Path), r.URL.Path, r.Method, result, map[string]interface{}{
"status_code": rw.statusCode,
"duration_ms": duration.Milliseconds(),
})
})
}
func mapMethodToEvent(method, path string) audit.EventType {
// 경로와 메서드로 이벤트 타입 매핑
if strings.Contains(path, "/documents") {
switch method {
case "POST":
return audit.EventDocumentCreate
case "GET":
return audit.EventDocumentRead
case "PUT", "PATCH":
return audit.EventDocumentUpdate
case "DELETE":
return audit.EventDocumentDelete
}
}
return audit.EventType(fmt.Sprintf("%s.%s", method, path))
}
CORS 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// internal/middleware/cors.go
package middleware
import (
"net/http"
"github.com/go-chi/cors"
)
func CORSMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // 5분
})
}
보안 헤더
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// internal/middleware/security_headers.go
package middleware
import (
"net/http"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HSTS
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// XSS Protection
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// CSP
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions Policy
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
next.ServeHTTP(w, r)
})
}
핵심 요약
보안 체크리스트
✅ 인증
- JWT RS256 사용
- 공개키/개인키 분리
- 토큰 만료 (1시간)
- Refresh Token (30일)
✅ 인가
- RBAC 구현
- 역할별 권한 매핑
- 권한 체크 미들웨어
- 최소 권한 원칙
✅ Rate Limiting
- Token Bucket 알고리즘
- 테넌트별 제한
- 계층적 제한 (플랜별)
- 429 응답
✅ 데이터 보호
- AES-256 GCM 암호화
- 필드 레벨 암호화
- TLS 통신
- RLS (Row-Level Security)
✅ 감사
- 모든 액션 로그
- IP, User-Agent 기록
- 성공/실패 구분
- 장기 보관
✅ 보안 헤더
- HSTS, CSP, X-Frame-Options
- CORS 설정
- Referrer Policy
작성일: 2024-12-13
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.