v2.8.0 6aced72

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

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