v2.10.1 43db1bb

handler.go RawHistoryFolder

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

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