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()) }