internal/store/sessions.go

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

142 lines · 4430 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
 67func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
 68	_, err := s.DB.Exec(
 69		"INSERT INTO web_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
 70		hash, userID, fmtTime(time.Now().Add(ttl)))
 71	return err
 72}
 73
 74// WebSessionUser resolves a session cookie hash to its user.
 75func (s *Store) WebSessionUser(hash string) (User, error) {
 76	var userID int64
 77	err := s.DB.QueryRow(
 78		"SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
 79		hash, fmtTime(time.Now())).Scan(&userID)
 80	if errors.Is(err, sql.ErrNoRows) {
 81		return User{}, ErrNotFound
 82	}
 83	if err != nil {
 84		return User{}, err
 85	}
 86	return s.UserByID(userID)
 87}
 88
 89func (s *Store) DeleteWebSession(hash string) error {
 90	_, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
 91	return err
 92}
 93
 94// WebSession is one browser session as its owner lists it. ID is the first
 95// twelve hex digits of the stored token hash: enough to name it, and a
 96// hash of the cookie rather than the cookie.
 97type WebSession struct {
 98	ID        string `json:"id"`
 99	CreatedAt string `json:"created_at"`
100	ExpiresAt string `json:"expires_at"`
101}
102
103// ListWebSessions lists the user's unexpired browser sessions, newest first.
104func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
105	rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at
106		FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
107		userID, fmtTime(time.Now()))
108	if err != nil {
109		return nil, err
110	}
111	defer rows.Close()
112	var out []WebSession
113	for rows.Next() {
114		var ws WebSession
115		if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt); err != nil {
116			return nil, err
117		}
118		out = append(out, ws)
119	}
120	return out, rows.Err()
121}
122
123// RevokeWebSession ends one of the user's sessions by its listed id.
124func (s *Store) RevokeWebSession(userID int64, id string) error {
125	res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ? AND substr(token_hash, 1, 12) = ?", userID, id)
126	if err != nil {
127		return err
128	}
129	if n, _ := res.RowsAffected(); n == 0 {
130		return ErrNotFound
131	}
132	return nil
133}
134
135// RevokeAllWebSessions ends every browser session the user has.
136func (s *Store) RevokeAllWebSessions(userID int64) (int64, error) {
137	res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID)
138	if err != nil {
139		return 0, err
140	}
141	return res.RowsAffected()
142}