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}
495
496// Tests for checkScopeAccess function
497
498func TestCheckScopeAccess_ActionMapping(t *testing.T) {
499 log := testLogger()
500
501 tests := []struct {
502 name string
503 method string
504 expectedAction string
505 }{
506 {"GET maps to pull", http.MethodGet, "pull"},
507 {"HEAD maps to pull", http.MethodHead, "pull"},
508 {"PUT maps to push", http.MethodPut, "push"},
509 {"POST maps to push", http.MethodPost, "push"},
510 {"PATCH maps to push", http.MethodPatch, "push"},
511 {"DELETE maps to push", http.MethodDelete, "push"},
512 {"OPTIONS defaults to pull", http.MethodOptions, "pull"},
513 }
514
515 for _, tt := range tests {
516 t.Run(tt.name, func(t *testing.T) {
517 claims := &domain.TokenClaims{
518 Scopes: []string{"repository:myrepo:" + tt.expectedAction},
519 }
520
521 req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
522 result := checkScopeAccess(req, claims, log)
523
524 assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
525 })
526 }
527}
528
529func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
530 log := testLogger()
531
532 tests := []struct {
533 name string
534 path string
535 wantRepo string
536 scopes []string
537 want bool
538 }{
539 {
540 name: "simple repo with manifests",
541 path: "/v2/myrepo/manifests/latest",
542 scopes: []string{"repository:myrepo:pull"},
543 want: true,
544 },
545 {
546 name: "simple repo with blobs",
547 path: "/v2/myrepo/blobs/sha256:abc123",
548 scopes: []string{"repository:myrepo:pull"},
549 want: true,
550 },
551 {
552 name: "nested repo with manifests",
553 path: "/v2/myorg/myapp/manifests/v1.0",
554 scopes: []string{"repository:myorg/myapp:pull"},
555 want: true,
556 },
557 {
558 name: "deeply nested repo",
559 path: "/v2/myorg/team/myapp/manifests/latest",
560 scopes: []string{"repository:myorg/team/myapp:pull"},
561 want: true,
562 },
563 {
564 name: "repo with tags list",
565 path: "/v2/myrepo/tags/list",
566 scopes: []string{"repository:myrepo:pull"},
567 want: true,
568 },
569 {
570 name: "wrong repo name",
571 path: "/v2/myrepo/manifests/latest",
572 scopes: []string{"repository:otherrepo:pull"},
573 want: false,
574 },
575 }
576
577 for _, tt := range tests {
578 t.Run(tt.name, func(t *testing.T) {
579 claims := &domain.TokenClaims{Scopes: tt.scopes}
580 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
581 result := checkScopeAccess(req, claims, log)
582 assert.Equal(t, tt.want, result)
583 })
584 }
585}
586
587func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
588 log := testLogger()
589
590 tests := []struct {
591 name string
592 path string
593 scopes []string
594 want bool
595 }{
596 {
597 name: "wildcard repo matches any",
598 path: "/v2/anyrepo/manifests/latest",
599 scopes: []string{"repository:*:pull"},
600 want: true,
601 },
602 {
603 name: "org wildcard matches repo in org",
604 path: "/v2/myorg/myapp/manifests/latest",
605 scopes: []string{"repository:myorg/*:pull"},
606 want: true,
607 },
608 {
609 name: "org wildcard does not match other org",
610 path: "/v2/otherorg/myapp/manifests/latest",
611 scopes: []string{"repository:myorg/*:pull"},
612 want: false,
613 },
614 {
615 name: "wildcard action grants push",
616 path: "/v2/myrepo/manifests/latest",
617 scopes: []string{"repository:myrepo:*"},
618 want: true,
619 },
620 }
621
622 for _, tt := range tests {
623 t.Run(tt.name, func(t *testing.T) {
624 claims := &domain.TokenClaims{Scopes: tt.scopes}
625 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
626 result := checkScopeAccess(req, claims, log)
627 assert.Equal(t, tt.want, result)
628 })
629 }
630}
631
632func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
633 log := testLogger()
634
635 tests := []struct {
636 name string
637 path string
638 scopes []string
639 want bool
640 }{
641 {
642 name: "v2 root is allowed",
643 path: "/v2/",
644 scopes: []string{"repository:myrepo:pull"},
645 want: true,
646 },
647 {
648 name: "non-v2 path is allowed",
649 path: "/healthz",
650 scopes: []string{"repository:myrepo:pull"},
651 want: true,
652 },
653 {
654 name: "catalog path is handled",
655 path: "/v2/_catalog",
656 scopes: []string{"repository:myrepo:pull"},
657 want: true,
658 },
659 }
660
661 for _, tt := range tests {
662 t.Run(tt.name, func(t *testing.T) {
663 claims := &domain.TokenClaims{Scopes: tt.scopes}
664 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
665 result := checkScopeAccess(req, claims, log)
666 assert.Equal(t, tt.want, result)
667 })
668 }
669}
670
671func TestCheckScopeAccess_DenialCases(t *testing.T) {
672 log := testLogger()
673
674 tests := []struct {
675 name string
676 method string
677 path string
678 scopes []string
679 want bool
680 }{
681 {
682 name: "pull scope denies push",
683 method: http.MethodPut,
684 path: "/v2/myrepo/manifests/latest",
685 scopes: []string{"repository:myrepo:pull"},
686 want: false,
687 },
688 {
689 name: "wrong repo denies access",
690 method: http.MethodGet,
691 path: "/v2/myrepo/manifests/latest",
692 scopes: []string{"repository:otherrepo:pull"},
693 want: false,
694 },
695 {
696 name: "empty scopes denies access",
697 method: http.MethodGet,
698 path: "/v2/myrepo/manifests/latest",
699 scopes: []string{},
700 want: false,
701 },
702 {
703 name: "invalid scope format is skipped",
704 method: http.MethodGet,
705 path: "/v2/myrepo/manifests/latest",
706 scopes: []string{"invalid-scope-format"},
707 want: false,
708 },
709 }
710
711 for _, tt := range tests {
712 t.Run(tt.name, func(t *testing.T) {
713 claims := &domain.TokenClaims{Scopes: tt.scopes}
714 req := httptest.NewRequest(tt.method, tt.path, nil)
715 result := checkScopeAccess(req, claims, log)
716 assert.Equal(t, tt.want, result)
717 })
718 }
719}