feat(api): authentication — login, two-step TOTP, invites and resets

Sessions are stored as hashes, so a database dump contains nothing that
can be pasted into a browser. A login that has passed the password but
not the second factor is modelled as a pending session: it expires, is
revocable, and reaches exactly one endpoint.

Signup is invite-only and takes the email address from the invite rather
than the form, so a link cannot be redirected to somebody else. The
first admin is created by an endpoint that refuses once any account
exists, checked and inserted in one transaction because it is
necessarily unauthenticated.

Login failures and forgotten-password requests answer identically
whether or not the address has an account — otherwise the form is an
account-existence oracle. Password rules are a length floor and nothing
else: composition rules push people towards worse passwords.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-16 23:31:29 +02:00
co-authored by Claude Opus 5
parent 8251e69155
commit 1bb644f4d1
15 changed files with 2943 additions and 10 deletions
+123
View File
@@ -0,0 +1,123 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
type APIToken struct {
ID string
UserID string
Name string
Prefix string
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
}
// CreateAPIToken returns the token in plaintext exactly once. Only its hash is
// stored, so it can never be shown again — the UI has to say so at the point of
// creation rather than offering a "reveal" that cannot work.
func (db *DB) CreateAPIToken(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *APIToken, error) {
token, prefix, err := auth.NewAPIToken()
if err != nil {
return "", nil, err
}
t := &APIToken{
ID: NewID(),
UserID: userID,
Name: name,
Prefix: prefix,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
}
var expires any
if expiresAt != nil {
expires = expiresAt.Unix()
}
_, err = db.ExecContext(ctx,
`INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.UserID, t.Name, auth.HashToken(token), t.Prefix, t.CreatedAt.Unix(), expires)
if err != nil {
return "", nil, err
}
return token, t, nil
}
// UserByAPIToken resolves a bearer token to its owner.
func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) {
var id, userID string
var expires sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`,
auth.HashToken(token)).Scan(&id, &userID, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if expires.Valid && time.Now().Unix() > expires.Int64 {
return nil, ErrNotFound
}
// last_used_at is what lets somebody delete the token they no longer recognise
// without wondering what will break. Written at most hourly: a token driving a
// sync loop would otherwise mean a write per request.
_, _ = db.ExecContext(ctx,
`UPDATE api_tokens SET last_used_at = ?
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
time.Now().Unix(), id, time.Now().Add(-time.Hour).Unix())
return db.UserByID(ctx, userID)
}
func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIToken
for rows.Next() {
var t APIToken
var created int64
var lastUsed, expires sql.NullInt64
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(created, 0).UTC()
if lastUsed.Valid {
v := time.Unix(lastUsed.Int64, 0).UTC()
t.LastUsedAt = &v
}
if expires.Valid {
v := time.Unix(expires.Int64, 0).UTC()
t.ExpiresAt = &v
}
out = append(out, t)
}
return out, rows.Err()
}
// DeleteAPIToken is scoped by user so an id from somewhere else deletes nothing.
func (db *DB) DeleteAPIToken(ctx context.Context, userID, tokenID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package store
import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"fmt"
"sync"
"time"
)
// NewID returns a UUIDv7: 48 bits of Unix milliseconds followed by randomness.
//
// Time-ordered ids matter more here than they look. SQLite stores rows in a B-tree
// keyed by the primary key, so random ids scatter inserts across the whole file and
// every new task dirties a different page. Sequential ids append. It also means
// `ORDER BY id` is creation order, which removes a column and an index from every
// table that would otherwise need one.
//
// Implemented directly rather than through a dependency: it is twenty lines, and the
// monotonic counter below is the only part with any subtlety.
func NewID() string {
var b [16]byte
ms, seq := nextTimestamp()
// 48-bit big-endian millisecond timestamp.
b[0] = byte(ms >> 40)
b[1] = byte(ms >> 32)
b[2] = byte(ms >> 24)
b[3] = byte(ms >> 16)
b[4] = byte(ms >> 8)
b[5] = byte(ms)
// Version 7 in the high nibble of byte 6, then 12 bits of sequence. The
// sequence is what keeps ids ordered *within* a millisecond — without it, two
// tasks created in the same tick sort by their random bits, and a list created
// by one fast paste comes back shuffled.
b[6] = 0x70 | byte(seq>>8)
b[7] = byte(seq)
if _, err := rand.Read(b[8:]); err != nil {
// crypto/rand does not fail on any supported platform; if it somehow
// does, continuing would silently produce guessable ids.
panic(fmt.Sprintf("store: read random for id: %v", err))
}
// Variant bits: 10xx in the top of byte 8.
b[8] = (b[8] & 0x3f) | 0x80
return hex.EncodeToString(b[:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:])
}
var idClock struct {
sync.Mutex
lastMS uint64
seq uint16
}
// nextTimestamp returns the current millisecond and a counter that increments for
// repeated calls inside the same one, so ids issued in a burst stay ordered.
func nextTimestamp() (uint64, uint16) {
idClock.Lock()
defer idClock.Unlock()
ms := uint64(time.Now().UnixMilli())
switch {
case ms > idClock.lastMS:
idClock.lastMS, idClock.seq = ms, 0
case idClock.seq < 0xfff:
idClock.seq++
default:
// More than 4096 ids in one millisecond: borrow from the next. The clock
// value stays ahead of real time until it catches up, which keeps ids
// unique and ordered rather than wrapping the counter.
idClock.lastMS++
idClock.seq = 0
ms = idClock.lastMS
}
return ms, idClock.seq
}
// IDTime recovers the moment an id was generated. Useful in diagnostics, and the
// reason a created_at column can be trusted against its own row's id.
func IDTime(id string) (time.Time, bool) {
clean := make([]byte, 0, 32)
for i := 0; i < len(id); i++ {
if id[i] != '-' {
clean = append(clean, id[i])
}
}
if len(clean) != 32 {
return time.Time{}, false
}
decoded := make([]byte, 16)
if _, err := hex.Decode(decoded, clean); err != nil {
return time.Time{}, false
}
if decoded[6]>>4 != 7 {
return time.Time{}, false
}
ms := uint64(binary.BigEndian.Uint16(decoded[0:2]))<<32 |
uint64(binary.BigEndian.Uint32(decoded[2:6]))
return time.UnixMilli(int64(ms)), true
}
+314
View File
@@ -0,0 +1,314 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
var ErrAlreadySetUp = errors.New("store: this instance already has an account")
type Invite struct {
ID string
Email string
ProjectID string // empty for an invite to the instance rather than to a project
Role Role
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// CreateFirstAdmin creates the very first account, and fails if one already exists.
//
// The check and the insert are in one transaction because the endpoint behind this
// is necessarily unauthenticated: two requests arriving together must not both find
// an empty database and both create an administrator. With SQLite serialising
// writes, the second transaction sees the first one's row and loses.
func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) error {
u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = NewID()
}
if u.AvatarColor == "" {
u.AvatarColor = avatarColorFor(u.Email)
}
u.IsAdmin = true
now := time.Now().UTC()
u.CreatedAt, u.UpdatedAt = now, now
return db.Tx(ctx, func(tx *sql.Tx) error {
var n int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrAlreadySetUp
}
_, err := tx.ExecContext(ctx,
`INSERT INTO users (id, email, name, password_hash, totp_enabled, avatar_color,
timezone, locale, is_admin, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?)`,
u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor,
orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"),
now.Unix(), now.Unix())
if err != nil {
return fmt.Errorf("insert first admin: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at)
VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`,
NewID(), inboxName, u.ID, now.Unix(), now.Unix())
return err
})
}
// CreateInvite issues an invite and returns the token for the emailed link.
// projectID may be empty, which invites somebody to the instance rather than to a
// particular project.
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if !role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", role)
}
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
inv := &Invite{
ID: NewID(),
Email: NormalizeEmail(email),
ProjectID: projectID,
Role: role,
CreatedBy: createdBy,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
var project any
if projectID != "" {
project = projectID
}
_, err = db.ExecContext(ctx,
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
if err != nil {
return "", nil, err
}
return token, inv, nil
}
// InviteByToken resolves an invite link. An expired or already-accepted invite is
// reported as not found: to whoever is holding the link, those are the same thing.
func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error) {
if token == "" {
return nil, ErrInviteInvalid
}
var inv Invite
var projectID sql.NullString
var created, expires int64
var accepted sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteInvalid
}
if err != nil {
return nil, err
}
if accepted.Valid || time.Now().Unix() > expires {
return nil, ErrInviteInvalid
}
inv.ProjectID = projectID.String
inv.CreatedAt = time.Unix(created, 0).UTC()
inv.ExpiresAt = time.Unix(expires, 0).UTC()
return &inv, nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
//
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
// same link produce one membership and one failure rather than two memberships.
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE invites SET accepted_at = ? WHERE id = ? AND accepted_at IS NULL`,
time.Now().Unix(), inviteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInviteInvalid
}
var projectID sql.NullString
var role Role
if err := tx.QueryRowContext(ctx,
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &role); err != nil {
return err
}
if !projectID.Valid || projectID.String == "" {
return nil // an invite to the instance, with no project attached
}
// ON CONFLICT rather than a plain insert: somebody may already be a member
// through another route, and being invited again should not fail.
_, err = tx.ExecContext(ctx,
`INSERT INTO project_members (project_id, user_id, role, added_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (project_id, user_id) DO UPDATE SET role = excluded.role`,
projectID.String, userID, string(role), time.Now().Unix())
return err
})
}
// --- password resets ----------------------------------------------------------
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
// a new one, so asking twice does not leave two working links in two inboxes.
func (db *DB) CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error) {
token, err := auth.NewToken()
if err != nil {
return "", err
}
now := time.Now()
err = db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at IS NULL`,
now.Unix(), userID); err != nil {
return err
}
_, err := tx.ExecContext(ctx,
`INSERT INTO password_resets (id, user_id, token_hash, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)`,
NewID(), userID, auth.HashToken(token), now.Unix(), now.Add(ttl).Unix())
return err
})
if err != nil {
return "", err
}
return token, nil
}
// UsePasswordReset consumes a reset token and returns whose it was. Guarded on
// used_at IS NULL so the link works exactly once.
func (db *DB) UsePasswordReset(ctx context.Context, token string) (string, error) {
if token == "" {
return "", ErrNotFound
}
hash := auth.HashToken(token)
var userID string
err := db.Tx(ctx, func(tx *sql.Tx) error {
var expires int64
var used sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT user_id, expires_at, used_at FROM password_resets WHERE token_hash = ?`, hash).
Scan(&userID, &expires, &used)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if used.Valid || time.Now().Unix() > expires {
return ErrNotFound
}
_, err = tx.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`,
time.Now().Unix(), hash)
return err
})
if err != nil {
return "", err
}
return userID, nil
}
// --- TOTP recovery codes -------------------------------------------------------
// ReplaceRecoveryCodes swaps the whole set. Codes are only ever issued as a batch,
// and a new batch must retire the old one — otherwise a printout somebody threw
// away still works.
func (db *DB) ReplaceRecoveryCodes(ctx context.Context, userID string, hashes []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`DELETE FROM totp_recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
now := time.Now().Unix()
for _, h := range hashes {
if _, err := tx.ExecContext(ctx,
`INSERT INTO totp_recovery_codes (id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)`, NewID(), userID, h, now); err != nil {
return err
}
}
return nil
})
}
// UseRecoveryCode spends a code if it matches an unused one. Guarded on
// used_at IS NULL, so a code works exactly once even if submitted twice at once.
func (db *DB) UseRecoveryCode(ctx context.Context, userID, code string) (bool, error) {
if code == "" {
return false, nil
}
rows, err := db.QueryContext(ctx,
`SELECT id, code_hash FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID)
if err != nil {
return false, err
}
var ids, hashes []string
for rows.Next() {
var id, hash string
if err := rows.Scan(&id, &hash); err != nil {
rows.Close()
return false, err
}
ids = append(ids, id)
hashes = append(hashes, hash)
}
rows.Close()
if err := rows.Err(); err != nil {
return false, err
}
i := auth.MatchRecoveryCode(hashes, code)
if i < 0 {
return false, nil
}
res, err := db.ExecContext(ctx,
`UPDATE totp_recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`,
time.Now().Unix(), ids[i])
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// CountRecoveryCodes reports how many are left, so the UI can warn before the last
// one is gone.
func (db *DB) CountRecoveryCodes(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL`,
userID).Scan(&n)
return n, err
}
+23
View File
@@ -0,0 +1,23 @@
-- Sessions gain a two-step state, and TOTP gains a way back in.
-- A session created by a correct password but not yet by a correct TOTP code.
-- Such a session authenticates exactly one thing — completing the TOTP step — and
-- nothing else. Modelling the half-finished login as a session rather than as a
-- separate token means it expires, is listed, and can be revoked by the same code
-- that handles every other session.
ALTER TABLE sessions ADD COLUMN pending_totp INTEGER NOT NULL DEFAULT 0;
-- Single-use codes for someone who has lost their authenticator.
--
-- Without these, losing a phone means losing the account, and the only remaining
-- repair is editing SQLite by hand — which is not a recovery story you can put in
-- front of somebody who invited their partner to a shared shopping list.
CREATE TABLE totp_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE INDEX idx_recovery_codes_user ON totp_recovery_codes (user_id) WHERE used_at IS NULL;
+172
View File
@@ -0,0 +1,172 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
type Session struct {
ID string // the hash, which is what is stored — never the cookie value
UserID string
UA string
IP string
PendingTOTP bool
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
}
// CreateSession issues a session and returns the value to put in the cookie.
//
// Only the hash is stored. A session cookie is a bearer credential — whoever holds
// it is logged in — so a database dump must not contain anything that can be pasted
// into a browser.
//
// pendingTOTP marks a login that has passed the password step but not the second
// factor. Such a session is accepted by exactly one endpoint.
func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time.Duration, pendingTOTP bool) (string, *Session, error) {
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
s := &Session{
ID: auth.HashToken(token),
UserID: userID,
UA: truncate(ua, 500),
IP: truncate(ip, 100),
PendingTOTP: pendingTOTP,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(ttl),
}
_, err = db.ExecContext(ctx,
`INSERT INTO sessions (id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.UserID, s.UA, s.IP, boolToInt(s.PendingTOTP),
s.CreatedAt.Unix(), s.LastSeenAt.Unix(), s.ExpiresAt.Unix())
if err != nil {
return "", nil, err
}
return token, s, nil
}
// SessionByToken looks up a session by the cookie value and returns it with its
// user. An expired session is reported as not found and deleted on the way past:
// expiry is checked here rather than left to the nightly sweep, so a stale cookie
// stops working at the moment it should.
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
id := auth.HashToken(token)
var s Session
var pending int
var created, lastSeen, expires int64
err := db.QueryRowContext(ctx,
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
FROM sessions WHERE id = ?`, id).
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrNotFound
}
if err != nil {
return nil, nil, err
}
s.PendingTOTP = pending == 1
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
if time.Now().After(s.ExpiresAt) {
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
return nil, nil, ErrNotFound
}
user, err := db.UserByID(ctx, s.UserID)
if err != nil {
return nil, nil, err
}
// last_seen_at drives the session list in settings ("this device, 2 minutes
// ago"). It is written at most once a minute: every request would be a write
// per request, which on SQLite means a write lock per request.
if time.Since(s.LastSeenAt) > time.Minute {
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
}
return &s, user, nil
}
// PromoteSession completes a two-step login: the session stops being pending and
// its clock restarts, so the full lifetime is counted from finishing the login and
// not from typing the password.
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
res, err := db.ExecContext(ctx,
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
time.Now().Add(ttl).Unix(), sessionID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (db *DB) DeleteSession(ctx context.Context, sessionID string) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
return err
}
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Session
for rows.Next() {
var s Session
var pending int
var created, lastSeen, expires int64
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
return nil, err
}
s.PendingTOTP = pending == 1
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, s)
}
return out, rows.Err()
}
// PurgeExpiredSessions is run by the nightly sweep. SessionByToken already refuses
// an expired session, so this only reclaims space and keeps the session list honest.
func (db *DB) PurgeExpiredSessions(ctx context.Context) (int64, error) {
res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
+12 -5
View File
@@ -50,21 +50,28 @@ func TestMigrateIsIdempotent(t *testing.T) {
if err != nil {
t.Fatalf("first open: %v", err)
}
var first int
if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&first); err != nil {
t.Fatalf("count migrations: %v", err)
}
db.Close()
if first == 0 {
t.Fatal("no migrations were applied")
}
// Re-opening must not try to re-run 0001 and fail on "table already exists".
// Re-opening must not re-run anything and fail on "table already exists".
db2, err := Open(path)
if err != nil {
t.Fatalf("second open: %v", err)
}
defer db2.Close()
var n int
if err := db2.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&n); err != nil {
var second int
if err := db2.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&second); err != nil {
t.Fatalf("count migrations: %v", err)
}
if n != 1 {
t.Errorf("schema_migrations has %d rows, want 1", n)
if second != first {
t.Errorf("re-opening applied %d further migrations", second-first)
}
}
+203
View File
@@ -0,0 +1,203 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
var (
ErrNotFound = errors.New("store: not found")
ErrEmailInUse = errors.New("store: that email address already has an account")
ErrInviteInvalid = errors.New("store: invite is not valid")
)
type User struct {
ID string
Email string
Name string
PasswordHash string
TOTPSecret string
TOTPEnabled bool
AvatarColor string
Timezone string
Locale string
IsAdmin bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
// with stray whitespace and inconsistent case from every direction — a login form,
// an invite, an import — and two spellings of one address must never become two
// accounts.
//
// Only case and surrounding space are touched. Gmail's dots-and-plus rules are
// deliberately not applied: they are Gmail's, not the internet's, and stripping a
// "+todo" suffix would break the very addressing people use to route mail here.
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// CreateUser inserts a user and gives them an Inbox, in one transaction.
//
// The Inbox is created here rather than lazily on first use because every other
// part of the app is entitled to assume it exists — a task with no project has
// nowhere to go, and "create it if missing" scattered across handlers is how two
// of them end up racing to create the second one.
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
u.Email = NormalizeEmail(u.Email)
if u.ID == "" {
u.ID = NewID()
}
if u.AvatarColor == "" {
u.AvatarColor = avatarColorFor(u.Email)
}
now := time.Now().UTC()
u.CreatedAt, u.UpdatedAt = now, now
return db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO users (id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, 0, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor,
orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"),
boolToInt(u.IsAdmin), now.Unix(), now.Unix())
if err != nil {
if isUniqueViolation(err) {
return ErrEmailInUse
}
return fmt.Errorf("insert user: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at)
VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`,
NewID(), inboxName, u.ID, now.Unix(), now.Unix())
if err != nil {
return fmt.Errorf("create inbox: %w", err)
}
return nil
})
}
func (db *DB) UserByEmail(ctx context.Context, email string) (*User, error) {
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email))
}
func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id)
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
var secret sql.NullString
var totpEnabled, isAdmin int
var created, updated int64
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
u.TOTPSecret = secret.String
u.TOTPEnabled = totpEnabled == 1
u.IsAdmin = isAdmin == 1
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
}
// UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n)
return n, err
}
// UpdatePasswordHash also clears every session but the one asking, so changing a
// password actually ends the sessions of whoever else was using the account. That
// is most of the reason people change one.
func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx,
`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`,
hash, time.Now().Unix(), userID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx,
`DELETE FROM sessions WHERE user_id = ? AND id <> ?`, userID, keepSessionID)
return err
})
}
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
var value any
if secret != "" {
value = secret
}
_, err := db.ExecContext(ctx,
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
value, boolToInt(enabled), time.Now().Unix(), userID)
return err
}
// InboxID returns the user's Inbox project.
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
var id string
err := db.QueryRowContext(ctx,
`SELECT id FROM projects WHERE owner_id = ? AND is_inbox = 1 AND deleted_at IS NULL`,
userID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
// avatarColorFor picks a stable colour from the address, so a person looks the same
// to everyone who shares a project with them without anyone having to choose one.
func avatarColorFor(seed string) string {
// Muted tones only: an avatar is a small mark beside text, not a highlight.
palette := []string{
"#8a8f98", "#7d8c7a", "#9a8577", "#7a8a9a", "#8f7f92",
"#a08a6b", "#6f8a86", "#947d7d",
}
var sum uint32
for i := 0; i < len(seed); i++ {
sum = sum*31 + uint32(seed[i])
}
return palette[sum%uint32(len(palette))]
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// isUniqueViolation recognises a broken UNIQUE constraint without depending on the
// driver's error type, which modernc's SQLite does not export in a usable form.
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
}