v2.14.0 5e11a00

handler.go RawHistoryFolder

1// Package auth implements the HTTP adapter for authentication endpoints.
2package auth
3
4import (
5	"crypto/subtle"
6	"encoding/json"
7	"net/http"
8	"strings"
9	"time"
10
11	"github.com/bnema/zerowrap"
12
13	"github.com/bnema/gordon/internal/adapters/dto"
14	"github.com/bnema/gordon/internal/boundaries/in"
15	"github.com/bnema/gordon/internal/domain"
16)
17
18// InternalAuth holds credentials for internal loopback registry access.
19type InternalAuth struct {
20	Username string
21	Password string
22}
23
24// Handler handles authentication requests at /auth/*.
25type Handler struct {
26	authSvc      in.AuthService
27	internalAuth InternalAuth
28	log          zerowrap.Logger
29}
30
31// NewHandler creates a new auth handler.
32func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
33	return &Handler{
34		authSvc:      authSvc,
35		internalAuth: internalAuth,
36		log:          log,
37	}
38}
39
40// PasswordRequest represents the request body for POST /auth/password.
41type PasswordRequest struct {
42	Username string `json:"username"`
43	Password string `json:"password"`
44}
45
46// PasswordResponse represents the response from POST /auth/password.
47type PasswordResponse struct {
48	Token     string `json:"token"`
49	ExpiresIn int    `json:"expires_in"`
50	IssuedAt  string `json:"issued_at"`
51}
52
53// ServeHTTP routes requests to the appropriate handler.
54func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
55	path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
56
57	switch path {
58	case "/password":
59		h.handlePassword(w, r)
60	case "/token":
61		h.handleToken(w, r)
62	default:
63		w.Header().Set("Content-Type", "application/json")
64		w.WriteHeader(http.StatusNotFound)
65		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
66	}
67}
68
69// handlePassword handles POST /auth/password requests.
70// Validates username/password and returns a long-lived JWT (7 days).
71// Only works when auth type is "password" - returns error for "token" auth type.
72func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
73	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
74		zerowrap.FieldLayer:   "adapter",
75		zerowrap.FieldAdapter: "http",
76		zerowrap.FieldHandler: "auth",
77		zerowrap.FieldMethod:  r.Method,
78		zerowrap.FieldPath:    r.URL.Path,
79	})
80	log := zerowrap.FromCtx(ctx)
81
82	if r.Method != http.MethodPost {
83		w.Header().Set("Content-Type", "application/json")
84		w.WriteHeader(http.StatusMethodNotAllowed)
85		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
86		return
87	}
88
89	// Check if auth is enabled
90	if h.authSvc == nil || !h.authSvc.IsEnabled() {
91		w.Header().Set("Content-Type", "application/json")
92		w.WriteHeader(http.StatusBadRequest)
93		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"})
94		return
95	}
96
97	// Password endpoint only works with password auth type
98	if h.authSvc.GetAuthType() != domain.AuthTypePassword {
99		w.Header().Set("Content-Type", "application/json")
100		w.WriteHeader(http.StatusBadRequest)
101		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{
102			Error: "password authentication not configured, use token-based auth",
103		})
104		return
105	}
106
107	// Parse request body
108	var req PasswordRequest
109	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
110		w.Header().Set("Content-Type", "application/json")
111		w.WriteHeader(http.StatusBadRequest)
112		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
113		return
114	}
115
116	if req.Username == "" || req.Password == "" {
117		w.Header().Set("Content-Type", "application/json")
118		w.WriteHeader(http.StatusBadRequest)
119		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
120		return
121	}
122
123	// Validate password
124	if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
125		log.Debug().
126			Str("username", req.Username).
127			Msg("password authentication failed")
128		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
129		w.Header().Set("Content-Type", "application/json")
130		w.WriteHeader(http.StatusUnauthorized)
131		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
132		return
133	}
134
135	// Generate 7-day token with full access (push, pull, admin)
136	expiry := 7 * 24 * time.Hour
137	scopes := []string{"push", "pull", "admin:*:*"}
138
139	token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
140	if err != nil {
141		log.Error().Err(err).Msg("failed to generate token")
142		w.Header().Set("Content-Type", "application/json")
143		w.WriteHeader(http.StatusInternalServerError)
144		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
145		return
146	}
147
148	response := PasswordResponse{
149		Token:     token,
150		ExpiresIn: int(expiry.Seconds()),
151		IssuedAt:  time.Now().UTC().Format(time.RFC3339),
152	}
153
154	w.Header().Set("Content-Type", "application/json")
155	w.WriteHeader(http.StatusOK)
156
157	if err := json.NewEncoder(w).Encode(response); err != nil {
158		log.Error().Err(err).Msg("failed to encode password response")
159	}
160
161	log.Debug().
162		Str("username", req.Username).
163		Int("expires_in", response.ExpiresIn).
164		Msg("long-lived token issued via password auth")
165}
166
167// handleToken handles GET /auth/token requests.
168// This is the Docker Registry v2 token server endpoint.
169// Issues short-lived access tokens for registry access.
170func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
171	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
172		zerowrap.FieldLayer:   "adapter",
173		zerowrap.FieldAdapter: "http",
174		zerowrap.FieldHandler: "auth",
175		zerowrap.FieldMethod:  r.Method,
176		zerowrap.FieldPath:    r.URL.Path,
177	})
178	log := zerowrap.FromCtx(ctx)
179
180	if r.Method != http.MethodGet {
181		w.Header().Set("Content-Type", "application/json")
182		w.WriteHeader(http.StatusMethodNotAllowed)
183		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
184		return
185	}
186
187	// Check if auth is enabled
188	if h.authSvc == nil || !h.authSvc.IsEnabled() {
189		// If auth is disabled, return an anonymous token
190		h.sendAnonymousToken(w, log)
191		return
192	}
193
194	// Get credentials from Basic Auth
195	username, password, ok := r.BasicAuth()
196	if !ok {
197		// No credentials provided - return anonymous token with limited scope
198		h.sendAnonymousToken(w, log)
199		return
200	}
201
202	// Check for internal registry auth (localhost requests with internal creds)
203	var authenticated bool
204	if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
205		authenticated = true
206		log.Debug().Str("username", username).Msg("internal registry auth accepted")
207	} else {
208		// Validate credentials based on auth type
209		switch h.authSvc.GetAuthType() {
210		case domain.AuthTypePassword:
211			authenticated = h.authSvc.ValidatePassword(ctx, username, password)
212		case domain.AuthTypeToken:
213			// For token auth, the password might be an existing JWT
214			claims, err := h.authSvc.ValidateToken(ctx, password)
215			if err == nil && claims.Subject == username {
216				authenticated = true
217			}
218		}
219	}
220
221	if !authenticated {
222		log.Debug().
223			Str("username", username).
224			Msg("token request authentication failed")
225		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
226		w.Header().Set("Content-Type", "application/json")
227		w.WriteHeader(http.StatusUnauthorized)
228		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
229		return
230	}
231
232	// Parse requested scopes from query parameter
233	requestedScopes := h.parseRequestedScopes(r, log)
234
235	// Generate a short-lived access token (5 minutes) - not stored
236	accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
237	if err != nil {
238		log.Error().Err(err).Msg("failed to generate access token")
239		w.Header().Set("Content-Type", "application/json")
240		w.WriteHeader(http.StatusInternalServerError)
241		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
242		return
243	}
244
245	response := dto.TokenResponse{
246		Token:     accessToken,
247		ExpiresIn: 300, // 5 minutes in seconds
248		IssuedAt:  time.Now().UTC().Format(time.RFC3339),
249	}
250
251	w.Header().Set("Content-Type", "application/json")
252	w.WriteHeader(http.StatusOK)
253
254	if err := json.NewEncoder(w).Encode(response); err != nil {
255		log.Error().Err(err).Msg("failed to encode token response")
256	}
257
258	log.Debug().
259		Str("username", username).
260		Int("expires_in", response.ExpiresIn).
261		Msg("access token issued")
262}
263
264// sendAnonymousToken sends a token for anonymous/unauthenticated access.
265func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
266	// For anonymous access, we issue a very short-lived token with limited scope
267	response := dto.TokenResponse{
268		Token:     "", // Empty token indicates limited access
269		ExpiresIn: 60, // 1 minute
270		IssuedAt:  time.Now().UTC().Format(time.RFC3339),
271	}
272
273	w.Header().Set("Content-Type", "application/json")
274	w.WriteHeader(http.StatusOK)
275
276	if err := json.NewEncoder(w).Encode(response); err != nil {
277		log.Error().Err(err).Msg("failed to encode anonymous token response")
278	}
279
280	log.Debug().Msg("anonymous token issued")
281}
282
283// isLocalhostRequest checks if the request originates from localhost.
284// SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
285func isLocalhostRequest(r *http.Request) bool {
286	host := r.RemoteAddr
287	// RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
288	return strings.HasPrefix(host, "127.") ||
289		strings.HasPrefix(host, "[::1]") ||
290		strings.HasPrefix(host, "::1")
291}
292
293// parseRequestedScopes extracts and validates scope parameters from the request.
294// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
295// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
296func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
297	scopeParams := r.URL.Query()["scope"]
298
299	if len(scopeParams) == 0 {
300		// Default scope: pull from any repository for backward compatibility
301		log.Debug().Msg("no scope requested, using default pull scope")
302		return []string{"repository:*:pull"}
303	}
304
305	var validScopes []string
306	for _, scopeStr := range scopeParams {
307		// Validate the scope format
308		scope, err := domain.ParseScope(scopeStr)
309		if err != nil {
310			log.Debug().
311				Err(err).
312				Str("scope", scopeStr).
313				Msg("invalid scope format, skipping")
314			continue
315		}
316
317		// Only allow repository scopes
318		if scope.Type != "repository" {
319			log.Debug().
320				Str("scope_type", scope.Type).
321				Str("scope", scopeStr).
322				Msg("unsupported scope type, skipping")
323			continue
324		}
325
326		validScopes = append(validScopes, scopeStr)
327	}
328
329	if len(validScopes) == 0 {
330		// All requested scopes were invalid, return default
331		log.Debug().Msg("all requested scopes invalid, using default pull scope")
332		return []string{"repository:*:pull"}
333	}
334
335	log.Debug().
336		Strs("scopes", validScopes).
337		Msg("parsed requested scopes")
338	return validScopes
339}
340
341// isInternalAuth checks if the credentials match internal registry auth.
342func (h *Handler) isInternalAuth(username, password string) bool {
343	if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
344		return false
345	}
346	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(h.internalAuth.Username)) == 1
347	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(h.internalAuth.Password)) == 1
348	return usernameMatch && passwordMatch
349}

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 8

Tags 106

↑↓ moveenter openesc close