v2.7.1 b3a38c8

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	"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}

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