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