325 lines
11 KiB
Go
325 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Session lifecycle errors.
|
|
var (
|
|
// ErrSessionNotFound means no row matched the token hash. It is returned
|
|
// for an unknown token and for a well-formed token that has been revoked;
|
|
// a caller must not distinguish the two to the client.
|
|
ErrSessionNotFound = errors.New("auth: session not found")
|
|
|
|
// ErrSessionExpired means a row matched but is no longer valid, by either
|
|
// the sliding or the absolute deadline.
|
|
ErrSessionExpired = errors.New("auth: session expired")
|
|
)
|
|
|
|
// Session is one row of the sessions table.
|
|
//
|
|
// TokenHash is the SHA-256 of the token, never the token. There is no field
|
|
// here that can hold the raw secret, by design: the only place it exists after
|
|
// Issue returns is the caller's cookie.
|
|
type Session struct {
|
|
ID string
|
|
UserID string
|
|
|
|
// TokenHash is lowercase hex SHA-256. See HashToken.
|
|
TokenHash string
|
|
|
|
// ExpiresAt is the sliding deadline; it moves forward as the session is
|
|
// used, never past AbsoluteExpiresAt.
|
|
ExpiresAt time.Time
|
|
|
|
// AbsoluteExpiresAt is fixed when the session is created and never moves.
|
|
AbsoluteExpiresAt time.Time
|
|
|
|
CreatedDate time.Time
|
|
LastSeenAt time.Time
|
|
}
|
|
|
|
// IsExpired reports whether the session is dead at the given instant, by
|
|
// either deadline. The absolute one is checked as well as the sliding one
|
|
// precisely because the sliding one can be moved.
|
|
func (s Session) IsExpired(now time.Time) bool {
|
|
return !now.Before(s.ExpiresAt) || !now.Before(s.AbsoluteExpiresAt)
|
|
}
|
|
|
|
// Policy is how long a session lives.
|
|
//
|
|
// Two pairs of durations, because "Remember Me" is a different risk than a
|
|
// session on a shared machine, and because a sliding window alone can be slid
|
|
// forever.
|
|
type Policy struct {
|
|
// IdleLifetime is how long a normal session survives without being used.
|
|
IdleLifetime time.Duration
|
|
// AbsoluteLifetime caps a normal session's total life regardless of use.
|
|
AbsoluteLifetime time.Duration
|
|
|
|
// RememberIdleLifetime and RememberAbsoluteLifetime are the same two
|
|
// bounds for a session created with Remember Me.
|
|
RememberIdleLifetime time.Duration
|
|
RememberAbsoluteLifetime time.Duration
|
|
}
|
|
|
|
// DefaultPolicy implements the Phase 3B session-lifetime decision.
|
|
//
|
|
// normal login 12 hours idle, capped at 24 hours of total life. Twelve hours
|
|
// covers a working day; the daily cap means an unattended tab
|
|
// cannot be slid along indefinitely.
|
|
// Remember Me 30 days idle, capped at 90 days. The user asked to stay
|
|
// signed in; 90 days is the point at which they re-prove it.
|
|
//
|
|
// The idle bound is what expires an abandoned session. The absolute bound is
|
|
// what guarantees no session lives forever.
|
|
var DefaultPolicy = Policy{
|
|
IdleLifetime: 12 * time.Hour,
|
|
AbsoluteLifetime: 24 * time.Hour,
|
|
RememberIdleLifetime: 30 * 24 * time.Hour,
|
|
RememberAbsoluteLifetime: 90 * 24 * time.Hour,
|
|
}
|
|
|
|
// lifetimes picks the pair that applies to this session.
|
|
func (p Policy) lifetimes(remember bool) (idle, absolute time.Duration) {
|
|
if remember {
|
|
return p.RememberIdleLifetime, p.RememberAbsoluteLifetime
|
|
}
|
|
return p.IdleLifetime, p.AbsoluteLifetime
|
|
}
|
|
|
|
func (p Policy) validate() error {
|
|
pairs := []struct {
|
|
name string
|
|
idle, absolute time.Duration
|
|
}{
|
|
{"normal", p.IdleLifetime, p.AbsoluteLifetime},
|
|
{"remember-me", p.RememberIdleLifetime, p.RememberAbsoluteLifetime},
|
|
}
|
|
for _, pair := range pairs {
|
|
if pair.idle <= 0 || pair.absolute <= 0 {
|
|
return fmt.Errorf("auth: %s session lifetimes must be positive", pair.name)
|
|
}
|
|
// An absolute bound below the idle bound would make the idle window
|
|
// unreachable, which is a configuration mistake rather than a policy.
|
|
if pair.absolute < pair.idle {
|
|
return fmt.Errorf("auth: %s absolute lifetime is shorter than its idle lifetime", pair.name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Store is the persistence the session lifecycle needs.
|
|
//
|
|
// An interface rather than a concrete type so that the lifecycle rules below
|
|
// are testable without a database, and so this package does not depend on pgx.
|
|
// The PostgreSQL implementation is PGStore, in store.go.
|
|
//
|
|
// Every method takes a token *hash*. No implementation ever receives a raw
|
|
// token, which is what makes it structurally impossible to store one.
|
|
type Store interface {
|
|
// Create inserts the session and fills in the id the database assigned,
|
|
// which is why it takes a pointer: the id is generated by the default on
|
|
// the column, so the caller cannot know it beforehand.
|
|
Create(ctx context.Context, s *Session) error
|
|
FindByTokenHash(ctx context.Context, tokenHash string) (Session, error)
|
|
Touch(ctx context.Context, id string, expiresAt, lastSeenAt time.Time) error
|
|
Delete(ctx context.Context, id string) error
|
|
DeleteByTokenHash(ctx context.Context, tokenHash string) error
|
|
DeleteExpired(ctx context.Context, now time.Time) (int64, error)
|
|
}
|
|
|
|
// Manager applies the session rules over a Store.
|
|
//
|
|
// It is the only place that turns a raw token into a hash, and the only place
|
|
// that decides whether a session is still alive.
|
|
type Manager struct {
|
|
store Store
|
|
policy Policy
|
|
|
|
// now is injectable so the expiry rules can be tested at a chosen instant
|
|
// rather than by sleeping. Production always leaves it as time.Now.
|
|
now func() time.Time
|
|
|
|
// slideThreshold avoids one UPDATE per request. The sliding deadline is
|
|
// only pushed forward once the session has used up this fraction of its
|
|
// idle window, so a burst of requests writes at most one row.
|
|
slideThreshold float64
|
|
}
|
|
|
|
// NewManager builds a Manager. An invalid policy is a programming error and is
|
|
// reported here rather than at the first login.
|
|
func NewManager(store Store, policy Policy) (*Manager, error) {
|
|
if store == nil {
|
|
return nil, errors.New("auth: session store is required")
|
|
}
|
|
if err := policy.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Manager{store: store, policy: policy, now: time.Now, slideThreshold: 0.5}, nil
|
|
}
|
|
|
|
// WithClock replaces the clock. For tests.
|
|
func (m *Manager) WithClock(now func() time.Time) *Manager {
|
|
if now != nil {
|
|
m.now = now
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Policy is the lifetime policy in force.
|
|
func (m *Manager) Policy() Policy { return m.policy }
|
|
|
|
// Issue creates a session for a user and returns the raw token exactly once.
|
|
//
|
|
// The token is the return value and is never stored: what reaches the database
|
|
// is HashToken(token). The caller's only job is to put the raw token straight
|
|
// into an HttpOnly cookie and then forget it — not log it, not echo it in a
|
|
// JSON body, not put it in a URL.
|
|
func (m *Manager) Issue(ctx context.Context, userID string, remember bool) (string, Session, error) {
|
|
if userID == "" {
|
|
return "", Session{}, errors.New("auth: user id is required")
|
|
}
|
|
|
|
token, err := GenerateToken()
|
|
if err != nil {
|
|
return "", Session{}, err
|
|
}
|
|
|
|
now := m.now().UTC()
|
|
idle, absolute := m.policy.lifetimes(remember)
|
|
s := Session{
|
|
UserID: userID,
|
|
TokenHash: HashToken(token),
|
|
ExpiresAt: now.Add(idle),
|
|
AbsoluteExpiresAt: now.Add(absolute),
|
|
CreatedDate: now,
|
|
LastSeenAt: now,
|
|
}
|
|
// The idle window can be the longer of the two only through a bad policy,
|
|
// which validate() rejects; clamping anyway keeps the database CHECK
|
|
// (expires_at <= absolute_expires_at) from being the thing that notices.
|
|
if s.ExpiresAt.After(s.AbsoluteExpiresAt) {
|
|
s.ExpiresAt = s.AbsoluteExpiresAt
|
|
}
|
|
|
|
if err := m.store.Create(ctx, &s); err != nil {
|
|
return "", Session{}, err
|
|
}
|
|
return token, s, nil
|
|
}
|
|
|
|
// Authenticate resolves a raw token to a live session, sliding its expiry.
|
|
//
|
|
// It returns ErrSessionNotFound for an unknown token and ErrSessionExpired for
|
|
// a dead one. Callers must answer the client identically in both cases: which
|
|
// of the two it was tells an attacker whether a guessed token ever existed.
|
|
//
|
|
// An expired row is deleted as it is found, so a session that times out is
|
|
// gone rather than waiting for the sweep.
|
|
func (m *Manager) Authenticate(ctx context.Context, token string) (Session, error) {
|
|
if token == "" {
|
|
return Session{}, ErrEmptyToken
|
|
}
|
|
|
|
hash := HashToken(token)
|
|
s, err := m.store.FindByTokenHash(ctx, hash)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
|
|
now := m.now().UTC()
|
|
if s.IsExpired(now) {
|
|
// Best effort: failing to delete does not make the session valid.
|
|
_ = m.store.Delete(ctx, s.ID)
|
|
return Session{}, ErrSessionExpired
|
|
}
|
|
|
|
if err := m.slide(ctx, &s, now); err != nil {
|
|
return Session{}, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// slide moves the sliding deadline forward, bounded by the absolute one.
|
|
//
|
|
// Only once the session is past slideThreshold of its idle window, so a page
|
|
// that fires ten requests does not fire ten UPDATEs. The idle window is
|
|
// recovered from the row rather than taken from the policy, so a session keeps
|
|
// the lifetime it was issued under even if the policy changes underneath it.
|
|
func (m *Manager) slide(ctx context.Context, s *Session, now time.Time) error {
|
|
idle := s.ExpiresAt.Sub(s.LastSeenAt)
|
|
if idle <= 0 {
|
|
return nil
|
|
}
|
|
if now.Sub(s.LastSeenAt) < time.Duration(float64(idle)*m.slideThreshold) {
|
|
return nil
|
|
}
|
|
|
|
next := now.Add(idle)
|
|
if next.After(s.AbsoluteExpiresAt) {
|
|
next = s.AbsoluteExpiresAt
|
|
}
|
|
if err := m.store.Touch(ctx, s.ID, next, now); err != nil {
|
|
return err
|
|
}
|
|
s.ExpiresAt = next
|
|
s.LastSeenAt = now
|
|
return nil
|
|
}
|
|
|
|
// Lookup resolves a raw token without sliding the expiry or deleting anything.
|
|
//
|
|
// A read-only Authenticate, for callers that need to inspect a session without
|
|
// treating the call as activity.
|
|
func (m *Manager) Lookup(ctx context.Context, token string) (Session, error) {
|
|
if token == "" {
|
|
return Session{}, ErrEmptyToken
|
|
}
|
|
s, err := m.store.FindByTokenHash(ctx, HashToken(token))
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
if s.IsExpired(m.now().UTC()) {
|
|
return Session{}, ErrSessionExpired
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Revoke deletes the session behind a raw token. This is what logout calls.
|
|
//
|
|
// Deleting an already-absent session is not an error: logging out twice, or
|
|
// logging out with a stale cookie, should succeed rather than fail loudly.
|
|
func (m *Manager) Revoke(ctx context.Context, token string) error {
|
|
if token == "" {
|
|
return ErrEmptyToken
|
|
}
|
|
err := m.store.DeleteByTokenHash(ctx, HashToken(token))
|
|
if errors.Is(err, ErrSessionNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RevokeID deletes a session by its row id, for callers that already hold one.
|
|
func (m *Manager) RevokeID(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return errors.New("auth: session id is required")
|
|
}
|
|
err := m.store.Delete(ctx, id)
|
|
if errors.Is(err, ErrSessionNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Sweep deletes every session that is past either deadline, and reports how
|
|
// many rows went. Authenticate already removes the expired sessions it meets;
|
|
// this collects the ones nobody comes back for.
|
|
func (m *Manager) Sweep(ctx context.Context) (int64, error) {
|
|
return m.store.DeleteExpired(ctx, m.now().UTC())
|
|
}
|