internal/store/sessions.go
131 lines · 3954 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// ConsumeLoginToken redeems a token exactly once; expired or used tokens
39// fail identically.
40func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
41 res, err := s.DB.Exec(`
42 UPDATE login_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
43 WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`,
44 hash, fmtTime(time.Now()))
45 if err != nil {
46 return 0, err
47 }
48 if n, _ := res.RowsAffected(); n == 0 {
49 return 0, ErrNotFound
50 }
51 var userID int64
52 err = s.DB.QueryRow("SELECT user_id FROM login_tokens WHERE token_hash = ?", hash).Scan(&userID)
53 return userID, err
54}
55
56func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
57 _, err := s.DB.Exec(
58 "INSERT INTO web_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
59 hash, userID, fmtTime(time.Now().Add(ttl)))
60 return err
61}
62
63// WebSessionUser resolves a session cookie hash to its user.
64func (s *Store) WebSessionUser(hash string) (User, error) {
65 var userID int64
66 err := s.DB.QueryRow(
67 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
68 hash, fmtTime(time.Now())).Scan(&userID)
69 if errors.Is(err, sql.ErrNoRows) {
70 return User{}, ErrNotFound
71 }
72 if err != nil {
73 return User{}, err
74 }
75 return s.UserByID(userID)
76}
77
78func (s *Store) DeleteWebSession(hash string) error {
79 _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
80 return err
81}
82
83// WebSession is one browser session as its owner lists it. ID is the first
84// twelve hex digits of the stored token hash: enough to name it, and a
85// hash of the cookie rather than the cookie.
86type WebSession struct {
87 ID string `json:"id"`
88 CreatedAt string `json:"created_at"`
89 ExpiresAt string `json:"expires_at"`
90}
91
92// ListWebSessions lists the user's unexpired browser sessions, newest first.
93func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
94 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at
95 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
96 userID, fmtTime(time.Now()))
97 if err != nil {
98 return nil, err
99 }
100 defer rows.Close()
101 var out []WebSession
102 for rows.Next() {
103 var ws WebSession
104 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt); err != nil {
105 return nil, err
106 }
107 out = append(out, ws)
108 }
109 return out, rows.Err()
110}
111
112// RevokeWebSession ends one of the user's sessions by its listed id.
113func (s *Store) RevokeWebSession(userID int64, id string) error {
114 res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ? AND substr(token_hash, 1, 12) = ?", userID, id)
115 if err != nil {
116 return err
117 }
118 if n, _ := res.RowsAffected(); n == 0 {
119 return ErrNotFound
120 }
121 return nil
122}
123
124// RevokeAllWebSessions ends every browser session the user has.
125func (s *Store) RevokeAllWebSessions(userID int64) (int64, error) {
126 res, err := s.DB.Exec("DELETE FROM web_sessions WHERE user_id = ?", userID)
127 if err != nil {
128 return 0, err
129 }
130 return res.RowsAffected()
131}