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