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