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