users.go
⎇
Raw
1package db
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "time"
8)
9
10// AdminUsername is the single privileged account name.
11const AdminUsername = "admin"
12
13type User struct {
14 ID int64
15 Username string
16 PasswordHash *string
17 CreatedAt string
18 AvatarVersion int64
19 IsPending bool
20 RegisterApplication *string
21}
22
23const userColumns = `id, username, password_hash, created_at, avatar_version, is_pending, register_application`
24
25func scanUser(row *sql.Row) (*User, error) {
26 var u User
27 err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt,
28 &u.AvatarVersion, &u.IsPending, &u.RegisterApplication)
29 if errors.Is(err, sql.ErrNoRows) {
30 return nil, nil
31 }
32 if err != nil {
33 return nil, err
34 }
35 return &u, nil
36}
37
38func (d *DB) UserByName(ctx context.Context, name string) (*User, error) {
39 return scanUser(d.QueryRowContext(ctx,
40 `SELECT `+userColumns+` FROM users WHERE username = ?`, name))
41}
42
43func (d *DB) UserByID(ctx context.Context, id int64) (*User, error) {
44 return scanUser(d.QueryRowContext(ctx,
45 `SELECT `+userColumns+` FROM users WHERE id = ?`, id))
46}
47
48// ActivePasswordHash returns the hash of a non-pending user, used by git
49// smart-HTTP basic auth. The second result is false when there is no such user.
50func (d *DB) ActivePasswordHash(ctx context.Context, username string) (string, bool, error) {
51 var hash *string
52 err := d.QueryRowContext(ctx,
53 `SELECT password_hash FROM users WHERE username = ? AND is_pending = 0`,
54 username).Scan(&hash)
55 if errors.Is(err, sql.ErrNoRows) || (err == nil && hash == nil) {
56 return "", false, nil
57 }
58 if err != nil {
59 return "", false, err
60 }
61 return *hash, true, nil
62}
63
64func (d *DB) CreateUser(ctx context.Context, username string, passwordHash *string,
65 createdAt string, isPending bool, registerApplication *string,
66) (int64, error) {
67 res, err := d.ExecContext(ctx,
68 `INSERT INTO users (username, password_hash, created_at, is_pending, register_application)
69 VALUES (?, ?, ?, ?, ?)`,
70 username, passwordHash, createdAt, isPending, registerApplication)
71 if err != nil {
72 return 0, err
73 }
74 return res.LastInsertId()
75}
76
77// SetPasskeySetupStarted marks an account as waiting for its first passkey,
78// or clears the mark when at is empty. A passwordless signup that never
79// registers a credential leaves an account nobody can log in to.
80func (d *DB) SetPasskeySetupStarted(ctx context.Context, id int64, at string) error {
81 var value any
82 if at != "" {
83 value = at
84 }
85 _, err := d.ExecContext(ctx,
86 `UPDATE users SET passkey_setup_started_at = ? WHERE id = ?`, value, id)
87 return err
88}
89
90// DeleteStalePasskeySignups removes abandoned passwordless signups: marked
91// before cutoff, still without a passkey and without a password. Sessions go
92// with them through the foreign key.
93func (d *DB) DeleteStalePasskeySignups(ctx context.Context, cutoff string) error {
94 _, err := d.ExecContext(ctx,
95 `DELETE FROM users
96 WHERE passkey_setup_started_at IS NOT NULL AND passkey_setup_started_at < ?
97 AND password_hash IS NULL
98 AND NOT EXISTS (SELECT 1 FROM passkeys WHERE passkeys.user_id = users.id)`, cutoff)
99 return err
100}
101
102func (d *DB) DeleteUser(ctx context.Context, id int64) error {
103 _, err := d.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
104 return err
105}
106
107// SetPasswordHash sets or clears (nil) a user's password.
108func (d *DB) SetPasswordHash(ctx context.Context, id int64, hash *string) error {
109 _, err := d.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
110 return err
111}
112
113// ResetCredentials sets a new password and removes every passkey and
114// session of the user.
115func (d *DB) ResetCredentials(ctx context.Context, id int64, hash string) error {
116 tx, err := d.BeginTx(ctx, nil)
117 if err != nil {
118 return err
119 }
120 defer tx.Rollback()
121 if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id); err != nil {
122 return err
123 }
124 for _, table := range []string{"passkeys", "sessions"} {
125 if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id = ?`, id); err != nil {
126 return err
127 }
128 }
129 return tx.Commit()
130}
131
132// ClearPasswordHash removes the password only while a passkey is left. The
133// check is part of the statement, so two concurrent removals cannot race an
134// account into having no login method at all. It reports false when it did
135// not run.
136func (d *DB) ClearPasswordHash(ctx context.Context, id int64) (bool, error) {
137 res, err := d.ExecContext(ctx,
138 `UPDATE users SET password_hash = NULL
139 WHERE id = ? AND EXISTS (SELECT 1 FROM passkeys WHERE user_id = ?)`, id, id)
140 if err != nil {
141 return false, err
142 }
143 n, err := res.RowsAffected()
144 return n > 0, err
145}
146
147func (d *DB) BumpAvatarVersion(ctx context.Context, id int64) error {
148 _, err := d.ExecContext(ctx, `UPDATE users SET avatar_version = avatar_version + 1 WHERE id = ?`, id)
149 return err
150}
151
152// PendingUser is one row of the admin approval queue.
153type PendingUser struct {
154 ID int64
155 Username string
156 RegisterApplication *string
157 CreatedAt string
158}
159
160func (d *DB) PendingUsers(ctx context.Context) ([]PendingUser, error) {
161 rows, err := d.QueryContext(ctx,
162 `SELECT id, username, register_application, created_at
163 FROM users WHERE is_pending = 1 ORDER BY created_at DESC`)
164 if err != nil {
165 return nil, err
166 }
167 defer rows.Close()
168 var out []PendingUser
169 for rows.Next() {
170 var p PendingUser
171 if err := rows.Scan(&p.ID, &p.Username, &p.RegisterApplication, &p.CreatedAt); err != nil {
172 return nil, err
173 }
174 out = append(out, p)
175 }
176 return out, rows.Err()
177}
178
179func (d *DB) ApprovePendingUser(ctx context.Context, id int64) error {
180 _, err := d.ExecContext(ctx, `UPDATE users SET is_pending = 0 WHERE id = ? AND is_pending = 1`, id)
181 return err
182}
183
184func (d *DB) RejectPendingUser(ctx context.Context, id int64) error {
185 _, err := d.ExecContext(ctx, `DELETE FROM users WHERE id = ? AND is_pending = 1`, id)
186 return err
187}
188
189func (d *DB) ApproveAllPendingUsers(ctx context.Context) error {
190 _, err := d.ExecContext(ctx, `UPDATE users SET is_pending = 0 WHERE is_pending = 1`)
191 return err
192}
193
194func (d *DB) RejectAllPendingUsers(ctx context.Context) error {
195 _, err := d.ExecContext(ctx, `DELETE FROM users WHERE is_pending = 1`)
196 return err
197}
198
199// NowISO formats the current time as UTC with milliseconds. Every timestamp
200// column in the database already uses this format.
201func NowISO() string {
202 return time.Now().UTC().Format(ISOLayout)
203}
204
205// ISOLayout is the layout NowISO uses. Callers that format a time other than
206// "now" use it directly.
207const ISOLayout = "2006-01-02T15:04:05.000Z"
208
209// InitAdmin creates the admin account when it does not exist yet. It reports
210// whether a new account was created.
211func (d *DB) InitAdmin(ctx context.Context, password string) (bool, error) {
212 existing, err := d.UserByName(ctx, AdminUsername)
213 if err != nil || existing != nil {
214 return false, err
215 }
216 hash, err := HashPassword(password)
217 if err != nil {
218 return false, err
219 }
220 if _, err := d.CreateUser(ctx, AdminUsername, &hash, NowISO(), false, nil); err != nil {
221 return false, err
222 }
223 return true, nil
224}
225