Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/config"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/ids"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
)
|
||||
|
||||
// Errors returned by the session service.
|
||||
var (
|
||||
// ErrInvalidCredentials is returned for both an unknown username and a
|
||||
// wrong password. The two cases are never distinguished to a caller.
|
||||
ErrInvalidCredentials = errors.New("the username or password is incorrect")
|
||||
ErrAccountDisabled = errors.New("this account is disabled")
|
||||
ErrTooManyAttempts = errors.New("too many sign-in attempts; please wait and try again")
|
||||
ErrNoSession = errors.New("no active session")
|
||||
)
|
||||
|
||||
// sessionTokenBytes is the entropy in a session cookie value.
|
||||
const sessionTokenBytes = 32
|
||||
|
||||
// Service performs authentication and session management.
|
||||
type Service struct {
|
||||
users domain.UserRepository
|
||||
sessions domain.SessionRepository
|
||||
cfg config.SessionConfig
|
||||
secure bool
|
||||
limiter *loginLimiter
|
||||
metrics *observability.Metrics
|
||||
}
|
||||
|
||||
// NewService builds the authentication service.
|
||||
func NewService(users domain.UserRepository, sessions domain.SessionRepository,
|
||||
cfg config.SessionConfig, secureCookies bool, m *observability.Metrics) *Service {
|
||||
return &Service{
|
||||
users: users,
|
||||
sessions: sessions,
|
||||
cfg: cfg,
|
||||
secure: secureCookies,
|
||||
limiter: newLoginLimiter(cfg.LoginRateBurst, cfg.LoginRateWindow),
|
||||
metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// CookieName returns the session cookie's name.
|
||||
func (s *Service) CookieName() string { return s.cfg.CookieName }
|
||||
|
||||
// LoginResult carries what a successful login produced.
|
||||
type LoginResult struct {
|
||||
User *domain.User
|
||||
Session *domain.Session
|
||||
// Token is the raw cookie value. It exists only for the length of the
|
||||
// response and is never stored or logged.
|
||||
Token string
|
||||
}
|
||||
|
||||
// Login authenticates a username and password and creates a new session.
|
||||
//
|
||||
// Several properties are load-bearing:
|
||||
// - rateKey (normally the client IP) is throttled before any database work;
|
||||
// - an unknown username still costs a full Argon2id verification, so timing
|
||||
// does not disclose which usernames exist;
|
||||
// - every failure returns the same error, whatever actually went wrong;
|
||||
// - a brand new session ID is minted, so a pre-existing session identifier
|
||||
// cannot be carried across the authentication boundary (session fixation).
|
||||
func (s *Service) Login(ctx context.Context, username, password, rateKey, userAgent string) (*LoginResult, error) {
|
||||
if !s.limiter.allow(rateKey) {
|
||||
s.count("rate_limited")
|
||||
return nil, ErrTooManyAttempts
|
||||
}
|
||||
|
||||
u, err := s.users.GetByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
// Spend comparable time so the response does not reveal that the
|
||||
// username is unknown.
|
||||
SpendDummyVerification(password)
|
||||
s.count("unknown_user")
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A federated account has no password hash. Refuse it here explicitly
|
||||
// rather than relying on hash verification to fail on an empty string: the
|
||||
// guarantee that an OAuth identity cannot be impersonated with a password
|
||||
// should not depend on the behaviour of a parser.
|
||||
if !u.CanLoginLocally() {
|
||||
SpendDummyVerification(password)
|
||||
s.count("federated_account")
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(password, u.PasswordHash)
|
||||
if err != nil {
|
||||
// A corrupt stored hash must not be reported to the client as
|
||||
// something distinguishable from a wrong password.
|
||||
observability.LoggerFrom(ctx).Error("password hash could not be verified",
|
||||
"user_id", u.ID, "error", err.Error())
|
||||
s.count("hash_error")
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if !ok {
|
||||
s.count("bad_password")
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if u.Disabled {
|
||||
s.count("disabled")
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
sess, token, err := s.createSession(ctx, u, userAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A successful login clears the attempt budget for this client.
|
||||
s.limiter.reset(rateKey)
|
||||
s.count("success")
|
||||
|
||||
now := time.Now().UTC()
|
||||
u.LastLoginAt = &now
|
||||
if err := s.users.Update(ctx, u); err != nil {
|
||||
// Not fatal: the session exists and the user is signed in.
|
||||
observability.LoggerFrom(ctx).Warn("could not record last login time", "error", err.Error())
|
||||
}
|
||||
|
||||
return &LoginResult{User: u, Session: sess, Token: token}, nil
|
||||
}
|
||||
|
||||
func (s *Service) count(outcome string) {
|
||||
if s.metrics != nil {
|
||||
s.metrics.AuthAttempts.WithLabelValues(outcome).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// createSession mints a fresh session and its opaque token.
|
||||
func (s *Service) createSession(ctx context.Context, u *domain.User, userAgent string) (*domain.Session, string, error) {
|
||||
token, err := ids.Token(sessionTokenBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
csrf, err := ids.Token(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
sess := &domain.Session{
|
||||
ID: ids.New(),
|
||||
TokenHash: ids.HashToken(token),
|
||||
CSRFToken: csrf,
|
||||
UserID: u.ID,
|
||||
WorkspaceID: u.WorkspaceID,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.IdleTimeout),
|
||||
AbsoluteExpiry: now.Add(s.cfg.AbsoluteTimeout),
|
||||
UserAgentHash: hashUserAgent(userAgent),
|
||||
}
|
||||
if err := s.sessions.Create(ctx, sess); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return sess, token, nil
|
||||
}
|
||||
|
||||
// Resolve validates a session token and returns the authenticated principal.
|
||||
//
|
||||
// It extends the idle expiry as a side effect, but only once per minute, so
|
||||
// that a busy page does not write to the database on every request.
|
||||
func (s *Service) Resolve(ctx context.Context, token string) (*domain.Principal, *domain.Session, error) {
|
||||
if token == "" {
|
||||
return nil, nil, ErrNoSession
|
||||
}
|
||||
sess, err := s.sessions.GetByTokenHash(ctx, ids.HashToken(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, nil, ErrNoSession
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if !sess.Active(now) {
|
||||
return nil, nil, ErrNoSession
|
||||
}
|
||||
|
||||
u, err := s.users.GetByID(ctx, sess.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, nil, ErrNoSession
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if u.Disabled {
|
||||
_ = s.sessions.Revoke(ctx, sess.ID)
|
||||
return nil, nil, ErrNoSession
|
||||
}
|
||||
|
||||
// The session's workspace is authoritative for the request. Should a user's
|
||||
// workspace change, existing sessions are revoked rather than silently
|
||||
// re-scoped, so an in-flight session cannot straddle two workspaces.
|
||||
if u.WorkspaceID != sess.WorkspaceID {
|
||||
_ = s.sessions.Revoke(ctx, sess.ID)
|
||||
return nil, nil, ErrNoSession
|
||||
}
|
||||
|
||||
if now.Sub(sess.LastSeenAt) > time.Minute {
|
||||
newExpiry := now.Add(s.cfg.IdleTimeout)
|
||||
if newExpiry.After(sess.AbsoluteExpiry) {
|
||||
newExpiry = sess.AbsoluteExpiry
|
||||
}
|
||||
if err := s.sessions.Touch(ctx, sess.ID, now, newExpiry); err != nil {
|
||||
observability.LoggerFrom(ctx).Debug("could not extend session", "error", err.Error())
|
||||
}
|
||||
sess.LastSeenAt = now
|
||||
sess.ExpiresAt = newExpiry
|
||||
}
|
||||
|
||||
return &domain.Principal{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
WorkspaceID: sess.WorkspaceID,
|
||||
SessionID: sess.ID,
|
||||
}, sess, nil
|
||||
}
|
||||
|
||||
// Logout revokes a session.
|
||||
func (s *Service) Logout(ctx context.Context, sessionID string) error {
|
||||
return s.sessions.Revoke(ctx, sessionID)
|
||||
}
|
||||
|
||||
// SweepExpired removes sessions past their absolute expiry.
|
||||
func (s *Service) SweepExpired(ctx context.Context) (int64, error) {
|
||||
return s.sessions.DeleteExpired(ctx, time.Now().UTC())
|
||||
}
|
||||
|
||||
// SetSessionCookie writes the session cookie.
|
||||
//
|
||||
// HttpOnly keeps the token away from JavaScript, SameSite=Lax blocks it from
|
||||
// cross-site form posts while still allowing ordinary top-level navigation, and
|
||||
// Secure is set whenever the deployment is served over HTTPS.
|
||||
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cfg.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSessionCookie expires the session cookie.
|
||||
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cfg.CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// TokenFromRequest reads the session token from the request cookie.
|
||||
func (s *Service) TokenFromRequest(r *http.Request) string {
|
||||
c, err := r.Cookie(s.cfg.CookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// CheckCSRF compares a submitted CSRF token with the session's, in constant
|
||||
// time.
|
||||
func CheckCSRF(sessionToken, submitted string) bool {
|
||||
if sessionToken == "" || submitted == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(submitted)) == 1
|
||||
}
|
||||
|
||||
func hashUserAgent(ua string) string {
|
||||
if ua == "" {
|
||||
return ""
|
||||
}
|
||||
if len(ua) > 512 {
|
||||
ua = ua[:512]
|
||||
}
|
||||
sum := sha256.Sum256([]byte(ua))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login rate limiting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// loginLimiter throttles sign-in attempts per client key.
|
||||
//
|
||||
// It is an in-process fixed-window counter: Relata is a single-process
|
||||
// application, so a shared store would add a dependency without adding
|
||||
// protection. The limit is on attempts, and a successful login clears the
|
||||
// counter, so a legitimate user who mistypes a few times is not locked out for
|
||||
// long.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
burst int
|
||||
window time.Duration
|
||||
// lastSweep bounds map growth from a flood of distinct keys.
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter(burst int, window time.Duration) *loginLimiter {
|
||||
if burst <= 0 {
|
||||
burst = 8
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 5 * time.Minute
|
||||
}
|
||||
return &loginLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
burst: burst,
|
||||
window: window,
|
||||
lastSweep: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) allow(key string) bool {
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
l.sweepLocked(now)
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok || now.Sub(b.start) > l.window {
|
||||
l.buckets[key] = &bucket{count: 1, start: now}
|
||||
return true
|
||||
}
|
||||
if b.count >= l.burst {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *loginLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.buckets, key)
|
||||
}
|
||||
|
||||
// sweepLocked drops expired buckets. Without it, an attacker cycling source
|
||||
// addresses would grow the map without bound.
|
||||
func (l *loginLimiter) sweepLocked(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < l.window {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.start) > l.window {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClientKey derives the rate-limiting key for a request.
|
||||
//
|
||||
// Proxy headers are honoured only when the operator has explicitly said the
|
||||
// deployment sits behind a trusted proxy. Trusting them by default would let
|
||||
// anyone defeat the rate limit by varying X-Forwarded-For.
|
||||
func ClientKey(r *http.Request, trustProxy bool) string {
|
||||
if trustProxy {
|
||||
// X-Real-IP first. A reverse proxy sets it to the address it actually
|
||||
// accepted the connection from, replacing anything the client sent, so
|
||||
// it is the one header here a client cannot choose.
|
||||
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
||||
return strings.TrimSpace(xr)
|
||||
}
|
||||
|
||||
// Otherwise the RIGHTMOST entry of X-Forwarded-For — the hop appended
|
||||
// by the nearest proxy, which is the last one we trust.
|
||||
//
|
||||
// The leftmost entry is the obvious choice and the wrong one: proxies
|
||||
// append rather than replace, so a client that sends
|
||||
// "X-Forwarded-For: 1.2.3.4" puts that value at the front. Reading it
|
||||
// would give every forged header its own rate-limit bucket and defeat
|
||||
// login throttling entirely — the attack the throttle exists to stop.
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.LastIndex(xff, ","); i >= 0 {
|
||||
return strings.TrimSpace(xff[i+1:])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
}
|
||||
host := r.RemoteAddr
|
||||
if i := strings.LastIndex(host, ":"); i > 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
Reference in New Issue
Block a user