v2.10.0 893d8fa

auth.go RawHistoryFolder

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		strings.HasPrefix(host, "localhost") {
308		return true
309	}
310	return false
311}
312
313func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
314	if internalAuth.Username == "" || internalAuth.Password == "" {
315		return false
316	}
317
318	username, password, ok := r.BasicAuth()
319	if !ok {
320		return false
321	}
322
323	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
324	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
325	return usernameMatch && passwordMatch
326}
327
328// actionFromMethod returns the registry action for an HTTP method.
329func actionFromMethod(method string) string {
330	switch method {
331	case http.MethodGet, http.MethodHead:
332		return domain.ScopeActionPull
333	case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
334		return domain.ScopeActionPush
335	default:
336		return domain.ScopeActionPull
337	}
338}
339
340// extractRepoName extracts the repository name from a registry path.
341// Returns empty string if the path is not a valid registry path or should be allowed.
342func extractRepoName(path string) (repoName string, shouldAllow bool) {
343	if !strings.HasPrefix(path, "/v2/") {
344		return "", true // Not a registry path, allow
345	}
346
347	pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
348	if len(pathParts) < 2 {
349		return "", true // Malformed path or /v2/ root, allow
350	}
351
352	// Find the boundary between repo name and route (manifests, blobs, tags)
353	var repoNameParts []string
354	for i, part := range pathParts {
355		if part == "manifests" || part == "blobs" || part == "tags" || part == "_catalog" {
356			repoNameParts = pathParts[:i]
357			break
358		}
359	}
360	if len(repoNameParts) == 0 {
361		return "", true // Special route like /v2/token, allow
362	}
363
364	return strings.Join(repoNameParts, "/"), false
365}
366
367// checkScopeAccess verifies the token has permission for the requested operation.
368// Maps HTTP method to registry action and checks if any token scope grants access.
369func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
370	action := actionFromMethod(r.Method)
371
372	repoName, shouldAllow := extractRepoName(r.URL.Path)
373	if shouldAllow {
374		return true
375	}
376
377	// Check if any token scope grants access
378	for _, scopeStr := range claims.Scopes {
379		// Handle simple scopes (e.g., "push", "pull") for backwards compatibility
380		if scopeStr == action || scopeStr == "*" {
381			log.Debug().
382				Str("repo", repoName).
383				Str("action", action).
384				Str("scope", scopeStr).
385				Msg("simple scope access granted")
386			return true
387		}
388
389		// Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
390		scope, err := domain.ParseScope(scopeStr)
391		if err != nil {
392			continue
393		}
394
395		if scope.CanAccess(repoName, action) {
396			log.Debug().
397				Str("repo", repoName).
398				Str("action", action).
399				Str("scope", scopeStr).
400				Msg("scope access granted")
401			return true
402		}
403	}
404
405	log.Debug().
406		Str("repo", repoName).
407		Str("action", action).
408		Strs("scopes", claims.Scopes).
409		Msg("no scope grants access")
410	return false
411}
412
413// sendForbidden sends an HTTP 403 response.
414func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {
415	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
416	w.Header().Set("Content-Type", "application/json")
417	w.WriteHeader(http.StatusForbidden)
418	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
419
420	log.Warn().
421		Str(zerowrap.FieldLayer, "adapter").
422		Str(zerowrap.FieldAdapter, "http").
423		Str(zerowrap.FieldMethod, r.Method).
424		Str(zerowrap.FieldPath, r.URL.Path).
425		Str(zerowrap.FieldClientIP, r.RemoteAddr).
426		Msg("forbidden: insufficient scope for operation")
427}
428
429// sendUnauthorized sends an HTTP 401 response with appropriate headers.
430func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
431	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
432
433	switch authType {
434	case domain.AuthTypeToken:
435		// For token auth, indicate the token server endpoint.
436		// SECURITY: Only use X-Forwarded-Host/Proto from trusted sources.
437		// The Host header from the request is used as the default.
438		// X-Forwarded-Host is NOT trusted here because this middleware doesn't
439		// have access to trusted proxy configuration, and an attacker could
440		// inject a malicious realm URL to phish for credentials.
441		realmHost := host
442
443		// Detect scheme from TLS state only (not from headers that can be spoofed)
444		scheme := "http"
445		if r.TLS != nil {
446			scheme = "https"
447		}
448
449		// SECURITY: Sanitize the host to prevent header injection.
450		// Remove any characters that could break the WWW-Authenticate header format.
451		realmHost = sanitizeHeaderValue(realmHost)
452
453		realm := scheme + "://" + realmHost + "/auth/token"
454		w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
455	default:
456		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
457	}
458
459	w.Header().Set("Content-Type", "application/json")
460	w.WriteHeader(http.StatusUnauthorized)
461	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
462
463	log.Warn().
464		Str(zerowrap.FieldLayer, "adapter").
465		Str(zerowrap.FieldAdapter, "http").
466		Str(zerowrap.FieldMethod, r.Method).
467		Str(zerowrap.FieldPath, r.URL.Path).
468		Str(zerowrap.FieldClientIP, r.RemoteAddr).
469		Msg("unauthorized registry access attempt")
470}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close