auth.go RawHistoryFolder

1package middleware
2
3import (
4	"context"
5	"crypto/subtle"
6	"encoding/json"
7	"errors"
8	"net"
9	"net/http"
10	"strings"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/adapters/dto"
15	"github.com/bnema/gordon/internal/adapters/in/http/httphelper"
16	"github.com/bnema/gordon/internal/adapters/in/http/registry/route"
17	"github.com/bnema/gordon/internal/boundaries/in"
18	"github.com/bnema/gordon/internal/domain"
19)
20
21// TokenClaimsKey is context key for storing token claims.
22// Using domain key for consistency across all auth flows.
23const TokenClaimsKey = domain.TokenClaimsKey
24
25// sanitizeHeaderValue removes characters that could enable header injection.
26// Only allows alphanumeric, dots, hyphens, colons, and square brackets
27// (sufficient for host:port and IPv6 addresses).
28func sanitizeHeaderValue(value string) string {
29	var b strings.Builder
30	b.Grow(len(value))
31	for _, r := range value {
32		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
33			r == '.' || r == '-' || r == ':' || r == '[' || r == ']' {
34			b.WriteRune(r)
35		}
36	}
37	return b.String()
38}
39
40// redactUsername partially redacts a username for logging.
41// Shows first and last character with asterisks in between.
42// SECURITY: Prevents full username exposure in logs.
43func redactUsername(username string) string {
44	if len(username) <= 2 {
45		return "***"
46	}
47	return username[:1] + "***" + username[len(username)-1:]
48}
49
50// InternalRegistryAuth holds the credentials used for loopback-only registry access.
51// These are generated per Gordon instance and are never exposed in config.
52type InternalRegistryAuth struct {
53	Username string
54	Password string
55}
56
57// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
58// supporting both password and token-based authentication.
59func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
60	return func(next http.Handler) http.Handler {
61		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62			// Allow localhost requests only with internal instance credentials.
63			if httphelper.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
64				log.Debug().
65					Str(zerowrap.FieldLayer, "adapter").
66					Str(zerowrap.FieldAdapter, "http").
67					Str(zerowrap.FieldMethod, r.Method).
68					Str(zerowrap.FieldPath, r.URL.Path).
69					Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
70					Msg("localhost request with internal auth - skipping auth")
71				next.ServeHTTP(w, r)
72				return
73			}
74
75			// Warn if not using TLS (skip for localhost — internal proxy traffic)
76			if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httphelper.IsLocalhostRequest(r) {
77				log.Warn().
78					Str(zerowrap.FieldLayer, "adapter").
79					Str(zerowrap.FieldAdapter, "http").
80					Str(zerowrap.FieldMethod, r.Method).
81					Str(zerowrap.FieldPath, r.URL.Path).
82					Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
83					Msg("registry auth over insecure HTTP connection")
84			}
85
86			// Check if auth is enabled
87			if !authSvc.IsEnabled() {
88				next.ServeHTTP(w, r)
89				return
90			}
91
92			ctx := r.Context()
93
94			// Authenticate based on auth type
95			// When auth type is "password", accept BOTH password and token auth
96			// (allows CI/CD tokens while still supporting interactive password login)
97			tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)
98
99			if authErr != nil {
100				if errors.Is(authErr, domain.ErrLongLivedToken) {
101					sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r, domain.ErrLongLivedToken.Error())
102				} else {
103					sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, trustedNets, r)
104				}
105				return
106			}
107
108			// SECURITY: Check scopes for token auth (per-repo access control)
109			if tokenClaims != nil {
110				if !checkScopeAccess(r, tokenClaims, log) {
111					sendForbidden(w, log, trustedNets, r)
112					return
113				}
114				// Store claims in context for downstream handlers that need access to token metadata
115				// (e.g., audit logging, rate limiting by subject, or future per-user quotas)
116				ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)
117				r = r.WithContext(ctx)
118			}
119
120			next.ServeHTTP(w, r)
121		})
122	}
123}
124
125// authenticateToken handles token-based authentication.
126// It supports both Bearer token in Authorization header and token-as-password for CI.
127// Returns (tokenClaims, error). A nil error means authentication succeeded.
128// Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used.
129func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {
130	// First, check for Bearer token
131	authHeader := r.Header.Get("Authorization")
132	if strings.HasPrefix(authHeader, "Bearer ") {
133		token := strings.TrimPrefix(authHeader, "Bearer ")
134		claims, err := authSvc.ValidateToken(ctx, token)
135		if err != nil {
136			log.Debug().
137				Err(err).
138				Str(zerowrap.FieldMethod, r.Method).
139				Str(zerowrap.FieldPath, r.URL.Path).
140				Msg("bearer token validation failed")
141			return nil, err
142		}
143
144		if !claims.IsEphemeral {
145			log.Warn().
146				Str("subject", claims.Subject).
147				Str(zerowrap.FieldMethod, r.Method).
148				Str(zerowrap.FieldPath, r.URL.Path).
149				Msg("long-lived token rejected on registry endpoint")
150			return nil, domain.ErrLongLivedToken
151		}
152
153		log.Debug().
154			Str("subject", claims.Subject).
155			Str(zerowrap.FieldMethod, r.Method).
156			Str(zerowrap.FieldPath, r.URL.Path).
157			Msg("bearer token authentication successful")
158		return claims, nil
159	}
160
161	// Fall back to token-as-password (for CI/automation)
162	// Username is the subject, password is the JWT token
163	username, password, ok := r.BasicAuth()
164	if !ok {
165		log.Debug().
166			Str(zerowrap.FieldMethod, r.Method).
167			Str(zerowrap.FieldPath, r.URL.Path).
168			Msg("no auth credentials provided")
169		return nil, errors.New("no auth credentials provided")
170	}
171
172	// Try to validate the password as a JWT token
173	claims, err := authSvc.ValidateToken(ctx, password)
174	if err != nil {
175		log.Debug().
176			Err(err).
177			Str("provided_username", redactUsername(username)).
178			Str(zerowrap.FieldMethod, r.Method).
179			Str(zerowrap.FieldPath, r.URL.Path).
180			Msg("token-as-password validation failed")
181		return nil, err
182	}
183
184	// Verify the username matches the token subject
185	if claims.Subject != username {
186		log.Debug().
187			Str("provided_username", redactUsername(username)).
188			Str("token_subject", redactUsername(claims.Subject)).
189			Str(zerowrap.FieldMethod, r.Method).
190			Str(zerowrap.FieldPath, r.URL.Path).
191			Msg("username does not match token subject")
192		return nil, errors.New("username does not match token subject")
193	}
194
195	if !claims.IsEphemeral {
196		log.Warn().
197			Str("subject", claims.Subject).
198			Str(zerowrap.FieldMethod, r.Method).
199			Str(zerowrap.FieldPath, r.URL.Path).
200			Msg("long-lived token rejected on registry endpoint")
201		return nil, domain.ErrLongLivedToken
202	}
203
204	log.Debug().
205		Str("subject", claims.Subject).
206		Str(zerowrap.FieldMethod, r.Method).
207		Str(zerowrap.FieldPath, r.URL.Path).
208		Msg("token-as-password authentication successful")
209	return claims, nil
210}
211
212func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
213	if internalAuth.Username == "" || internalAuth.Password == "" {
214		return false
215	}
216
217	username, password, ok := r.BasicAuth()
218	if !ok {
219		return false
220	}
221
222	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
223	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
224	return usernameMatch && passwordMatch
225}
226
227// actionFromMethod returns the registry action for an HTTP method.
228func actionFromMethod(method string) string {
229	switch method {
230	case http.MethodGet, http.MethodHead:
231		return domain.ScopeActionPull
232	case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
233		return domain.ScopeActionPush
234	default:
235		return domain.ScopeActionPull
236	}
237}
238
239// checkScopeAccess verifies the token has permission for the requested operation.
240// It parses the path with the same parser the registry handler dispatches
241// on, so authorization and dispatch always name the same repository; a path
242// the handler would reject is denied rather than allowed.
243func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
244	action := actionFromMethod(r.Method)
245
246	op, err := route.Parse(r.URL.Path)
247	if err != nil {
248		log.Debug().Err(err).Str("path", r.URL.Path).Msg("malformed registry path denied")
249		return false
250	}
251	if !op.RequiresRepositoryAuth() {
252		return true
253	}
254
255	// Delegate matching to domain layer
256	if domain.ScopesGrantRegistryAccess(claims.Scopes, op.Repository, action) {
257		log.Debug().
258			Str("repo", op.Repository).
259			Str("action", action).
260			Strs("scopes", claims.Scopes).
261			Msg("scope access granted")
262		return true
263	}
264
265	log.Debug().
266		Str("repo", op.Repository).
267		Str("action", action).
268		Strs("scopes", claims.Scopes).
269		Msg("no scope grants access")
270	return false
271}
272
273// sendForbidden sends an HTTP 403 response.
274func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request) {
275	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
276	w.Header().Set("Content-Type", "application/json")
277	w.WriteHeader(http.StatusForbidden)
278	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
279
280	log.Warn().
281		Str(zerowrap.FieldLayer, "adapter").
282		Str(zerowrap.FieldAdapter, "http").
283		Str(zerowrap.FieldMethod, r.Method).
284		Str(zerowrap.FieldPath, r.URL.Path).
285		Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
286		Msg("forbidden: insufficient scope for operation")
287}
288
289// sendUnauthorizedMsg sends an HTTP 401 response with a custom error message.
290func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request, msg string) {
291	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
292
293	switch authType {
294	case domain.AuthTypeToken:
295		realmHost := host
296		scheme := "http"
297		if r.TLS != nil {
298			scheme = "https"
299		}
300		realmHost = sanitizeHeaderValue(realmHost)
301		realm := scheme + "://" + realmHost + "/auth/token"
302		w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
303	default:
304		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
305	}
306
307	w.Header().Set("Content-Type", "application/json")
308	w.WriteHeader(http.StatusUnauthorized)
309	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: msg})
310
311	log.Warn().
312		Str(zerowrap.FieldLayer, "adapter").
313		Str(zerowrap.FieldAdapter, "http").
314		Str(zerowrap.FieldMethod, r.Method).
315		Str(zerowrap.FieldPath, r.URL.Path).
316		Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
317		Msg("unauthorized registry access attempt")
318}
319
320// sendUnauthorized sends an HTTP 401 response with appropriate headers.
321func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, trustedNets []*net.IPNet, r *http.Request) {
322	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
323
324	switch authType {
325	case domain.AuthTypeToken:
326		// For token auth, indicate the token server endpoint.
327		// SECURITY: Only use X-Forwarded-Host/Proto from trusted sources.
328		// The Host header from the request is used as the default.
329		// X-Forwarded-Host is NOT trusted here because this middleware doesn't
330		// have access to trusted proxy configuration, and an attacker could
331		// inject a malicious realm URL to phish for credentials.
332		realmHost := host
333
334		// Detect scheme from TLS state only (not from headers that can be spoofed)
335		scheme := "http"
336		if r.TLS != nil {
337			scheme = "https"
338		}
339
340		// SECURITY: Sanitize the host to prevent header injection.
341		// Remove any characters that could break the WWW-Authenticate header format.
342		realmHost = sanitizeHeaderValue(realmHost)
343
344		realm := scheme + "://" + realmHost + "/auth/token"
345		w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
346	default:
347		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
348	}
349
350	w.Header().Set("Content-Type", "application/json")
351	w.WriteHeader(http.StatusUnauthorized)
352	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
353
354	log.Warn().
355		Str(zerowrap.FieldLayer, "adapter").
356		Str(zerowrap.FieldAdapter, "http").
357		Str(zerowrap.FieldMethod, r.Method).
358		Str(zerowrap.FieldPath, r.URL.Path).
359		Str(zerowrap.FieldClientIP, GetClientIP(r, trustedNets)).
360		Msg("unauthorized registry access attempt")
361}

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