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", redactUsername(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", redactUsername(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// redactUsername partially redacts a username for logging.
93// Shows first and last character with asterisks in between.
94// SECURITY: Prevents full username exposure in logs.
95func redactUsername(username string) string {
96 if len(username) <= 2 {
97 return "***"
98 }
99 return username[:1] + "***" + username[len(username)-1:]
100}
101
102// InternalRegistryAuth holds the credentials used for loopback-only registry access.
103// These are generated per Gordon instance and are never exposed in config.
104type InternalRegistryAuth struct {
105 Username string
106 Password string
107}
108
109// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
110// supporting both password and token-based authentication.
111func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
112 return func(next http.Handler) http.Handler {
113 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114 // Allow localhost requests only with internal instance credentials.
115 if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
116 log.Debug().
117 Str(zerowrap.FieldLayer, "adapter").
118 Str(zerowrap.FieldAdapter, "http").
119 Str(zerowrap.FieldMethod, r.Method).
120 Str(zerowrap.FieldPath, r.URL.Path).
121 Str(zerowrap.FieldClientIP, r.RemoteAddr).
122 Msg("localhost request with internal auth - skipping auth")
123 next.ServeHTTP(w, r)
124 return
125 }
126
127 // Warn if not using TLS
128 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
129 log.Warn().
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("registry auth over insecure HTTP connection")
136 }
137
138 // Check if auth is enabled
139 if !authSvc.IsEnabled() {
140 next.ServeHTTP(w, r)
141 return
142 }
143
144 ctx := r.Context()
145
146 // Authenticate based on auth type
147 // When auth type is "password", accept BOTH password and token auth
148 // (allows CI/CD tokens while still supporting interactive password login)
149 var authenticated bool
150 var tokenClaims *domain.TokenClaims
151 switch authSvc.GetAuthType() {
152 case domain.AuthTypePassword:
153 // Try password auth first
154 authenticated = authenticatePassword(ctx, r, authSvc, log)
155 if !authenticated {
156 // Fall back to token auth (for CI/CD tokens)
157 authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
158 }
159 case domain.AuthTypeToken:
160 authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
161 default:
162 log.Error().
163 Str("auth_type", string(authSvc.GetAuthType())).
164 Msg("unknown auth type")
165 authenticated = false
166 }
167
168 if !authenticated {
169 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
170 return
171 }
172
173 // SECURITY: Check scopes for token auth (per-repo access control)
174 if tokenClaims != nil {
175 if !checkScopeAccess(r, tokenClaims, log) {
176 sendForbidden(w, log, r)
177 return
178 }
179 // Store claims in context for downstream handlers that need access to token metadata
180 // (e.g., audit logging, rate limiting by subject, or future per-user quotas)
181 ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)
182 r = r.WithContext(ctx)
183 }
184
185 next.ServeHTTP(w, r)
186 })
187 }
188}
189
190// authenticatePassword handles password-based authentication.
191func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
192 username, password, ok := r.BasicAuth()
193 if !ok {
194 log.Debug().
195 Str(zerowrap.FieldMethod, r.Method).
196 Str(zerowrap.FieldPath, r.URL.Path).
197 Msg("no basic auth provided")
198 return false
199 }
200
201 if authSvc.ValidatePassword(ctx, username, password) {
202 log.Debug().
203 Str("username", username).
204 Str(zerowrap.FieldMethod, r.Method).
205 Str(zerowrap.FieldPath, r.URL.Path).
206 Msg("password authentication successful")
207 return true
208 }
209
210 log.Debug().
211 Str("provided_username", redactUsername(username)).
212 Str(zerowrap.FieldMethod, r.Method).
213 Str(zerowrap.FieldPath, r.URL.Path).
214 Msg("password authentication failed")
215 return false
216}
217
218// authenticateToken handles token-based authentication.
219// It supports both Bearer token in Authorization header and token-as-password for CI.
220// Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.
221func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (bool, *domain.TokenClaims) {
222 // First, check for Bearer token
223 authHeader := r.Header.Get("Authorization")
224 if strings.HasPrefix(authHeader, "Bearer ") {
225 token := strings.TrimPrefix(authHeader, "Bearer ")
226 claims, err := authSvc.ValidateToken(ctx, token)
227 if err != nil {
228 log.Debug().
229 Err(err).
230 Str(zerowrap.FieldMethod, r.Method).
231 Str(zerowrap.FieldPath, r.URL.Path).
232 Msg("bearer token validation failed")
233 return false, nil
234 }
235
236 log.Debug().
237 Str("subject", claims.Subject).
238 Str(zerowrap.FieldMethod, r.Method).
239 Str(zerowrap.FieldPath, r.URL.Path).
240 Msg("bearer token authentication successful")
241 return true, claims
242 }
243
244 // Fall back to token-as-password (for CI/automation)
245 // Username is the subject, password is the JWT token
246 username, password, ok := r.BasicAuth()
247 if !ok {
248 log.Debug().
249 Str(zerowrap.FieldMethod, r.Method).
250 Str(zerowrap.FieldPath, r.URL.Path).
251 Msg("no auth credentials provided")
252 return false, nil
253 }
254
255 // Try to validate the password as a JWT token
256 claims, err := authSvc.ValidateToken(ctx, password)
257 if err != nil {
258 log.Debug().
259 Err(err).
260 Str("provided_username", redactUsername(username)).
261 Str(zerowrap.FieldMethod, r.Method).
262 Str(zerowrap.FieldPath, r.URL.Path).
263 Msg("token-as-password validation failed")
264 return false, nil
265 }
266
267 // Verify the username matches the token subject
268 if claims.Subject != username {
269 log.Debug().
270 Str("provided_username", redactUsername(username)).
271 Str("token_subject", redactUsername(claims.Subject)).
272 Str(zerowrap.FieldMethod, r.Method).
273 Str(zerowrap.FieldPath, r.URL.Path).
274 Msg("username does not match token subject")
275 return false, nil
276 }
277
278 log.Debug().
279 Str("subject", claims.Subject).
280 Str(zerowrap.FieldMethod, r.Method).
281 Str(zerowrap.FieldPath, r.URL.Path).
282 Msg("token-as-password authentication successful")
283 return true, claims
284}
285
286// isLocalhostRequest checks if the request originates from localhost.
287// This is used to allow Gordon to pull from its own registry with internal auth.
288func isLocalhostRequest(r *http.Request) bool {
289 host := r.RemoteAddr
290 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
291 if strings.HasPrefix(host, "127.") ||
292 strings.HasPrefix(host, "[::1]") ||
293 strings.HasPrefix(host, "::1") ||
294 strings.HasPrefix(host, "localhost") {
295 return true
296 }
297 return false
298}
299
300func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
301 if internalAuth.Username == "" || internalAuth.Password == "" {
302 return false
303 }
304
305 username, password, ok := r.BasicAuth()
306 if !ok {
307 return false
308 }
309
310 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
311 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
312 return usernameMatch && passwordMatch
313}
314
315// actionFromMethod returns the registry action for an HTTP method.
316func actionFromMethod(method string) string {
317 switch method {
318 case http.MethodGet, http.MethodHead:
319 return domain.ScopeActionPull
320 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
321 return domain.ScopeActionPush
322 default:
323 return domain.ScopeActionPull
324 }
325}
326
327// extractRepoName extracts the repository name from a registry path.
328// Returns empty string if the path is not a valid registry path or should be allowed.
329func extractRepoName(path string) (repoName string, shouldAllow bool) {
330 if !strings.HasPrefix(path, "/v2/") {
331 return "", true // Not a registry path, allow
332 }
333
334 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
335 if len(pathParts) < 2 {
336 return "", true // Malformed path or /v2/ root, allow
337 }
338
339 // Find the boundary between repo name and route (manifests, blobs, tags)
340 var repoNameParts []string
341 for i, part := range pathParts {
342 if part == "manifests" || part == "blobs" || part == "tags" || part == "_catalog" {
343 repoNameParts = pathParts[:i]
344 break
345 }
346 }
347 if len(repoNameParts) == 0 {
348 return "", true // Special route like /v2/token, allow
349 }
350
351 return strings.Join(repoNameParts, "/"), false
352}
353
354// checkScopeAccess verifies the token has permission for the requested operation.
355// Maps HTTP method to registry action and checks if any token scope grants access.
356func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
357 action := actionFromMethod(r.Method)
358
359 repoName, shouldAllow := extractRepoName(r.URL.Path)
360 if shouldAllow {
361 return true
362 }
363
364 // Check if any token scope grants access
365 for _, scopeStr := range claims.Scopes {
366 // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
367 if scopeStr == action || scopeStr == "*" {
368 log.Debug().
369 Str("repo", repoName).
370 Str("action", action).
371 Str("scope", scopeStr).
372 Msg("simple scope access granted")
373 return true
374 }
375
376 // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
377 scope, err := domain.ParseScope(scopeStr)
378 if err != nil {
379 continue
380 }
381
382 if scope.CanAccess(repoName, action) {
383 log.Debug().
384 Str("repo", repoName).
385 Str("action", action).
386 Str("scope", scopeStr).
387 Msg("scope access granted")
388 return true
389 }
390 }
391
392 log.Debug().
393 Str("repo", repoName).
394 Str("action", action).
395 Strs("scopes", claims.Scopes).
396 Msg("no scope grants access")
397 return false
398}
399
400// sendForbidden sends an HTTP 403 response.
401func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {
402 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
403 w.Header().Set("Content-Type", "application/json")
404 w.WriteHeader(http.StatusForbidden)
405 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
406
407 log.Warn().
408 Str(zerowrap.FieldLayer, "adapter").
409 Str(zerowrap.FieldAdapter, "http").
410 Str(zerowrap.FieldMethod, r.Method).
411 Str(zerowrap.FieldPath, r.URL.Path).
412 Str(zerowrap.FieldClientIP, r.RemoteAddr).
413 Msg("forbidden: insufficient scope for operation")
414}
415
416// sendUnauthorized sends an HTTP 401 response with appropriate headers.
417func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
418 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
419
420 switch authType {
421 case domain.AuthTypeToken:
422 // For token auth, indicate the token server endpoint
423 // Use X-Forwarded-Host if behind proxy, otherwise use Host header
424 realmHost := r.Header.Get("X-Forwarded-Host")
425 if realmHost == "" {
426 realmHost = host
427 }
428 // Detect scheme: prioritize X-Forwarded-Proto (set by reverse proxies like Cloudflare)
429 scheme := "http"
430 if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
431 scheme = proto
432 } else if r.TLS != nil {
433 scheme = "https"
434 }
435 realm := scheme + "://" + realmHost + "/auth/token"
436 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
437 default:
438 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
439 }
440
441 w.Header().Set("Content-Type", "application/json")
442 w.WriteHeader(http.StatusUnauthorized)
443 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
444
445 log.Warn().
446 Str(zerowrap.FieldLayer, "adapter").
447 Str(zerowrap.FieldAdapter, "http").
448 Str(zerowrap.FieldMethod, r.Method).
449 Str(zerowrap.FieldPath, r.URL.Path).
450 Str(zerowrap.FieldClientIP, r.RemoteAddr).
451 Msg("unauthorized registry access attempt")
452}