v2.5.0 b9b6391

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

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 8

Tags 106

↑↓ moveenter openesc close