1package middleware
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14
15 "gordon/internal/domain"
16)
17
18func testLogger() zerowrap.Logger {
19 return zerowrap.Default()
20}
21
22func TestRegistryAuth_ValidCredentials(t *testing.T) {
23 log := testLogger()
24 middleware := RegistryAuth("admin", "secret", log)
25
26 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 w.WriteHeader(http.StatusOK)
28 w.Write([]byte("authenticated"))
29 }))
30
31 req := httptest.NewRequest("GET", "/v2/", nil)
32 req.SetBasicAuth("admin", "secret")
33 rec := httptest.NewRecorder()
34
35 handler.ServeHTTP(rec, req)
36
37 assert.Equal(t, http.StatusOK, rec.Code)
38 assert.Equal(t, "authenticated", rec.Body.String())
39}
40
41func TestRegistryAuth_InvalidUsername(t *testing.T) {
42 log := testLogger()
43 middleware := RegistryAuth("admin", "secret", log)
44
45 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46 w.WriteHeader(http.StatusOK)
47 }))
48
49 req := httptest.NewRequest("GET", "/v2/", nil)
50 req.SetBasicAuth("wronguser", "secret")
51 rec := httptest.NewRecorder()
52
53 handler.ServeHTTP(rec, req)
54
55 assert.Equal(t, http.StatusUnauthorized, rec.Code)
56 assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
57 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
58}
59
60func TestRegistryAuth_InvalidPassword(t *testing.T) {
61 log := testLogger()
62 middleware := RegistryAuth("admin", "secret", log)
63
64 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65 w.WriteHeader(http.StatusOK)
66 }))
67
68 req := httptest.NewRequest("GET", "/v2/", nil)
69 req.SetBasicAuth("admin", "wrongpassword")
70 rec := httptest.NewRecorder()
71
72 handler.ServeHTTP(rec, req)
73
74 assert.Equal(t, http.StatusUnauthorized, rec.Code)
75}
76
77func TestRegistryAuth_NoCredentials(t *testing.T) {
78 log := testLogger()
79 middleware := RegistryAuth("admin", "secret", log)
80
81 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82 w.WriteHeader(http.StatusOK)
83 }))
84
85 req := httptest.NewRequest("GET", "/v2/", nil)
86 // No auth header
87 rec := httptest.NewRecorder()
88
89 handler.ServeHTTP(rec, req)
90
91 assert.Equal(t, http.StatusUnauthorized, rec.Code)
92 assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
93}
94
95func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
96 log := testLogger()
97 middleware := RegistryAuth("admin", "secret", log)
98
99 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 w.WriteHeader(http.StatusOK)
101 }))
102
103 req := httptest.NewRequest("GET", "/v2/", nil)
104 req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
105 rec := httptest.NewRecorder()
106
107 handler.ServeHTTP(rec, req)
108
109 assert.Equal(t, http.StatusUnauthorized, rec.Code)
110}
111
112func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
113 log := testLogger()
114 middleware := RegistryAuth("admin", "secret", log)
115
116 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 w.WriteHeader(http.StatusOK)
118 }))
119
120 req := httptest.NewRequest("GET", "/v2/", nil)
121 req.Header.Set("Authorization", "Bearer some-token")
122 rec := httptest.NewRecorder()
123
124 handler.ServeHTTP(rec, req)
125
126 assert.Equal(t, http.StatusUnauthorized, rec.Code)
127}
128
129func TestRegistryAuth_EmptyCredentials(t *testing.T) {
130 log := testLogger()
131 middleware := RegistryAuth("admin", "secret", log)
132
133 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134 w.WriteHeader(http.StatusOK)
135 }))
136
137 req := httptest.NewRequest("GET", "/v2/", nil)
138 req.SetBasicAuth("", "")
139 rec := httptest.NewRecorder()
140
141 handler.ServeHTTP(rec, req)
142
143 assert.Equal(t, http.StatusUnauthorized, rec.Code)
144}
145
146func TestRegistryAuth_PartialCredentials(t *testing.T) {
147 log := testLogger()
148 middleware := RegistryAuth("admin", "secret", log)
149
150 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151 w.WriteHeader(http.StatusOK)
152 }))
153
154 // Only username, empty password
155 req := httptest.NewRequest("GET", "/v2/", nil)
156 req.SetBasicAuth("admin", "")
157 rec := httptest.NewRecorder()
158
159 handler.ServeHTTP(rec, req)
160
161 assert.Equal(t, http.StatusUnauthorized, rec.Code)
162}
163
164func TestIsAuthenticated_ValidCredentials(t *testing.T) {
165 log := testLogger()
166
167 req := httptest.NewRequest("GET", "/v2/", nil)
168 req.SetBasicAuth("admin", "secret")
169
170 result := isAuthenticated(req, "admin", "secret", log)
171
172 assert.True(t, result)
173}
174
175func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
176 log := testLogger()
177
178 req := httptest.NewRequest("GET", "/v2/", nil)
179 req.SetBasicAuth("admin", "wrong")
180
181 result := isAuthenticated(req, "admin", "secret", log)
182
183 assert.False(t, result)
184}
185
186func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
187 log := testLogger()
188
189 req := httptest.NewRequest("GET", "/v2/", nil)
190
191 result := isAuthenticated(req, "admin", "secret", log)
192
193 assert.False(t, result)
194}
195
196func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
197 // This test verifies that constant-time comparison is used
198 // by checking both username and password are validated
199 log := testLogger()
200
201 tests := []struct {
202 name string
203 username string
204 password string
205 expectedUser string
206 expectedPass string
207 shouldAuth bool
208 }{
209 {
210 name: "both correct",
211 username: "admin",
212 password: "secret",
213 expectedUser: "admin",
214 expectedPass: "secret",
215 shouldAuth: true,
216 },
217 {
218 name: "username wrong first char",
219 username: "bdmin",
220 password: "secret",
221 expectedUser: "admin",
222 expectedPass: "secret",
223 shouldAuth: false,
224 },
225 {
226 name: "username wrong last char",
227 username: "admio",
228 password: "secret",
229 expectedUser: "admin",
230 expectedPass: "secret",
231 shouldAuth: false,
232 },
233 {
234 name: "password wrong first char",
235 username: "admin",
236 password: "aecret",
237 expectedUser: "admin",
238 expectedPass: "secret",
239 shouldAuth: false,
240 },
241 {
242 name: "password wrong last char",
243 username: "admin",
244 password: "secreo",
245 expectedUser: "admin",
246 expectedPass: "secret",
247 shouldAuth: false,
248 },
249 {
250 name: "different length username",
251 username: "adm",
252 password: "secret",
253 expectedUser: "admin",
254 expectedPass: "secret",
255 shouldAuth: false,
256 },
257 {
258 name: "different length password",
259 username: "admin",
260 password: "sec",
261 expectedUser: "admin",
262 expectedPass: "secret",
263 shouldAuth: false,
264 },
265 }
266
267 for _, tt := range tests {
268 t.Run(tt.name, func(t *testing.T) {
269 req := httptest.NewRequest("GET", "/v2/", nil)
270 req.SetBasicAuth(tt.username, tt.password)
271
272 result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
273
274 assert.Equal(t, tt.shouldAuth, result)
275 })
276 }
277}
278
279func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
280 log := testLogger()
281
282 tests := []struct {
283 name string
284 username string
285 password string
286 }{
287 {"unicode username", "用户", "password"},
288 {"unicode password", "admin", "密码"},
289 {"special chars", "admin@example.com", "p@ss:w0rd!"},
290 {"spaces", "admin user", "pass word"},
291 {"colon in password", "admin", "pass:word"},
292 }
293
294 for _, tt := range tests {
295 t.Run(tt.name, func(t *testing.T) {
296 middleware := RegistryAuth(tt.username, tt.password, log)
297
298 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299 w.WriteHeader(http.StatusOK)
300 }))
301
302 req := httptest.NewRequest("GET", "/v2/", nil)
303 req.SetBasicAuth(tt.username, tt.password)
304 rec := httptest.NewRecorder()
305
306 handler.ServeHTTP(rec, req)
307
308 assert.Equal(t, http.StatusOK, rec.Code)
309 })
310 }
311}
312
313func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
314 log := testLogger()
315 middleware := RegistryAuth("admin", "secret", log)
316
317 handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318 w.WriteHeader(http.StatusOK)
319 }))
320
321 tests := []struct {
322 name string
323 authHeader string
324 wantStatus int
325 }{
326 {
327 name: "empty basic auth",
328 authHeader: "Basic ",
329 wantStatus: http.StatusUnauthorized,
330 },
331 {
332 name: "basic auth with only colon",
333 authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
334 wantStatus: http.StatusUnauthorized,
335 },
336 {
337 name: "missing colon in decoded value",
338 authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
339 wantStatus: http.StatusUnauthorized,
340 },
341 }
342
343 for _, tt := range tests {
344 t.Run(tt.name, func(t *testing.T) {
345 req := httptest.NewRequest("GET", "/v2/", nil)
346 req.Header.Set("Authorization", tt.authHeader)
347 rec := httptest.NewRecorder()
348
349 handler.ServeHTTP(rec, req)
350
351 assert.Equal(t, tt.wantStatus, rec.Code)
352 })
353 }
354}
355
356func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
357 log := testLogger()
358 called := false
359 authSvc := stubAuthService{
360 enabled: true,
361 authType: domain.AuthTypePassword,
362 validatePassword: func(_ context.Context, _ string, _ string) bool {
363 called = true
364 return false
365 },
366 }
367 internalAuth := InternalRegistryAuth{
368 Username: "internal",
369 Password: "secret",
370 }
371
372 handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
373 w.WriteHeader(http.StatusOK)
374 }))
375
376 req := httptest.NewRequest("GET", "/v2/", nil)
377 req.RemoteAddr = "127.0.0.1:12345"
378 req.SetBasicAuth("internal", "secret")
379 rec := httptest.NewRecorder()
380
381 handler.ServeHTTP(rec, req)
382
383 assert.Equal(t, http.StatusOK, rec.Code)
384 assert.False(t, called, "auth service should not be used for internal loopback auth")
385}
386
387func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
388 log := testLogger()
389 called := false
390 authSvc := stubAuthService{
391 enabled: true,
392 authType: domain.AuthTypePassword,
393 validatePassword: func(_ context.Context, _ string, _ string) bool {
394 called = true
395 return false
396 },
397 }
398 internalAuth := InternalRegistryAuth{
399 Username: "internal",
400 Password: "secret",
401 }
402
403 handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
404 w.WriteHeader(http.StatusOK)
405 }))
406
407 req := httptest.NewRequest("GET", "/v2/", nil)
408 req.RemoteAddr = "127.0.0.1:12345"
409 req.SetBasicAuth("internal", "wrong")
410 rec := httptest.NewRecorder()
411
412 handler.ServeHTTP(rec, req)
413
414 assert.Equal(t, http.StatusUnauthorized, rec.Code)
415 assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
416 assert.True(t, called, "auth service should be used when internal auth fails")
417}
418
419func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
420 log := testLogger()
421 called := false
422 authSvc := stubAuthService{
423 enabled: true,
424 authType: domain.AuthTypePassword,
425 validatePassword: func(_ context.Context, _ string, _ string) bool {
426 called = true
427 return false
428 },
429 }
430 internalAuth := InternalRegistryAuth{
431 Username: "internal",
432 Password: "secret",
433 }
434
435 handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
436 w.WriteHeader(http.StatusOK)
437 }))
438
439 req := httptest.NewRequest("GET", "/v2/", nil)
440 req.RemoteAddr = "192.168.1.10:12345"
441 req.SetBasicAuth("internal", "secret")
442 rec := httptest.NewRecorder()
443
444 handler.ServeHTTP(rec, req)
445
446 assert.Equal(t, http.StatusUnauthorized, rec.Code)
447 assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
448 assert.True(t, called, "auth service should be used for non-localhost requests")
449}
450
451type stubAuthService struct {
452 enabled bool
453 authType domain.AuthType
454 validatePassword func(ctx context.Context, username, password string) bool
455 validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
456}
457
458func (s stubAuthService) GetAuthType() domain.AuthType {
459 return s.authType
460}
461
462func (s stubAuthService) IsEnabled() bool {
463 return s.enabled
464}
465
466func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
467 if s.validatePassword != nil {
468 return s.validatePassword(ctx, username, password)
469 }
470 return false
471}
472
473func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
474 if s.validateToken != nil {
475 return s.validateToken(ctx, tokenString)
476 }
477 return nil, errors.New("not implemented")
478}
479
480func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
481 return "", errors.New("not implemented")
482}
483
484func (s stubAuthService) RevokeToken(context.Context, string) error {
485 return errors.New("not implemented")
486}
487
488func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
489 return nil, errors.New("not implemented")
490}
491
492func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
493 return "", errors.New("not implemented")
494}