v2.2.2 dbdcf7e

auth.go RawHistoryFolder

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}

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