Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/ids"
|
||||
)
|
||||
|
||||
// Errors returned by the token service. They are deliberately few: a caller
|
||||
// presenting a bad token learns that it was refused, not why.
|
||||
var (
|
||||
ErrInvalidToken = errors.New("api token is not valid")
|
||||
ErrTokenScope = errors.New("api token lacks the required scope")
|
||||
ErrTokenRateLimit = errors.New("api token is making too many requests")
|
||||
)
|
||||
|
||||
const (
|
||||
// tokenPrefix marks the credential and carries a version, so a future
|
||||
// format change is distinguishable rather than ambiguous.
|
||||
tokenPrefix = "rlt_v1_"
|
||||
// tokenBytes is the entropy behind a token. 32 bytes is far beyond
|
||||
// guessing, which is the point: there is no rate limit that makes a
|
||||
// low-entropy secret safe.
|
||||
tokenBytes = 32
|
||||
// displayPrefixLen is how much of the token is kept in clear so a person
|
||||
// can tell their tokens apart. Short enough to be useless as a credential.
|
||||
displayPrefixLen = 12
|
||||
// touchInterval throttles last-used writes. A busy token would otherwise
|
||||
// turn every read request into a write.
|
||||
touchInterval = time.Minute
|
||||
// maxTokenNameLen bounds the person's own label.
|
||||
maxTokenNameLen = 80
|
||||
// maxTokensPerUser bounds how many live tokens one person may hold.
|
||||
maxTokensPerUser = 25
|
||||
)
|
||||
|
||||
// TokenService issues and verifies API tokens.
|
||||
type TokenService struct {
|
||||
tokens domain.APITokenRepository
|
||||
users domain.UserRepository
|
||||
cfg TokenConfig
|
||||
|
||||
// touched remembers when each token's last-used time was last written, so
|
||||
// the throttle survives across requests without a database read.
|
||||
mu sync.Mutex
|
||||
touched map[string]time.Time
|
||||
|
||||
limiter *tokenLimiter
|
||||
}
|
||||
|
||||
// TokenConfig bounds token lifetimes and request rates.
|
||||
type TokenConfig struct {
|
||||
DefaultTTL time.Duration
|
||||
MaxTTL time.Duration
|
||||
// RateBurst requests are allowed per RateWindow per token.
|
||||
RateBurst int
|
||||
RateWindow time.Duration
|
||||
}
|
||||
|
||||
// NewTokenService builds the service.
|
||||
func NewTokenService(tokens domain.APITokenRepository, users domain.UserRepository, cfg TokenConfig) *TokenService {
|
||||
if cfg.DefaultTTL <= 0 {
|
||||
cfg.DefaultTTL = 90 * 24 * time.Hour
|
||||
}
|
||||
if cfg.MaxTTL <= 0 {
|
||||
cfg.MaxTTL = 365 * 24 * time.Hour
|
||||
}
|
||||
if cfg.RateBurst <= 0 {
|
||||
cfg.RateBurst = 600
|
||||
}
|
||||
if cfg.RateWindow <= 0 {
|
||||
cfg.RateWindow = time.Minute
|
||||
}
|
||||
return &TokenService{
|
||||
tokens: tokens,
|
||||
users: users,
|
||||
cfg: cfg,
|
||||
touched: map[string]time.Time{},
|
||||
limiter: newTokenLimiter(cfg.RateBurst, cfg.RateWindow),
|
||||
}
|
||||
}
|
||||
|
||||
// IssuedToken is returned once, at creation. The Secret is never recoverable
|
||||
// afterwards because only its hash is stored.
|
||||
type IssuedToken struct {
|
||||
Token *domain.APIToken
|
||||
Secret string
|
||||
}
|
||||
|
||||
// CreateRequest describes a token to issue.
|
||||
type CreateRequest struct {
|
||||
Name string
|
||||
Scopes []domain.TokenScope
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// Create issues a token for the principal.
|
||||
//
|
||||
// The granted scopes are the intersection of what was asked for and what the
|
||||
// person's role permits. Asking for more is not an error — it is narrowed and
|
||||
// the caller is shown what was actually granted — because the alternative is a
|
||||
// form that fails for reasons the person cannot see.
|
||||
func (s *TokenService) Create(ctx context.Context, p *domain.Principal, req CreateRequest) (*IssuedToken, error) {
|
||||
if p == nil || p.UserID == "" || p.WorkspaceID == "" {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
// A token cannot mint another token: that would let a leaked credential
|
||||
// extend its own life indefinitely.
|
||||
if p.ViaToken() {
|
||||
return nil, fmt.Errorf("%w: API tokens cannot create API tokens", domain.ErrForbidden)
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: a token needs a name so you can tell it from the others", domain.ErrInvalidInput)
|
||||
}
|
||||
if len([]rune(name)) > maxTokenNameLen {
|
||||
return nil, fmt.Errorf("%w: a token name may be at most %d characters", domain.ErrInvalidInput, maxTokenNameLen)
|
||||
}
|
||||
|
||||
granted := make([]domain.TokenScope, 0, len(req.Scopes))
|
||||
seen := map[domain.TokenScope]bool{}
|
||||
for _, sc := range req.Scopes {
|
||||
if !sc.Valid() {
|
||||
return nil, fmt.Errorf("%w: %q is not a recognised scope", domain.ErrInvalidInput, sc)
|
||||
}
|
||||
if seen[sc] || !domain.RoleGrants(p.Role, sc) {
|
||||
continue
|
||||
}
|
||||
seen[sc] = true
|
||||
granted = append(granted, sc)
|
||||
}
|
||||
if len(granted) == 0 {
|
||||
return nil, fmt.Errorf("%w: none of the requested scopes are available to a %s",
|
||||
domain.ErrInvalidInput, p.Role)
|
||||
}
|
||||
|
||||
ttl := req.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = s.cfg.DefaultTTL
|
||||
}
|
||||
if ttl > s.cfg.MaxTTL {
|
||||
ttl = s.cfg.MaxTTL
|
||||
}
|
||||
|
||||
existing, err := s.tokens.ListForUser(ctx, p.WorkspaceID, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
live := 0
|
||||
now := time.Now().UTC()
|
||||
for _, t := range existing {
|
||||
if t.Active(now) {
|
||||
live++
|
||||
}
|
||||
}
|
||||
if live >= maxTokensPerUser {
|
||||
return nil, fmt.Errorf("%w: you already hold %d active tokens; revoke one first",
|
||||
domain.ErrInvalidInput, live)
|
||||
}
|
||||
|
||||
secret, err := newTokenSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tok := &domain.APIToken{
|
||||
ID: ids.New(),
|
||||
WorkspaceID: p.WorkspaceID,
|
||||
UserID: p.UserID,
|
||||
Name: name,
|
||||
Prefix: secret[:displayPrefixLen],
|
||||
TokenHash: ids.HashToken(secret),
|
||||
Scopes: granted,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
if err := s.tokens.Create(ctx, tok); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IssuedToken{Token: tok, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// List returns the principal's own tokens.
|
||||
func (s *TokenService) List(ctx context.Context, p *domain.Principal) ([]*domain.APIToken, error) {
|
||||
if p == nil || p.UserID == "" {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
return s.tokens.ListForUser(ctx, p.WorkspaceID, p.UserID)
|
||||
}
|
||||
|
||||
// Revoke marks one of the principal's tokens unusable.
|
||||
//
|
||||
// A person may only revoke their own tokens: the lookup is scoped to the
|
||||
// workspace and the owner is checked, so an ID guessed from elsewhere resolves
|
||||
// to nothing rather than to someone else's credential.
|
||||
func (s *TokenService) Revoke(ctx context.Context, p *domain.Principal, id string) error {
|
||||
if p == nil || p.UserID == "" {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
if p.ViaToken() {
|
||||
return fmt.Errorf("%w: API tokens cannot revoke API tokens", domain.ErrForbidden)
|
||||
}
|
||||
tok, err := s.tokens.Get(ctx, p.WorkspaceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tok.UserID != p.UserID && p.Role != domain.RoleAdmin {
|
||||
// Indistinguishable from a token that does not exist.
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return s.tokens.Revoke(ctx, p.WorkspaceID, id, p.UserID, time.Now().UTC())
|
||||
}
|
||||
|
||||
// Authenticate resolves a bearer credential into a principal.
|
||||
//
|
||||
// Every refusal returns ErrInvalidToken. A caller learns that the credential
|
||||
// was rejected and nothing more — not whether the token existed, expired, was
|
||||
// revoked, or belonged to a disabled account.
|
||||
func (s *TokenService) Authenticate(ctx context.Context, secret string) (*domain.Principal, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" || !strings.HasPrefix(secret, tokenPrefix) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
tok, err := s.tokens.GetByHash(ctx, ids.HashToken(secret))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if !tok.Active(now) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if !s.limiter.allow(tok.ID) {
|
||||
return nil, ErrTokenRateLimit
|
||||
}
|
||||
|
||||
// The user is re-read on every request rather than cached on the token, so
|
||||
// disabling an account or changing a role takes effect immediately instead
|
||||
// of at the token's expiry.
|
||||
u, err := s.users.GetByID(ctx, tok.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if u.Disabled || u.WorkspaceID != tok.WorkspaceID {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
s.touch(ctx, tok.ID, now)
|
||||
|
||||
return &domain.Principal{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
WorkspaceID: u.WorkspaceID,
|
||||
TokenID: tok.ID,
|
||||
TokenScopes: tok.Scopes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// touch records use, at most once per touchInterval per token.
|
||||
func (s *TokenService) touch(ctx context.Context, id string, now time.Time) {
|
||||
s.mu.Lock()
|
||||
last, ok := s.touched[id]
|
||||
if ok && now.Sub(last) < touchInterval {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.touched[id] = now
|
||||
// Bound the map: it is a throttle, not a record.
|
||||
if len(s.touched) > 10_000 {
|
||||
for k, t := range s.touched {
|
||||
if now.Sub(t) > touchInterval {
|
||||
delete(s.touched, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Best-effort. Failing to record use must not fail the request that used it.
|
||||
_ = s.tokens.TouchLastUsed(ctx, id, now)
|
||||
}
|
||||
|
||||
// BearerFromRequest extracts the credential from an Authorization header.
|
||||
//
|
||||
// Only the Bearer scheme is accepted, and only from the header — never from a
|
||||
// query parameter, which would end up in access logs, browser history and
|
||||
// referrer headers.
|
||||
func BearerFromRequest(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
const scheme = "bearer "
|
||||
if len(h) <= len(scheme) || !strings.EqualFold(h[:len(scheme)], scheme) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(scheme):])
|
||||
}
|
||||
|
||||
// newTokenSecret mints a token. The encoding is base64url without padding so
|
||||
// the value is safe in a header, a shell variable and a configuration file.
|
||||
func newTokenSecret() (string, error) {
|
||||
buf := make([]byte, tokenBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate api token: %w", err)
|
||||
}
|
||||
return tokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// tokenLimiter is a fixed-window counter per token. It exists to bound the
|
||||
// damage a leaked or runaway token can do, not to shape traffic.
|
||||
type tokenLimiter struct {
|
||||
mu sync.Mutex
|
||||
burst int
|
||||
window time.Duration
|
||||
seen map[string]*tokenWindow
|
||||
}
|
||||
|
||||
type tokenWindow struct {
|
||||
count int
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func newTokenLimiter(burst int, window time.Duration) *tokenLimiter {
|
||||
return &tokenLimiter{burst: burst, window: window, seen: map[string]*tokenWindow{}}
|
||||
}
|
||||
|
||||
func (l *tokenLimiter) allow(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
w, ok := l.seen[id]
|
||||
if !ok || now.Sub(w.start) >= l.window {
|
||||
if len(l.seen) > 10_000 {
|
||||
for k, v := range l.seen {
|
||||
if now.Sub(v.start) >= l.window {
|
||||
delete(l.seen, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
l.seen[id] = &tokenWindow{count: 1, start: now}
|
||||
return true
|
||||
}
|
||||
if w.count >= l.burst {
|
||||
return false
|
||||
}
|
||||
w.count++
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user