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}