v2.7.0 fac7705

service.go RawHistoryFolder

1// Package auth implements the authentication use case for the registry.
2package auth
3
4import (
5	"context"
6	"crypto/subtle"
7	"errors"
8	"fmt"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/golang-jwt/jwt/v5"
13	"github.com/google/uuid"
14	"golang.org/x/crypto/bcrypt"
15
16	"gordon/internal/boundaries/out"
17	"gordon/internal/domain"
18)
19
20const (
21	// TokenIssuer is the issuer claim for generated tokens.
22	TokenIssuer = "gordon-registry"
23	// DefaultBcryptCost is the default cost for bcrypt hashing.
24	DefaultBcryptCost = 12
25	// MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
26	// Tokens with this lifetime or less skip store validation.
27	MaxAccessTokenLifetime = 5 * time.Minute
28	// maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
29	maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
30)
31
32// Config holds the authentication configuration.
33type Config struct {
34	Enabled      bool
35	AuthType     domain.AuthType
36	Username     string
37	PasswordHash string        // bcrypt hash for password auth
38	TokenSecret  []byte        // signing secret for token auth
39	TokenExpiry  time.Duration // default token expiry (0 = never)
40}
41
42// Service implements the AuthService interface.
43type Service struct {
44	config     Config
45	tokenStore out.TokenStore
46	log        zerowrap.Logger
47}
48
49// NewService creates a new auth service.
50func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
51	return &Service{
52		config:     config,
53		tokenStore: tokenStore,
54		log:        log,
55	}
56}
57
58// GetAuthType returns the configured authentication type.
59func (s *Service) GetAuthType() domain.AuthType {
60	return s.config.AuthType
61}
62
63// IsEnabled returns whether authentication is enabled.
64func (s *Service) IsEnabled() bool {
65	return s.config.Enabled
66}
67
68// ValidatePassword checks if the username and password are valid.
69func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
70	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71		zerowrap.FieldLayer:   "usecase",
72		zerowrap.FieldUseCase: "ValidatePassword",
73		"username":            username,
74	})
75	log := zerowrap.FromCtx(ctx)
76
77	// Constant-time username comparison
78	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
79
80	// Bcrypt comparison (already constant-time)
81	err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
82	passwordMatch := err == nil
83
84	if !usernameMatch || !passwordMatch {
85		log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
86		return false
87	}
88
89	log.Debug().Msg("password validation successful")
90	return true
91}
92
93// ValidateToken validates a JWT token and returns its claims.
94func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
95	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
96		zerowrap.FieldLayer:   "usecase",
97		zerowrap.FieldUseCase: "ValidateToken",
98	})
99	log := zerowrap.FromCtx(ctx)
100
101	claims, err := s.parseTokenClaims(tokenString)
102	if err != nil {
103		log.Debug().Err(err).Msg("failed to parse token")
104		return nil, err
105	}
106
107	tokenClaims := buildTokenClaims(claims)
108
109	// Access tokens (short-lived, ≤5min, recently issued) skip store validation.
110	// CLI-generated tokens must exist in store to prevent use of externally-created tokens.
111	// Security: require token to be recently issued (within MaxAccessTokenLifetime) to prevent
112	// attackers with stolen secrets from creating arbitrary short-lived tokens.
113	isAccessToken := s.isEphemeralAccessToken(tokenClaims)
114
115	if !isAccessToken {
116		if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
117			return nil, err
118		}
119		if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
120			return nil, err
121		}
122	}
123
124	if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
125		return nil, err
126	}
127
128	log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
129	return tokenClaims, nil
130}
131
132// ensureTokenExists verifies the token exists in the store and the JTI matches.
133func (s *Service) ensureTokenExists(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
134	_, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
135	if err != nil {
136		if errors.Is(err, domain.ErrTokenNotFound) {
137			log.Debug().Str("subject", tokenClaims.Subject).Msg("token not found in store")
138			return domain.ErrInvalidToken
139		}
140		// SECURITY: Fail closed on store errors
141		log.Error().Err(err).Msg("failed to verify token existence")
142		return fmt.Errorf("unable to verify token: %w", err)
143	}
144
145	// Verify the token ID matches the stored token
146	if storedToken.ID != tokenClaims.ID {
147		log.Debug().
148			Str("expected_id", storedToken.ID).
149			Str("actual_id", tokenClaims.ID).
150			Msg("token ID mismatch")
151		return domain.ErrInvalidToken
152	}
153
154	return nil
155}
156
157func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
158	// Parse token with issuer validation
159	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
160		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
161			return nil, domain.ErrInvalidToken
162		}
163		return s.config.TokenSecret, nil
164	}, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation
165	if err != nil {
166		return nil, domain.ErrInvalidToken
167	}
168
169	if !token.Valid {
170		return nil, domain.ErrInvalidToken
171	}
172
173	claims, ok := token.Claims.(jwt.MapClaims)
174	if !ok {
175		return nil, domain.ErrInvalidToken
176	}
177
178	// SECURITY: Double-check issuer claim matches expected value
179	if iss, ok := claims["iss"].(string); !ok || iss != TokenIssuer {
180		return nil, domain.ErrInvalidToken
181	}
182
183	return claims, nil
184}
185
186func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
187	tokenClaims := &domain.TokenClaims{
188		Issuer: getStringClaim(claims, "iss"),
189	}
190
191	if jti, ok := claims["jti"].(string); ok {
192		tokenClaims.ID = jti
193	}
194	if sub, ok := claims["sub"].(string); ok {
195		tokenClaims.Subject = sub
196	}
197	if iat, ok := claims["iat"].(float64); ok {
198		tokenClaims.IssuedAt = int64(iat)
199	}
200	if exp, ok := claims["exp"].(float64); ok {
201		tokenClaims.ExpiresAt = int64(exp)
202	}
203	if scopes, ok := claims["scopes"].([]any); ok {
204		for _, scope := range scopes {
205			if s, ok := scope.(string); ok {
206				tokenClaims.Scopes = append(tokenClaims.Scopes, s)
207			}
208		}
209	}
210
211	return tokenClaims
212}
213
214func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
215	revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
216	if err != nil {
217		// SECURITY: Fail closed - deny access on revocation check error
218		log.Error().Err(err).Str("token_id", tokenClaims.ID).Msg("failed to check token revocation, denying access")
219		return fmt.Errorf("unable to verify token status: %w", err)
220	}
221	if revoked {
222		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
223		return domain.ErrRevokedToken
224	}
225
226	return nil
227}
228
229func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
230	if tokenClaims.ExpiresAt <= 0 {
231		return nil
232	}
233
234	if time.Now().Unix() > tokenClaims.ExpiresAt {
235		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
236		return domain.ErrExpiredToken
237	}
238
239	return nil
240}
241
242// isEphemeralAccessToken checks if a token is a short-lived access token that
243// doesn't require store validation. Access tokens must:
244// 1. Have a positive expiry (not never-expiring)
245// 2. Have lifetime ≤ MaxAccessTokenLifetime
246// 3. Be recently issued (within MaxAccessTokenLifetime) to prevent replay attacks
247func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
248	if claims.ExpiresAt <= 0 {
249		return false // Never-expiring tokens require store validation
250	}
251
252	lifetime := claims.ExpiresAt - claims.IssuedAt
253	if lifetime > maxAccessTokenLifetimeSecs {
254		return false // Long-lived tokens require store validation
255	}
256
257	// Security: ensure token was recently issued to prevent attackers with
258	// stolen secrets from creating arbitrary short-lived tokens
259	now := time.Now().Unix()
260	tokenAge := now - claims.IssuedAt
261	if tokenAge > maxAccessTokenLifetimeSecs {
262		return false // Old tokens require store validation
263	}
264
265	return true
266}
267
268// GenerateToken creates a new JWT token for the given subject.
269func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
270	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
271		zerowrap.FieldLayer:   "usecase",
272		zerowrap.FieldUseCase: "GenerateToken",
273		"subject":             subject,
274	})
275	log := zerowrap.FromCtx(ctx)
276
277	tokenID := uuid.New().String()
278	now := time.Now()
279
280	// Build token
281	token := &domain.Token{
282		ID:       tokenID,
283		Subject:  subject,
284		Scopes:   scopes,
285		IssuedAt: now,
286		Revoked:  false,
287	}
288
289	// Build JWT claims
290	claims := jwt.MapClaims{
291		"jti":    tokenID,
292		"sub":    subject,
293		"iss":    TokenIssuer,
294		"iat":    now.Unix(),
295		"nbf":    now.Unix(), // SECURITY: Not-before claim for clock skew protection
296		"scopes": scopes,
297	}
298
299	// Set expiry if specified
300	if expiry > 0 {
301		expiresAt := now.Add(expiry)
302		token.ExpiresAt = expiresAt
303		claims["exp"] = expiresAt.Unix()
304	}
305	// If expiry is 0, token never expires (no exp claim)
306
307	// Sign token
308	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
309	tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
310	if err != nil {
311		return "", log.WrapErr(err, "failed to sign token")
312	}
313
314	// Store token
315	if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
316		return "", log.WrapErr(err, "failed to save token")
317	}
318
319	log.Info().
320		Str("token_id", tokenID).
321		Str("subject", subject).
322		Time("expires_at", token.ExpiresAt).
323		Msg("token generated")
324
325	return tokenString, nil
326}
327
328// GenerateAccessToken creates a short-lived JWT for registry access without storing it.
329// Used by /v2/token endpoint - these tokens don't need persistence.
330// Expiry must be > 0 and <= MaxAccessTokenLifetime to prevent misuse.
331func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
332	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
333		zerowrap.FieldLayer:   "usecase",
334		zerowrap.FieldUseCase: "GenerateAccessToken",
335		"subject":             subject,
336	})
337	log := zerowrap.FromCtx(ctx)
338
339	// Enforce expiry constraints to prevent misuse
340	if expiry <= 0 {
341		return "", fmt.Errorf("access token expiry must be positive")
342	}
343	if expiry > MaxAccessTokenLifetime {
344		return "", fmt.Errorf("access token expiry exceeds maximum of %v", MaxAccessTokenLifetime)
345	}
346
347	tokenID := uuid.New().String()
348	now := time.Now()
349
350	claims := jwt.MapClaims{
351		"jti":    tokenID,
352		"sub":    subject,
353		"iss":    TokenIssuer,
354		"iat":    now.Unix(),
355		"nbf":    now.Unix(), // SECURITY: Not-before claim for clock skew protection
356		"scopes": scopes,
357		"exp":    now.Add(expiry).Unix(),
358	}
359
360	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
361	tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
362	if err != nil {
363		return "", log.WrapErr(err, "failed to sign access token")
364	}
365
366	log.Debug().
367		Str("token_id", tokenID).
368		Str("subject", subject).
369		Msg("access token generated")
370
371	return tokenString, nil
372}
373
374// RevokeToken revokes a token by its ID.
375func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
376	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
377		zerowrap.FieldLayer:   "usecase",
378		zerowrap.FieldUseCase: "RevokeToken",
379		"token_id":            tokenID,
380	})
381	log := zerowrap.FromCtx(ctx)
382
383	if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
384		return log.WrapErr(err, "failed to revoke token")
385	}
386
387	log.Info().Msg("token revoked")
388	return nil
389}
390
391// RevokeAllTokens revokes all stored tokens and returns the count of revoked tokens.
392func (s *Service) RevokeAllTokens(ctx context.Context) (int, error) {
393	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
394		zerowrap.FieldLayer:   "usecase",
395		zerowrap.FieldUseCase: "RevokeAllTokens",
396	})
397	log := zerowrap.FromCtx(ctx)
398
399	tokens, err := s.tokenStore.ListTokens(ctx)
400	if err != nil {
401		return 0, log.WrapErr(err, "failed to list tokens")
402	}
403
404	revoked := 0
405	for _, token := range tokens {
406		if token.Revoked {
407			continue // Already revoked
408		}
409		if err := s.tokenStore.Revoke(ctx, token.ID); err != nil {
410			log.Warn().Err(err).Str("token_id", token.ID).Msg("failed to revoke token")
411			continue
412		}
413		revoked++
414	}
415
416	log.Info().Int("count", revoked).Msg("all tokens revoked")
417	return revoked, nil
418}
419
420// ListTokens returns all stored tokens.
421func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
422	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
423		zerowrap.FieldLayer:   "usecase",
424		zerowrap.FieldUseCase: "ListTokens",
425	})
426	log := zerowrap.FromCtx(ctx)
427
428	tokens, err := s.tokenStore.ListTokens(ctx)
429	if err != nil {
430		return nil, log.WrapErr(err, "failed to list tokens")
431	}
432
433	return tokens, nil
434}
435
436// GeneratePasswordHash generates a bcrypt hash for a password.
437func (s *Service) GeneratePasswordHash(password string) (string, error) {
438	hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
439	if err != nil {
440		return "", err
441	}
442	return string(hash), nil
443}
444
445// getStringClaim safely extracts a string claim from JWT claims.
446func getStringClaim(claims jwt.MapClaims, key string) string {
447	if val, ok := claims[key].(string); ok {
448		return val
449	}
450	return ""
451}

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