Phase 1: accounts and sessions
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4fb60a8ab
commit
6004bb01de
68 files changed
+9729
-66
No files matched your search
@@ -35,6 +35,16 @@ type Options struct {
|
||||
// Settings holds the statements for the server_settings table.
|
||||
Settings SettingsQueries
|
||||
|
||||
// Rebind translates the `?` placeholders that the shared component
|
||||
// stores are written with into this driver's style. SQLite needs no
|
||||
// translation and leaves it nil; PostgreSQL turns them into $1, $2...
|
||||
Rebind func(query string) string
|
||||
|
||||
// IsUniqueViolation recognises this driver's unique-constraint error, so
|
||||
// that a taken user name can be told apart from a database failure
|
||||
// without a check-then-insert race.
|
||||
IsUniqueViolation func(error) bool
|
||||
|
||||
// Logger is used for migration progress.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
@@ -46,6 +56,7 @@ type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
settings *Settings
|
||||
users *Users
|
||||
}
|
||||
|
||||
// New builds a Store. It does not touch the database.
|
||||
@@ -61,6 +72,7 @@ func New(opts Options) (*Store, error) {
|
||||
db: opts.DB,
|
||||
log: opts.Logger,
|
||||
settings: NewSettings(opts.DB, opts.Settings),
|
||||
users: NewUsers(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -79,6 +91,9 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
// Settings implements storage.Database.
|
||||
func (s *Store) Settings() storage.SettingsStore { return s.settings }
|
||||
|
||||
// Users implements storage.Database.
|
||||
func (s *Store) Users() storage.UserStore { return s.users }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
ms, err := s.migrations()
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// Users is the shared SQL implementation of storage.UserStore.
|
||||
//
|
||||
// Unlike the settings store, whose four statements each driver writes in its
|
||||
// own style, the user store has around thirty. Keeping two copies of them
|
||||
// differing only in `?` versus `$1` would be a standing invitation to fix a
|
||||
// bug in one and not the other, so the SQL is written once with `?` and the
|
||||
// driver supplies a Rebind function. The statements themselves use only
|
||||
// syntax both engines agree on: ON CONFLICT ... DO UPDATE with `excluded`,
|
||||
// LIMIT/OFFSET, and comparisons against bound values rather than boolean
|
||||
// literals.
|
||||
type Users struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
isUnique func(error) bool
|
||||
}
|
||||
|
||||
// NewUsers builds a user store. rebind translates the `?` placeholders into
|
||||
// the driver's own style, and isUnique recognises its unique-constraint
|
||||
// error; nil means "no translation" and "never", respectively.
|
||||
func NewUsers(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Users {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
if isUnique == nil {
|
||||
isUnique = func(error) bool { return false }
|
||||
}
|
||||
return &Users{db: db, rebind: rebind, isUnique: isUnique}
|
||||
}
|
||||
|
||||
// defaultAccountPageSize bounds an admin user listing that asks for no limit,
|
||||
// so a forgotten ?limit= cannot stream the whole user table.
|
||||
const defaultAccountPageSize = 100
|
||||
|
||||
// maxAccountPageSize caps what a caller may ask for.
|
||||
const maxAccountPageSize = 1000
|
||||
|
||||
// q rebinds a statement for this driver.
|
||||
func (u *Users) q(query string) string { return u.rebind(query) }
|
||||
|
||||
// tx runs fn in a transaction, rolling back on any error. The rollback after
|
||||
// a successful commit is a no-op, which is why it can be deferred blindly.
|
||||
func (u *Users) tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := u.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const insertAccountSQL = `
|
||||
INSERT INTO user_accounts (localpart, password_hash, display_name, admin, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
|
||||
// CreateAccount implements storage.UserStore.
|
||||
//
|
||||
// Taken localparts are detected by inserting and letting the primary key
|
||||
// complain, rather than by checking first: the check-then-insert version has
|
||||
// a race that two simultaneous registrations of the same name would walk
|
||||
// straight into.
|
||||
func (u *Users) CreateAccount(ctx context.Context, acc storage.NewAccount) (storage.Account, error) {
|
||||
_, err := u.db.ExecContext(ctx, u.q(insertAccountSQL),
|
||||
acc.Localpart, acc.PasswordHash, acc.DisplayName, acc.Admin, acc.CreatedAtMS)
|
||||
if err != nil {
|
||||
if u.isUnique(err) {
|
||||
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, storage.ErrUserExists)
|
||||
}
|
||||
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, err)
|
||||
}
|
||||
return storage.Account{
|
||||
Localpart: acc.Localpart,
|
||||
DisplayName: acc.DisplayName,
|
||||
Admin: acc.Admin,
|
||||
CreatedAtMS: acc.CreatedAtMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const selectAccountSQL = `
|
||||
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
|
||||
FROM user_accounts WHERE localpart = ?`
|
||||
|
||||
// Account implements storage.UserStore.
|
||||
func (u *Users) Account(ctx context.Context, localpart string) (storage.Account, error) {
|
||||
var acc storage.Account
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectAccountSQL), localpart).Scan(
|
||||
&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
|
||||
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, err)
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
const selectPasswordHashSQL = `SELECT password_hash FROM user_accounts WHERE localpart = ?`
|
||||
|
||||
// PasswordHash implements storage.UserStore.
|
||||
func (u *Users) PasswordHash(ctx context.Context, localpart string) (string, error) {
|
||||
var hash string
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectPasswordHashSQL), localpart).Scan(&hash)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", fmt.Errorf("password for %q: %w", localpart, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("password for %q: %w", localpart, err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// SetPasswordHash implements storage.UserStore.
|
||||
func (u *Users) SetPasswordHash(ctx context.Context, localpart, hash string) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET password_hash = ? WHERE localpart = ?`, hash)
|
||||
}
|
||||
|
||||
// SetAdmin implements storage.UserStore.
|
||||
func (u *Users) SetAdmin(ctx context.Context, localpart string, admin bool) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET admin = ? WHERE localpart = ?`, admin)
|
||||
}
|
||||
|
||||
// SetDisplayName implements storage.UserStore.
|
||||
func (u *Users) SetDisplayName(ctx context.Context, localpart, displayName string) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET display_name = ? WHERE localpart = ?`, displayName)
|
||||
}
|
||||
|
||||
// SetAvatarURL implements storage.UserStore.
|
||||
func (u *Users) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET avatar_url = ? WHERE localpart = ?`, avatarURL)
|
||||
}
|
||||
|
||||
// updateAccount runs a single-column update and turns "nothing changed" into
|
||||
// ErrNoSuchUser, so callers do not have to look the account up first.
|
||||
func (u *Users) updateAccount(ctx context.Context, localpart, query string, value any) error {
|
||||
res, err := u.db.ExecContext(ctx, u.q(query), value, localpart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q: %w", localpart, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q: %w", localpart, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("update account %q: %w", localpart, storage.ErrNoSuchUser)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// likeEscape is the escape character the search patterns use.
|
||||
//
|
||||
// It is deliberately not a backslash: a backslash in a SQL string literal
|
||||
// means different things depending on PostgreSQL's standard_conforming_
|
||||
// strings setting, and "!" means exactly one thing everywhere.
|
||||
const likeEscape = '!'
|
||||
|
||||
const (
|
||||
countAccountsSQL = `
|
||||
SELECT COUNT(*) FROM user_accounts
|
||||
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'`
|
||||
|
||||
listAccountsSQL = `
|
||||
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
|
||||
FROM user_accounts
|
||||
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'
|
||||
ORDER BY localpart
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
|
||||
// ListAccounts implements storage.UserStore.
|
||||
//
|
||||
// The search is a LOWER(...) LIKE rather than ILIKE, which is PostgreSQL
|
||||
// only, and the pattern is escaped so that searching for "_" does not
|
||||
// quietly match every single-character name.
|
||||
func (u *Users) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
|
||||
pattern := likeContains(filter.Search)
|
||||
|
||||
limit := filter.Limit
|
||||
switch {
|
||||
case limit <= 0:
|
||||
limit = defaultAccountPageSize
|
||||
case limit > maxAccountPageSize:
|
||||
limit = maxAccountPageSize
|
||||
}
|
||||
offset := max(filter.Offset, 0)
|
||||
|
||||
var total int
|
||||
if err := u.db.QueryRowContext(ctx, u.q(countAccountsSQL), pattern, pattern).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count accounts: %w", err)
|
||||
}
|
||||
|
||||
rows, err := u.db.QueryContext(ctx, u.q(listAccountsSQL), pattern, pattern, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
accounts := []storage.Account{}
|
||||
for rows.Next() {
|
||||
var acc storage.Account
|
||||
if err := rows.Scan(&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
|
||||
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan account: %w", err)
|
||||
}
|
||||
accounts = append(accounts, acc)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
return accounts, total, nil
|
||||
}
|
||||
|
||||
// likeContains turns a search term into a LIKE pattern matching it
|
||||
// anywhere, escaping the two wildcards and the escape character itself. The
|
||||
// statements using it declare ESCAPE explicitly, because SQLite has no
|
||||
// default escape character at all.
|
||||
func likeContains(search string) string {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
var b strings.Builder
|
||||
b.Grow(len(search) + 2)
|
||||
b.WriteByte('%')
|
||||
for _, r := range search {
|
||||
if r == '%' || r == '_' || r == likeEscape {
|
||||
b.WriteByte(likeEscape)
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
b.WriteByte('%')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const statsSQL = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM user_accounts),
|
||||
(SELECT COUNT(*) FROM user_accounts WHERE admin = ?),
|
||||
(SELECT COUNT(*) FROM user_accounts WHERE deactivated = ?),
|
||||
(SELECT COUNT(*) FROM user_devices)`
|
||||
|
||||
// Stats implements storage.UserStore.
|
||||
func (u *Users) Stats(ctx context.Context) (storage.Stats, error) {
|
||||
var s storage.Stats
|
||||
// The flags are compared against bound values rather than TRUE, which
|
||||
// SQLite only learned in 3.23 and which PostgreSQL spells differently
|
||||
// from an integer.
|
||||
err := u.db.QueryRowContext(ctx, u.q(statsSQL), true, true).
|
||||
Scan(&s.Users, &s.Admins, &s.Deactivated, &s.Devices)
|
||||
if err != nil {
|
||||
return storage.Stats{}, fmt.Errorf("user stats: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectDeviceForUpdateSQL = `
|
||||
SELECT display_name, created_at_ms FROM user_devices
|
||||
WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
insertDeviceSQL = `
|
||||
INSERT INTO user_devices
|
||||
(localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
touchDeviceSQL = `
|
||||
UPDATE user_devices SET last_seen_ms = ?, last_seen_ip = ?, user_agent = ?
|
||||
WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
deleteDeviceTokensSQL = `DELETE FROM user_access_tokens WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
deleteDeviceRefreshTokensSQL = `DELETE FROM user_refresh_tokens WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
insertAccessTokenSQL = `
|
||||
INSERT INTO user_access_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
|
||||
insertRefreshTokenSQL = `
|
||||
INSERT INTO user_refresh_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
|
||||
accountExistsSQL = `SELECT 1 FROM user_accounts WHERE localpart = ?`
|
||||
)
|
||||
|
||||
// CreateSession implements storage.UserStore.
|
||||
//
|
||||
// Logging in on a device ID that already exists keeps the device — and its
|
||||
// display name, because initial_device_display_name only applies to a device
|
||||
// being created — but revokes the tokens it held, which is what the spec
|
||||
// requires.
|
||||
func (u *Users) CreateSession(ctx context.Context, s storage.NewSession) (storage.Device, error) {
|
||||
device := storage.Device{
|
||||
Localpart: s.Localpart,
|
||||
ID: s.DeviceID,
|
||||
DisplayName: s.DisplayName,
|
||||
CreatedAtMS: s.CreatedAtMS,
|
||||
LastSeenMS: s.CreatedAtMS,
|
||||
LastSeenIP: s.LastSeenIP,
|
||||
UserAgent: s.UserAgent,
|
||||
}
|
||||
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
var exists int
|
||||
switch err := tx.QueryRowContext(ctx, u.q(accountExistsSQL), s.Localpart).Scan(&exists); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("create session for %q: %w", s.Localpart, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return fmt.Errorf("create session for %q: %w", s.Localpart, err)
|
||||
}
|
||||
|
||||
var existingName string
|
||||
var existingCreated int64
|
||||
err := tx.QueryRowContext(ctx, u.q(selectDeviceForUpdateSQL), s.Localpart, s.DeviceID).
|
||||
Scan(&existingName, &existingCreated)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertDeviceSQL),
|
||||
s.Localpart, s.DeviceID, s.DisplayName, s.CreatedAtMS,
|
||||
s.CreatedAtMS, s.LastSeenIP, s.UserAgent); err != nil {
|
||||
return fmt.Errorf("create device %q: %w", s.DeviceID, err)
|
||||
}
|
||||
case err != nil:
|
||||
return fmt.Errorf("read device %q: %w", s.DeviceID, err)
|
||||
default:
|
||||
device.DisplayName = existingName
|
||||
device.CreatedAtMS = existingCreated
|
||||
if _, err := tx.ExecContext(ctx, u.q(touchDeviceSQL),
|
||||
s.CreatedAtMS, s.LastSeenIP, s.UserAgent, s.Localpart, s.DeviceID); err != nil {
|
||||
return fmt.Errorf("update device %q: %w", s.DeviceID, err)
|
||||
}
|
||||
for _, query := range []string{deleteDeviceTokensSQL, deleteDeviceRefreshTokensSQL} {
|
||||
if _, err := tx.ExecContext(ctx, u.q(query), s.Localpart, s.DeviceID); err != nil {
|
||||
return fmt.Errorf("revoke tokens of device %q: %w", s.DeviceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
|
||||
s.AccessTokenHash, s.Localpart, s.DeviceID,
|
||||
s.CreatedAtMS, s.AccessTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store access token: %w", err)
|
||||
}
|
||||
if s.RefreshTokenHash != "" {
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
|
||||
s.RefreshTokenHash, s.Localpart, s.DeviceID,
|
||||
s.CreatedAtMS, s.RefreshTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Device{}, err
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectRefreshTokenSQL = `
|
||||
SELECT localpart, device_id, expires_at_ms FROM user_refresh_tokens WHERE token_hash = ?`
|
||||
|
||||
deleteRefreshTokenSQL = `DELETE FROM user_refresh_tokens WHERE token_hash = ?`
|
||||
|
||||
selectAccountFlagsSQL = `SELECT admin, deactivated FROM user_accounts WHERE localpart = ?`
|
||||
)
|
||||
|
||||
// RefreshSession implements storage.UserStore.
|
||||
//
|
||||
// The spent refresh token and the access token it had minted both go away
|
||||
// immediately. The spec allows keeping the old access token alive for a grace
|
||||
// period; refusing it at once is the stricter reading and means a stolen
|
||||
// token stops working the moment the real client refreshes.
|
||||
func (u *Users) RefreshSession(ctx context.Context, r storage.RefreshedSession) (storage.TokenInfo, error) {
|
||||
var info storage.TokenInfo
|
||||
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
var expiresAt int64
|
||||
err := tx.QueryRowContext(ctx, u.q(selectRefreshTokenSQL), r.RefreshTokenHash).
|
||||
Scan(&info.Localpart, &info.DeviceID, &expiresAt)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("refresh token: %w", storage.ErrNoSuchToken)
|
||||
case err != nil:
|
||||
return fmt.Errorf("refresh token: %w", err)
|
||||
}
|
||||
if expiresAt != 0 && expiresAt <= r.RefreshedAtMS {
|
||||
// Clean the dead token up on the way past; nothing else sweeps.
|
||||
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
|
||||
return fmt.Errorf("delete expired refresh token: %w", err)
|
||||
}
|
||||
return fmt.Errorf("refresh token has expired: %w", storage.ErrNoSuchToken)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
|
||||
return fmt.Errorf("spend refresh token: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, u.q(deleteDeviceTokensSQL), info.Localpart, info.DeviceID); err != nil {
|
||||
return fmt.Errorf("revoke previous access token: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
|
||||
r.NewAccessTokenHash, info.Localpart, info.DeviceID,
|
||||
r.RefreshedAtMS, r.AccessTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store access token: %w", err)
|
||||
}
|
||||
if r.NewRefreshTokenHash != "" {
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
|
||||
r.NewRefreshTokenHash, info.Localpart, info.DeviceID,
|
||||
r.RefreshedAtMS, r.RefreshTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.QueryRowContext(ctx, u.q(selectAccountFlagsSQL), info.Localpart).
|
||||
Scan(&info.Admin, &info.Deactivated); err != nil {
|
||||
return fmt.Errorf("read account %q: %w", info.Localpart, err)
|
||||
}
|
||||
info.ExpiresAtMS = r.AccessTokenExpiresAtMS
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return storage.TokenInfo{}, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
const selectTokenInfoSQL = `
|
||||
SELECT t.localpart, t.device_id, t.expires_at_ms, a.admin, a.deactivated
|
||||
FROM user_access_tokens t
|
||||
JOIN user_accounts a ON a.localpart = t.localpart
|
||||
WHERE t.token_hash = ?`
|
||||
|
||||
// TokenInfo implements storage.UserStore.
|
||||
func (u *Users) TokenInfo(ctx context.Context, tokenHash string) (storage.TokenInfo, error) {
|
||||
var info storage.TokenInfo
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectTokenInfoSQL), tokenHash).Scan(
|
||||
&info.Localpart, &info.DeviceID, &info.ExpiresAtMS, &info.Admin, &info.Deactivated)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.TokenInfo{}, storage.ErrNoSuchToken
|
||||
case err != nil:
|
||||
return storage.TokenInfo{}, fmt.Errorf("look up access token: %w", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// TouchDevice implements storage.UserStore. A device that has gone away
|
||||
// between authenticating and recording the visit is not an error: the
|
||||
// request is being answered either way.
|
||||
func (u *Users) TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error {
|
||||
if _, err := u.db.ExecContext(ctx, u.q(touchDeviceSQL),
|
||||
seenAtMS, ip, userAgent, localpart, deviceID); err != nil {
|
||||
return fmt.Errorf("touch device %q: %w", deviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectDeviceSQL = `
|
||||
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
|
||||
FROM user_devices WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
selectDevicesSQL = `
|
||||
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
|
||||
FROM user_devices WHERE localpart = ? ORDER BY device_id`
|
||||
|
||||
updateDeviceDisplayNameSQL = `
|
||||
UPDATE user_devices SET display_name = ? WHERE localpart = ? AND device_id = ?`
|
||||
)
|
||||
|
||||
// Device implements storage.UserStore.
|
||||
func (u *Users) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
|
||||
var d storage.Device
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectDeviceSQL), localpart, deviceID).Scan(
|
||||
&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
|
||||
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, storage.ErrNoSuchDevice)
|
||||
case err != nil:
|
||||
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Devices implements storage.UserStore.
|
||||
func (u *Users) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
|
||||
rows, err := u.db.QueryContext(ctx, u.q(selectDevicesSQL), localpart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
devices := []storage.Device{}
|
||||
for rows.Next() {
|
||||
var d storage.Device
|
||||
if err := rows.Scan(&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
|
||||
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent); err != nil {
|
||||
return nil, fmt.Errorf("scan device: %w", err)
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// SetDeviceDisplayName implements storage.UserStore.
|
||||
func (u *Users) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
|
||||
res, err := u.db.ExecContext(ctx, u.q(updateDeviceDisplayNameSQL), displayName, localpart, deviceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename device %q: %w", deviceID, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename device %q: %w", deviceID, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("rename device %q: %w", deviceID, storage.ErrNoSuchDevice)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDevices implements storage.UserStore.
|
||||
//
|
||||
// Tokens are deleted explicitly rather than left to ON DELETE CASCADE: SQLite
|
||||
// only enforces foreign keys when the connection asks it to, so relying on
|
||||
// the cascade would make revocation depend on a DSN pragma.
|
||||
func (u *Users) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error) {
|
||||
if len(deviceIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
args := make([]any, 0, len(deviceIDs)+1)
|
||||
args = append(args, localpart)
|
||||
for _, id := range deviceIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
in := "(" + strings.TrimSuffix(strings.Repeat("?,", len(deviceIDs)), ",") + ")"
|
||||
|
||||
var deleted int
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
|
||||
query := "DELETE FROM " + table + " WHERE localpart = ? AND device_id IN " + in
|
||||
if _, err := tx.ExecContext(ctx, u.q(query), args...); err != nil {
|
||||
return fmt.Errorf("revoke tokens: %w", err)
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
u.q("DELETE FROM user_devices WHERE localpart = ? AND device_id IN "+in), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices: %w", err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices: %w", err)
|
||||
}
|
||||
deleted = int(affected)
|
||||
return nil
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// DeleteAllDevices implements storage.UserStore.
|
||||
func (u *Users) DeleteAllDevices(ctx context.Context, localpart string) (int, error) {
|
||||
var deleted int
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
u.q("DELETE FROM "+table+" WHERE localpart = ?"), localpart); err != nil {
|
||||
return fmt.Errorf("revoke tokens of %q: %w", localpart, err)
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
u.q("DELETE FROM user_devices WHERE localpart = ?"), localpart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices of %q: %w", localpart, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices of %q: %w", localpart, err)
|
||||
}
|
||||
deleted = int(affected)
|
||||
return nil
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
const (
|
||||
selectAccountDataSQL = `
|
||||
SELECT content FROM user_account_data
|
||||
WHERE localpart = ? AND room_id = ? AND data_type = ?`
|
||||
|
||||
upsertAccountDataSQL = `
|
||||
INSERT INTO user_account_data (localpart, room_id, data_type, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (localpart, room_id, data_type)
|
||||
DO UPDATE SET content = excluded.content`
|
||||
)
|
||||
|
||||
// AccountData implements storage.UserStore.
|
||||
func (u *Users) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
|
||||
var content string
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectAccountDataSQL), localpart, roomID, dataType).Scan(&content)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, fmt.Errorf("account data %q: %w", dataType, storage.ErrNoSuchAccountData)
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("account data %q: %w", dataType, err)
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
// SetAccountData implements storage.UserStore. The content is stored as text
|
||||
// rather than a JSON column type: the two engines disagree on what JSON
|
||||
// normalisation does, and account data has to come back out byte for byte.
|
||||
func (u *Users) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
|
||||
if _, err := u.db.ExecContext(ctx, u.q(upsertAccountDataSQL),
|
||||
localpart, roomID, dataType, string(content)); err != nil {
|
||||
return fmt.Errorf("set account data %q: %w", dataType, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time proof that the shared implementation satisfies the interface.
|
||||
var _ storage.UserStore = (*Users)(nil)
|
||||
Reference in new issue
Block a user