v2.2.0 64d39bf

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)
26
27// Config holds the authentication configuration.
28type Config struct {
29	Enabled      bool
30	AuthType     domain.AuthType
31	Username     string
32	PasswordHash string        // bcrypt hash for password auth
33	TokenSecret  []byte        // signing secret for token auth
34	TokenExpiry  time.Duration // default token expiry (0 = never)
35}
36
37// Service implements the AuthService interface.
38type Service struct {
39	config     Config
40	tokenStore out.TokenStore
41	log        zerowrap.Logger
42}
43
44// NewService creates a new auth service.
45func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
46	return &Service{
47		config:     config,
48		tokenStore: tokenStore,
49		log:        log,
50	}
51}
52
53// GetAuthType returns the configured authentication type.
54func (s *Service) GetAuthType() domain.AuthType {
55	return s.config.AuthType
56}
57
58// IsEnabled returns whether authentication is enabled.
59func (s *Service) IsEnabled() bool {
60	return s.config.Enabled
61}
62
63// ValidatePassword checks if the username and password are valid.
64func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
65	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
66		zerowrap.FieldLayer:   "usecase",
67		zerowrap.FieldUseCase: "ValidatePassword",
68		"username":            username,
69	})
70	log := zerowrap.FromCtx(ctx)
71
72	// Constant-time username comparison
73	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
74
75	// Bcrypt comparison (already constant-time)
76	err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
77	passwordMatch := err == nil
78
79	if !usernameMatch || !passwordMatch {
80		log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
81		return false
82	}
83
84	log.Debug().Msg("password validation successful")
85	return true
86}
87
88// ValidateToken validates a JWT token and returns its claims.
89func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
90	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
91		zerowrap.FieldLayer:   "usecase",
92		zerowrap.FieldUseCase: "ValidateToken",
93	})
94	log := zerowrap.FromCtx(ctx)
95
96	claims, err := s.parseTokenClaims(tokenString)
97	if err != nil {
98		log.Debug().Err(err).Msg("failed to parse token")
99		return nil, err
100	}
101
102	tokenClaims := buildTokenClaims(claims)
103
104	// SECURITY: Verify token exists in store (prevents use of externally-created tokens)
105	if err := s.ensureTokenExists(ctx, tokenClaims, log); err != nil {
106		return nil, err
107	}
108
109	if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
110		return nil, err
111	}
112
113	if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
114		return nil, err
115	}
116
117	log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
118	return tokenClaims, nil
119}
120
121// ensureTokenExists verifies the token exists in the store and the JTI matches.
122func (s *Service) ensureTokenExists(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
123	_, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject)
124	if err != nil {
125		if errors.Is(err, domain.ErrTokenNotFound) {
126			log.Debug().Str("subject", tokenClaims.Subject).Msg("token not found in store")
127			return domain.ErrInvalidToken
128		}
129		// SECURITY: Fail closed on store errors
130		log.Error().Err(err).Msg("failed to verify token existence")
131		return fmt.Errorf("unable to verify token: %w", err)
132	}
133
134	// Verify the token ID matches the stored token
135	if storedToken.ID != tokenClaims.ID {
136		log.Debug().
137			Str("expected_id", storedToken.ID).
138			Str("actual_id", tokenClaims.ID).
139			Msg("token ID mismatch")
140		return domain.ErrInvalidToken
141	}
142
143	return nil
144}
145
146func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
147	// Parse token with issuer validation
148	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
149		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
150			return nil, domain.ErrInvalidToken
151		}
152		return s.config.TokenSecret, nil
153	}, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation
154	if err != nil {
155		return nil, domain.ErrInvalidToken
156	}
157
158	if !token.Valid {
159		return nil, domain.ErrInvalidToken
160	}
161
162	claims, ok := token.Claims.(jwt.MapClaims)
163	if !ok {
164		return nil, domain.ErrInvalidToken
165	}
166
167	// SECURITY: Double-check issuer claim matches expected value
168	if iss, ok := claims["iss"].(string); !ok || iss != TokenIssuer {
169		return nil, domain.ErrInvalidToken
170	}
171
172	return claims, nil
173}
174
175func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
176	tokenClaims := &domain.TokenClaims{
177		Issuer: getStringClaim(claims, "iss"),
178	}
179
180	if jti, ok := claims["jti"].(string); ok {
181		tokenClaims.ID = jti
182	}
183	if sub, ok := claims["sub"].(string); ok {
184		tokenClaims.Subject = sub
185	}
186	if iat, ok := claims["iat"].(float64); ok {
187		tokenClaims.IssuedAt = int64(iat)
188	}
189	if exp, ok := claims["exp"].(float64); ok {
190		tokenClaims.ExpiresAt = int64(exp)
191	}
192	if scopes, ok := claims["scopes"].([]any); ok {
193		for _, scope := range scopes {
194			if s, ok := scope.(string); ok {
195				tokenClaims.Scopes = append(tokenClaims.Scopes, s)
196			}
197		}
198	}
199
200	return tokenClaims
201}
202
203func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
204	revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
205	if err != nil {
206		// SECURITY: Fail closed - deny access on revocation check error
207		log.Error().Err(err).Str("token_id", tokenClaims.ID).Msg("failed to check token revocation, denying access")
208		return fmt.Errorf("unable to verify token status: %w", err)
209	}
210	if revoked {
211		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
212		return domain.ErrRevokedToken
213	}
214
215	return nil
216}
217
218func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
219	if tokenClaims.ExpiresAt <= 0 {
220		return nil
221	}
222
223	if time.Now().Unix() > tokenClaims.ExpiresAt {
224		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
225		return domain.ErrExpiredToken
226	}
227
228	return nil
229}
230
231// GenerateToken creates a new JWT token for the given subject.
232func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
233	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
234		zerowrap.FieldLayer:   "usecase",
235		zerowrap.FieldUseCase: "GenerateToken",
236		"subject":             subject,
237	})
238	log := zerowrap.FromCtx(ctx)
239
240	tokenID := uuid.New().String()
241	now := time.Now()
242
243	// Build token
244	token := &domain.Token{
245		ID:       tokenID,
246		Subject:  subject,
247		Scopes:   scopes,
248		IssuedAt: now,
249		Revoked:  false,
250	}
251
252	// Build JWT claims
253	claims := jwt.MapClaims{
254		"jti":    tokenID,
255		"sub":    subject,
256		"iss":    TokenIssuer,
257		"iat":    now.Unix(),
258		"scopes": scopes,
259	}
260
261	// Set expiry if specified
262	if expiry > 0 {
263		expiresAt := now.Add(expiry)
264		token.ExpiresAt = expiresAt
265		claims["exp"] = expiresAt.Unix()
266	}
267	// If expiry is 0, token never expires (no exp claim)
268
269	// Sign token
270	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
271	tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
272	if err != nil {
273		return "", log.WrapErr(err, "failed to sign token")
274	}
275
276	// Store token
277	if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
278		return "", log.WrapErr(err, "failed to save token")
279	}
280
281	log.Info().
282		Str("token_id", tokenID).
283		Str("subject", subject).
284		Time("expires_at", token.ExpiresAt).
285		Msg("token generated")
286
287	return tokenString, nil
288}
289
290// RevokeToken revokes a token by its ID.
291func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
292	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
293		zerowrap.FieldLayer:   "usecase",
294		zerowrap.FieldUseCase: "RevokeToken",
295		"token_id":            tokenID,
296	})
297	log := zerowrap.FromCtx(ctx)
298
299	if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
300		return log.WrapErr(err, "failed to revoke token")
301	}
302
303	log.Info().Msg("token revoked")
304	return nil
305}
306
307// ListTokens returns all stored tokens.
308func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
309	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
310		zerowrap.FieldLayer:   "usecase",
311		zerowrap.FieldUseCase: "ListTokens",
312	})
313	log := zerowrap.FromCtx(ctx)
314
315	tokens, err := s.tokenStore.ListTokens(ctx)
316	if err != nil {
317		return nil, log.WrapErr(err, "failed to list tokens")
318	}
319
320	return tokens, nil
321}
322
323// GeneratePasswordHash generates a bcrypt hash for a password.
324func (s *Service) GeneratePasswordHash(password string) (string, error) {
325	hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
326	if err != nil {
327		return "", err
328	}
329	return string(hash), nil
330}
331
332// getStringClaim safely extracts a string claim from JWT claims.
333func getStringClaim(claims jwt.MapClaims, key string) string {
334	if val, ok := claims[key].(string); ok {
335		return val
336	}
337	return ""
338}

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