internal/store/sessions.go

v1.40.0
gitbay/internal/store/sessions.go history · blame · raw

168 lines · 5721 bytes

  1package store
  2
  3import (
  4	"crypto/rand"
  5	"crypto/sha256"
  6	"database/sql"
  7	"encoding/hex"
  8	"errors"
  9	"time"
 10)
 11
 12// NewToken returns a fresh random token and its storage hash. Only the hash
 13// is persisted; the token itself goes to the user once.
 14func NewToken() (token, hash string, err error) {
 15	var b [32]byte
 16	if _, err := rand.Read(b[:]); err != nil {
 17		return "", "", err
 18	}
 19	token = hex.EncodeToString(b[:])
 20	return token, HashToken(token), nil
 21}
 22
 23func HashToken(token string) string {
 24	sum := sha256.Sum256([]byte(token))
 25	return hex.EncodeToString(sum[:])
 26}
 27
 28func fmtTime(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05.000Z") }
 29
 30// CreateLoginToken stores a one-time login token hash.
 31func (s *Store) CreateLoginToken(userID int64, hash string, ttl time.Duration) error {
 32	_, err := s.DB.Exec(
 33		"INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
 34		hash, userID, fmtTime(time.Now().Add(ttl)))
 35	return err
 36}
 37
 38// CountLoginTokensSince counts the login tokens minted for a user within a
 39// window. An unauthenticated request can ask for a login link, so the mint
 40// needs a durable per-account bound the way email verification does (#136).
 41func (s *Store) CountLoginTokensSince(userID int64, since time.Time) (int, error) {
 42	var n int
 43	err := s.DB.QueryRow(
 44		"SELECT count(*) FROM login_tokens WHERE user_id = ? AND created_at > ?",
 45		userID, fmtTime(since)).Scan(&n)
 46	return n, err
 47}
 48
 49// ConsumeLoginToken redeems a token exactly once; expired or used tokens
 50// fail identically.
 51func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
 52	res, err := s.DB.Exec(`
 53		UPDATE login_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
 54		WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`,
 55		hash, fmtTime(time.Now()))
 56	if err != nil {
 57		return 0, err
 58	}
 59	if n, _ := res.RowsAffected(); n == 0 {
 60		return 0, ErrNotFound
 61	}
 62	var userID int64
 63	err = s.DB.QueryRow("SELECT user_id FROM login_tokens WHERE token_hash = ?", hash).Scan(&userID)
 64	return userID, err
 65}
 66
 67// WebSessionIdle is how long a browser session lasts without a request.
 68// Each use moves its expiry this far ahead, never past the cap it was
 69// created with. Migration 0062 repeats the value for sessions it
 70// converts.
 71const WebSessionIdle = 12 * time.Hour
 72
 73// CreateWebSession stores a session that lapses after WebSessionIdle
 74// without use, and after ttl regardless.
 75func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
 76	now := time.Now()
 77	_, err := s.DB.Exec(
 78		"INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)",
 79		hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now))
 80	return err
 81}
 82
 83// WebSessionUser resolves a session cookie hash to its user, with the
 84// session's sign-in time, and renews the session's idle expiry. A
 85// session is written at most once a minute, so a burst of requests
 86// costs one UPDATE. Renewal never moves created_at: only a login
 87// creates a session, so created_at is when it signed in.
 88func (s *Store) WebSessionUser(hash string) (User, error) {
 89	now := time.Now()
 90	var userID int64
 91	var created string
 92	err := s.DB.QueryRow(
 93		"SELECT user_id, created_at FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
 94		hash, fmtTime(now)).Scan(&userID, &created)
 95	if errors.Is(err, sql.ErrNoRows) {
 96		return User{}, ErrNotFound
 97	}
 98	if err != nil {
 99		return User{}, err
100	}
101	s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
102		WHERE token_hash = ? AND last_used_at < ?`,
103		fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
104	u, err := s.UserByID(userID)
105	if err != nil {
106		return User{}, err
107	}
108	if t := parseTime(sql.NullString{String: created, Valid: true}); t != nil {
109		u.SignedInAt = *t
110	}
111	return u, nil
112}
113
114func (s *Store) DeleteWebSession(hash string) error {
115	_, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
116	return err
117}
118
119// WebSession is one browser session as its owner lists it. ID is the first
120// twelve hex digits of the stored token hash: enough to name it, and a
121// hash of the cookie rather than the cookie.
122type WebSession struct {
123	ID         string `json:"id"`
124	CreatedAt  string `json:"created_at"`
125	ExpiresAt  string `json:"expires_at"`
126	LastUsedAt string `json:"last_used_at"`
127}
128
129// ListWebSessions lists the user's unexpired browser sessions, newest first.
130func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
131	rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
132		FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
133		userID, fmtTime(time.Now()))
134	if err != nil {
135		return nil, err
136	}
137	defer rows.Close()
138	var out []WebSession
139	for rows.Next() {
140		var ws WebSession
141		if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
142			return nil, err
143		}
144		out = append(out, ws)
145	}
146	return out, rows.Err()
147}
148
149// RevokeWebSession ends one of the user's sessions by its listed id.
150func (s *Store) RevokeWebSession(userID int64, id string) error {
151	res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ? AND substr(token_hash, 1, 12) = ?", userID, id)
152	if err != nil {
153		return err
154	}
155	if n, _ := res.RowsAffected(); n == 0 {
156		return ErrNotFound
157	}
158	return nil
159}
160
161// RevokeAllWebSessions ends every browser session the user has.
162func (s *Store) RevokeAllWebSessions(userID int64) (int64, error) {
163	res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID)
164	if err != nil {
165		return 0, err
166	}
167	return res.RowsAffected()
168}