internal/store/sessions.go
158 lines · 5380 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 and renews
84// the session's idle expiry. A session is written at most once a
85// minute, so a burst of requests costs one UPDATE.
86func (s *Store) WebSessionUser(hash string) (User, error) {
87 now := time.Now()
88 var userID int64
89 err := s.DB.QueryRow(
90 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
91 hash, fmtTime(now)).Scan(&userID)
92 if errors.Is(err, sql.ErrNoRows) {
93 return User{}, ErrNotFound
94 }
95 if err != nil {
96 return User{}, err
97 }
98 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
99 WHERE token_hash = ? AND last_used_at < ?`,
100 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
101 return s.UserByID(userID)
102}
103
104func (s *Store) DeleteWebSession(hash string) error {
105 _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
106 return err
107}
108
109// WebSession is one browser session as its owner lists it. ID is the first
110// twelve hex digits of the stored token hash: enough to name it, and a
111// hash of the cookie rather than the cookie.
112type WebSession struct {
113 ID string `json:"id"`
114 CreatedAt string `json:"created_at"`
115 ExpiresAt string `json:"expires_at"`
116 LastUsedAt string `json:"last_used_at"`
117}
118
119// ListWebSessions lists the user's unexpired browser sessions, newest first.
120func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
121 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
122 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
123 userID, fmtTime(time.Now()))
124 if err != nil {
125 return nil, err
126 }
127 defer rows.Close()
128 var out []WebSession
129 for rows.Next() {
130 var ws WebSession
131 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
132 return nil, err
133 }
134 out = append(out, ws)
135 }
136 return out, rows.Err()
137}
138
139// RevokeWebSession ends one of the user's sessions by its listed id.
140func (s *Store) RevokeWebSession(userID int64, id string) error {
141 res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ? AND substr(token_hash, 1, 12) = ?", userID, id)
142 if err != nil {
143 return err
144 }
145 if n, _ := res.RowsAffected(); n == 0 {
146 return ErrNotFound
147 }
148 return nil
149}
150
151// RevokeAllWebSessions ends every browser session the user has.
152func (s *Store) RevokeAllWebSessions(userID int64) (int64, error) {
153 res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID)
154 if err != nil {
155 return 0, err
156 }
157 return res.RowsAffected()
158}