1package middleware
2
3import (
4 "context"
5 "crypto/subtle"
6 "encoding/json"
7 "net/http"
8 "strings"
9
10 "github.com/bnema/zerowrap"
11
12 "gordon/internal/adapters/dto"
13 "gordon/internal/boundaries/in"
14 "gordon/internal/domain"
15)
16
17// contextKey is a type for context keys used by this package.
18type contextKey string
19
20// TokenClaimsKey is the context key for storing token claims.
21const TokenClaimsKey contextKey = "tokenClaims"
22
23// RegistryAuth middleware provides Docker Registry authentication.
24func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
25 return func(next http.Handler) http.Handler {
26 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 // Require auth for all registry operations
28 if !isAuthenticated(r, username, password, log) {
29 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
30 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
31 w.Header().Set("Content-Type", "application/json")
32 w.WriteHeader(http.StatusUnauthorized)
33 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
34
35 log.Warn().
36 Str(zerowrap.FieldLayer, "adapter").
37 Str(zerowrap.FieldAdapter, "http").
38 Str(zerowrap.FieldMethod, r.Method).
39 Str(zerowrap.FieldPath, r.URL.Path).
40 Str(zerowrap.FieldClientIP, r.RemoteAddr).
41 Msg("unauthorized registry access attempt")
42 return
43 }
44
45 next.ServeHTTP(w, r)
46 })
47 }
48}
49
50// isAuthenticated checks basic auth credentials.
51func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool {
52 authHeader := r.Header.Get("Authorization")
53 log.Debug().
54 Str(zerowrap.FieldLayer, "adapter").
55 Str(zerowrap.FieldAdapter, "http").
56 Str(zerowrap.FieldMethod, r.Method).
57 Str(zerowrap.FieldPath, r.URL.Path).
58 Bool("has_auth_header", authHeader != "").
59 Msg("processing authentication request")
60
61 username, password, ok := r.BasicAuth()
62 if !ok {
63 log.Debug().
64 Str(zerowrap.FieldMethod, r.Method).
65 Str(zerowrap.FieldPath, r.URL.Path).
66 Msg("no basic auth provided")
67 return false
68 }
69
70 // Use constant-time comparison to prevent timing attacks
71 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
72 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
73
74 authenticated := usernameMatch && passwordMatch
75 if !authenticated {
76 log.Debug().
77 Str("provided_username", username).
78 Str(zerowrap.FieldMethod, r.Method).
79 Str(zerowrap.FieldPath, r.URL.Path).
80 Msg("authentication failed")
81 } else {
82 log.Debug().
83 Str("username", username).
84 Str(zerowrap.FieldMethod, r.Method).
85 Str(zerowrap.FieldPath, r.URL.Path).
86 Msg("authentication successful")
87 }
88
89 return authenticated
90}
91
92// InternalRegistryAuth holds the credentials used for loopback-only registry access.
93// These are generated per Gordon instance and are never exposed in config.
94type InternalRegistryAuth struct {
95 Username string
96 Password string
97}
98
99// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
100// supporting both password and token-based authentication.
101func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
102 return func(next http.Handler) http.Handler {
103 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104 // Allow localhost requests only with internal instance credentials.
105 if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
106 log.Debug().
107 Str(zerowrap.FieldLayer, "adapter").
108 Str(zerowrap.FieldAdapter, "http").
109 Str(zerowrap.FieldMethod, r.Method).
110 Str(zerowrap.FieldPath, r.URL.Path).
111 Str(zerowrap.FieldClientIP, r.RemoteAddr).
112 Msg("localhost request with internal auth - skipping auth")
113 next.ServeHTTP(w, r)
114 return
115 }
116
117 // Warn if not using TLS
118 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
119 log.Warn().
120 Str(zerowrap.FieldLayer, "adapter").
121 Str(zerowrap.FieldAdapter, "http").
122 Str(zerowrap.FieldMethod, r.Method).
123 Str(zerowrap.FieldPath, r.URL.Path).
124 Str(zerowrap.FieldClientIP, r.RemoteAddr).
125 Msg("registry auth over insecure HTTP connection")
126 }
127
128 // Check if auth is enabled
129 if !authSvc.IsEnabled() {
130 next.ServeHTTP(w, r)
131 return
132 }
133
134 ctx := r.Context()
135
136 // Authenticate based on auth type
137 var authenticated bool
138 var tokenClaims *domain.TokenClaims
139 switch authSvc.GetAuthType() {
140 case domain.AuthTypePassword:
141 authenticated = authenticatePassword(ctx, r, authSvc, log)
142 case domain.AuthTypeToken:
143 authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
144 default:
145 log.Error().
146 Str("auth_type", string(authSvc.GetAuthType())).
147 Msg("unknown auth type")
148 authenticated = false
149 }
150
151 if !authenticated {
152 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
153 return
154 }
155
156 // SECURITY: Check scopes for token auth (per-repo access control)
157 if tokenClaims != nil {
158 if !checkScopeAccess(r, tokenClaims, log) {
159 sendForbidden(w, log, r)
160 return
161 }
162 // Store claims in context for downstream handlers that need access to token metadata
163 // (e.g., audit logging, rate limiting by subject, or future per-user quotas)
164 ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)
165 r = r.WithContext(ctx)
166 }
167
168 next.ServeHTTP(w, r)
169 })
170 }
171}
172
173// authenticatePassword handles password-based authentication.
174func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
175 username, password, ok := r.BasicAuth()
176 if !ok {
177 log.Debug().
178 Str(zerowrap.FieldMethod, r.Method).
179 Str(zerowrap.FieldPath, r.URL.Path).
180 Msg("no basic auth provided")
181 return false
182 }
183
184 if authSvc.ValidatePassword(ctx, username, password) {
185 log.Debug().
186 Str("username", username).
187 Str(zerowrap.FieldMethod, r.Method).
188 Str(zerowrap.FieldPath, r.URL.Path).
189 Msg("password authentication successful")
190 return true
191 }
192
193 log.Debug().
194 Str("provided_username", username).
195 Str(zerowrap.FieldMethod, r.Method).
196 Str(zerowrap.FieldPath, r.URL.Path).
197 Msg("password authentication failed")
198 return false
199}
200
201// authenticateToken handles token-based authentication.
202// It supports both Bearer token in Authorization header and token-as-password for CI.
203// Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.
204func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (bool, *domain.TokenClaims) {
205 // First, check for Bearer token
206 authHeader := r.Header.Get("Authorization")
207 if strings.HasPrefix(authHeader, "Bearer ") {
208 token := strings.TrimPrefix(authHeader, "Bearer ")
209 claims, err := authSvc.ValidateToken(ctx, token)
210 if err != nil {
211 log.Debug().
212 Err(err).
213 Str(zerowrap.FieldMethod, r.Method).
214 Str(zerowrap.FieldPath, r.URL.Path).
215 Msg("bearer token validation failed")
216 return false, nil
217 }
218
219 log.Debug().
220 Str("subject", claims.Subject).
221 Str(zerowrap.FieldMethod, r.Method).
222 Str(zerowrap.FieldPath, r.URL.Path).
223 Msg("bearer token authentication successful")
224 return true, claims
225 }
226
227 // Fall back to token-as-password (for CI/automation)
228 // Username is the subject, password is the JWT token
229 username, password, ok := r.BasicAuth()
230 if !ok {
231 log.Debug().
232 Str(zerowrap.FieldMethod, r.Method).
233 Str(zerowrap.FieldPath, r.URL.Path).
234 Msg("no auth credentials provided")
235 return false, nil
236 }
237
238 // Try to validate the password as a JWT token
239 claims, err := authSvc.ValidateToken(ctx, password)
240 if err != nil {
241 log.Debug().
242 Err(err).
243 Str("provided_username", username).
244 Str(zerowrap.FieldMethod, r.Method).
245 Str(zerowrap.FieldPath, r.URL.Path).
246 Msg("token-as-password validation failed")
247 return false, nil
248 }
249
250 // Verify the username matches the token subject
251 if claims.Subject != username {
252 log.Debug().
253 Str("provided_username", username).
254 Str("token_subject", claims.Subject).
255 Str(zerowrap.FieldMethod, r.Method).
256 Str(zerowrap.FieldPath, r.URL.Path).
257 Msg("username does not match token subject")
258 return false, nil
259 }
260
261 log.Debug().
262 Str("subject", claims.Subject).
263 Str(zerowrap.FieldMethod, r.Method).
264 Str(zerowrap.FieldPath, r.URL.Path).
265 Msg("token-as-password authentication successful")
266 return true, claims
267}
268
269// isLocalhostRequest checks if the request originates from localhost.
270// This is used to allow Gordon to pull from its own registry with internal auth.
271func isLocalhostRequest(r *http.Request) bool {
272 host := r.RemoteAddr
273 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
274 if strings.HasPrefix(host, "127.") ||
275 strings.HasPrefix(host, "[::1]") ||
276 strings.HasPrefix(host, "::1") ||
277 strings.HasPrefix(host, "localhost") {
278 return true
279 }
280 return false
281}
282
283func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
284 if internalAuth.Username == "" || internalAuth.Password == "" {
285 return false
286 }
287
288 username, password, ok := r.BasicAuth()
289 if !ok {
290 return false
291 }
292
293 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
294 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
295 return usernameMatch && passwordMatch
296}
297
298// checkScopeAccess verifies the token has permission for the requested operation.
299// Maps HTTP method to registry action and checks if any token scope grants access.
300func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
301 // Determine required action from HTTP method
302 var action string
303 switch r.Method {
304 case http.MethodGet, http.MethodHead:
305 action = domain.ScopeActionPull
306 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
307 action = domain.ScopeActionPush
308 default:
309 action = domain.ScopeActionPull // Default to pull for unknown methods
310 }
311
312 // Extract repository name from URL path
313 // Paths are like /v2/{name}/manifests/{reference} or /v2/{name}/blobs/{digest}
314 path := r.URL.Path
315 if !strings.HasPrefix(path, "/v2/") {
316 // Not a registry path, allow
317 return true
318 }
319
320 // Remove /v2/ prefix and find repository name
321 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
322 if len(pathParts) < 2 {
323 // Malformed path or /v2/ root, allow
324 return true
325 }
326
327 // Find the boundary between repo name and route (manifests, blobs, tags)
328 var repoNameParts []string
329 for i, part := range pathParts {
330 if part == "manifests" || part == "blobs" || part == "tags" || part == "_catalog" {
331 repoNameParts = pathParts[:i]
332 break
333 }
334 }
335 if len(repoNameParts) == 0 {
336 // Could be a special route like /v2/token, allow
337 return true
338 }
339
340 repoName := strings.Join(repoNameParts, "/")
341
342 // Check if any token scope grants access
343 for _, scopeStr := range claims.Scopes {
344 scope, err := domain.ParseScope(scopeStr)
345 if err != nil {
346 log.Debug().Err(err).Str("scope", scopeStr).Msg("failed to parse scope")
347 continue
348 }
349
350 if scope.CanAccess(repoName, action) {
351 log.Debug().
352 Str("repo", repoName).
353 Str("action", action).
354 Str("scope", scopeStr).
355 Msg("scope access granted")
356 return true
357 }
358 }
359
360 log.Debug().
361 Str("repo", repoName).
362 Str("action", action).
363 Strs("scopes", claims.Scopes).
364 Msg("no scope grants access")
365 return false
366}
367
368// sendForbidden sends an HTTP 403 response.
369func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {
370 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
371 w.Header().Set("Content-Type", "application/json")
372 w.WriteHeader(http.StatusForbidden)
373 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
374
375 log.Warn().
376 Str(zerowrap.FieldLayer, "adapter").
377 Str(zerowrap.FieldAdapter, "http").
378 Str(zerowrap.FieldMethod, r.Method).
379 Str(zerowrap.FieldPath, r.URL.Path).
380 Str(zerowrap.FieldClientIP, r.RemoteAddr).
381 Msg("forbidden: insufficient scope for operation")
382}
383
384// sendUnauthorized sends an HTTP 401 response with appropriate headers.
385func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
386 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
387
388 switch authType {
389 case domain.AuthTypeToken:
390 // For token auth, indicate the token server endpoint
391 // Use X-Forwarded-Host if behind proxy, otherwise use Host header
392 realmHost := r.Header.Get("X-Forwarded-Host")
393 if realmHost == "" {
394 realmHost = host
395 }
396 // Detect scheme: prioritize X-Forwarded-Proto (set by reverse proxies like Cloudflare)
397 scheme := "http"
398 if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
399 scheme = proto
400 } else if r.TLS != nil {
401 scheme = "https"
402 }
403 realm := scheme + "://" + realmHost + "/v2/token"
404 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
405 default:
406 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
407 }
408
409 w.Header().Set("Content-Type", "application/json")
410 w.WriteHeader(http.StatusUnauthorized)
411 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
412
413 log.Warn().
414 Str(zerowrap.FieldLayer, "adapter").
415 Str(zerowrap.FieldAdapter, "http").
416 Str(zerowrap.FieldMethod, r.Method).
417 Str(zerowrap.FieldPath, r.URL.Path).
418 Str(zerowrap.FieldClientIP, r.RemoteAddr).
419 Msg("unauthorized registry access attempt")
420}