internal/store/sessions.go
168 lines · 5721 bytes
14 symbols in this file
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}