v3.4.1 de8f68e

handler.go RawHistoryFolder

1// Package auth implements the HTTP adapter for authentication endpoints.
2package auth
3
4import (
5	"context"
6	"crypto/subtle"
7	"encoding/json"
8	"net/http"
9	"strings"
10	"time"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/adapters/dto"
15	"github.com/bnema/gordon/internal/adapters/in/http/httphelper"
16	"github.com/bnema/gordon/internal/boundaries/in"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// InternalAuth holds credentials for internal loopback registry access.
21type InternalAuth struct {
22	Username string
23	Password string
24}
25
26// Handler handles authentication requests at /auth/*.
27type Handler struct {
28	authSvc      in.AuthService
29	internalAuth InternalAuth
30	log          zerowrap.Logger
31}
32
33// NewHandler creates a new auth handler.
34func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
35	return &Handler{
36		authSvc:      authSvc,
37		internalAuth: internalAuth,
38		log:          log,
39	}
40}
41
42func setNoStoreHeaders(w http.ResponseWriter) {
43	w.Header().Set("Cache-Control", "no-store")
44	w.Header().Set("Pragma", "no-cache")
45}
46
47// ServeHTTP routes requests to the appropriate handler.
48func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
49	path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
50
51	switch path {
52	case "/password":
53		h.handlePassword(w, r)
54	case "/token":
55		h.handleToken(w, r)
56	default:
57		w.Header().Set("Content-Type", "application/json")
58		w.WriteHeader(http.StatusNotFound)
59		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
60	}
61}
62
63// handlePassword handles POST /auth/password requests.
64// Password authentication has been removed; this endpoint always returns Gone.
65func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
66	setNoStoreHeaders(w)
67	w.Header().Set("Content-Type", "application/json")
68	w.WriteHeader(http.StatusGone)
69	_ = json.NewEncoder(w).Encode(dto.ErrorResponse{
70		Error: "password authentication has been removed, use token-based auth",
71	})
72}
73
74// handleToken handles GET /auth/token requests.
75// This is the Docker Registry v2 token server endpoint.
76// Issues short-lived access tokens for registry access.
77func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
78	setNoStoreHeaders(w)
79
80	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
81		zerowrap.FieldLayer:   "adapter",
82		zerowrap.FieldAdapter: "http",
83		zerowrap.FieldHandler: "auth",
84		zerowrap.FieldMethod:  r.Method,
85		zerowrap.FieldPath:    r.URL.Path,
86	})
87	log := zerowrap.FromCtx(ctx)
88
89	if r.Method != http.MethodGet {
90		w.Header().Set("Content-Type", "application/json")
91		w.WriteHeader(http.StatusMethodNotAllowed)
92		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
93		return
94	}
95
96	// Check if auth is enabled
97	if h.authSvc == nil || !h.authSvc.IsEnabled() {
98		w.Header().Set("Content-Type", "application/json")
99		w.WriteHeader(http.StatusServiceUnavailable)
100		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
101		return
102	}
103
104	// Get credentials from Basic Auth
105	username, password, ok := r.BasicAuth()
106	if !ok {
107		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
108		w.Header().Set("Content-Type", "application/json")
109		w.WriteHeader(http.StatusUnauthorized)
110		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
111		return
112	}
113
114	authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log)
115	if !authenticated {
116		log.Debug().
117			Str("username", username).
118			Msg("token request authentication failed")
119		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
120		w.Header().Set("Content-Type", "application/json")
121		w.WriteHeader(http.StatusUnauthorized)
122		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
123		return
124	}
125
126	// Parse requested scopes from query parameter
127	requestedScopes := h.parseRequestedScopes(r, log)
128	if parentClaims != nil {
129		requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log)
130		if len(requestedScopes) == 0 {
131			w.Header().Set("Content-Type", "application/json")
132			w.WriteHeader(http.StatusForbidden)
133			_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"})
134			return
135		}
136	}
137
138	// Generate a short-lived access token with configurable TTL - not stored
139	ttl := h.authSvc.GetAccessTokenTTL()
140	accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl)
141	if err != nil {
142		log.Error().Err(err).Msg("failed to generate access token")
143		w.Header().Set("Content-Type", "application/json")
144		w.WriteHeader(http.StatusInternalServerError)
145		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
146		return
147	}
148
149	response := dto.TokenResponse{
150		Token:     accessToken,
151		ExpiresIn: int(ttl.Seconds()),
152		IssuedAt:  time.Now().UTC().Format(time.RFC3339),
153	}
154
155	w.Header().Set("Content-Type", "application/json")
156	w.WriteHeader(http.StatusOK)
157
158	if err := json.NewEncoder(w).Encode(response); err != nil {
159		log.Error().Err(err).Msg("failed to encode token response")
160	}
161
162	log.Debug().
163		Str("username", username).
164		Int("expires_in", response.ExpiresIn).
165		Msg("access token issued")
166}
167
168func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
169	if httphelper.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
170		log.Debug().Str("username", username).Msg("internal registry auth accepted")
171		return true, &domain.TokenClaims{
172			Subject: username,
173			Scopes:  []string{"push", "pull"},
174		}
175	}
176
177	// Validate JWT token sent via Basic Auth password field.
178	claims, err := h.authSvc.ValidateToken(ctx, password)
179	if err == nil && claims.Subject == username {
180		if claims.IsEphemeral {
181			log.Debug().Msg("rejected ephemeral token as exchange credential")
182			return false, nil
183		}
184		return true, claims
185	}
186
187	return false, nil
188}
189
190func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
191	var effective []string
192
193	for _, reqScopeStr := range requestedScopes {
194		reqScope, err := domain.ParseScope(reqScopeStr)
195		if err != nil {
196			continue
197		}
198
199		var checkAccess func([]string, string, string) bool
200		switch reqScope.Type {
201		case domain.ScopeTypeRepository:
202			checkAccess = domain.ScopesGrantRegistryAccess
203		case domain.ScopeTypeAdmin:
204			checkAccess = domain.ScopesGrantAdminAccess
205		default:
206			continue
207		}
208
209		if s := buildEffectiveScope(reqScope, grantedScopes, checkAccess); s != "" {
210			effective = append(effective, s)
211		}
212	}
213
214	log.Debug().
215		Strs("requested_scopes", requestedScopes).
216		Strs("granted_scopes", grantedScopes).
217		Strs("effective_scopes", effective).
218		Msg("calculated effective scopes from parent token")
219
220	return effective
221}
222
223// buildEffectiveScope filters a requested scope's actions through checkAccess and returns
224// the resulting scope string, or "" if no actions were granted.
225func buildEffectiveScope(reqScope *domain.Scope, grantedScopes []string, checkAccess func([]string, string, string) bool) string {
226	allowedActions := make([]string, 0, len(reqScope.Actions))
227	for _, action := range reqScope.Actions {
228		if checkAccess(grantedScopes, reqScope.Name, action) {
229			allowedActions = append(allowedActions, action)
230		}
231	}
232	if len(allowedActions) == 0 {
233		return ""
234	}
235	return (&domain.Scope{
236		Type:    reqScope.Type,
237		Name:    reqScope.Name,
238		Actions: allowedActions,
239	}).String()
240}
241
242// parseRequestedScopes extracts and validates scope parameters from the request.
243// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
244// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
245func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
246	scopeParams := r.URL.Query()["scope"]
247
248	if len(scopeParams) == 0 {
249		// Default scope: pull from any repository for backward compatibility
250		log.Debug().Msg("no scope requested, using default pull scope")
251		return []string{"repository:*:pull"}
252	}
253
254	var validScopes []string
255	for _, scopeStr := range scopeParams {
256		// Validate the scope format
257		scope, err := domain.ParseScope(scopeStr)
258		if err != nil {
259			log.Debug().
260				Err(err).
261				Str("scope", scopeStr).
262				Msg("invalid scope format, skipping")
263			continue
264		}
265
266		// Allow repository and admin scopes
267		if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin {
268			log.Debug().
269				Str("scope_type", scope.Type).
270				Str("scope", scopeStr).
271				Msg("unsupported scope type, skipping")
272			continue
273		}
274
275		validScopes = append(validScopes, scopeStr)
276	}
277
278	if len(validScopes) == 0 {
279		// All requested scopes were invalid, return default
280		log.Debug().Msg("all requested scopes invalid, using default pull scope")
281		return []string{"repository:*:pull"}
282	}
283
284	log.Debug().
285		Strs("scopes", validScopes).
286		Msg("parsed requested scopes")
287	return validScopes
288}
289
290// isInternalAuth checks if the credentials match internal registry auth.
291func (h *Handler) isInternalAuth(username, password string) bool {
292	if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
293		return false
294	}
295	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(h.internalAuth.Username)) == 1
296	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(h.internalAuth.Password)) == 1
297	return usernameMatch && passwordMatch
298}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close