428 lines
12 KiB
Go
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, "[]")
|
|
}
|