internal/store/sessions.go
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}