first commit
This commit is contained in:
324
go-api/internal/auth/session.go
Normal file
324
go-api/internal/auth/session.go
Normal file
@@ -0,0 +1,324 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user