v2.1.0 0fb867d

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// RegistryAuth middleware provides Docker Registry authentication.
16func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler {
17	return func(next http.Handler) http.Handler {
18		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19			// Require auth for all registry operations
20			if !isAuthenticated(r, username, password, log) {
21				w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
22				w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
23				http.Error(w, "Unauthorized", http.StatusUnauthorized)
24
25				log.Warn().
26					Str(zerowrap.FieldLayer, "adapter").
27					Str(zerowrap.FieldAdapter, "http").
28					Str(zerowrap.FieldMethod, r.Method).
29					Str(zerowrap.FieldPath, r.URL.Path).
30					Str(zerowrap.FieldClientIP, r.RemoteAddr).
31					Msg("unauthorized registry access attempt")
32				return
33			}
34
35			next.ServeHTTP(w, r)
36		})
37	}
38}
39
40// isAuthenticated checks basic auth credentials.
41func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool {
42	authHeader := r.Header.Get("Authorization")
43	log.Debug().
44		Str(zerowrap.FieldLayer, "adapter").
45		Str(zerowrap.FieldAdapter, "http").
46		Str(zerowrap.FieldMethod, r.Method).
47		Str(zerowrap.FieldPath, r.URL.Path).
48		Bool("has_auth_header", authHeader != "").
49		Msg("processing authentication request")
50
51	username, password, ok := r.BasicAuth()
52	if !ok {
53		log.Debug().
54			Str(zerowrap.FieldMethod, r.Method).
55			Str(zerowrap.FieldPath, r.URL.Path).
56			Msg("no basic auth provided")
57		return false
58	}
59
60	// Use constant-time comparison to prevent timing attacks
61	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
62	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
63
64	authenticated := usernameMatch && passwordMatch
65	if !authenticated {
66		log.Debug().
67			Str("provided_username", username).
68			Str(zerowrap.FieldMethod, r.Method).
69			Str(zerowrap.FieldPath, r.URL.Path).
70			Msg("authentication failed")
71	} else {
72		log.Debug().
73			Str("username", username).
74			Str(zerowrap.FieldMethod, r.Method).
75			Str(zerowrap.FieldPath, r.URL.Path).
76			Msg("authentication successful")
77	}
78
79	return authenticated
80}
81
82// InternalRegistryAuth holds the credentials used for loopback-only registry access.
83// These are generated per Gordon instance and are never exposed in config.
84type InternalRegistryAuth struct {
85	Username string
86	Password string
87}
88
89// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
90// supporting both password and token-based authentication.
91func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
92	return func(next http.Handler) http.Handler {
93		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94			// Allow localhost requests only with internal instance credentials.
95			if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
96				log.Debug().
97					Str(zerowrap.FieldLayer, "adapter").
98					Str(zerowrap.FieldAdapter, "http").
99					Str(zerowrap.FieldMethod, r.Method).
100					Str(zerowrap.FieldPath, r.URL.Path).
101					Str(zerowrap.FieldClientIP, r.RemoteAddr).
102					Msg("localhost request with internal auth - skipping auth")
103				next.ServeHTTP(w, r)
104				return
105			}
106
107			// Warn if not using TLS
108			if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
109				log.Warn().
110					Str(zerowrap.FieldLayer, "adapter").
111					Str(zerowrap.FieldAdapter, "http").
112					Str(zerowrap.FieldMethod, r.Method).
113					Str(zerowrap.FieldPath, r.URL.Path).
114					Str(zerowrap.FieldClientIP, r.RemoteAddr).
115					Msg("registry auth over insecure HTTP connection")
116			}
117
118			// Check if auth is enabled
119			if !authSvc.IsEnabled() {
120				next.ServeHTTP(w, r)
121				return
122			}
123
124			ctx := r.Context()
125
126			// Authenticate based on auth type
127			var authenticated bool
128			switch authSvc.GetAuthType() {
129			case domain.AuthTypePassword:
130				authenticated = authenticatePassword(ctx, r, authSvc, log)
131			case domain.AuthTypeToken:
132				authenticated = authenticateToken(ctx, r, authSvc, log)
133			default:
134				log.Error().
135					Str("auth_type", string(authSvc.GetAuthType())).
136					Msg("unknown auth type")
137				authenticated = false
138			}
139
140			if !authenticated {
141				sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
142				return
143			}
144
145			next.ServeHTTP(w, r)
146		})
147	}
148}
149
150// authenticatePassword handles password-based authentication.
151func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
152	username, password, ok := r.BasicAuth()
153	if !ok {
154		log.Debug().
155			Str(zerowrap.FieldMethod, r.Method).
156			Str(zerowrap.FieldPath, r.URL.Path).
157			Msg("no basic auth provided")
158		return false
159	}
160
161	if authSvc.ValidatePassword(ctx, username, password) {
162		log.Debug().
163			Str("username", username).
164			Str(zerowrap.FieldMethod, r.Method).
165			Str(zerowrap.FieldPath, r.URL.Path).
166			Msg("password authentication successful")
167		return true
168	}
169
170	log.Debug().
171		Str("provided_username", username).
172		Str(zerowrap.FieldMethod, r.Method).
173		Str(zerowrap.FieldPath, r.URL.Path).
174		Msg("password authentication failed")
175	return false
176}
177
178// authenticateToken handles token-based authentication.
179// It supports both Bearer token in Authorization header and token-as-password for CI.
180func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
181	// First, check for Bearer token
182	authHeader := r.Header.Get("Authorization")
183	if strings.HasPrefix(authHeader, "Bearer ") {
184		token := strings.TrimPrefix(authHeader, "Bearer ")
185		claims, err := authSvc.ValidateToken(ctx, token)
186		if err != nil {
187			log.Debug().
188				Err(err).
189				Str(zerowrap.FieldMethod, r.Method).
190				Str(zerowrap.FieldPath, r.URL.Path).
191				Msg("bearer token validation failed")
192			return false
193		}
194
195		log.Debug().
196			Str("subject", claims.Subject).
197			Str(zerowrap.FieldMethod, r.Method).
198			Str(zerowrap.FieldPath, r.URL.Path).
199			Msg("bearer token authentication successful")
200		return true
201	}
202
203	// Fall back to token-as-password (for CI/automation)
204	// Username is the subject, password is the JWT token
205	username, password, ok := r.BasicAuth()
206	if !ok {
207		log.Debug().
208			Str(zerowrap.FieldMethod, r.Method).
209			Str(zerowrap.FieldPath, r.URL.Path).
210			Msg("no auth credentials provided")
211		return false
212	}
213
214	// Try to validate the password as a JWT token
215	claims, err := authSvc.ValidateToken(ctx, password)
216	if err != nil {
217		log.Debug().
218			Err(err).
219			Str("provided_username", username).
220			Str(zerowrap.FieldMethod, r.Method).
221			Str(zerowrap.FieldPath, r.URL.Path).
222			Msg("token-as-password validation failed")
223		return false
224	}
225
226	// Verify the username matches the token subject
227	if claims.Subject != username {
228		log.Debug().
229			Str("provided_username", username).
230			Str("token_subject", claims.Subject).
231			Str(zerowrap.FieldMethod, r.Method).
232			Str(zerowrap.FieldPath, r.URL.Path).
233			Msg("username does not match token subject")
234		return false
235	}
236
237	log.Debug().
238		Str("subject", claims.Subject).
239		Str(zerowrap.FieldMethod, r.Method).
240		Str(zerowrap.FieldPath, r.URL.Path).
241		Msg("token-as-password authentication successful")
242	return true
243}
244
245// isLocalhostRequest checks if the request originates from localhost.
246// This is used to allow Gordon to pull from its own registry with internal auth.
247func isLocalhostRequest(r *http.Request) bool {
248	host := r.RemoteAddr
249	// RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
250	if strings.HasPrefix(host, "127.") ||
251		strings.HasPrefix(host, "[::1]") ||
252		strings.HasPrefix(host, "::1") ||
253		strings.HasPrefix(host, "localhost") {
254		return true
255	}
256	return false
257}
258
259func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
260	if internalAuth.Username == "" || internalAuth.Password == "" {
261		return false
262	}
263
264	username, password, ok := r.BasicAuth()
265	if !ok {
266		return false
267	}
268
269	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
270	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
271	return usernameMatch && passwordMatch
272}
273
274// sendUnauthorized sends an HTTP 401 response with appropriate headers.
275func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
276	w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
277
278	switch authType {
279	case domain.AuthTypeToken:
280		// For token auth, indicate the token server endpoint
281		// Detect scheme from request (TLS or X-Forwarded-Proto header)
282		scheme := "http"
283		if r.TLS != nil {
284			scheme = "https"
285		} else if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
286			scheme = proto
287		}
288		realm := scheme + "://" + host + "/v2/token"
289		w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
290	default:
291		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
292	}
293
294	http.Error(w, "Unauthorized", http.StatusUnauthorized)
295
296	log.Warn().
297		Str(zerowrap.FieldLayer, "adapter").
298		Str(zerowrap.FieldAdapter, "http").
299		Str(zerowrap.FieldMethod, r.Method).
300		Str(zerowrap.FieldPath, r.URL.Path).
301		Str(zerowrap.FieldClientIP, r.RemoteAddr).
302		Msg("unauthorized registry access attempt")
303}

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