v2.1.0 0fb867d

service.go RawHistoryFolder

1// Package auth implements the authentication use case for the registry.
2package auth
3
4import (
5	"context"
6	"crypto/subtle"
7	"time"
8
9	"github.com/bnema/zerowrap"
10	"github.com/golang-jwt/jwt/v5"
11	"github.com/google/uuid"
12	"golang.org/x/crypto/bcrypt"
13
14	"gordon/internal/boundaries/out"
15	"gordon/internal/domain"
16)
17
18const (
19	// TokenIssuer is the issuer claim for generated tokens.
20	TokenIssuer = "gordon-registry"
21	// DefaultBcryptCost is the default cost for bcrypt hashing.
22	DefaultBcryptCost = 12
23)
24
25// Config holds the authentication configuration.
26type Config struct {
27	Enabled      bool
28	AuthType     domain.AuthType
29	Username     string
30	PasswordHash string        // bcrypt hash for password auth
31	TokenSecret  []byte        // signing secret for token auth
32	TokenExpiry  time.Duration // default token expiry (0 = never)
33}
34
35// Service implements the AuthService interface.
36type Service struct {
37	config     Config
38	tokenStore out.TokenStore
39	log        zerowrap.Logger
40}
41
42// NewService creates a new auth service.
43func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
44	return &Service{
45		config:     config,
46		tokenStore: tokenStore,
47		log:        log,
48	}
49}
50
51// GetAuthType returns the configured authentication type.
52func (s *Service) GetAuthType() domain.AuthType {
53	return s.config.AuthType
54}
55
56// IsEnabled returns whether authentication is enabled.
57func (s *Service) IsEnabled() bool {
58	return s.config.Enabled
59}
60
61// ValidatePassword checks if the username and password are valid.
62func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
63	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64		zerowrap.FieldLayer:   "usecase",
65		zerowrap.FieldUseCase: "ValidatePassword",
66		"username":            username,
67	})
68	log := zerowrap.FromCtx(ctx)
69
70	// Constant-time username comparison
71	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
72
73	// Bcrypt comparison (already constant-time)
74	err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
75	passwordMatch := err == nil
76
77	if !usernameMatch || !passwordMatch {
78		log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
79		return false
80	}
81
82	log.Debug().Msg("password validation successful")
83	return true
84}
85
86// ValidateToken validates a JWT token and returns its claims.
87func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
88	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
89		zerowrap.FieldLayer:   "usecase",
90		zerowrap.FieldUseCase: "ValidateToken",
91	})
92	log := zerowrap.FromCtx(ctx)
93
94	claims, err := s.parseTokenClaims(tokenString)
95	if err != nil {
96		log.Debug().Err(err).Msg("failed to parse token")
97		return nil, err
98	}
99
100	tokenClaims := buildTokenClaims(claims)
101
102	if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil {
103		return nil, err
104	}
105
106	if err := ensureTokenNotExpired(tokenClaims, log); err != nil {
107		return nil, err
108	}
109
110	log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
111	return tokenClaims, nil
112}
113
114func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
115	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
116		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
117			return nil, domain.ErrInvalidToken
118		}
119		return s.config.TokenSecret, nil
120	})
121	if err != nil {
122		return nil, domain.ErrInvalidToken
123	}
124
125	if !token.Valid {
126		return nil, domain.ErrInvalidToken
127	}
128
129	claims, ok := token.Claims.(jwt.MapClaims)
130	if !ok {
131		return nil, domain.ErrInvalidToken
132	}
133
134	return claims, nil
135}
136
137func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims {
138	tokenClaims := &domain.TokenClaims{
139		Issuer: getStringClaim(claims, "iss"),
140	}
141
142	if jti, ok := claims["jti"].(string); ok {
143		tokenClaims.ID = jti
144	}
145	if sub, ok := claims["sub"].(string); ok {
146		tokenClaims.Subject = sub
147	}
148	if iat, ok := claims["iat"].(float64); ok {
149		tokenClaims.IssuedAt = int64(iat)
150	}
151	if exp, ok := claims["exp"].(float64); ok {
152		tokenClaims.ExpiresAt = int64(exp)
153	}
154	if scopes, ok := claims["scopes"].([]any); ok {
155		for _, scope := range scopes {
156			if s, ok := scope.(string); ok {
157				tokenClaims.Scopes = append(tokenClaims.Scopes, s)
158			}
159		}
160	}
161
162	return tokenClaims
163}
164
165func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
166	revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
167	if err != nil {
168		log.Warn().Err(err).Msg("failed to check token revocation")
169		return nil
170	}
171	if revoked {
172		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
173		return domain.ErrRevokedToken
174	}
175
176	return nil
177}
178
179func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error {
180	if tokenClaims.ExpiresAt <= 0 {
181		return nil
182	}
183
184	if time.Now().Unix() > tokenClaims.ExpiresAt {
185		log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
186		return domain.ErrExpiredToken
187	}
188
189	return nil
190}
191
192// GenerateToken creates a new JWT token for the given subject.
193func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
194	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
195		zerowrap.FieldLayer:   "usecase",
196		zerowrap.FieldUseCase: "GenerateToken",
197		"subject":             subject,
198	})
199	log := zerowrap.FromCtx(ctx)
200
201	tokenID := uuid.New().String()
202	now := time.Now()
203
204	// Build token
205	token := &domain.Token{
206		ID:       tokenID,
207		Subject:  subject,
208		Scopes:   scopes,
209		IssuedAt: now,
210		Revoked:  false,
211	}
212
213	// Build JWT claims
214	claims := jwt.MapClaims{
215		"jti":    tokenID,
216		"sub":    subject,
217		"iss":    TokenIssuer,
218		"iat":    now.Unix(),
219		"scopes": scopes,
220	}
221
222	// Set expiry if specified
223	if expiry > 0 {
224		expiresAt := now.Add(expiry)
225		token.ExpiresAt = expiresAt
226		claims["exp"] = expiresAt.Unix()
227	}
228	// If expiry is 0, token never expires (no exp claim)
229
230	// Sign token
231	jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
232	tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
233	if err != nil {
234		return "", log.WrapErr(err, "failed to sign token")
235	}
236
237	// Store token
238	if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
239		return "", log.WrapErr(err, "failed to save token")
240	}
241
242	log.Info().
243		Str("token_id", tokenID).
244		Str("subject", subject).
245		Time("expires_at", token.ExpiresAt).
246		Msg("token generated")
247
248	return tokenString, nil
249}
250
251// RevokeToken revokes a token by its ID.
252func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
253	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
254		zerowrap.FieldLayer:   "usecase",
255		zerowrap.FieldUseCase: "RevokeToken",
256		"token_id":            tokenID,
257	})
258	log := zerowrap.FromCtx(ctx)
259
260	if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
261		return log.WrapErr(err, "failed to revoke token")
262	}
263
264	log.Info().Msg("token revoked")
265	return nil
266}
267
268// ListTokens returns all stored tokens.
269func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
270	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
271		zerowrap.FieldLayer:   "usecase",
272		zerowrap.FieldUseCase: "ListTokens",
273	})
274	log := zerowrap.FromCtx(ctx)
275
276	tokens, err := s.tokenStore.ListTokens(ctx)
277	if err != nil {
278		return nil, log.WrapErr(err, "failed to list tokens")
279	}
280
281	return tokens, nil
282}
283
284// GeneratePasswordHash generates a bcrypt hash for a password.
285func (s *Service) GeneratePasswordHash(password string) (string, error) {
286	hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
287	if err != nil {
288		return "", err
289	}
290	return string(hash), nil
291}
292
293// getStringClaim safely extracts a string claim from JWT claims.
294func getStringClaim(claims jwt.MapClaims, key string) string {
295	if val, ok := claims[key].(string); ok {
296		return val
297	}
298	return ""
299}

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