Files
2026-08-11 10:40:10 +01:00

428 lines
12 KiB
Go

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, "[]")
}