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