1package admin
2
3import (
4 "context"
5 "encoding/json"
6 "net"
7 "net/http"
8 "strings"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/adapters/dto"
13 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
14 "github.com/bnema/gordon/internal/boundaries/in"
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// AuthMiddleware creates middleware that validates admin API authentication.
20// Both globalLimiter and ipLimiter can be nil to disable rate limiting.
21// The trustedNets parameter is used for proper IP extraction behind reverse proxies.
22func AuthMiddleware(
23 authSvc in.AuthService,
24 globalLimiter out.RateLimiter,
25 ipLimiter out.RateLimiter,
26 trustedNets []*net.IPNet,
27 log zerowrap.Logger,
28) func(http.Handler) http.Handler {
29 return func(next http.Handler) http.Handler {
30 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 ctx := r.Context()
32
33 // Check global rate limit
34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35 w.Header().Set("Content-Type", "application/json")
36 w.Header().Set("Retry-After", "1")
37 w.WriteHeader(http.StatusTooManyRequests)
38 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
39 return
40 }
41
42 // Check per-IP rate limit
43 if ipLimiter != nil {
44 ip := middleware.GetClientIP(r, trustedNets)
45 if !ipLimiter.Allow(ctx, "ip:"+ip) {
46 w.Header().Set("Content-Type", "application/json")
47 w.Header().Set("Retry-After", "1")
48 w.WriteHeader(http.StatusTooManyRequests)
49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
50 return
51 }
52 }
53
54 // Check if auth is enabled
55 if !authSvc.IsEnabled() {
56 // Auth disabled, log warning and allow all requests
57 log.Warn().
58 Str("path", r.URL.Path).
59 Str("method", r.Method).
60 Str("remote_addr", r.RemoteAddr).
61 Msg("auth disabled - allowing unauthenticated admin API access")
62 next.ServeHTTP(w, r)
63 return
64 }
65
66 // Extract token from Authorization header
67 auth := r.Header.Get("Authorization")
68 if auth == "" {
69 sendUnauthorized(w, "missing authorization header")
70 return
71 }
72
73 // Support both "Bearer <token>" and direct token
74 token := auth
75 if strings.HasPrefix(auth, "Bearer ") {
76 token = strings.TrimPrefix(auth, "Bearer ")
77 }
78
79 // Validate token
80 claims, err := authSvc.ValidateToken(ctx, token)
81 if err != nil {
82 log.Warn().Err(err).Msg("invalid admin token")
83 sendUnauthorized(w, "invalid token")
84 return
85 }
86
87 // Check if token has admin scopes
88 hasAdminScope := false
89 for _, scope := range claims.Scopes {
90 if strings.HasPrefix(scope, domain.ScopeTypeAdmin+":") {
91 hasAdminScope = true
92 break
93 }
94 }
95
96 if !hasAdminScope {
97 log.Warn().Str("subject", claims.Subject).Msg("token missing admin scopes")
98 sendForbidden(w, "admin scope required")
99 return
100 }
101
102 // Add claims to context for downstream handlers
103 ctx = context.WithValue(ctx, domain.ContextKeyScopes, claims.Scopes)
104 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
105 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
106
107 next.ServeHTTP(w, r.WithContext(ctx))
108 })
109 }
110}
111
112// RequireScope creates middleware that checks for a specific admin scope.
113func RequireScope(resource, action string) func(http.Handler) http.Handler {
114 return func(next http.Handler) http.Handler {
115 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116 ctx := r.Context()
117
118 // Get scopes from context
119 scopes, ok := ctx.Value(domain.ContextKeyScopes).([]string)
120 if !ok {
121 sendForbidden(w, "no scopes in context")
122 return
123 }
124
125 // Check if user has required scope
126 if !domain.HasAdminAccess(scopes, resource, action) {
127 sendForbidden(w, "insufficient permissions")
128 return
129 }
130
131 next.ServeHTTP(w, r)
132 })
133 }
134}
135
136// GetSubject retrieves the authenticated subject from the context.
137func GetSubject(ctx context.Context) string {
138 subject, _ := ctx.Value(domain.ContextKeySubject).(string)
139 return subject
140}
141
142// GetScopes retrieves the token scopes from the context.
143func GetScopes(ctx context.Context) []string {
144 scopes, _ := ctx.Value(domain.ContextKeyScopes).([]string)
145 return scopes
146}
147
148// HasAccess checks if the context has access to the given resource and action.
149func HasAccess(ctx context.Context, resource, action string) bool {
150 scopes := GetScopes(ctx)
151 return domain.HasAdminAccess(scopes, resource, action)
152}
153
154func sendUnauthorized(w http.ResponseWriter, message string) {
155 w.Header().Set("Content-Type", "application/json")
156 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
157 w.WriteHeader(http.StatusUnauthorized)
158 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
159}
160
161func sendForbidden(w http.ResponseWriter, message string) {
162 w.Header().Set("Content-Type", "application/json")
163 w.WriteHeader(http.StatusForbidden)
164 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
165}