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

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:
tajniak81andClaude Opus 5 committed 2026-10-15 18:18:37 +02:00
1 parent b4fb60a8ab
commit 6004bb01de
68 files changed
+9729 -66

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
// Package adminapi serves /_warp/admin/v1: the API behind the admin panel.
//
// It is deliberately not part of the Matrix spec. Everything a client needs
// goes through the Client-Server API; this is the operator's surface, and
// the only thing in Warp that is ours rather than Matrix's.
//
// Authentication is a normal Matrix access token belonging to an account
// with the admin flag, so the panel logs in the way any client does and
// there is no second credential to manage.
//
// Phase 1 is the skeleton PLAN.md section 7 asks for: the two runtime
// switches, user listing and creation, and the dashboard counters. Full user
// management, registration tokens, the federation allow/deny list editor and
// the audit log arrive in Phase 9.
package adminapi
import (
"errors"
"log/slog"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Prefix is the path every admin endpoint lives under.
const Prefix = "/_warp/admin/v1"
// AdminAPI serves the admin endpoints.
type AdminAPI struct {
serverName string
users *userapi.API
settings *settings.Service
federation *federation.Gate
database storage.Database
log *slog.Logger
}
// Options configure the component.
type Options struct {
ServerName string
Users *userapi.API
Settings *settings.Service
Federation *federation.Gate
// Database is used for the driver name the dashboard shows and for the
// migration state; the admin API does not write to it directly.
Database storage.Database
Logger *slog.Logger
}
// New builds the component.
func New(opts Options) (*AdminAPI, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("adminapi: ServerName must not be empty")
case opts.Users == nil:
return nil, errors.New("adminapi: Users must not be nil")
case opts.Settings == nil:
return nil, errors.New("adminapi: Settings must not be nil")
}
if opts.Federation == nil {
opts.Federation = federation.NewGate(nil, nil)
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
return &AdminAPI{
serverName: opts.ServerName,
users: opts.Users,
settings: opts.Settings,
federation: opts.Federation,
database: opts.Database,
log: opts.Logger,
}, nil
}
// RegisterRoutes adds the admin endpoints to the router.
func (a *AdminAPI) RegisterRoutes(r *httputil.Router) {
a.handle(r, "GET "+Prefix+"/federation", a.handleGetFederation)
a.handle(r, "PUT "+Prefix+"/federation", a.handlePutFederation)
a.handle(r, "GET "+Prefix+"/registration", a.handleGetRegistration)
a.handle(r, "PUT "+Prefix+"/registration", a.handlePutRegistration)
a.handle(r, "GET "+Prefix+"/users", a.handleGetUsers)
a.handle(r, "POST "+Prefix+"/users", a.handlePostUsers)
a.handle(r, "GET "+Prefix+"/stats", a.handleGetStats)
}
// adminHandler is a handler that has already been shown to be an admin.
type adminHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
// handle registers an admin-only endpoint.
//
// Admin endpoints are not rate limited. They are reachable only with an
// admin token, and an operator bulk-loading users through the panel is
// exactly the traffic a limiter would get in the way of.
func (a *AdminAPI) handle(r *httputil.Router, pattern string, h adminHandler) {
r.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) error {
auth, err := a.requireAdmin(req)
if err != nil {
return err
}
return h(w, req, auth)
})
}
// requireAdmin resolves the access token and insists on the admin flag.
//
// A non-admin token gets M_FORBIDDEN rather than a 404: the caller is a
// legitimate user of this server who is not allowed here, and pretending
// the endpoint does not exist would only make the panel harder to debug.
func (a *AdminAPI) requireAdmin(r *http.Request) (userapi.Authenticated, error) {
token := accessTokenFrom(r)
if token == "" {
return userapi.Authenticated{}, httputil.MissingToken()
}
auth, err := a.users.Authenticate(r.Context(), token)
switch {
case err == nil:
case errors.Is(err, userapi.ErrNoSuchToken):
return userapi.Authenticated{}, httputil.UnknownToken(false)
case errors.Is(err, userapi.ErrTokenExpired):
return userapi.Authenticated{}, httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return userapi.Authenticated{}, httputil.UserDeactivated()
default:
return userapi.Authenticated{}, err
}
if !auth.Admin {
return userapi.Authenticated{}, httputil.Forbidden("This endpoint requires a server administrator")
}
return auth, nil
}
// accessTokenFrom reads the bearer token. The admin panel is a normal
// Matrix client, so it sends the header form; the query parameter is not
// accepted here, because an admin token in a URL ends up in access logs.
func accessTokenFrom(r *http.Request) string {
header := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(header) <= len(prefix) || !equalFoldASCII(header[:len(prefix)], prefix) {
return ""
}
return header[len(prefix):]
}
// equalFoldASCII compares two ASCII strings case-insensitively. The scheme
// of an Authorization header is case-insensitive per RFC 9110.
func equalFoldASCII(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := range len(a) {
x, y := a[i], b[i]
if 'A' <= x && x <= 'Z' {
x += 'a' - 'A'
}
if 'A' <= y && y <= 'Z' {
y += 'a' - 'A'
}
if x != y {
return false
}
}
return true
}
// switchResponse is the shape both runtime switches report.
type switchResponse struct {
Enabled bool `json:"enabled"`
// Toggleable is false when the config file pinned the setting, in
// which case the panel shows the switch greyed out rather than letting
// an operator flip something that will bounce.
Toggleable bool `json:"toggleable"`
}
// switchRequest is the body of either PUT.
type switchRequest struct {
Enabled *bool `json:"enabled"`
}
// federationResponse adds the allow and deny lists to the switch.
type federationResponse struct {
switchResponse
Allowlist []string `json:"allowlist"`
Denylist []string `json:"denylist"`
}
// handleGetFederation implements GET /_warp/admin/v1/federation.
func (a *AdminAPI) handleGetFederation(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
allowlist, denylist := a.federation.Policy().Lists()
httputil.WriteJSON(w, http.StatusOK, federationResponse{
switchResponse: switchResponse{
Enabled: a.settings.FederationEnabled(),
Toggleable: a.settings.FederationToggleable(),
},
// Editing these is Phase 9; they are reported now because the
// dashboard has to show which servers a running Warp will talk to.
Allowlist: allowlist,
Denylist: denylist,
})
return nil
}
// handlePutFederation implements PUT /_warp/admin/v1/federation, the switch
// PLAN.md section 3 describes: no restart, the gate picks it up on the next
// request.
func (a *AdminAPI) handlePutFederation(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
enabled, err := decodeSwitch(w, r)
if err != nil {
return err
}
if err := a.settings.SetFederationEnabled(r.Context(), enabled); err != nil {
return toggleError(err, "federation")
}
a.log.Info("federation switch changed through the admin API",
"enabled", enabled, "by", auth.UserID)
return a.handleGetFederation(w, r, auth)
}
// handleGetRegistration implements GET /_warp/admin/v1/registration.
func (a *AdminAPI) handleGetRegistration(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, switchResponse{
Enabled: a.settings.RegistrationEnabled(),
Toggleable: a.settings.RegistrationToggleable(),
})
return nil
}
// handlePutRegistration implements PUT /_warp/admin/v1/registration.
func (a *AdminAPI) handlePutRegistration(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
enabled, err := decodeSwitch(w, r)
if err != nil {
return err
}
if err := a.settings.SetRegistrationEnabled(r.Context(), enabled); err != nil {
return toggleError(err, "registration")
}
a.log.Info("registration switch changed through the admin API",
"enabled", enabled, "by", auth.UserID)
return a.handleGetRegistration(w, r, auth)
}
// decodeSwitch reads {"enabled": bool}. The field is a pointer so that a
// body without it is a missing parameter rather than a silent "false".
func decodeSwitch(w http.ResponseWriter, r *http.Request) (bool, error) {
var req switchRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return false, err
}
if req.Enabled == nil {
return false, httputil.MissingParam("enabled is required")
}
return *req.Enabled, nil
}
// toggleError turns a refused toggle into the right answer: a setting the
// config file pinned is a 409, because the request was well formed and the
// server's configuration is what stands in the way.
func toggleError(err error, name string) error {
if errors.Is(err, settings.ErrNotToggleable) {
return httputil.NewError(http.StatusConflict, httputil.ErrCodeForbidden,
"The %s switch is pinned by the configuration file (%s.allow_runtime_toggle is false)",
name, name)
}
return err
}
// statsResponse is the dashboard payload: PLAN.md section 8.1 lists users,
// rooms, events, federation status, storage driver and version as the first
// thing the panel shows. The room and event counters join it in Phase 3,
// when there is something to count.
type statsResponse struct {
ServerName string `json:"server_name"`
Version string `json:"version"`
Database string `json:"database"`
Users int `json:"users"`
Admins int `json:"admins"`
Deactivated int `json:"deactivated"`
Devices int `json:"devices"`
FederationEnabled bool `json:"federation_enabled"`
RegistrationEnabled bool `json:"registration_enabled"`
}
// handleGetStats implements GET /_warp/admin/v1/stats.
func (a *AdminAPI) handleGetStats(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
stats, err := a.users.Stats(r.Context())
if err != nil {
return err
}
resp := statsResponse{
ServerName: a.serverName,
Version: buildinfo.Version,
Users: stats.Users,
Admins: stats.Admins,
Deactivated: stats.Deactivated,
Devices: stats.Devices,
FederationEnabled: a.settings.FederationEnabled(),
RegistrationEnabled: a.settings.RegistrationEnabled(),
}
if a.database != nil {
resp.Database = a.database.Driver()
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
+412
View File
@@ -0,0 +1,412 @@
package adminapi_test
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/dariuszjaniak/warp/server/internal/adminapi"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// testServer is an admin API on a throwaway SQLite database.
type testServer struct {
t *testing.T
handler http.Handler
users *userapi.API
settings *settings.Service
}
// newTestServer builds the component. defaults are the config-file values
// the settings service starts from, which is how a test pins a switch.
func newTestServer(t *testing.T, defaults settings.Defaults) *testServer {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
users, err := userapi.New(userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
settingsService, err := settings.New(t.Context(), db.Settings(), defaults, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("settings.New: %v", err)
}
api, err := adminapi.New(adminapi.Options{
ServerName: testServerName,
Users: users,
Settings: settingsService,
Federation: federation.NewGate(settingsService.FederationEnabled,
federation.NewPolicy([]string{"friend.example"}, []string{"spam.example"})),
Database: db,
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("adminapi.New: %v", err)
}
router := httputil.NewRouter(nil)
api.RegisterRoutes(router)
return &testServer{t: t, handler: router, users: users, settings: settingsService}
}
func (ts *testServer) do(method, path, token string, body any) *httptest.ResponseRecorder {
ts.t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
ts.t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, req)
return rec
}
// admin creates an administrator and returns its access token.
func (ts *testServer) admin(localpart string) string {
ts.t.Helper()
return ts.account(localpart, true)
}
// user creates an ordinary account and returns its access token.
func (ts *testServer) user(localpart string) string {
ts.t.Helper()
return ts.account(localpart, false)
}
func (ts *testServer) account(localpart string, admin bool) string {
ts.t.Helper()
if _, err := ts.users.CreateAccount(ts.t.Context(), userapi.NewAccountRequest{
Localpart: localpart,
Password: "sUp3rs3kr1t",
Admin: admin,
}); err != nil {
ts.t.Fatalf("CreateAccount: %v", err)
}
session, err := ts.users.CreateSession(ts.t.Context(), userapi.SessionRequest{Localpart: localpart})
if err != nil {
ts.t.Fatalf("CreateSession: %v", err)
}
return session.AccessToken
}
func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response is not JSON (%s): %v", rec.Body.String(), err)
}
return out
}
func expectStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body.String())
}
}
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, errcode string) {
t.Helper()
expectStatus(t, rec, status)
if got := jsonBody(t, rec)["errcode"]; got != errcode {
t.Errorf("errcode = %v, want %s (body: %s)", got, errcode, rec.Body.String())
}
}
const (
federationPath = adminapi.Prefix + "/federation"
registrationPath = adminapi.Prefix + "/registration"
usersPath = adminapi.Prefix + "/users"
statsPath = adminapi.Prefix + "/stats"
)
var toggleable = settings.Defaults{
FederationAllowRuntimeToggle: true,
RegistrationAllowRuntimeToggle: true,
}
// TestAdminAuth is the gate everything else here depends on.
func TestAdminAuth(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
ordinary := ts.user("alice")
for name, tc := range map[string]struct {
token string
status int
code string
}{
"no token": {"", http.StatusUnauthorized, "M_MISSING_TOKEN"},
"unknown token": {"not-a-token", http.StatusUnauthorized, "M_UNKNOWN_TOKEN"},
"ordinary user": {ordinary, http.StatusForbidden, "M_FORBIDDEN"},
} {
t.Run(name, func(t *testing.T) {
expectError(t, ts.do(http.MethodGet, statsPath, tc.token, nil), tc.status, tc.code)
})
}
expectStatus(t, ts.do(http.MethodGet, statsPath, admin, nil), http.StatusOK)
}
// TestAdminTokenNotAcceptedInQueryString: an admin token in a URL ends up in
// every access log between here and the browser.
func TestAdminTokenNotAcceptedInQueryString(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
expectError(t, ts.do(http.MethodGet, statsPath+"?access_token="+admin, "", nil),
http.StatusUnauthorized, "M_MISSING_TOKEN")
}
// TestFederationSwitch is the admin half of PLAN.md section 3: the switch
// flips at runtime and the gate follows.
func TestFederationSwitch(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
rec := ts.do(http.MethodGet, federationPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["enabled"] != false {
t.Errorf("federation should start disabled, got %v", body["enabled"])
}
if body["toggleable"] != true {
t.Errorf("toggleable = %v, want true", body["toggleable"])
}
// The dashboard needs to show which servers a running Warp will talk
// to, even though editing the lists is Phase 9.
if allowlist, _ := body["allowlist"].([]any); len(allowlist) != 1 || allowlist[0] != "friend.example" {
t.Errorf("allowlist = %v", body["allowlist"])
}
if denylist, _ := body["denylist"].([]any); len(denylist) != 1 || denylist[0] != "spam.example" {
t.Errorf("denylist = %v", body["denylist"])
}
rec = ts.do(http.MethodPut, federationPath, admin, map[string]any{"enabled": true})
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["enabled"] != true {
t.Errorf("the PUT should report the new state, got %s", rec.Body.String())
}
if !ts.settings.FederationEnabled() {
t.Error("the settings service did not pick the change up")
}
rec = ts.do(http.MethodPut, federationPath, admin, map[string]any{"enabled": false})
expectStatus(t, rec, http.StatusOK)
if ts.settings.FederationEnabled() {
t.Error("switching back off did not take effect")
}
}
func TestRegistrationSwitch(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
rec := ts.do(http.MethodPut, registrationPath, admin, map[string]any{"enabled": true})
expectStatus(t, rec, http.StatusOK)
if !ts.settings.RegistrationEnabled() {
t.Error("registration was not enabled")
}
rec = ts.do(http.MethodGet, registrationPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["enabled"] != true {
t.Errorf("GET = %s", rec.Body.String())
}
}
// TestSwitchPinnedByConfig covers settings.ErrNotToggleable: the request was
// fine, the server's own configuration is what refuses it, so it is a
// conflict rather than a bad request.
func TestSwitchPinnedByConfig(t *testing.T) {
ts := newTestServer(t, settings.Defaults{})
admin := ts.admin("root")
for _, path := range []string{federationPath, registrationPath} {
rec := ts.do(http.MethodPut, path, admin, map[string]any{"enabled": true})
expectError(t, rec, http.StatusConflict, "M_FORBIDDEN")
if msg, _ := jsonBody(t, rec)["error"].(string); msg == "" {
t.Error("the refusal must say which setting is pinned")
}
// And the GET says so in advance, so the panel can grey the
// switch out instead of offering something that will bounce.
rec = ts.do(http.MethodGet, path, admin, nil)
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["toggleable"] != false {
t.Errorf("GET %s reports toggleable, but it is pinned", path)
}
}
}
func TestSwitchRequiresTheEnabledField(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
expectError(t, ts.do(http.MethodPut, federationPath, admin, map[string]any{}),
http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestListUsers(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
ts.user("alice")
ts.user("bob")
rec := ts.do(http.MethodGet, usersPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if total, _ := body["total"].(float64); total != 3 {
t.Errorf("total = %v, want 3", body["total"])
}
users, _ := body["users"].([]any)
if len(users) != 3 {
t.Fatalf("got %d users, want 3", len(users))
}
first, _ := users[0].(map[string]any)
if first["user_id"] != "@alice:warp.example" {
t.Errorf("users are not ordered by localpart: %s", rec.Body.String())
}
if first["admin"] != false {
t.Errorf("alice should not be an admin: %v", first)
}
// Paging reports the full total so the panel can show how many pages
// there are.
rec = ts.do(http.MethodGet, usersPath+"?limit=1&offset=1", admin, nil)
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
users, _ = body["users"].([]any)
if len(users) != 1 || users[0].(map[string]any)["user_id"] != "@bob:warp.example" {
t.Errorf("paged listing = %s", rec.Body.String())
}
if total, _ := body["total"].(float64); total != 3 {
t.Errorf("paged total = %v, want 3", body["total"])
}
rec = ts.do(http.MethodGet, usersPath+"?search=ali", admin, nil)
expectStatus(t, rec, http.StatusOK)
if total, _ := jsonBody(t, rec)["total"].(float64); total != 1 {
t.Errorf("search found %v users, want 1", jsonBody(t, rec)["total"])
}
expectError(t, ts.do(http.MethodGet, usersPath+"?limit=lots", admin, nil),
http.StatusBadRequest, "M_INVALID_PARAM")
expectError(t, ts.do(http.MethodGet, usersPath+"?offset=-1", admin, nil),
http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestCreateUser covers the way an operator adds an account to a server with
// registration switched off — which is the normal state of a private Warp.
func TestCreateUser(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
if ts.settings.RegistrationEnabled() {
t.Fatal("this test needs registration to be off")
}
rec := ts.do(http.MethodPost, usersPath, admin, map[string]any{
"username": "Alice",
"password": "sUp3rs3kr1t",
"display_name": "Alice",
"admin": true,
})
expectStatus(t, rec, http.StatusCreated)
body := jsonBody(t, rec)
if body["user_id"] != "@alice:warp.example" {
t.Errorf("user_id = %v; the localpart should be normalised", body["user_id"])
}
if body["admin"] != true {
t.Errorf("admin = %v, want true", body["admin"])
}
// The new account can really log in, which is the only thing that
// makes creating it useful.
if err := ts.users.CheckPassword(t.Context(), "alice", "sUp3rs3kr1t"); err != nil {
t.Errorf("the created account cannot log in: %v", err)
}
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{
"username": "alice", "password": "sUp3rs3kr1t",
}), http.StatusBadRequest, "M_USER_IN_USE")
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{
"username": "not valid", "password": "sUp3rs3kr1t",
}), http.StatusBadRequest, "M_INVALID_USERNAME")
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{"username": "bob"}),
http.StatusBadRequest, "M_MISSING_PARAM")
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{"password": "x"}),
http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestStats(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
ts.user("alice")
rec := ts.do(http.MethodGet, statsPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["server_name"] != testServerName {
t.Errorf("server_name = %v", body["server_name"])
}
if body["database"] != storage.DriverSQLite {
t.Errorf("database = %v, want sqlite", body["database"])
}
if users, _ := body["users"].(float64); users != 2 {
t.Errorf("users = %v, want 2", body["users"])
}
if admins, _ := body["admins"].(float64); admins != 1 {
t.Errorf("admins = %v, want 1", body["admins"])
}
// Each account above got one session, so the device count follows.
if devices, _ := body["devices"].(float64); devices != 2 {
t.Errorf("devices = %v, want 2", body["devices"])
}
if body["federation_enabled"] != false || body["registration_enabled"] != false {
t.Errorf("switch state = %s", rec.Body.String())
}
}
+149
View File
@@ -0,0 +1,149 @@
package adminapi
import (
"errors"
"net/http"
"strconv"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// adminUser is one row of the user list. It is a flatter shape than the
// Client-Server API's profile object because the panel shows it as a table.
type adminUser struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
Admin bool `json:"admin"`
Deactivated bool `json:"deactivated"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type usersResponse struct {
Users []adminUser `json:"users"`
// Total is the number of users matching the query, not the number
// returned, so the panel can page.
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// defaultUserPageLimit is what a request without ?limit= gets.
const defaultUserPageLimit = 50
// handleGetUsers implements GET /_warp/admin/v1/users.
func (a *AdminAPI) handleGetUsers(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
query := r.URL.Query()
limit, err := intParam(query.Get("limit"), defaultUserPageLimit)
if err != nil {
return httputil.InvalidParam("limit must be a number")
}
offset, err := intParam(query.Get("offset"), 0)
if err != nil {
return httputil.InvalidParam("offset must be a number")
}
if limit < 0 || offset < 0 {
return httputil.InvalidParam("limit and offset must not be negative")
}
accounts, total, err := a.users.ListAccounts(r.Context(), storage.AccountFilter{
Search: query.Get("search"),
Limit: limit,
Offset: offset,
})
if err != nil {
return err
}
users := make([]adminUser, 0, len(accounts))
for _, acc := range accounts {
users = append(users, adminUser{
UserID: a.users.UserID(acc.Localpart),
DisplayName: acc.DisplayName,
AvatarURL: acc.AvatarURL,
Admin: acc.Admin,
Deactivated: acc.Deactivated,
CreatedAtMS: acc.CreatedAtMS,
})
}
httputil.WriteJSON(w, http.StatusOK, usersResponse{
Users: users,
Total: total,
Limit: limit,
Offset: offset,
})
return nil
}
// createUserRequest is the body of POST /_warp/admin/v1/users.
type createUserRequest struct {
// Username is the localpart; it is normalised like any other.
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Admin bool `json:"admin"`
}
// handlePostUsers implements POST /_warp/admin/v1/users.
//
// This is how an operator adds an account on a server with registration
// turned off, so it deliberately ignores the registration switch: it is the
// admin's own action, not a public sign-up. It does require a password —
// unlike `warp create-account`, which may leave one unset — because an
// account created from the panel is meant to be handed to somebody.
func (a *AdminAPI) handlePostUsers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req createUserRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Username == "" {
return httputil.MissingParam("username is required")
}
if req.Password == "" {
return httputil.MissingParam("password is required")
}
if len(req.Password) > userapi.MaxPasswordLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword,
"Password must be at most %d bytes", userapi.MaxPasswordLength)
}
account, err := a.users.CreateAccount(r.Context(), userapi.NewAccountRequest{
Localpart: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Admin: req.Admin,
})
switch {
case errors.Is(err, userapi.ErrUserExists):
return httputil.UserInUse()
case errors.Is(err, userapi.ErrInvalidLocalpart):
return httputil.InvalidUsername("%s", err.Error())
case err != nil:
return err
}
a.log.Info("account created through the admin API",
"user_id", a.users.UserID(account.Localpart), "admin", account.Admin, "by", auth.UserID)
httputil.WriteJSON(w, http.StatusCreated, adminUser{
UserID: a.users.UserID(account.Localpart),
DisplayName: account.DisplayName,
Admin: account.Admin,
CreatedAtMS: account.CreatedAtMS,
})
return nil
}
// intParam parses an optional integer query parameter.
func intParam(raw string, fallback int) (int, error) {
if raw == "" {
return fallback, nil
}
return strconv.Atoi(raw)
}
+154
View File
@@ -0,0 +1,154 @@
package clientapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// MaxAccountDataSize caps one account data blob. The spec sets no limit, but
// account data is sent down /sync in full on every initial sync, so an
// unbounded blob is an unbounded sync.
const MaxAccountDataSize = 64 * 1024
// typeFullyRead is the account data type the spec reserves: it is set
// through POST /rooms/{roomId}/read_markers, which keeps the read marker and
// the receipt in step, so writing it directly is refused.
const typeFullyRead = "m.fully_read"
// handleGetAccountData implements
// GET /_matrix/client/v3/user/{userId}/account_data/{type}.
func (c *ClientAPI) handleGetAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
return c.getAccountData(w, r, auth, "")
}
// handleGetRoomAccountData implements the per-room variant.
func (c *ClientAPI) handleGetRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID := r.PathValue("roomID")
if err := validateRoomID(roomID); err != nil {
return err
}
return c.getAccountData(w, r, auth, roomID)
}
func (c *ClientAPI) getAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
// Account data is private, so even a read is only ever of one's own —
// an admin reading it would be reading a user's client settings, which
// the admin API has no business doing either.
localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID"))
if err != nil {
return err
}
dataType := r.PathValue("dataType")
if dataType == "" {
return httputil.MissingParam("An account data type is required")
}
content, err := c.users.AccountData(r.Context(), localpart, roomID, dataType)
switch {
case errors.Is(err, userapi.ErrNoSuchAccountData):
return httputil.NotFound("Account data not found")
case err != nil:
return err
}
// The stored bytes are the client's own JSON and go back out verbatim,
// so they are written directly rather than round-tripped through a map.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(content); err != nil {
httputil.LoggerFrom(r.Context()).Debug("writing account data failed", "error", err)
}
return nil
}
// handlePutAccountData implements
// PUT /_matrix/client/v3/user/{userId}/account_data/{type}.
func (c *ClientAPI) handlePutAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
return c.putAccountData(w, r, auth, "")
}
// handlePutRoomAccountData implements the per-room variant.
func (c *ClientAPI) handlePutRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID := r.PathValue("roomID")
if err := validateRoomID(roomID); err != nil {
return err
}
return c.putAccountData(w, r, auth, roomID)
}
func (c *ClientAPI) putAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID"))
if err != nil {
return err
}
dataType := r.PathValue("dataType")
if dataType == "" {
return httputil.MissingParam("An account data type is required")
}
if dataType == typeFullyRead {
return httputil.BadJSON("%s may only be set through /rooms/{roomId}/read_markers", typeFullyRead)
}
// The content is stored as the client sent it, so it is read as raw
// JSON and only checked for being a JSON object at all.
var content json.RawMessage
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
if len(content) > MaxAccountDataSize {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
"Account data must be at most %d bytes", MaxAccountDataSize)
}
if !json.Valid(content) {
return httputil.NotJSON()
}
if trimmed := strings.TrimSpace(string(content)); !strings.HasPrefix(trimmed, "{") {
return httputil.BadJSON("Account data content must be a JSON object")
}
if err := c.users.SetAccountData(r.Context(), localpart, roomID, dataType, content); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// requireOwnAccountData resolves the path's user ID and insists it is the
// caller's own. Unlike a profile, account data is not something an admin may
// reach into: it is the user's client configuration.
func (c *ClientAPI) requireOwnAccountData(auth userapi.Authenticated, pathUserID string) (string, error) {
localpart, err := c.users.ParseLocalUserID(pathUserID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
return "", httputil.Forbidden("Cannot access the account data of another server")
case err != nil:
return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID)
}
if localpart != auth.Localpart {
return "", httputil.Forbidden("Cannot access the account data of another user")
}
return localpart, nil
}
// validateRoomID checks the shape of a room ID in a path.
//
// Phase 1 has no rooms, so there is nothing to check it against: room
// account data is simply a second key space, and a client may set it for a
// room it has not joined. The full check — that the room exists and the user
// is in it — belongs to the roomserver in Phase 3.
func validateRoomID(roomID string) error {
switch {
case roomID == "":
return httputil.MissingParam("A room ID is required")
case !strings.HasPrefix(roomID, "!"):
return httputil.InvalidParam("%s is not a valid room ID", roomID)
case len(roomID) > 255:
return httputil.InvalidParam("Room IDs must be at most 255 bytes")
}
return nil
}
@@ -0,0 +1,167 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
)
// accountDataPath builds the global account data path for a user.
func accountDataPath(userID, dataType string) string {
return "/_matrix/client/v3/user/" + userID + "/account_data/" + dataType
}
// roomAccountDataPath builds the per-room variant.
func roomAccountDataPath(userID, roomID, dataType string) string {
return "/_matrix/client/v3/user/" + userID + "/rooms/" + roomID + "/account_data/" + dataType
}
func TestAccountDataRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
path := accountDataPath(acc.UserID, "test.key")
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: map[string]any{"value": "first"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["value"]; got != "first" {
t.Errorf("value = %v, want first", got)
}
rec = ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: map[string]any{"value": "second"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
if got := jsonBody(t, rec)["value"]; got != "second" {
t.Errorf("value after overwriting = %v, want second", got)
}
}
func TestRoomAccountDataRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
const roomID = "!room:warp.example"
path := roomAccountDataPath(acc.UserID, roomID, "test.key")
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: map[string]any{"value": "room first"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["value"]; got != "room first" {
t.Errorf("value = %v, want %q", got, "room first")
}
// Room account data is its own key space: the global entry of the same
// type is untouched.
expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), acc.AccessToken),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestAccountDataIsPrivate(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.do(request{method: http.MethodPut, path: accountDataPath(alice.UserID, "test.key"),
token: alice.AccessToken, body: map[string]any{"value": "secret"}})
expectStatus(t, rec, http.StatusOK)
// Not even an admin: this is the user's own client configuration, not
// something the server operator has business reading.
ts.makeAdmin(bob)
for _, method := range []string{http.MethodGet, http.MethodPut} {
rec := ts.do(request{method: method, path: accountDataPath(alice.UserID, "test.key"),
token: bob.AccessToken, body: map[string]any{"value": "nosy"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
}
}
func TestAccountDataRequiresAToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), ""),
http.StatusUnauthorized, "M_MISSING_TOKEN")
}
// TestAccountDataRejectsFullyRead covers the one type the spec reserves: it
// has to travel with a read receipt, so writing it directly is refused.
func TestAccountDataRejectsFullyRead(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut,
path: roomAccountDataPath(acc.UserID, "!room:warp.example", "m.fully_read"),
token: acc.AccessToken, body: map[string]any{"event_id": "$x"}})
expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON")
}
func TestAccountDataRejectsNonObjects(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
path := accountDataPath(acc.UserID, "test.key")
for name, body := range map[string]any{
"an array": []any{1, 2, 3},
"a string": "just a string",
"a number": 42,
"a boolean": true,
} {
t.Run(name, func(t *testing.T) {
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: body})
expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON")
})
}
}
func TestAccountDataRejectsOversizedContent(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: accountDataPath(acc.UserID, "test.key"),
token: acc.AccessToken, body: map[string]any{"value": strings.Repeat("x", 70*1024)}})
expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE")
}
func TestRoomAccountDataRejectsMalformedRoomID(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut,
path: roomAccountDataPath(acc.UserID, "not-a-room-id", "test.key"),
token: acc.AccessToken, body: map[string]any{"value": "x"}})
expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestAccountDataIsReturnedVerbatim matters because account data is the
// client's own JSON: a server that re-serialises it can reorder keys or
// change number formatting under a client that is comparing bytes.
func TestAccountDataIsReturnedVerbatim(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
path := accountDataPath(acc.UserID, "test.key")
const content = `{"b":1,"a":{"nested":[1,2,3]},"big":10000000000000001}`
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: rawJSON(content)})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := rec.Body.String(); got != content {
t.Errorf("account data came back as %s, want %s", got, content)
}
}
// rawJSON is marshalled as-is, so a test can send bytes of its choosing.
type rawJSON string
func (r rawJSON) MarshalJSON() ([]byte, error) { return []byte(r), nil }
+101
View File
@@ -0,0 +1,101 @@
package clientapi
import (
"errors"
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// AuthedHandler is a handler that needs an authenticated user. The identity
// is passed in rather than hidden in the request context, so a handler
// cannot forget to check it and the compiler says which endpoints are
// protected.
type AuthedHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
// withAuth resolves the access token before calling h.
func (c *ClientAPI) withAuth(h AuthedHandler) httputil.Handler {
return func(w http.ResponseWriter, r *http.Request) error {
auth, err := c.authenticate(r)
if err != nil {
return err
}
c.users.RecordActivity(r.Context(), auth, c.clientIP(r), r.UserAgent())
return h(w, r, auth)
}
}
// authenticate turns a request's access token into an identity.
func (c *ClientAPI) authenticate(r *http.Request) (userapi.Authenticated, error) {
token := accessTokenFrom(r)
if token == "" {
return userapi.Authenticated{}, httputil.MissingToken()
}
auth, err := c.users.Authenticate(r.Context(), token)
switch {
case err == nil:
return auth, nil
case errors.Is(err, userapi.ErrNoSuchToken):
return userapi.Authenticated{}, httputil.UnknownToken(false)
case errors.Is(err, userapi.ErrTokenExpired):
// A soft logout keeps the client's encryption state: the token
// aged out, the device is still real, and logging in again
// restores the session rather than replacing it.
return userapi.Authenticated{}, httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return userapi.Authenticated{}, httputil.UserDeactivated()
default:
return userapi.Authenticated{}, err
}
}
// accessTokenFrom extracts the access token from a request.
//
// The Authorization header is the only form the current spec defines, but
// the ?access_token= query parameter is still what media URLs embedded in
// web clients and a long tail of older clients use, so both are accepted.
func accessTokenFrom(r *http.Request) string {
if header := r.Header.Get("Authorization"); header != "" {
if token, ok := bearerToken(header); ok {
return token
}
// A malformed Authorization header is not a reason to fall through
// to the query string: that would let a broken header hide a
// working token and make the failure hard to see.
return ""
}
return r.URL.Query().Get("access_token")
}
// bearerToken parses "Bearer <token>", case-insensitively on the scheme.
func bearerToken(header string) (string, bool) {
scheme, token, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") {
return "", false
}
token = strings.TrimSpace(token)
return token, token != ""
}
// requireSelf checks that the user ID in a path belongs to the requester.
//
// Several endpoints are addressed by user ID even though they only ever
// operate on the caller's own data. The spec's answer for someone else's is
// M_FORBIDDEN; admins are allowed through, which is what makes the admin
// panel able to fix up a user's profile.
func (c *ClientAPI) requireSelf(auth userapi.Authenticated, pathUserID string) (string, error) {
localpart, err := c.users.ParseLocalUserID(pathUserID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
return "", httputil.Forbidden("Cannot change data belonging to another server")
case err != nil:
return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID)
}
if localpart != auth.Localpart && !auth.Admin {
return "", httputil.Forbidden("Cannot change the data of another user")
}
return localpart, nil
}
+64
View File
@@ -0,0 +1,64 @@
package clientapi
import (
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// DefaultRoomVersion is what this server creates rooms at.
//
// PLAN.md's decision is to implement the latest stable room version first
// and backfill older ones only as federation needs them, so the registry
// here has exactly one entry until the event core lands in Phase 2.
const DefaultRoomVersion = "11"
// roomVersionsCapability is the m.room_versions capability.
type roomVersionsCapability struct {
Default string `json:"default"`
Available map[string]string `json:"available"`
}
// booleanCapability is the shape of the several capabilities that are only
// an on/off switch.
type booleanCapability struct {
Enabled bool `json:"enabled"`
}
type capabilitiesResponse struct {
Capabilities capabilities `json:"capabilities"`
}
type capabilities struct {
RoomVersions roomVersionsCapability `json:"m.room_versions"`
ChangePassword booleanCapability `json:"m.change_password"`
SetDisplayName booleanCapability `json:"m.set_displayname"`
SetAvatarURL booleanCapability `json:"m.set_avatar_url"`
ThreePIDChanges booleanCapability `json:"m.3pid_changes"`
}
// handleCapabilities implements GET /_matrix/client/v3/capabilities.
//
// The endpoint needs a token: what a server will let you do depends on who
// you are, and the spec makes it authenticated for that reason.
//
// Every flag here is the truth about this build, not an aspiration.
// Password changes and third-party identifiers arrive with the rest of
// account management in Phase 9, so they report false and clients hide the
// buttons rather than offering something that 404s.
func (c *ClientAPI) handleCapabilities(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, capabilitiesResponse{
Capabilities: capabilities{
RoomVersions: roomVersionsCapability{
Default: DefaultRoomVersion,
Available: map[string]string{DefaultRoomVersion: "stable"},
},
ChangePassword: booleanCapability{Enabled: false},
SetDisplayName: booleanCapability{Enabled: true},
SetAvatarURL: booleanCapability{Enabled: true},
ThreePIDChanges: booleanCapability{Enabled: false},
},
})
return nil
}
+204
View File
@@ -0,0 +1,204 @@
// Package clientapi implements the Client-Server API: the HTTP surface
// clients talk to.
//
// Phase 1 covers everything about identity — registration, login, sessions,
// devices, profiles, account data and capabilities. Rooms, messages and
// /sync arrive in Phases 3 and 4; the route table here is the place they
// will be added.
//
// Handlers are thin. They decode a request, map it onto a userapi call and
// shape the response; the rules live in userapi, and the storage in
// internal/storage. A handler that needs more than that is a sign something
// belongs further down.
package clientapi
import (
"errors"
"log/slog"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// ClientAPI serves the Client-Server API.
type ClientAPI struct {
serverName string
clientBaseURL string
users *userapi.API
settings *settings.Service
federation *federation.Gate
registration config.Registration
log *slog.Logger
now func() time.Time
uia *uiaStore
clientIP ratelimit.KeyFunc
// One limiter per endpoint class, so a client hammering /login cannot
// spend the budget of /profile.
limitDefault *ratelimit.Limiter
limitLogin *ratelimit.Limiter
limitRegister *ratelimit.Limiter
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name.
ServerName string
// ClientBaseURL is the URL advertised to clients, the same value
// /.well-known/matrix/client carries. A successful login repeats it so
// that a client which only knew the server name does not have to fetch
// the discovery document separately.
ClientBaseURL string
// Users is the userapi component.
Users *userapi.API
// Settings is the runtime-settings service; the registration switch is
// read from it on every registration attempt.
Settings *settings.Service
// Federation gates the endpoints that would need a remote server.
Federation *federation.Gate
// Registration is the file configuration of the registration flow.
Registration config.Registration
// RateLimit configures the per-endpoint buckets.
RateLimit config.RateLimit
// TrustXForwardedFor takes the client address from the proxy header.
TrustXForwardedFor bool
Logger *slog.Logger
// Now is the clock, so tests can expire sessions without waiting.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*ClientAPI, error) {
if opts.ServerName == "" {
return nil, errors.New("clientapi: ServerName must not be empty")
}
if opts.Users == nil {
return nil, errors.New("clientapi: Users must not be nil")
}
if opts.Settings == nil {
return nil, errors.New("clientapi: Settings must not be nil")
}
if opts.Federation == nil {
opts.Federation = federation.NewGate(nil, nil)
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
c := &ClientAPI{
serverName: opts.ServerName,
clientBaseURL: opts.ClientBaseURL,
users: opts.Users,
settings: opts.Settings,
federation: opts.Federation,
registration: opts.Registration,
log: opts.Logger,
now: opts.Now,
uia: newUIAStore(uiaSessionTTL, opts.Now),
clientIP: ratelimit.ClientIP(opts.TrustXForwardedFor),
}
if opts.RateLimit.Enabled {
c.limitDefault = ratelimit.New(bucket(opts.RateLimit.Default))
c.limitLogin = ratelimit.New(bucket(opts.RateLimit.Login))
c.limitRegister = ratelimit.New(bucket(opts.RateLimit.Register))
} else {
c.limitDefault, c.limitLogin, c.limitRegister =
ratelimit.Disabled(), ratelimit.Disabled(), ratelimit.Disabled()
}
return c, nil
}
func bucket(b config.RateLimitBucket) ratelimit.Policy {
return ratelimit.Policy{Burst: b.Burst, Interval: b.Interval}
}
// Client-Server API path prefixes. Warp serves the versioned paths only:
// the r0 prefix was deprecated in Matrix 1.4, and every client that still
// sends it also understands a 404 and falls back to version negotiation
// through /_matrix/client/versions.
const (
v3 = "/_matrix/client/v3"
v1 = "/_matrix/client/v1"
)
// RegisterRoutes adds the Client-Server endpoints to the router.
func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
// Registration. The token-validity endpoint is v1 because that is the
// version the spec introduced it at.
c.public(r, "POST "+v3+"/register", c.limitRegister, c.handleRegister)
c.public(r, "GET "+v3+"/register/available", c.limitRegister, c.handleRegisterAvailable)
c.public(r, "GET "+v1+"/register/m.login.registration_token/validity",
c.limitRegister, c.handleRegistrationTokenValidity)
// Sessions.
c.public(r, "GET "+v3+"/login", c.limitDefault, c.handleLoginFlows)
c.public(r, "POST "+v3+"/login", c.limitLogin, c.handleLogin)
c.public(r, "POST "+v3+"/refresh", c.limitLogin, c.handleRefresh)
c.authed(r, "POST "+v3+"/logout", c.limitDefault, c.handleLogout)
c.authed(r, "POST "+v3+"/logout/all", c.limitDefault, c.handleLogoutAll)
// Account.
c.authed(r, "GET "+v3+"/account/whoami", c.limitDefault, c.handleWhoami)
c.authed(r, "GET "+v3+"/capabilities", c.limitDefault, c.handleCapabilities)
// Devices.
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
c.authed(r, "GET "+v3+"/devices/{deviceID}", c.limitDefault, c.handleGetDevice)
c.authed(r, "PUT "+v3+"/devices/{deviceID}", c.limitDefault, c.handlePutDevice)
c.authed(r, "DELETE "+v3+"/devices/{deviceID}", c.limitDefault, c.handleDeleteDevice)
c.authed(r, "POST "+v3+"/delete_devices", c.limitDefault, c.handleDeleteDevices)
// Profiles are world-readable: a client showing a room member's name
// has no token for that member's server, and the spec makes the GETs
// unauthenticated for exactly that reason.
c.public(r, "GET "+v3+"/profile/{userID}", c.limitDefault, c.handleGetProfile)
c.public(r, "GET "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handleGetDisplayName)
c.public(r, "GET "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handleGetAvatarURL)
c.authed(r, "PUT "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handlePutDisplayName)
c.authed(r, "PUT "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handlePutAvatarURL)
// Account data.
c.authed(r, "GET "+v3+"/user/{userID}/account_data/{dataType}",
c.limitDefault, c.handleGetAccountData)
c.authed(r, "PUT "+v3+"/user/{userID}/account_data/{dataType}",
c.limitDefault, c.handlePutAccountData)
c.authed(r, "GET "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
c.limitDefault, c.handleGetRoomAccountData)
c.authed(r, "PUT "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
c.limitDefault, c.handlePutRoomAccountData)
c.authed(r, "GET "+v3+"/sync", c.limitDefault, c.handleSync)
}
// public registers an endpoint that needs no access token.
func (c *ClientAPI) public(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h httputil.Handler) {
r.Handle(pattern, limiter.Middleware(c.clientIP)(httputil.Wrap(h)))
}
// authed registers an endpoint that needs one.
//
// The rate limiter still keys on the client address rather than the user:
// it runs before the token is resolved, and a limiter that had to hit the
// database to find its own key would be a denial of service of its own.
func (c *ClientAPI) authed(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h AuthedHandler) {
r.Handle(pattern, limiter.Middleware(c.clientIP)(httputil.Wrap(c.withAuth(h))))
}
+268
View File
@@ -0,0 +1,268 @@
package clientapi_test
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// testServer is a Client-Server API on a throwaway SQLite database.
//
// It is built from the real components rather than fakes: the point of these
// tests is the behaviour a client sees, and a fake userapi would only prove
// that the handlers call the methods the fake expects.
type testServer struct {
t *testing.T
handler http.Handler
users *userapi.API
settings *settings.Service
fedGate *federation.Gate
}
// newTestServer builds a server. tweak adjusts the options before the
// component is created, which is how a test turns on registration tokens or
// rate limiting.
func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path + "?_pragma=busy_timeout(5000)",
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
users, err := userapi.New(userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
// Registration is on and the limiter is off by default: almost every
// test needs an account, and almost none is about rate limiting.
settingsService, err := settings.New(t.Context(), db.Settings(), settings.Defaults{
RegistrationEnabled: true,
RegistrationAllowRuntimeToggle: true,
FederationAllowRuntimeToggle: true,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("settings.New: %v", err)
}
gate := federation.NewGate(settingsService.FederationEnabled, nil)
opts := clientapi.Options{
ServerName: testServerName,
Users: users,
Settings: settingsService,
Federation: gate,
Logger: slog.New(slog.DiscardHandler),
}
if tweak != nil {
tweak(&opts)
}
api, err := clientapi.New(opts)
if err != nil {
t.Fatalf("clientapi.New: %v", err)
}
router := httputil.NewRouter(nil)
api.RegisterRoutes(router)
return &testServer{
t: t,
handler: router,
users: users,
settings: settingsService,
fedGate: gate,
}
}
// request is one HTTP call under test.
type request struct {
method string
path string
body any
token string
remote string
}
// do performs a request. A nil body sends none at all, which is what the
// endpoints whose body is optional have to cope with.
func (ts *testServer) do(req request) *httptest.ResponseRecorder {
ts.t.Helper()
var reader io.Reader
if req.body != nil {
raw, err := json.Marshal(req.body)
if err != nil {
ts.t.Fatalf("marshalling the request body: %v", err)
}
reader = bytes.NewReader(raw)
}
httpReq := httptest.NewRequest(req.method, req.path, reader)
if req.body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
if req.token != "" {
httpReq.Header.Set("Authorization", "Bearer "+req.token)
}
if req.remote != "" {
httpReq.RemoteAddr = req.remote
}
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, httpReq)
return rec
}
// get is the common read-only case.
func (ts *testServer) get(path, token string) *httptest.ResponseRecorder {
ts.t.Helper()
return ts.do(request{method: http.MethodGet, path: path, token: token})
}
// decode parses a response body as JSON.
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
body := rec.Body.Bytes()
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("response is not JSON (%s): %v", body, err)
}
return out
}
// jsonBody parses a response into a generic map.
func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
return decode[map[string]any](t, rec)
}
// expectStatus fails unless the response has the given status.
func expectStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body.String())
}
}
// expectError fails unless the response is the given Matrix error.
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, errcode string) {
t.Helper()
expectStatus(t, rec, status)
body := jsonBody(t, rec)
if got, _ := body["errcode"].(string); got != errcode {
t.Errorf("errcode = %q, want %q (body: %s)", got, errcode, rec.Body.String())
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the error envelope must carry a human-readable message")
}
}
// account is a registered user plus its first session, which is what most
// tests need before they can do anything.
type account struct {
UserID string
Localpart string
DeviceID string
AccessToken string
Password string
}
// register walks the full two-step registration flow, as a client does.
func (ts *testServer) register(localpart, password string) account {
ts.t.Helper()
rec := ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/register",
body: map[string]any{"username": localpart, "password": password},
})
expectStatus(ts.t, rec, http.StatusUnauthorized)
session, _ := jsonBody(ts.t, rec)["session"].(string)
if session == "" {
ts.t.Fatalf("the challenge carried no session: %s", rec.Body.String())
}
rec = ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/register",
body: map[string]any{
"username": localpart,
"password": password,
"auth": map[string]any{"type": "m.login.dummy", "session": session},
},
})
expectStatus(ts.t, rec, http.StatusOK)
body := jsonBody(ts.t, rec)
acc := account{Password: password}
acc.UserID, _ = body["user_id"].(string)
acc.AccessToken, _ = body["access_token"].(string)
acc.DeviceID, _ = body["device_id"].(string)
acc.Localpart = userapi.NormaliseLocalpart(localpart)
if acc.AccessToken == "" || acc.DeviceID == "" {
ts.t.Fatalf("registration returned no session: %s", rec.Body.String())
}
return acc
}
// login creates a second session for an existing account.
func (ts *testServer) login(acc account, deviceID string) account {
ts.t.Helper()
body := map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": acc.Password,
}
if deviceID != "" {
body["device_id"] = deviceID
}
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/login", body: body})
expectStatus(ts.t, rec, http.StatusOK)
parsed := jsonBody(ts.t, rec)
out := acc
out.AccessToken, _ = parsed["access_token"].(string)
out.DeviceID, _ = parsed["device_id"].(string)
return out
}
// makeAdmin grants the admin flag to an existing account.
func (ts *testServer) makeAdmin(acc account) {
ts.t.Helper()
if err := ts.users.SetAdmin(ts.t.Context(), acc.Localpart, true); err != nil {
ts.t.Fatalf("SetAdmin: %v", err)
}
}
// withRateLimit is a tweak that turns on the limiter with a given policy.
func withRateLimit(limit config.RateLimit) func(*clientapi.Options) {
return func(o *clientapi.Options) { o.RateLimit = limit }
}
+189
View File
@@ -0,0 +1,189 @@
package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// deviceResponse is one entry of the device list.
type deviceResponse struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name,omitempty"`
// LastSeenIP and LastSeenTS are omitted until the device has been seen,
// which for a freshly created one is the same request that created it.
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastSeenTS int64 `json:"last_seen_ts,omitempty"`
}
type devicesResponse struct {
Devices []deviceResponse `json:"devices"`
}
func newDeviceResponse(d storage.Device) deviceResponse {
return deviceResponse{
DeviceID: d.ID,
DisplayName: d.DisplayName,
LastSeenIP: d.LastSeenIP,
LastSeenTS: d.LastSeenMS,
}
}
// handleGetDevices implements GET /_matrix/client/v3/devices.
func (c *ClientAPI) handleGetDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
devices, err := c.users.Devices(r.Context(), auth.Localpart)
if err != nil {
return err
}
out := make([]deviceResponse, 0, len(devices))
for _, d := range devices {
out = append(out, newDeviceResponse(d))
}
httputil.WriteJSON(w, http.StatusOK, devicesResponse{Devices: out})
return nil
}
// handleGetDevice implements GET /_matrix/client/v3/devices/{deviceId}.
func (c *ClientAPI) handleGetDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
device, err := c.users.Device(r.Context(), auth.Localpart, r.PathValue("deviceID"))
switch {
case errors.Is(err, userapi.ErrNoSuchDevice):
return httputil.NotFound("Unknown device")
case err != nil:
return err
}
httputil.WriteJSON(w, http.StatusOK, newDeviceResponse(device))
return nil
}
// putDeviceRequest is the body of PUT /devices/{deviceId}.
type putDeviceRequest struct {
DisplayName string `json:"display_name"`
}
// handlePutDevice implements PUT /_matrix/client/v3/devices/{deviceId}.
func (c *ClientAPI) handlePutDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req putDeviceRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
err := c.users.SetDeviceDisplayName(r.Context(), auth.Localpart, r.PathValue("deviceID"), req.DisplayName)
switch {
case errors.Is(err, userapi.ErrNoSuchDevice):
return httputil.NotFound("Unknown device")
case err != nil:
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// deleteDeviceRequest is the body of DELETE /devices/{deviceId}: nothing but
// the authentication.
type deleteDeviceRequest struct {
Auth *uiaRequest `json:"auth"`
}
// deleteDevicesRequest is the body of POST /delete_devices.
type deleteDevicesRequest struct {
Devices []string `json:"devices"`
Auth *uiaRequest `json:"auth"`
}
// handleDeleteDevice implements DELETE /_matrix/client/v3/devices/{deviceId}.
func (c *ClientAPI) handleDeleteDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req deleteDeviceRequest
// The body may be absent: a client's first attempt carries no
// authentication, because the 401 is how it learns the endpoint needs
// any.
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
return c.deleteDevicesWithUIA(w, r, auth, req.Auth, []string{r.PathValue("deviceID")})
}
// handleDeleteDevices implements POST /_matrix/client/v3/delete_devices.
func (c *ClientAPI) handleDeleteDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req deleteDevicesRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
return c.deleteDevicesWithUIA(w, r, auth, req.Auth, req.Devices)
}
// deleteDevicesWithUIA is the shared half of the two device-deletion
// endpoints: both ask the user to re-enter their password first.
//
// Revoking a device logs out whoever holds its token, so a stolen access
// token must not be enough to do it — that is exactly the position a victim
// is in when they go to remove the attacker's session.
func (c *ClientAPI) deleteDevicesWithUIA(
w http.ResponseWriter,
r *http.Request,
auth userapi.Authenticated,
authReq *uiaRequest,
deviceIDs []string,
) error {
flows := []uiaFlow{{Stages: []string{stagePassword}}}
sessionID, done, err := c.beginOrAdvanceUIA(w, flows, authReq,
func(_ string, stage *uiaRequest) (*httputil.MatrixError, error) {
return c.passwordStage(r, auth, stage)
})
if err != nil || !done {
return err
}
if err := c.users.DeleteDevices(r.Context(), auth.Localpart, deviceIDs); err != nil {
return err
}
c.uia.remove(sessionID)
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// passwordStage checks an m.login.password stage of an interactive
// authentication.
//
// The identity in the stage must be the user making the request. Without
// that check, knowing *any* account's password would be enough to pass the
// re-authentication on somebody else's session — which is the flaw the
// stage exists to prevent, and which Complement tests for directly.
func (c *ClientAPI) passwordStage(r *http.Request, auth userapi.Authenticated, stage *uiaRequest) (*httputil.MatrixError, error) {
identifier := stage.Identifier.User
switch {
case identifier != "" && stage.Identifier.Type != identifierUser:
return httputil.InvalidParam("Login identifier type %q is not supported", stage.Identifier.Type), nil
case identifier == "":
identifier = stage.User
}
if identifier == "" {
return httputil.MissingParam("A user identifier is required"), nil
}
localpart, err := c.users.ParseLocalUserID(identifier)
if err != nil {
return httputil.Forbidden("Invalid username or password"), nil
}
if localpart != auth.Localpart {
// Not a failed stage but a refusal: no password will make this
// request acceptable, so it ends here with a 403 instead of
// inviting another attempt.
return nil, httputil.Forbidden(
"The user in the authentication must be the one making the request")
}
switch err := c.users.CheckPassword(r.Context(), localpart, stage.Password); {
case err == nil:
return nil, nil
case errors.Is(err, userapi.ErrWrongPassword), errors.Is(err, userapi.ErrNoPassword):
return httputil.Forbidden("Invalid username or password"), nil
default:
return nil, err
}
}
+250
View File
@@ -0,0 +1,250 @@
package clientapi_test
import (
"net/http"
"testing"
)
const devicesPath = "/_matrix/client/v3/devices"
func TestGetDevices(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
ts.login(acc, "login_device")
ts.login(acc, "login_device_2")
rec := ts.get(devicesPath, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
devices, _ := jsonBody(t, rec)["devices"].([]any)
seen := map[string]bool{}
for _, device := range devices {
id, _ := device.(map[string]any)["device_id"].(string)
seen[id] = true
}
for _, want := range []string{acc.DeviceID, "login_device", "login_device_2"} {
if !seen[want] {
t.Errorf("/devices did not return %s (got %v)", want, seen)
}
}
}
// TestGetDevicesIsPerUser is the check that matters most here: one user must
// never see another's sessions.
func TestGetDevicesIsPerUser(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.get(devicesPath, bob.AccessToken)
expectStatus(t, rec, http.StatusOK)
devices, _ := jsonBody(t, rec)["devices"].([]any)
if len(devices) != 1 {
t.Fatalf("bob sees %d devices, want 1", len(devices))
}
if devices[0].(map[string]any)["device_id"] == alice.DeviceID {
t.Fatal("bob can see alice's device")
}
}
func TestGetDevice(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"device_id": "login_device",
"initial_device_display_name": "device display",
}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(devicesPath+"/login_device", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["device_id"] != "login_device" {
t.Errorf("device_id = %v", body["device_id"])
}
if body["display_name"] != "device display" {
t.Errorf("display_name = %v, want %q", body["display_name"], "device display")
}
}
func TestGetUnknownDeviceIs404(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
expectError(t, ts.get(devicesPath+"/unknown_device", acc.AccessToken),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestPutDevice(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: devicesPath + "/" + acc.DeviceID,
token: acc.AccessToken, body: map[string]any{"display_name": "new device display"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(devicesPath+"/"+acc.DeviceID, acc.AccessToken)
if got := jsonBody(t, rec)["display_name"]; got != "new device display" {
t.Errorf("display_name = %v, want the updated one", got)
}
}
func TestPutUnknownDeviceIs404(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: devicesPath + "/unknown_device",
token: acc.AccessToken, body: map[string]any{"display_name": "x"}})
expectError(t, rec, http.StatusNotFound, "M_NOT_FOUND")
}
// TestDeleteDeviceRequiresInteractiveAuth is the reason this endpoint is
// interactive: a stolen access token must not be enough to log out the real
// owner, which is exactly what they are trying to do when they reach for it.
func TestDeleteDeviceRequiresInteractiveAuth(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
victim := ts.login(acc, "")
path := devicesPath + "/" + victim.DeviceID
// No body at all: the client does not know yet that it has to
// authenticate, so this is the normal first request.
rec := ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken})
expectStatus(t, rec, http.StatusUnauthorized)
body := jsonBody(t, rec)
for _, key := range []string{"session", "flows", "params"} {
if _, present := body[key]; !present {
t.Errorf("the challenge is missing %s: %s", key, rec.Body.String())
}
}
flows, _ := body["flows"].([]any)
stages, _ := flows[0].(map[string]any)["stages"].([]any)
if len(stages) != 1 || stages[0] != "m.login.password" {
t.Fatalf("stages = %v, want [m.login.password]", stages)
}
session := body["session"]
// The wrong password fails the stage and says so, keeping the session.
rec = ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "super-wrong-password",
"session": session,
}}})
expectStatus(t, rec, http.StatusUnauthorized)
body = jsonBody(t, rec)
if body["errcode"] != "M_FORBIDDEN" {
t.Errorf("errcode = %v, want M_FORBIDDEN: %s", body["errcode"], rec.Body.String())
}
for _, key := range []string{"session", "flows", "params", "error"} {
if _, present := body[key]; !present {
t.Errorf("a failed stage is missing %s: %s", key, rec.Body.String())
}
}
// The device survived the failed attempt.
expectStatus(t, ts.get(path, acc.AccessToken), http.StatusOK)
// The right password completes it.
rec = ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"session": session,
}}})
expectStatus(t, rec, http.StatusOK)
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
expectError(t, ts.get("/_matrix/client/v3/sync", victim.AccessToken),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
}
// TestDeleteDeviceRequiresTheOwnersPassword is the flaw the stage exists to
// prevent: knowing *somebody's* password must not pass the
// re-authentication on another account's session.
func TestDeleteDeviceRequiresTheOwnersPassword(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
victim := ts.login(alice, "")
path := devicesPath + "/" + victim.DeviceID
rec := ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": bob.UserID},
"password": "bobspassword",
"session": session,
}}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
// Alice's own password still works.
rec = ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": alice.UserID},
"password": "superuser",
"session": session,
}}})
expectStatus(t, rec, http.StatusOK)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
}
func TestDeleteDevices(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
first := ts.login(acc, "DEVICE_ONE")
second := ts.login(acc, "DEVICE_TWO")
const path = "/_matrix/client/v3/delete_devices"
rec := ts.do(request{method: http.MethodPost, path: path, token: acc.AccessToken,
body: map[string]any{"devices": []string{"DEVICE_ONE", "DEVICE_TWO"}}})
expectStatus(t, rec, http.StatusUnauthorized)
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: path, token: acc.AccessToken,
body: map[string]any{
"devices": []string{"DEVICE_ONE", "DEVICE_TWO"},
"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"session": session,
},
}})
expectStatus(t, rec, http.StatusOK)
for _, token := range []string{first.AccessToken, second.AccessToken} {
expectError(t, ts.get("/_matrix/client/v3/sync", token),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
}
// The session doing the deleting is untouched.
expectStatus(t, ts.get("/_matrix/client/v3/sync", acc.AccessToken), http.StatusOK)
}
// TestDeviceLastSeenIsRecorded covers the bookkeeping the device list shows.
func TestDeviceLastSeenIsRecorded(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get(devicesPath+"/"+acc.DeviceID, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if ts, _ := body["last_seen_ts"].(float64); ts <= 0 {
t.Errorf("last_seen_ts = %v, want the time of the request that created it", body["last_seen_ts"])
}
}
+249
View File
@@ -0,0 +1,249 @@
package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Login identifier types. Third-party identifiers (email, phone) need an
// identity server, which is a non-goal for now.
const (
identifierUser = "m.id.user"
)
// loginIdentifier is the `identifier` object of a login or a password stage.
type loginIdentifier struct {
Type string `json:"type"`
User string `json:"user"`
}
// loginRequest is the body of POST /login.
type loginRequest struct {
Type string `json:"type"`
Identifier loginIdentifier `json:"identifier"`
// User is the deprecated identifier form, still sent by older clients
// and by some test harnesses.
User string `json:"user"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
InitialDeviceDisplayName string `json:"initial_device_display_name"`
RefreshToken bool `json:"refresh_token"`
}
// loginFlow is one entry of GET /login.
//
// The spec defines a flow as having `stages`, but for login every flow has
// exactly one, and clients have always read a flat `type`. Both are sent:
// `type` for the clients, `stages` for a strict reading of the spec.
type loginFlow struct {
Type string `json:"type"`
Stages []string `json:"stages,omitempty"`
}
type loginFlowsResponse struct {
Flows []loginFlow `json:"flows"`
}
// loginResponse is the body of a successful login.
type loginResponse struct {
UserID string `json:"user_id"`
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
// HomeServer is deprecated but still read by older clients.
HomeServer string `json:"home_server,omitempty"`
WellKnown *wellKnownDiscovery `json:"well_known,omitempty"`
}
// wellKnownDiscovery mirrors /.well-known/matrix/client, which the spec
// allows a login response to carry so a client does not have to fetch it.
type wellKnownDiscovery struct {
Homeserver wellKnownBaseURL `json:"m.homeserver"`
}
type wellKnownBaseURL struct {
BaseURL string `json:"base_url"`
}
// handleLoginFlows implements GET /_matrix/client/v3/login.
func (c *ClientAPI) handleLoginFlows(w http.ResponseWriter, _ *http.Request) error {
httputil.WriteJSON(w, http.StatusOK, loginFlowsResponse{
Flows: []loginFlow{{Type: stagePassword, Stages: []string{stagePassword}}},
})
return nil
}
// handleLogin implements POST /_matrix/client/v3/login.
//
// Every rejection is the same M_FORBIDDEN, whether the account does not
// exist, has no password or the password is wrong, and userapi spends the
// same work on each. A login endpoint that distinguishes them is a user
// directory for anyone who asks.
func (c *ClientAPI) handleLogin(w http.ResponseWriter, r *http.Request) error {
var req loginRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Type != stagePassword {
return httputil.Unknown(http.StatusBadRequest, "Login type %q is not supported", req.Type)
}
identifier := req.Identifier.User
switch {
case identifier != "" && req.Identifier.Type != identifierUser:
return httputil.InvalidParam("Login identifier type %q is not supported", req.Identifier.Type)
case identifier == "":
identifier = req.User
}
if identifier == "" {
return httputil.MissingParam("A user identifier is required")
}
localpart, err := c.users.ParseLocalUserID(identifier)
switch {
case errors.Is(err, userapi.ErrForeignServer):
// Not a federation matter: an account on another server has its
// password there, and no amount of federation changes that.
return httputil.Forbidden("Cannot log in as a user of another server")
case err != nil:
return httputil.Forbidden("Invalid username or password")
}
switch err := c.users.CheckPassword(r.Context(), localpart, req.Password); {
case err == nil:
case errors.Is(err, userapi.ErrWrongPassword), errors.Is(err, userapi.ErrNoPassword):
return httputil.Forbidden("Invalid username or password")
default:
return err
}
account, err := c.users.Account(r.Context(), localpart)
if err != nil {
return err
}
if account.Deactivated {
return httputil.UserDeactivated()
}
if req.DeviceID != "" {
if err := userapi.ValidateDeviceID(req.DeviceID); err != nil {
return httputil.InvalidParam("%s", err.Error())
}
}
session, err := c.users.CreateSession(r.Context(), userapi.SessionRequest{
Localpart: localpart,
DeviceID: req.DeviceID,
DisplayName: req.InitialDeviceDisplayName,
WithRefreshToken: req.RefreshToken,
IP: c.clientIP(r),
UserAgent: r.UserAgent(),
})
if err != nil {
return err
}
resp := loginResponse{
UserID: session.UserID,
AccessToken: session.AccessToken,
DeviceID: session.DeviceID,
RefreshToken: session.RefreshToken,
ExpiresInMS: session.ExpiresInMS,
HomeServer: c.serverName,
}
if c.clientBaseURL != "" {
resp.WellKnown = &wellKnownDiscovery{
Homeserver: wellKnownBaseURL{BaseURL: c.clientBaseURL},
}
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// refreshRequest is the body of POST /refresh.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// refreshResponse is its answer.
type refreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
}
// handleRefresh implements POST /_matrix/client/v3/refresh.
//
// It is unauthenticated on purpose: the refresh token *is* the credential,
// and the access token it replaces has usually already expired.
func (c *ClientAPI) handleRefresh(w http.ResponseWriter, r *http.Request) error {
var req refreshRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.RefreshToken == "" {
return httputil.MissingParam("refresh_token is required")
}
session, err := c.users.RefreshSession(r.Context(), req.RefreshToken)
switch {
case err == nil:
case errors.Is(err, userapi.ErrNoSuchToken):
// A soft logout: the device is probably still legitimate, it just
// cannot prove it any more.
return httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return httputil.UserDeactivated()
default:
return err
}
httputil.WriteJSON(w, http.StatusOK, refreshResponse{
AccessToken: session.AccessToken,
RefreshToken: session.RefreshToken,
ExpiresInMS: session.ExpiresInMS,
})
return nil
}
// handleLogout implements POST /_matrix/client/v3/logout. Logging out
// deletes the device, not only the token: that is what the spec says, and it
// is what keeps a user's device list from filling up with dead entries.
func (c *ClientAPI) handleLogout(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.users.DeleteDevices(r.Context(), auth.Localpart, []string{auth.DeviceID}); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleLogoutAll implements POST /_matrix/client/v3/logout/all.
func (c *ClientAPI) handleLogoutAll(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.users.DeleteAllDevices(r.Context(), auth.Localpart); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleWhoami implements GET /_matrix/client/v3/account/whoami.
func (c *ClientAPI) handleWhoami(w http.ResponseWriter, _ *http.Request, auth userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"user_id": auth.UserID,
"device_id": auth.DeviceID,
// Guest accounts are not implemented, so this is always false
// rather than omitted: clients branch on it.
"is_guest": false,
})
return nil
}
+525
View File
@@ -0,0 +1,525 @@
package clientapi_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
)
const loginPath = "/_matrix/client/v3/login"
func TestLoginFlows(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get(loginPath, "")
expectStatus(t, rec, http.StatusOK)
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
flows, _ := jsonBody(t, rec)["flows"].([]any)
if len(flows) == 0 {
t.Fatal("flows must not be empty")
}
// Clients read the flat `type`; a strict reading of the spec wants
// `stages`. Both are sent, so both are checked.
for _, flow := range flows {
entry, _ := flow.(map[string]any)
if entry["type"] != "m.login.password" {
t.Errorf("flow type = %v, want m.login.password", entry["type"])
}
if stages, ok := entry["stages"].([]any); !ok || len(stages) == 0 {
t.Errorf("flow %v has no stages", entry)
}
}
}
func TestLoginWithPassword(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
for _, field := range []string{"user_id", "access_token", "device_id"} {
if value, _ := body[field].(string); value == "" {
t.Errorf("login response is missing %s: %s", field, rec.Body.String())
}
}
if body["user_id"] != acc.UserID {
t.Errorf("user_id = %v, want %s", body["user_id"], acc.UserID)
}
// Logging in must not reuse the registration's device, or a second
// client would silently take over the first one's session.
if body["device_id"] == acc.DeviceID {
t.Error("a login without a device_id must create a new device")
}
}
// TestLoginAcceptsEveryIdentifierForm covers the three shapes clients send:
// the full user ID, the bare localpart, and the deprecated top-level `user`.
func TestLoginAcceptsEveryIdentifierForm(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
for name, body := range map[string]map[string]any{
"full user ID": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@alice:warp.example"},
"password": "superuser",
},
"localpart only": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
},
"deprecated user field": {
"type": "m.login.password",
"user": "alice",
"password": "superuser",
},
"uppercase user ID": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@ALICE:warp.example"},
"password": "superuser",
},
"uppercase server name": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@alice:WARP.example"},
"password": "superuser",
},
} {
t.Run(name, func(t *testing.T) {
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body})
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["user_id"]; got != "@alice:warp.example" {
t.Errorf("user_id = %v, want @alice:warp.example", got)
}
})
}
}
func TestLoginHonoursRequestedDeviceID(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"device_id": "test_device_id",
"initial_device_display_name": "device display",
}})
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["device_id"]; got != "test_device_id" {
t.Errorf("device_id = %v, want test_device_id", got)
}
}
// TestLoginOnExistingDeviceRevokesItsToken is the spec's rule for a login
// naming a device that already exists: the device stays, its old token does
// not.
func TestLoginOnExistingDeviceRevokesItsToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
first := ts.login(acc, "SHARED")
second := ts.login(acc, "SHARED")
if first.AccessToken == second.AccessToken {
t.Fatal("the second login returned the same token")
}
expectError(t, ts.get("/_matrix/client/v3/account/whoami", first.AccessToken),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", second.AccessToken), http.StatusOK)
}
// TestLoginRejectionsAreIndistinguishable keeps the login endpoint from
// becoming a user directory: every failure is the same answer.
func TestLoginRejectionsAreIndistinguishable(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
for name, body := range map[string]map[string]any{
"wrong password": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong_password",
},
"unknown user": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "i-dont-exist"},
"password": "superuser",
},
"malformed user ID": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@not-a-user-id"},
"password": "superuser",
},
} {
t.Run(name, func(t *testing.T) {
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
})
}
}
func TestLoginRejectsForeignUser(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@bob:other.example"},
"password": "superuser",
}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
}
func TestLoginRejectsUnsupportedType(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.token",
"token": "whatever",
}})
expectError(t, rec, http.StatusBadRequest, "M_UNKNOWN")
}
func TestLoginRequiresAnIdentifier(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"password": "superuser",
}})
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestLogout(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
second := ts.login(acc, "")
rec := ts.get("/_matrix/client/v3/devices", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if devices, _ := jsonBody(t, rec)["devices"].([]any); len(devices) != 2 {
t.Fatalf("expected 2 devices before logging out, got %d", len(devices))
}
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: second.AccessToken})
expectStatus(t, rec, http.StatusOK)
// The token is dead and the device is gone, not merely signed out.
expectError(t, ts.get("/_matrix/client/v3/sync", second.AccessToken),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
rec = ts.get("/_matrix/client/v3/devices", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
devices, _ := jsonBody(t, rec)["devices"].([]any)
if len(devices) != 1 {
t.Fatalf("expected 1 device after logging out, got %d", len(devices))
}
if devices[0].(map[string]any)["device_id"] != acc.DeviceID {
t.Error("the wrong device was removed")
}
}
func TestLogoutAll(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
second := ts.login(acc, "")
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout/all", token: second.AccessToken})
expectStatus(t, rec, http.StatusOK)
for name, token := range map[string]string{"the caller": second.AccessToken, "the other session": acc.AccessToken} {
t.Run(name, func(t *testing.T) {
expectError(t, ts.get("/_matrix/client/v3/sync", token),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
})
}
}
func TestLogoutRequiresAToken(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout"})
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: "invalidAccessToken"})
expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
}
// TestRefreshToken covers the rotation: a refresh swaps both halves of the
// pair, and the spent ones stop working.
func TestRefreshToken(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
"refresh_token": true,
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
oldAccess, _ := body["access_token"].(string)
refresh, _ := body["refresh_token"].(string)
if refresh == "" {
t.Fatalf("refresh_token: true produced no refresh token: %s", rec.Body.String())
}
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
body: map[string]any{"refresh_token": refresh}})
expectStatus(t, rec, http.StatusOK)
refreshed := jsonBody(t, rec)
newAccess, _ := refreshed["access_token"].(string)
newRefresh, _ := refreshed["refresh_token"].(string)
if newAccess == "" || newRefresh == "" || newRefresh == refresh {
t.Fatalf("refresh did not rotate the pair: %s", rec.Body.String())
}
expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", newAccess), http.StatusOK)
expectError(t, ts.get("/_matrix/client/v3/account/whoami", oldAccess),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
// Replaying a spent refresh token is a soft logout, not a second
// rotation.
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
body: map[string]any{"refresh_token": refresh}})
expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
if jsonBody(t, rec)["soft_logout"] != true {
t.Error("a dead refresh token should be reported as a soft logout")
}
}
func TestRefreshRequiresAToken(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
body: map[string]any{}})
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
}
// TestLoginWithoutRefreshTokenOmitsIt keeps the response honest for the
// clients that do not support rotation.
func TestLoginWithoutRefreshTokenOmitsIt(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if _, present := body["refresh_token"]; present {
t.Error("a login that did not ask for a refresh token must not get one")
}
if _, present := body["expires_in_ms"]; present {
t.Error("a token that does not expire must not report a lifetime")
}
}
func TestLoginIsRateLimited(t *testing.T) {
ts := newTestServer(t, withRateLimit(config.RateLimit{
Enabled: true,
Default: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond},
Login: config.RateLimitBucket{Burst: 2, Interval: time.Minute},
Register: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond},
}))
attempt := func() int {
return ts.do(request{
method: http.MethodPost,
path: loginPath,
remote: "192.0.2.10:4321",
body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong",
},
}).Code
}
for i := range 2 {
if status := attempt(); status != http.StatusForbidden {
t.Fatalf("attempt %d status = %d, want 403", i+1, status)
}
}
rec := ts.do(request{
method: http.MethodPost,
path: loginPath,
remote: "192.0.2.10:4321",
body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong",
},
})
expectError(t, rec, http.StatusTooManyRequests, "M_LIMIT_EXCEEDED")
retryAfter, _ := jsonBody(t, rec)["retry_after_ms"].(float64)
if retryAfter <= 0 {
t.Errorf("retry_after_ms = %v, want a positive number", retryAfter)
}
// Another address has its own budget: one noisy client must not lock
// everybody out.
ts2 := ts.do(request{
method: http.MethodPost,
path: loginPath,
remote: "198.51.100.7:4321",
body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong",
},
})
if ts2.Code == http.StatusTooManyRequests {
t.Error("the limiter is keyed globally rather than per client")
}
// And the budget is per endpoint class, so /login running dry does not
// take the rest of the API with it.
if rec := ts.get("/_matrix/client/v3/login", ""); rec.Code != http.StatusOK {
t.Errorf("GET /login status = %d; limiters must be per endpoint", rec.Code)
}
}
// TestAccessTokenInQueryString covers the form older clients and media URLs
// still use.
func TestAccessTokenInQueryString(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/account/whoami?access_token="+acc.AccessToken, "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["user_id"] != acc.UserID {
t.Errorf("whoami = %s", rec.Body.String())
}
}
func TestMalformedAuthorizationHeaderIsRejected(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
req := request{method: http.MethodGet, path: "/_matrix/client/v3/account/whoami"}
for name, header := range map[string]string{
"no scheme": acc.AccessToken,
"wrong scheme": "Basic " + acc.AccessToken,
"empty bearer": "Bearer ",
} {
t.Run(name, func(t *testing.T) {
httpReq := requestWithRawAuth(t, req, header)
rec := serve(ts, httpReq)
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
})
}
// The scheme itself is case-insensitive, per RFC 9110.
rec := serve(ts, requestWithRawAuth(t, req, "bearer "+acc.AccessToken))
expectStatus(t, rec, http.StatusOK)
}
func TestWhoami(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["user_id"] != acc.UserID || body["device_id"] != acc.DeviceID {
t.Errorf("whoami = %s", rec.Body.String())
}
if body["is_guest"] != false {
t.Errorf("is_guest = %v, want false", body["is_guest"])
}
}
func TestCapabilities(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
// The endpoint is authenticated: what a server lets you do depends on
// who you are.
expectError(t, ts.get("/_matrix/client/v3/capabilities", ""),
http.StatusUnauthorized, "M_MISSING_TOKEN")
rec := ts.get("/_matrix/client/v3/capabilities", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
caps, _ := jsonBody(t, rec)["capabilities"].(map[string]any)
for _, key := range []string{"m.room_versions", "m.change_password", "m.set_displayname"} {
if _, present := caps[key]; !present {
t.Errorf("capabilities is missing %s: %s", key, rec.Body.String())
}
}
versions, _ := caps["m.room_versions"].(map[string]any)
if versions["default"] != clientapi.DefaultRoomVersion {
t.Errorf("default room version = %v, want %s", versions["default"], clientapi.DefaultRoomVersion)
}
if available, _ := versions["available"].(map[string]any); len(available) == 0 {
t.Error("m.room_versions.available must list at least the default")
}
}
// TestSyncPlaceholder documents what the Phase 1 /sync does: it answers a
// valid token with an empty, well-formed response, and refuses a revoked
// one. Phase 4 replaces it with the real syncapi.
func TestSyncPlaceholder(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/sync", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if next, _ := body["next_batch"].(string); next == "" {
t.Error("/sync must return a next_batch token")
}
rooms, _ := body["rooms"].(map[string]any)
for _, key := range []string{"join", "invite", "leave"} {
if _, present := rooms[key]; !present {
t.Errorf("rooms is missing %s: %s", key, rec.Body.String())
}
}
}
func TestUnknownClientEndpoint(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get("/_matrix/client/v3/rooms/!a:b/messages", "")
expectError(t, rec, http.StatusNotFound, "M_UNRECOGNIZED")
}
// serve runs a prepared request through the handler.
func serve(ts *testServer, req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, req)
return rec
}
// requestWithRawAuth builds a request carrying an Authorization header
// verbatim, so the parsing of the header itself can be tested.
func requestWithRawAuth(t *testing.T, req request, header string) *http.Request {
t.Helper()
httpReq := httptest.NewRequest(req.method, req.path, nil)
httpReq.Header.Set("Authorization", header)
return httpReq
}
+157
View File
@@ -0,0 +1,157 @@
package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// profileResponse is the body of GET /profile/{userId}. Both fields are
// omitted when unset: the spec distinguishes "no display name" from an empty
// one, and clients fall back to the localpart for the former.
type profileResponse struct {
DisplayName string `json:"displayname,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// MaxDisplayNameLength and MaxAvatarURLLength bound the profile fields.
// The spec sets no limit; these are what keeps a member list from carrying a
// megabyte of display name per user once rooms exist.
const (
MaxDisplayNameLength = 256
MaxAvatarURLLength = 1024
)
// handleGetProfile implements GET /_matrix/client/v3/profile/{userId}.
func (c *ClientAPI) handleGetProfile(w http.ResponseWriter, r *http.Request) error {
account, err := c.lookupProfile(r)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, profileResponse{
DisplayName: account.DisplayName,
AvatarURL: account.AvatarURL,
})
return nil
}
// handleGetDisplayName implements GET /profile/{userId}/displayname.
func (c *ClientAPI) handleGetDisplayName(w http.ResponseWriter, r *http.Request) error {
account, err := c.lookupProfile(r)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, profileResponse{DisplayName: account.DisplayName})
return nil
}
// handleGetAvatarURL implements GET /profile/{userId}/avatar_url.
func (c *ClientAPI) handleGetAvatarURL(w http.ResponseWriter, r *http.Request) error {
account, err := c.lookupProfile(r)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, profileResponse{AvatarURL: account.AvatarURL})
return nil
}
// lookupProfile resolves the user ID in the path to a local account.
//
// A user ID on another server is where the federation switch shows up in the
// Client-Server API: with federation off there is no way to answer and the
// spec's M_FORBIDDEN is the honest reply, as PLAN.md section 3 requires.
// With federation on, the lookup itself is Phase 5 work.
func (c *ClientAPI) lookupProfile(r *http.Request) (storage.Account, error) {
userID := r.PathValue("userID")
localpart, err := c.users.ParseLocalUserID(userID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
if !c.federation.Enabled() {
return storage.Account{}, federation.RejectClientRequest(
"looking up the profile of a user on another server")
}
return storage.Account{}, httputil.NotFound(
"Profiles of users on other servers cannot be fetched yet")
case err != nil:
return storage.Account{}, httputil.InvalidParam("%s is not a valid user ID", userID)
}
account, err := c.users.Account(r.Context(), localpart)
switch {
case errors.Is(err, userapi.ErrNoSuchUser):
return storage.Account{}, httputil.NotFound("Profile not found")
case err != nil:
return storage.Account{}, err
}
return account, nil
}
// putDisplayNameRequest is the body of PUT /profile/{userId}/displayname.
type putDisplayNameRequest struct {
DisplayName string `json:"displayname"`
}
// putAvatarURLRequest is the body of PUT /profile/{userId}/avatar_url.
type putAvatarURLRequest struct {
AvatarURL string `json:"avatar_url"`
}
// handlePutDisplayName implements PUT /profile/{userId}/displayname.
func (c *ClientAPI) handlePutDisplayName(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
localpart, err := c.requireSelf(auth, r.PathValue("userID"))
if err != nil {
return err
}
var req putDisplayNameRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if len(req.DisplayName) > MaxDisplayNameLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
"Display names must be at most %d bytes", MaxDisplayNameLength)
}
if err := c.users.SetDisplayName(r.Context(), localpart, req.DisplayName); err != nil {
return profileWriteError(err)
}
// Once rooms exist this also has to rewrite the m.room.member event in
// every room the user is in; that belongs to the roomserver in Phase 3.
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handlePutAvatarURL implements PUT /profile/{userId}/avatar_url.
func (c *ClientAPI) handlePutAvatarURL(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
localpart, err := c.requireSelf(auth, r.PathValue("userID"))
if err != nil {
return err
}
var req putAvatarURLRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if len(req.AvatarURL) > MaxAvatarURLLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
"Avatar URLs must be at most %d bytes", MaxAvatarURLLength)
}
if err := c.users.SetAvatarURL(r.Context(), localpart, req.AvatarURL); err != nil {
return profileWriteError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// profileWriteError maps a failed profile update. An admin may write to
// another user's profile, so "no such user" is reachable here.
func profileWriteError(err error) error {
if errors.Is(err, userapi.ErrNoSuchUser) {
return httputil.NotFound("Profile not found")
}
return err
}
+147
View File
@@ -0,0 +1,147 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
)
const profilePath = "/_matrix/client/v3/profile"
func TestProfileRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
// A fresh account has neither field, and the spec distinguishes that
// from an empty one: clients fall back to the localpart.
rec := ts.get(profilePath+"/"+acc.UserID, "")
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if _, present := body["displayname"]; present {
t.Errorf("an unset display name must be omitted, got %s", rec.Body.String())
}
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": "Alice"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/avatar_url",
token: acc.AccessToken, body: map[string]any{"avatar_url": "mxc://warp.example/abc"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(profilePath+"/"+acc.UserID, "")
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
if body["displayname"] != "Alice" || body["avatar_url"] != "mxc://warp.example/abc" {
t.Errorf("profile = %s", rec.Body.String())
}
// The per-field endpoints return only their own field.
rec = ts.get(profilePath+"/"+acc.UserID+"/displayname", "")
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
if body["displayname"] != "Alice" {
t.Errorf("displayname = %v", body["displayname"])
}
if _, present := body["avatar_url"]; present {
t.Error("/displayname must not include the avatar")
}
rec = ts.get(profilePath+"/"+acc.UserID+"/avatar_url", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["avatar_url"] != "mxc://warp.example/abc" {
t.Errorf("avatar_url = %s", rec.Body.String())
}
}
// TestProfileReadsArePublic covers the rule a member list depends on: a
// client showing somebody's name has no token for them.
func TestProfileReadsArePublic(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": "my_display_name"}})
expectStatus(t, rec, http.StatusOK)
for _, path := range []string{
profilePath + "/" + acc.UserID,
profilePath + "/" + acc.UserID + "/displayname",
} {
rec := ts.get(path, "")
expectStatus(t, rec, http.StatusOK)
if name := jsonBody(t, rec)["displayname"]; name != "my_display_name" {
t.Errorf("GET %s without a token returned %v", path, name)
}
}
}
func TestProfileWritesAreOwnOnly(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + alice.UserID + "/displayname",
token: bob.AccessToken, body: map[string]any{"displayname": "not bob"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
// An admin may, which is what lets the panel clean up a profile.
ts.makeAdmin(bob)
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + alice.UserID + "/displayname",
token: bob.AccessToken, body: map[string]any{"displayname": "set by an admin"}})
expectStatus(t, rec, http.StatusOK)
}
func TestProfileWritesRequireAToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
body: map[string]any{"displayname": "Alice"}})
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
}
func TestProfileOfUnknownUserIs404(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(profilePath+"/@nobody:warp.example", ""),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestProfileRejectsMalformedUserID(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(profilePath+"/@no-server-name", ""),
http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestProfileOfRemoteUserHonoursTheFederationSwitch is where PLAN.md
// section 3 shows up in the Client-Server API: with federation off there is
// no way to answer, and the spec's M_FORBIDDEN with a clear message is the
// honest reply.
func TestProfileOfRemoteUserHonoursTheFederationSwitch(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get(profilePath+"/@bob:other.example", "")
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
if msg, _ := jsonBody(t, rec)["error"].(string); !strings.Contains(msg, "Federation is disabled") {
t.Errorf("the message should say why, got %q", msg)
}
// With the switch on it is no longer a refusal, only something that is
// not implemented until Phase 5.
if err := ts.settings.SetFederationEnabled(t.Context(), true); err != nil {
t.Fatalf("SetFederationEnabled: %v", err)
}
expectError(t, ts.get(profilePath+"/@bob:other.example", ""),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestProfileRejectsOverlongDisplayName(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": strings.Repeat("x", 1000)}})
expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE")
}
+254
View File
@@ -0,0 +1,254 @@
package clientapi
import (
"crypto/rand"
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// registerRequest is the body of POST /register.
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
InitialDeviceDisplayName string `json:"initial_device_display_name"`
// InhibitLogin asks for the account without a session, for a client
// registering on someone else's behalf.
InhibitLogin bool `json:"inhibit_login"`
RefreshToken bool `json:"refresh_token"`
Auth *uiaRequest `json:"auth"`
}
// registerResponse is the body of a successful POST /register.
type registerResponse struct {
UserID string `json:"user_id"`
// The session fields are absent when inhibit_login was set.
AccessToken string `json:"access_token,omitempty"`
DeviceID string `json:"device_id,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
// HomeServer is deprecated and still read by older clients, which
// treat its absence as a malformed response.
HomeServer string `json:"home_server,omitempty"`
}
// handleRegister implements POST /_matrix/client/v3/register.
//
// The order of the checks matters and is not the obvious one. The spec has
// the registration flow start with a 401 listing the flows, so an empty
// body must get the challenge rather than a complaint about the missing
// password — but a username that is unusable or already taken has to be
// rejected *before* the challenge, because a client that has to complete a
// flow only to be told the name was taken cannot retry without starting
// over. Complement checks both halves of that.
func (c *ClientAPI) handleRegister(w http.ResponseWriter, r *http.Request) error {
var req registerRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
// Guest registration needs the room-level restrictions that come with
// the roomserver, so it is refused rather than half-implemented.
if kind := r.URL.Query().Get("kind"); kind != "" && kind != "user" {
if kind == "guest" {
return httputil.Forbidden("Guest access is not supported by this server")
}
return httputil.InvalidParam("Unknown registration kind %q", kind)
}
if !c.settings.RegistrationEnabled() {
return httputil.Forbidden("Registration is disabled on this server")
}
localpart := userapi.NormaliseLocalpart(req.Username)
if localpart != "" {
if err := userapi.ValidateLocalpart(localpart, c.serverName); err != nil {
return httputil.InvalidUsername("%s", err.Error())
}
taken, err := c.users.AccountExists(r.Context(), localpart)
if err != nil {
return err
}
if taken {
return httputil.UserInUse()
}
}
flows := c.registrationFlows()
sessionID, done, err := c.beginOrAdvanceUIA(w, flows, req.Auth, c.registrationStage)
if err != nil || !done {
return err
}
// Only now that the client has authenticated is the rest of the body
// worth validating.
if req.Password == "" {
return httputil.MissingParam("A password is required to register an account")
}
if len(req.Password) > userapi.MaxPasswordLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword,
"Password must be at most %d bytes", userapi.MaxPasswordLength)
}
if req.DeviceID != "" {
if err := userapi.ValidateDeviceID(req.DeviceID); err != nil {
return httputil.InvalidParam("%s", err.Error())
}
}
if localpart == "" {
// The spec lets a client leave the name to the server.
if localpart, err = generateLocalpart(); err != nil {
return err
}
}
if _, err := c.users.CreateAccount(r.Context(), userapi.NewAccountRequest{
Localpart: localpart,
Password: req.Password,
}); err != nil {
switch {
case errors.Is(err, userapi.ErrUserExists):
// Lost the race against another registration of the same name
// between the check above and the insert.
return httputil.UserInUse()
case errors.Is(err, userapi.ErrInvalidLocalpart):
return httputil.InvalidUsername("%s", err.Error())
default:
return err
}
}
// The flow has been acted on; a replay of the same request must not
// create a second account.
c.uia.remove(sessionID)
resp := registerResponse{
UserID: c.users.UserID(localpart),
HomeServer: c.serverName,
}
if req.InhibitLogin {
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
session, err := c.users.CreateSession(r.Context(), userapi.SessionRequest{
Localpart: localpart,
DeviceID: req.DeviceID,
DisplayName: req.InitialDeviceDisplayName,
WithRefreshToken: req.RefreshToken,
IP: c.clientIP(r),
UserAgent: r.UserAgent(),
})
if err != nil {
return err
}
resp.AccessToken = session.AccessToken
resp.DeviceID = session.DeviceID
resp.RefreshToken = session.RefreshToken
resp.ExpiresInMS = session.ExpiresInMS
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// registrationFlows is what a client has to complete to register.
//
// m.login.dummy is a stage that is satisfied by being attempted. It exists
// so that the flow is never empty: the spec's registration is always
// interactive, and a server with no real requirements still has to offer
// something for the client to complete.
func (c *ClientAPI) registrationFlows() []uiaFlow {
if c.registration.RequiresToken() {
return []uiaFlow{{Stages: []string{stageRegistrationToken, stageDummy}}}
}
return []uiaFlow{{Stages: []string{stageDummy}}}
}
// registrationStage checks one stage of the registration flow.
func (c *ClientAPI) registrationStage(stage string, auth *uiaRequest) (*httputil.MatrixError, error) {
switch stage {
case stageDummy:
return nil, nil
case stageRegistrationToken:
if auth.Token == "" {
return httputil.MissingParam("A registration token is required"), nil
}
if !c.registration.TokenValid(auth.Token) {
return httputil.Forbidden("Invalid registration token"), nil
}
return nil, nil
default:
// beginOrAdvanceUIA only calls this for stages it offered, so
// reaching here means the flow list and this switch disagree.
return nil, httputil.InternalError()
}
}
// handleRegisterAvailable implements GET /register/available, the live check
// a client runs while the user is typing a name.
func (c *ClientAPI) handleRegisterAvailable(w http.ResponseWriter, r *http.Request) error {
username := r.URL.Query().Get("username")
if username == "" {
return httputil.MissingParam("The username query parameter is required")
}
localpart := userapi.NormaliseLocalpart(username)
if err := userapi.ValidateLocalpart(localpart, c.serverName); err != nil {
return httputil.InvalidUsername("%s", err.Error())
}
taken, err := c.users.AccountExists(r.Context(), localpart)
if err != nil {
return err
}
if taken {
return httputil.UserInUse()
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"available": true})
return nil
}
// handleRegistrationTokenValidity implements
// GET /_matrix/client/v1/register/m.login.registration_token/validity, which
// lets a client tell the user their token is wrong before they fill in the
// rest of the form.
func (c *ClientAPI) handleRegistrationTokenValidity(w http.ResponseWriter, r *http.Request) error {
token := r.URL.Query().Get("token")
if token == "" {
return httputil.MissingParam("The token query parameter is required")
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"valid": c.registration.TokenValid(token)})
return nil
}
// generatedLocalpartAlphabet is the character set for a server-chosen user
// name: lowercase letters and digits, which are valid everywhere in a
// localpart and unambiguous when read out.
const generatedLocalpartAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// generatedLocalpartLength gives roughly 62 bits of entropy, enough that a
// server-generated name will not collide with an existing one in practice.
const generatedLocalpartLength = 12
// generateLocalpart invents a user name for a registration that did not ask
// for one.
func generateLocalpart() (string, error) {
raw := make([]byte, generatedLocalpartLength)
if _, err := rand.Read(raw); err != nil {
return "", err
}
out := make([]byte, generatedLocalpartLength)
for i, b := range raw {
out[i] = generatedLocalpartAlphabet[int(b)%len(generatedLocalpartAlphabet)]
}
return string(out), nil
}
+391
View File
@@ -0,0 +1,391 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
)
const registerPath = "/_matrix/client/v3/register"
// TestRegisterReturnsFlows covers the first half of the registration
// handshake: an empty body gets the flows and a session, not a complaint
// about the missing password.
func TestRegisterReturnsFlows(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{}})
expectStatus(t, rec, http.StatusUnauthorized)
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
body := jsonBody(t, rec)
if _, ok := body["session"].(string); !ok {
t.Error("the challenge must carry a session")
}
if _, ok := body["params"].(map[string]any); !ok {
t.Error("the challenge must carry a params object, even an empty one")
}
flows, ok := body["flows"].([]any)
if !ok || len(flows) == 0 {
t.Fatalf("flows = %v, want a non-empty array", body["flows"])
}
for _, flow := range flows {
stages, ok := flow.(map[string]any)["stages"].([]any)
if !ok || len(stages) == 0 {
t.Fatalf("every flow needs a non-empty stages array, got %v", flow)
}
if stages[0] != "m.login.dummy" {
t.Errorf("first stage = %v, want m.login.dummy", stages[0])
}
}
}
func TestRegisterCreatesAccount(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "sUp3rs3kr1t")
if acc.UserID != "@alice:warp.example" {
t.Errorf("user_id = %q, want @alice:warp.example", acc.UserID)
}
// The token works straight away: a registration that needs a separate
// login is a registration that half worked.
rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["user_id"]; got != acc.UserID {
t.Errorf("whoami = %v, want %s", got, acc.UserID)
}
}
// TestRegisterDowncasesUsername pins the behaviour clients rely on: a
// localpart is lowercase, so a capital in the request is normalised rather
// than rejected.
func TestRegisterDowncasesUsername(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("user-UPPER", "sUp3rs3kr1t")
if acc.UserID != "@user-upper:warp.example" {
t.Errorf("user_id = %q, want @user-upper:warp.example", acc.UserID)
}
}
func TestRegisterHonoursRequestedDeviceID(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"device_id": "my_device_id",
"initial_device_display_name": "a phone",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["device_id"]; got != "my_device_id" {
t.Errorf("device_id = %v, want my_device_id", got)
}
}
// TestRegisterRejectsBadUsernamesBeforeUIA is the half of the ordering that
// is easy to get wrong: a username that can never work is rejected with a
// 400 straight away, rather than after the client has completed a flow.
func TestRegisterRejectsBadUsernamesBeforeUIA(t *testing.T) {
ts := newTestServer(t, nil)
for _, ch := range []string{"!", `"`, ":", "?", `\`, "@", "[", "]", "{", "|", "}", "£", "é", "\n", "'", ","} {
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "user-" + ch + "-reject", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusBadRequest, "M_INVALID_USERNAME")
}
// The characters the spec does allow must still get through.
for _, ch := range []string{"q", "3", ".", "_", "=", "-", "/", "+"} {
ts.register("chrtest"+ch, "sUp3rs3kr1t")
}
}
func TestRegisterRejectsOverlongUsername(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": strings.Repeat("a", 256),
"password": "sUp3rs3kr1t",
}})
expectError(t, rec, http.StatusBadRequest, "M_INVALID_USERNAME")
}
// TestRegisterRejectsTakenUsernameBeforeUIA is the other half: the name is
// checked before the challenge, so a client is not asked to authenticate
// only to be told the name was gone all along.
func TestRegisterRejectsTakenUsernameBeforeUIA(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "sUp3rs3kr1t")
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
// Capitalisation does not get around it, because the name is
// normalised first.
rec = ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "ALICE", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
}
// TestRegisterRequiresTheSession covers the rule that makes the flow a flow:
// an `auth` without the session the server handed out starts again rather
// than completing.
func TestRegisterRequiresTheSession(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy"},
}})
expectStatus(t, rec, http.StatusUnauthorized)
// An unknown session is treated the same way, with a fresh one offered.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": "not-a-session"},
}})
expectStatus(t, rec, http.StatusUnauthorized)
if _, ok := jsonBody(t, rec)["session"].(string); !ok {
t.Error("a rejected session must come with a new one to try")
}
}
func TestRegisterRejectsUnknownStage(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.recaptcha", "session": session},
}})
expectError(t, rec, http.StatusBadRequest, "M_UNRECOGNIZED")
}
func TestRegisterRequiresPasswordAfterUIA(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice"}})
expectStatus(t, rec, http.StatusUnauthorized)
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestRegisterInhibitLogin(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"inhibit_login": true,
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["user_id"] != "@alice:warp.example" {
t.Errorf("user_id = %v", body["user_id"])
}
for _, field := range []string{"access_token", "device_id"} {
if _, present := body[field]; present {
t.Errorf("inhibit_login must suppress %s", field)
}
}
}
// TestRegisterGeneratesUsername covers the case where the client leaves the
// choice of name to the server.
func TestRegisterGeneratesUsername(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
userID, _ := jsonBody(t, rec)["user_id"].(string)
if !strings.HasPrefix(userID, "@") || !strings.HasSuffix(userID, ":warp.example") {
t.Errorf("generated user_id = %q, want @<something>:warp.example", userID)
}
if len(userID) <= len("@:warp.example") {
t.Errorf("generated user_id %q has an empty localpart", userID)
}
}
func TestRegisterSessionIsSpentOnce(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
complete := map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}
expectStatus(t, ts.do(request{method: http.MethodPost, path: registerPath, body: complete}), http.StatusOK)
// Replaying the identical request must not create a second account; the
// name is taken now, and the session is gone either way.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: complete})
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
}
func TestRegisterRespectsTheRegistrationSwitch(t *testing.T) {
ts := newTestServer(t, nil)
if err := ts.settings.SetRegistrationEnabled(t.Context(), false); err != nil {
t.Fatalf("SetRegistrationEnabled: %v", err)
}
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
// And comes back without a restart, which is the point of the switch.
if err := ts.settings.SetRegistrationEnabled(t.Context(), true); err != nil {
t.Fatalf("SetRegistrationEnabled: %v", err)
}
ts.register("alice", "sUp3rs3kr1t")
}
func TestRegisterRejectsGuests(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath + "?kind=guest",
body: map[string]any{}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
}
// TestRegisterWithRegistrationToken covers the second stage a server with
// invite-only sign-up adds to the flow.
func TestRegisterWithRegistrationToken(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.Registration = config.Registration{Enabled: true, Tokens: []string{"let-me-in"}}
})
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
expectStatus(t, rec, http.StatusUnauthorized)
body := jsonBody(t, rec)
flows, _ := body["flows"].([]any)
stages, _ := flows[0].(map[string]any)["stages"].([]any)
if len(stages) != 2 || stages[0] != "m.login.registration_token" {
t.Fatalf("stages = %v, want the token stage first", stages)
}
session := body["session"]
// A wrong token fails the stage: the client gets the error and the
// flows together, so it can prompt again without starting over.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{
"type": "m.login.registration_token", "session": session, "token": "nope",
},
}})
expectStatus(t, rec, http.StatusUnauthorized)
body = jsonBody(t, rec)
if body["errcode"] != "M_FORBIDDEN" {
t.Errorf("a failed stage must say why, got %s", rec.Body.String())
}
if body["session"] != session {
t.Error("a failed stage must keep the session alive")
}
// The right one advances the flow but does not finish it.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{
"type": "m.login.registration_token", "session": session, "token": "let-me-in",
},
}})
expectStatus(t, rec, http.StatusUnauthorized)
completed, _ := jsonBody(t, rec)["completed"].([]any)
if len(completed) != 1 || completed[0] != "m.login.registration_token" {
t.Fatalf("completed = %v, want the token stage", completed)
}
// And the dummy stage completes it.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["user_id"] != "@alice:warp.example" {
t.Errorf("user_id = %v", jsonBody(t, rec)["user_id"])
}
}
func TestRegisterAvailable(t *testing.T) {
ts := newTestServer(t, nil)
const path = "/_matrix/client/v3/register/available"
rec := ts.get(path+"?username=free_name", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["available"] != true {
t.Errorf("available = %v, want true", jsonBody(t, rec)["available"])
}
ts.register("taken", "sUp3rs3kr1t")
expectError(t, ts.get(path+"?username=taken", ""), http.StatusBadRequest, "M_USER_IN_USE")
expectError(t, ts.get(path+"?username=not,valid", ""), http.StatusBadRequest, "M_INVALID_USERNAME")
expectError(t, ts.get(path, ""), http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestRegistrationTokenValidity(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.Registration = config.Registration{Enabled: true, Tokens: []string{"let-me-in"}}
})
const path = "/_matrix/client/v1/register/m.login.registration_token/validity"
rec := ts.get(path+"?token=let-me-in", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["valid"] != true {
t.Errorf("valid = %v, want true", jsonBody(t, rec)["valid"])
}
rec = ts.get(path+"?token=nope", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["valid"] != false {
t.Errorf("valid = %v, want false", jsonBody(t, rec)["valid"])
}
expectError(t, ts.get(path, ""), http.StatusBadRequest, "M_MISSING_PARAM")
}
+73
View File
@@ -0,0 +1,73 @@
package clientapi
import (
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// syncResponse is the subset of the /sync body that is meaningful before
// there are any rooms.
type syncResponse struct {
NextBatch string `json:"next_batch"`
Rooms syncRooms `json:"rooms"`
Presence syncEventList `json:"presence"`
AccountData syncEventList `json:"account_data"`
ToDevice syncEventList `json:"to_device"`
DeviceLists syncDeviceLists `json:"device_lists"`
}
type syncRooms struct {
Join map[string]any `json:"join"`
Invite map[string]any `json:"invite"`
Leave map[string]any `json:"leave"`
Knock map[string]any `json:"knock"`
}
type syncEventList struct {
Events []any `json:"events"`
}
type syncDeviceLists struct {
Changed []string `json:"changed"`
Left []string `json:"left"`
}
// syncPlaceholderToken is the only stream position Phase 1 can be at.
//
// It is a real token shape rather than an empty string so that a client
// storing it and sending it back as ?since= gets the same answer, which is
// the correct one: nothing has happened.
const syncPlaceholderToken = "s0_0"
// handleSync implements GET /_matrix/client/v3/sync as far as Phase 1 can.
//
// This is a placeholder, and deliberately a working one. There are no rooms
// until Phase 3 and no event streams until Phase 4, so an empty sync is not
// a lie — it is the complete state of this server. What it buys in the
// meantime is the thing every client and the test suite check first: that a
// valid token syncs and a revoked one does not. Without it, "logging out
// invalidates the session" cannot be observed at all.
//
// Phase 4 replaces this wholesale with the syncapi: stream tokens, long
// polling, filters and lazy-loaded members.
func (c *ClientAPI) handleSync(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
// The `timeout` parameter is ignored rather than honoured: blocking for
// 30 seconds to return a response that cannot change would hold a
// connection open for nothing.
httputil.WriteJSON(w, http.StatusOK, syncResponse{
NextBatch: syncPlaceholderToken,
Rooms: syncRooms{
Join: map[string]any{},
Invite: map[string]any{},
Leave: map[string]any{},
Knock: map[string]any{},
},
Presence: syncEventList{Events: []any{}},
AccountData: syncEventList{Events: []any{}},
ToDevice: syncEventList{Events: []any{}},
DeviceLists: syncDeviceLists{Changed: []string{}, Left: []string{}},
})
return nil
}
+295
View File
@@ -0,0 +1,295 @@
package clientapi
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"slices"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// User-Interactive Authentication stage types. The spec's full list is long;
// these are the ones Warp implements. CAPTCHA, email and terms stages belong
// to deployments with public sign-up and arrive with the rest of that story.
const (
stageDummy = "m.login.dummy"
stagePassword = "m.login.password"
stageRegistrationToken = "m.login.registration_token"
)
// uiaFlow is one route through the stages. A client may satisfy any flow.
type uiaFlow struct {
Stages []string `json:"stages"`
}
// uiaChallenge is the 401 body of an incomplete User-Interactive
// Authentication: the flows on offer, the session to quote them against, and
// what has been done so far.
//
// When a stage has just been failed it also carries an error envelope, which
// is why errcode and error are part of this type rather than sent as a
// separate Matrix error: the spec puts both in one 401 body so the client
// can show the failure and retry the stage in one step.
type uiaChallenge struct {
Flows []uiaFlow `json:"flows"`
Params map[string]any `json:"params"`
Session string `json:"session"`
Completed []string `json:"completed,omitempty"`
ErrCode string `json:"errcode,omitempty"`
Error string `json:"error,omitempty"`
}
// uiaSessionTTL is how long an unfinished authentication may sit idle.
// Clients complete a flow in seconds; the window only has to be long enough
// for a person to read a prompt and type a password.
const uiaSessionTTL = 15 * time.Minute
// uiaSession tracks one client's progress through a flow set.
type uiaSession struct {
id string
completed []string
expiresAt time.Time
}
// uiaStore holds the in-flight authentication sessions.
//
// Sessions live in memory. They are worthless after a few minutes, they are
// only ever read by the process that wrote them (Warp is one process by
// design), and a restart costs a client exactly one extra round trip —
// whereas a table would need its own schema, its own sweeper and a write on
// every stage.
type uiaStore struct {
ttl time.Duration
now func() time.Time
mu sync.Mutex
sessions map[string]*uiaSession
sweptAt time.Time
}
func newUIAStore(ttl time.Duration, now func() time.Time) *uiaStore {
if now == nil {
now = time.Now
}
return &uiaStore{ttl: ttl, now: now, sessions: map[string]*uiaSession{}}
}
// create starts a session.
func (s *uiaStore) create() (*uiaSession, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate auth session: %w", err)
}
session := &uiaSession{
id: base64.RawURLEncoding.EncodeToString(raw),
expiresAt: s.now().Add(s.ttl),
}
s.mu.Lock()
defer s.mu.Unlock()
s.sweep()
s.sessions[session.id] = session
return session, nil
}
// get returns a live session, or nil if it is unknown or expired.
func (s *uiaStore) get(id string) *uiaSession {
if id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil
}
if s.now().After(session.expiresAt) {
delete(s.sessions, id)
return nil
}
// Each copy handed out is independent, so a caller cannot mutate the
// stored progress by accident.
return &uiaSession{id: session.id, completed: slices.Clone(session.completed), expiresAt: session.expiresAt}
}
// complete records a finished stage and returns the session's new progress.
func (s *uiaStore) complete(id, stage string) []string {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil
}
if !slices.Contains(session.completed, stage) {
session.completed = append(session.completed, stage)
}
// Finishing a stage is progress, so the window starts again: a flow
// with several stages must not time out halfway through.
session.expiresAt = s.now().Add(s.ttl)
return slices.Clone(session.completed)
}
// remove forgets a session, which happens once its flow has been acted on.
// Leaving it behind would let a replayed request create a second account or
// delete a second device.
func (s *uiaStore) remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
// sweep drops expired sessions. It runs under the caller's lock, at most
// once per TTL.
func (s *uiaStore) sweep() {
now := s.now()
if now.Sub(s.sweptAt) < s.ttl {
return
}
s.sweptAt = now
for id, session := range s.sessions {
if now.After(session.expiresAt) {
delete(s.sessions, id)
}
}
}
// uiaRequest is the `auth` dictionary a client sends. The stage-specific
// fields are flattened into it rather than nested, which is how the spec
// defines each stage's payload.
type uiaRequest struct {
Type string `json:"type"`
Session string `json:"session"`
// Token is the m.login.registration_token payload.
Token string `json:"token"`
// Identifier and Password are the m.login.password payload.
Identifier loginIdentifier `json:"identifier"`
Password string `json:"password"`
// User is the deprecated top-level form of Identifier, still sent by
// older clients.
User string `json:"user"`
}
// flowSatisfied reports whether completed covers every stage of any flow.
func flowSatisfied(flows []uiaFlow, completed []string) bool {
for _, flow := range flows {
done := true
for _, stage := range flow.Stages {
if !slices.Contains(completed, stage) {
done = false
break
}
}
if done {
return true
}
}
return false
}
// challenge writes a 401 asking the client to complete a stage.
//
// cause is optional: when a stage was attempted and failed, the client gets
// the failure and the flows in one body, which is what lets it re-prompt
// without starting over.
func (c *ClientAPI) challenge(w http.ResponseWriter, flows []uiaFlow, session *uiaSession, cause *httputil.MatrixError) {
body := uiaChallenge{
Flows: flows,
// Always an object, never null: clients and Complement both expect
// `params` to be present even when no stage needs parameters.
Params: map[string]any{},
Session: session.id,
Completed: session.completed,
}
if cause != nil {
body.ErrCode, body.Error = cause.Code, cause.Message
}
httputil.WriteJSON(w, http.StatusUnauthorized, body)
}
// stageHandler checks one stage of a flow.
//
// The two return values are different kinds of "no": a *MatrixError is a
// stage the client may retry, and is sent back inside the 401 alongside the
// flows; a plain error ends the request. Wrong password is the first kind,
// authenticating as somebody else is the second.
type stageHandler func(stage string, auth *uiaRequest) (*httputil.MatrixError, error)
// beginOrAdvanceUIA drives one request through a flow set.
//
// It returns done=true when the client has satisfied a flow and the caller
// may go ahead; otherwise it has already written the 401 challenge. The
// returned session ID is the one to forget once the operation succeeds.
func (c *ClientAPI) beginOrAdvanceUIA(
w http.ResponseWriter,
flows []uiaFlow,
auth *uiaRequest,
handleStage stageHandler,
) (sessionID string, done bool, err error) {
// No auth at all, or an auth without the session the server handed out,
// starts a new attempt. The spec requires the session to be quoted back,
// so a request that drops it is not treated as a continuation.
if auth == nil || auth.Type == "" || auth.Session == "" {
session, createErr := c.uia.create()
if createErr != nil {
return "", false, createErr
}
c.challenge(w, flows, session, nil)
return "", false, nil
}
session := c.uia.get(auth.Session)
if session == nil {
// An unknown or expired session: start a fresh one rather than
// failing, so a client that was slow can simply carry on.
session, createErr := c.uia.create()
if createErr != nil {
return "", false, createErr
}
c.challenge(w, flows, session,
httputil.NewError(http.StatusUnauthorized, httputil.ErrCodeUnrecognized,
"The authentication session has expired; start again"))
return "", false, nil
}
if !stageOffered(flows, auth.Type) {
return "", false, httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnrecognized,
"Unknown authentication type %q", auth.Type)
}
retryable, fatal := handleStage(auth.Type, auth)
if fatal != nil {
return "", false, fatal
}
if retryable != nil {
c.challenge(w, flows, session, retryable)
return "", false, nil
}
session.completed = c.uia.complete(session.id, auth.Type)
if !flowSatisfied(flows, session.completed) {
c.challenge(w, flows, session, nil)
return "", false, nil
}
return session.id, true, nil
}
// stageOffered reports whether stage appears in any of the flows, so that a
// client cannot complete a stage this operation never asked for.
func stageOffered(flows []uiaFlow, stage string) bool {
for _, flow := range flows {
if slices.Contains(flow.Stages, stage) {
return true
}
}
return false
}
@@ -0,0 +1,52 @@
package clientapi_test
import (
"net/http"
"testing"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
)
// TestLoginCarriesDiscovery covers the shortcut the spec allows: a client
// that only knew the server name learns its API URL from the login response
// instead of fetching /.well-known separately.
func TestLoginCarriesDiscovery(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.ClientBaseURL = "https://matrix.warp.example"
})
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
wellKnown, ok := jsonBody(t, rec)["well_known"].(map[string]any)
if !ok {
t.Fatalf("login response carries no well_known: %s", rec.Body.String())
}
homeserver, _ := wellKnown["m.homeserver"].(map[string]any)
if homeserver["base_url"] != "https://matrix.warp.example" {
t.Errorf("base_url = %v", homeserver["base_url"])
}
}
// TestLoginOmitsDiscoveryWhenUnconfigured keeps the response from carrying
// an empty object a client would have to guess at.
func TestLoginOmitsDiscoveryWhenUnconfigured(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
if _, present := jsonBody(t, rec)["well_known"]; present {
t.Error("well_known must be omitted when no client base URL is configured")
}
}
+107
View File
@@ -3,6 +3,7 @@
package config
import (
"crypto/subtle"
"errors"
"fmt"
"net/url"
@@ -96,6 +97,51 @@ type ClientAPI struct {
// browser. The Matrix spec expects "*"; narrow it if the only client is
// your own web app on a known origin.
CORSAllowedOrigins []string `yaml:"cors_allowed_origins" env:"WARP_CORS_ALLOWED_ORIGINS"`
// TrustXForwardedFor takes the client address from the X-Forwarded-For
// header instead of the socket. Turn it on only behind a reverse proxy
// that overwrites the header: anyone can send it, and the rate limiter
// keys its buckets on the result.
TrustXForwardedFor bool `yaml:"trust_x_forwarded_for" env:"WARP_TRUST_X_FORWARDED_FOR"`
// AccessTokenLifetime expires access tokens after this long. Zero — the
// default — issues tokens that never expire, which is what clients
// without refresh-token support need.
AccessTokenLifetime time.Duration `yaml:"access_token_lifetime" env:"WARP_ACCESS_TOKEN_LIFETIME"`
// RefreshTokenLifetime bounds how long a refresh token may be exchanged
// for a new access token. Zero means no expiry.
RefreshTokenLifetime time.Duration `yaml:"refresh_token_lifetime" env:"WARP_REFRESH_TOKEN_LIFETIME"`
RateLimit RateLimit `yaml:"rate_limit"`
}
// RateLimit configures the per-endpoint token buckets in front of the C-S
// API. Login and registration get their own, tighter, buckets because they
// are the two endpoints worth brute-forcing.
//
// Only `enabled` has an environment override: the three buckets share one Go
// type, so per-bucket `env` tags would all collide on the same variable.
// Turning the whole limiter off (WARP_RATE_LIMIT_ENABLED=false) is what test
// harnesses such as Complement need anyway.
type RateLimit struct {
Enabled bool `yaml:"enabled" env:"WARP_RATE_LIMIT_ENABLED"`
// Default applies to every rate-limited endpoint without its own bucket.
Default RateLimitBucket `yaml:"default"`
// Login covers /login and /refresh.
Login RateLimitBucket `yaml:"login"`
// Register covers /register and /register/available.
Register RateLimitBucket `yaml:"register"`
}
// RateLimitBucket is one token bucket: Burst requests may arrive back to
// back, and one slot returns every Interval.
type RateLimitBucket struct {
Burst int `yaml:"burst"`
Interval time.Duration `yaml:"interval"`
}
// Federation controls the server-to-server surface. See PLAN.md section 3: it
@@ -118,6 +164,30 @@ type Federation struct {
type Registration struct {
Enabled bool `yaml:"enabled" env:"WARP_REGISTRATION_ENABLED"`
AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE"`
// Tokens, when non-empty, adds m.login.registration_token as a required
// stage to the registration flow and lists the tokens that satisfy it.
//
// Phase 1 keeps the tokens in the config file: per-token use counts and
// expiry belong to the admin token management of Phase 9, and a table
// nothing can write to yet would be dead weight.
Tokens []string `yaml:"tokens" env:"WARP_REGISTRATION_TOKENS"`
}
// RequiresToken reports whether the registration flow includes the
// m.login.registration_token stage.
func (r Registration) RequiresToken() bool { return len(r.Tokens) > 0 }
// TokenValid reports whether token is one of the configured registration
// tokens. Every candidate is compared in constant time, and the loop does
// not stop early, so neither the value nor the position of a token leaks
// through the time this takes.
func (r Registration) TokenValid(token string) bool {
found := 0
for _, want := range r.Tokens {
found |= subtle.ConstantTimeCompare([]byte(token), []byte(want))
}
return found == 1
}
// Logging configures slog.
@@ -163,6 +233,14 @@ func Default() *Config {
},
ClientAPI: ClientAPI{
CORSAllowedOrigins: []string{"*"},
RateLimit: RateLimit{
Enabled: true,
// Generous enough that a chatty client never notices,
// tight enough that a password-guessing script does.
Default: RateLimitBucket{Burst: 30, Interval: 100 * time.Millisecond},
Login: RateLimitBucket{Burst: 10, Interval: time.Second},
Register: RateLimitBucket{Burst: 5, Interval: 2 * time.Second},
},
},
Federation: Federation{
Enabled: false,
@@ -252,6 +330,35 @@ func (c *Config) Validate() error {
bad("metrics.path: must start with /")
}
if c.ClientAPI.AccessTokenLifetime < 0 {
bad("client_api.access_token_lifetime: must not be negative")
}
if c.ClientAPI.RefreshTokenLifetime < 0 {
bad("client_api.refresh_token_lifetime: must not be negative")
}
if c.ClientAPI.RateLimit.Enabled {
for _, b := range []struct {
name string
bucket RateLimitBucket
}{
{"default", c.ClientAPI.RateLimit.Default},
{"login", c.ClientAPI.RateLimit.Login},
{"register", c.ClientAPI.RateLimit.Register},
} {
if b.bucket.Burst < 1 {
bad("client_api.rate_limit.%s.burst: must be at least 1", b.name)
}
if b.bucket.Interval <= 0 {
bad("client_api.rate_limit.%s.interval: must be positive", b.name)
}
}
}
for i, token := range c.Registration.Tokens {
if strings.TrimSpace(token) == "" {
bad("registration.tokens[%d]: must not be empty", i)
}
}
for i, s := range c.Federation.Allowlist {
if err := validateServerName(s); err != nil {
bad("federation.allowlist[%d]: %w", i, err)
+139
View File
@@ -0,0 +1,139 @@
package config
import (
"strings"
"testing"
"time"
)
func TestRateLimitDefaults(t *testing.T) {
cfg := Default()
if !cfg.ClientAPI.RateLimit.Enabled {
t.Error("rate limiting must be on by default: login is worth brute-forcing")
}
for name, bucket := range map[string]RateLimitBucket{
"default": cfg.ClientAPI.RateLimit.Default,
"login": cfg.ClientAPI.RateLimit.Login,
"register": cfg.ClientAPI.RateLimit.Register,
} {
if bucket.Burst < 1 || bucket.Interval <= 0 {
t.Errorf("the %s bucket is unusable by default: %+v", name, bucket)
}
}
// Login and registration are deliberately tighter than the rest.
if cfg.ClientAPI.RateLimit.Login.Interval <= cfg.ClientAPI.RateLimit.Default.Interval {
t.Error("the login bucket should refill more slowly than the default one")
}
if cfg.ClientAPI.RateLimit.Register.Interval <= cfg.ClientAPI.RateLimit.Login.Interval {
t.Error("the registration bucket should refill more slowly than the login one")
}
if cfg.ClientAPI.AccessTokenLifetime != 0 {
t.Error("access tokens must not expire by default: clients without refresh support need that")
}
if cfg.ClientAPI.TrustXForwardedFor {
t.Error("X-Forwarded-For must not be trusted by default: it is spoofable")
}
}
func TestRateLimitCanBeDisabledFromTheEnvironment(t *testing.T) {
cfg := Default()
if err := ApplyEnv(cfg, func(key string) string {
if key == "WARP_RATE_LIMIT_ENABLED" {
return "false"
}
return ""
}); err != nil {
t.Fatalf("ApplyEnv: %v", err)
}
if cfg.ClientAPI.RateLimit.Enabled {
t.Error("WARP_RATE_LIMIT_ENABLED=false did not take effect")
}
}
func TestValidateRejectsUnusableRateLimits(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.ClientAPI.RateLimit.Login.Burst = 0
cfg.ClientAPI.RateLimit.Register.Interval = 0
cfg.ClientAPI.AccessTokenLifetime = -time.Second
err := cfg.Validate()
if err == nil {
t.Fatal("expected validation to fail")
}
for _, want := range []string{
"client_api.rate_limit.login.burst",
"client_api.rate_limit.register.interval",
"client_api.access_token_lifetime",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %s, got: %v", want, err)
}
}
}
// TestDisabledRateLimitsSkipValidation: a deployment that turned the limiter
// off should not have to keep its buckets valid.
func TestDisabledRateLimitsSkipValidation(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.ClientAPI.RateLimit = RateLimit{Enabled: false}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate with rate limiting off = %v, want nil", err)
}
}
func TestRegistrationTokens(t *testing.T) {
cfg := Default()
if cfg.Registration.RequiresToken() {
t.Error("no tokens configured means no token stage")
}
if cfg.Registration.TokenValid("") || cfg.Registration.TokenValid("anything") {
t.Error("with no tokens configured, nothing may validate")
}
cfg.Registration.Tokens = []string{"first-token", "second-token"}
if !cfg.Registration.RequiresToken() {
t.Error("configured tokens must add the token stage")
}
for _, token := range []string{"first-token", "second-token"} {
if !cfg.Registration.TokenValid(token) {
t.Errorf("TokenValid(%q) = false, want true", token)
}
}
for _, token := range []string{"", "FIRST-TOKEN", "first-toke", "first-tokenn", "nope"} {
if cfg.Registration.TokenValid(token) {
t.Errorf("TokenValid(%q) = true, want false", token)
}
}
}
func TestValidateRejectsEmptyRegistrationToken(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.Registration.Tokens = []string{"fine", " "}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "registration.tokens[1]") {
t.Fatalf("expected an error naming the empty token, got: %v", err)
}
}
func TestRegistrationTokensFromTheEnvironment(t *testing.T) {
cfg := Default()
if err := ApplyEnv(cfg, func(key string) string {
if key == "WARP_REGISTRATION_TOKENS" {
return "one, two"
}
return ""
}); err != nil {
t.Fatalf("ApplyEnv: %v", err)
}
if len(cfg.Registration.Tokens) != 2 || !cfg.Registration.TokenValid("two") {
t.Errorf("tokens = %v", cfg.Registration.Tokens)
}
}
+30
View File
@@ -25,6 +25,13 @@ const (
ErrCodeInvalidParam = "M_INVALID_PARAM"
ErrCodeTooLarge = "M_TOO_LARGE"
ErrCodeUnsupportedVer = "M_UNSUPPORTED_ROOM_VERSION"
// Account and session codes, added with the userapi in Phase 1.
ErrCodeUserInUse = "M_USER_IN_USE"
ErrCodeInvalidUsername = "M_INVALID_USERNAME"
ErrCodeUserDeactivated = "M_USER_DEACTIVATED"
ErrCodeExclusive = "M_EXCLUSIVE"
ErrCodeWeakPassword = "M_WEAK_PASSWORD"
)
// MatrixError is the standard Matrix error envelope. Every non-2xx response
@@ -130,3 +137,26 @@ func LimitExceeded(retryAfterMS int64) *MatrixError {
func InternalError() *MatrixError {
return NewError(http.StatusInternalServerError, ErrCodeUnknown, "Internal server error")
}
// UserInUse is M_USER_IN_USE (400): the requested user name is taken.
func UserInUse() *MatrixError {
return NewError(http.StatusBadRequest, ErrCodeUserInUse, "Desired user ID is already taken")
}
// InvalidUsername is M_INVALID_USERNAME (400): the user name is not usable,
// either because of its characters or its length.
func InvalidUsername(format string, args ...any) *MatrixError {
return NewError(http.StatusBadRequest, ErrCodeInvalidUsername, format, args...)
}
// UserDeactivated is M_USER_DEACTIVATED (403).
func UserDeactivated() *MatrixError {
return NewError(http.StatusForbidden, ErrCodeUserDeactivated, "This account has been deactivated")
}
// Unknown is M_UNKNOWN with an explicit status, for the handful of cases the
// spec answers with that code rather than a specific one — an unsupported
// login type, for instance.
func Unknown(status int, format string, args ...any) *MatrixError {
return NewError(status, ErrCodeUnknown, format, args...)
}
+26 -7
View File
@@ -1,6 +1,7 @@
package httputil
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -67,26 +68,44 @@ func WriteError(w http.ResponseWriter, r *http.Request, err error) {
// DecodeJSON reads a JSON request body into v, enforcing MaxRequestBody and
// mapping a missing, oversized or malformed body onto the right Matrix error.
func DecodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
present, err := DecodeOptionalJSON(w, r, v)
if err != nil {
return err
}
if !present {
return NotJSON()
}
return nil
}
// DecodeOptionalJSON is DecodeJSON for an endpoint whose body may legitimately
// be absent, reporting whether there was one. A malformed body is still an
// error: only a genuinely empty one is "not sent".
//
// DELETE /devices/{deviceId} is the case this exists for. A client sends it
// with no body at all, because it does not know until the 401 comes back that
// the endpoint is interactive.
func DecodeOptionalJSON(w http.ResponseWriter, r *http.Request, v any) (bool, error) {
body := http.MaxBytesReader(w, r.Body, MaxRequestBody)
data, err := io.ReadAll(body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return NewError(http.StatusRequestEntityTooLarge, ErrCodeTooLarge, "Request body too large")
return false, NewError(http.StatusRequestEntityTooLarge, ErrCodeTooLarge, "Request body too large")
}
return NotJSON()
return false, NotJSON()
}
if len(data) == 0 {
return NotJSON()
if len(bytes.TrimSpace(data)) == 0 {
return false, nil
}
if err := json.Unmarshal(data, v); err != nil {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return BadJSON("Invalid value for field %q", typeErr.Field)
return false, BadJSON("Invalid value for field %q", typeErr.Field)
}
return NotJSON()
return false, NotJSON()
}
return nil
return true, nil
}
// Handler is an http.Handler whose errors are turned into Matrix envelopes by
+164
View File
@@ -0,0 +1,164 @@
// Package ratelimit is the per-endpoint rate limiting the Client-Server API
// needs: a token bucket per caller, answered with M_LIMIT_EXCEEDED and a
// retry_after_ms the client is expected to honour.
//
// Each rate-limited route gets its own Limiter, so a client hammering
// /login cannot use up the budget of /profile. Buckets are keyed per caller
// within a limiter; the key is supplied by the caller (the client address
// for unauthenticated endpoints, the user ID once a request is
// authenticated).
package ratelimit
import (
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// Policy is one bucket's shape: Burst requests may arrive back to back, and
// one slot returns every Interval.
type Policy struct {
Burst int
Interval time.Duration
}
// Limiter is a set of token buckets, one per key.
//
// The implementation stores a single timestamp per key — the moment the
// bucket would next be completely full — rather than a count and a refill
// time. That is the whole state a leaky bucket needs: how far that moment is
// in the future says how many slots are in use, and the arithmetic cannot
// drift the way a periodically refilled counter can.
type Limiter struct {
policy Policy
// span is Burst × Interval: the longest the "full again" moment can be
// pushed into the future, which is what makes the bucket finite.
span time.Duration
now func() time.Time
mu sync.Mutex
fullAt map[string]time.Time
sweptAt time.Time
disabled bool
}
// sweepInterval is how often idle buckets are collected. Without it the map
// would keep one entry per address that ever connected.
const sweepInterval = time.Minute
// New builds a limiter. A policy with a non-positive burst or interval
// disables it, so that a limiter is always safe to call even when the
// configuration turned rate limiting off.
func New(policy Policy) *Limiter {
l := &Limiter{policy: policy, now: time.Now, fullAt: map[string]time.Time{}}
if policy.Burst <= 0 || policy.Interval <= 0 {
l.disabled = true
return l
}
l.span = time.Duration(policy.Burst) * policy.Interval
return l
}
// Disabled returns a limiter that allows everything, for a deployment with
// rate limiting switched off.
func Disabled() *Limiter { return &Limiter{disabled: true} }
// Allow records one request against key. It returns how long the caller
// should wait; a zero duration means the request may proceed.
func (l *Limiter) Allow(key string) time.Duration {
if l.disabled {
return 0
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
l.sweep(now)
// An unseen or long-idle bucket starts full, which is the same as
// "would be completely full as of now".
fullAt := l.fullAt[key]
if fullAt.Before(now) {
fullAt = now
}
// Taking a slot pushes the "full again" moment one interval further
// out. It is allowed as long as that keeps it within the bucket's span;
// beyond that, every slot is in use and the overshoot is exactly how
// long the caller has to wait for the next one.
next := fullAt.Add(l.policy.Interval)
if overshoot := next.Sub(now) - l.span; overshoot > 0 {
return overshoot
}
l.fullAt[key] = next
return 0
}
// sweep drops buckets that have been full — and therefore idle — for a
// while. It runs at most once per sweepInterval and under the caller's lock,
// so it costs nothing on a busy server beyond one comparison per request.
func (l *Limiter) sweep(now time.Time) {
if now.Sub(l.sweptAt) < sweepInterval {
return
}
l.sweptAt = now
for key, fullAt := range l.fullAt {
if fullAt.Before(now) {
delete(l.fullAt, key)
}
}
}
// KeyFunc derives the bucket key for a request.
type KeyFunc func(*http.Request) string
// Middleware rejects requests over the limit with M_LIMIT_EXCEEDED.
func (l *Limiter) Middleware(key KeyFunc) httputil.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if retryAfter := l.Allow(key(r)); retryAfter > 0 {
// Rounded up: a client that waits exactly retry_after_ms
// and is told to wait again learns nothing useful.
ms := retryAfter.Milliseconds()
if retryAfter%time.Millisecond != 0 {
ms++
}
httputil.WriteError(w, r, httputil.LimitExceeded(ms))
return
}
next.ServeHTTP(w, r)
})
}
}
// ClientIP returns the address a request came from, as a rate limiting key.
//
// trustForwardedFor takes the left-most X-Forwarded-For entry instead of the
// socket address. It must only be turned on behind a proxy that overwrites
// the header: anyone can send it, and a spoofable key is no key at all.
func ClientIP(trustForwardedFor bool) KeyFunc {
return func(r *http.Request) string {
if trustForwardedFor {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
first, _, _ := strings.Cut(forwarded, ",")
if first = strings.TrimSpace(first); first != "" {
return first
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// RemoteAddr is not host:port for a unix socket or a synthetic
// request; use it whole rather than losing the distinction.
return r.RemoteAddr
}
return host
}
}
+165
View File
@@ -0,0 +1,165 @@
package ratelimit
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// clock is a hand-wound clock, so the tests do not sleep.
type clock struct{ at time.Time }
func (c *clock) now() time.Time { return c.at }
func (c *clock) add(d time.Duration) { c.at = c.at.Add(d) }
func newTestLimiter(policy Policy) (*Limiter, *clock) {
c := &clock{at: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
l := New(policy)
l.now = c.now
return l, c
}
func TestLimiterAllowsBurstThenRefills(t *testing.T) {
l, c := newTestLimiter(Policy{Burst: 3, Interval: time.Second})
for i := range 3 {
if wait := l.Allow("alice"); wait != 0 {
t.Fatalf("request %d of the burst was limited (wait %v)", i+1, wait)
}
}
wait := l.Allow("alice")
if wait <= 0 {
t.Fatal("the fourth request should have been limited")
}
if wait != time.Second {
t.Errorf("retry after = %v, want 1s (one interval)", wait)
}
// Half an interval is not enough for a slot to come back.
c.add(500 * time.Millisecond)
if wait := l.Allow("alice"); wait != 500*time.Millisecond {
t.Errorf("retry after half an interval = %v, want 500ms", wait)
}
c.add(500 * time.Millisecond)
if wait := l.Allow("alice"); wait != 0 {
t.Errorf("a slot should have come back after one interval, got wait %v", wait)
}
}
func TestLimiterKeysAreIndependent(t *testing.T) {
l, _ := newTestLimiter(Policy{Burst: 1, Interval: time.Second})
if wait := l.Allow("alice"); wait != 0 {
t.Fatalf("alice was limited straight away: %v", wait)
}
if wait := l.Allow("alice"); wait == 0 {
t.Fatal("alice should be out of budget")
}
if wait := l.Allow("bob"); wait != 0 {
t.Errorf("bob was limited by alice's traffic: %v", wait)
}
}
func TestLimiterRecoversFullyWhenIdle(t *testing.T) {
l, c := newTestLimiter(Policy{Burst: 2, Interval: time.Second})
l.Allow("alice")
l.Allow("alice")
c.add(time.Hour)
for i := range 2 {
if wait := l.Allow("alice"); wait != 0 {
t.Errorf("after an idle hour, request %d was limited: %v", i+1, wait)
}
}
}
func TestLimiterSweepsIdleBuckets(t *testing.T) {
l, c := newTestLimiter(Policy{Burst: 1, Interval: time.Second})
l.Allow("alice")
c.add(sweepInterval + time.Second)
// This call triggers the sweep, which must drop alice's long-full bucket
// rather than keeping one entry per address that ever connected.
l.Allow("bob")
l.mu.Lock()
defer l.mu.Unlock()
if _, present := l.fullAt["alice"]; present {
t.Error("an idle bucket survived the sweep")
}
if _, present := l.fullAt["bob"]; !present {
t.Error("the active bucket was swept away")
}
}
func TestDisabledLimiterAllowsEverything(t *testing.T) {
for name, l := range map[string]*Limiter{
"explicitly disabled": Disabled(),
"zero burst": New(Policy{Burst: 0, Interval: time.Second}),
"zero interval": New(Policy{Burst: 10, Interval: 0}),
} {
t.Run(name, func(t *testing.T) {
for range 100 {
if wait := l.Allow("alice"); wait != 0 {
t.Fatalf("a disabled limiter limited a request: %v", wait)
}
}
})
}
}
func TestMiddlewareReturnsLimitExceeded(t *testing.T) {
l, _ := newTestLimiter(Policy{Burst: 1, Interval: 1500 * time.Millisecond})
handler := l.Middleware(ClientIP(false))(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
call := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/_matrix/client/v3/login", nil)
req.RemoteAddr = "192.0.2.10:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
if rec := call(); rec.Code != http.StatusOK {
t.Fatalf("first request status = %d, want 200", rec.Code)
}
rec := call()
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want 429", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
body := rec.Body.String()
for _, want := range []string{`"errcode":"M_LIMIT_EXCEEDED"`, `"retry_after_ms":1500`} {
if !strings.Contains(body, want) {
t.Errorf("body %s is missing %s", body, want)
}
}
}
func TestClientIPUsesForwardedForOnlyWhenTrusted(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.10:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.7, 192.0.2.1")
if got := ClientIP(false)(req); got != "192.0.2.10" {
t.Errorf("untrusted X-Forwarded-For was used: key = %q", got)
}
if got := ClientIP(true)(req); got != "203.0.113.7" {
t.Errorf("trusted X-Forwarded-For key = %q, want the left-most entry", got)
}
// A request without the header falls back to the socket either way.
req.Header.Del("X-Forwarded-For")
if got := ClientIP(true)(req); got != "192.0.2.10" {
t.Errorf("key without the header = %q, want the socket address", got)
}
}
+169
View File
@@ -0,0 +1,169 @@
package setup_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/setup"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// post sends a JSON body through the fully wrapped handler.
func post(t *testing.T, srv *setup.Server, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// getWithToken is `get` plus an access token.
func getWithToken(t *testing.T, srv *setup.Server, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// TestClientAPIIsWiredUp walks a client's first minutes through the whole
// stack — middleware, router, metrics, components — rather than through a
// hand-built router, which is what the per-package tests use. It is here to
// catch a route that was written but never registered.
func TestClientAPIIsWiredUp(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.Registration.Enabled = true
// The limiter keys on the client address, and every httptest
// request comes from the same one.
c.ClientAPI.RateLimit.Enabled = false
})
rec := post(t, srv, "/_matrix/client/v3/register",
map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("register status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
}
session := decode[map[string]any](t, rec)["session"]
rec = post(t, srv, "/_matrix/client/v3/register", map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}, "")
if rec.Code != http.StatusOK {
t.Fatalf("register status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
registered := decode[map[string]any](t, rec)
token, _ := registered["access_token"].(string)
if token == "" {
t.Fatalf("registration returned no access token: %s", rec.Body.String())
}
for _, path := range []string{
"/_matrix/client/v3/account/whoami",
"/_matrix/client/v3/capabilities",
"/_matrix/client/v3/devices",
"/_matrix/client/v3/sync",
} {
if rec := getWithToken(t, srv, path, token); rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200 (body: %s)", path, rec.Code, rec.Body.String())
}
}
// Profiles are reachable without a token.
if rec := get(t, srv, http.MethodGet, "/_matrix/client/v3/profile/@alice:warp.example"); rec.Code != http.StatusOK {
t.Errorf("GET /profile = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
rec = post(t, srv, "/_matrix/client/v3/login", map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "sUp3rs3kr1t",
}, "")
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
}
// TestAdminAPIIsWiredUp does the same for /_warp/admin/v1, and checks the
// one thing only the full stack can show: that the admin routes are labelled
// for the metrics like any other.
func TestAdminAPIIsWiredUp(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.Metrics.Enabled = true })
account, err := srv.Users().CreateAccount(t.Context(), userapi.NewAccountRequest{
Localpart: "root",
Password: "sUp3rs3kr1t",
Admin: true,
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
session, err := srv.Users().CreateSession(t.Context(), userapi.SessionRequest{
Localpart: account.Localpart,
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
rec := getWithToken(t, srv, "/_warp/admin/v1/stats", session.AccessToken)
if rec.Code != http.StatusOK {
t.Fatalf("GET /stats = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got := decode[map[string]any](t, rec)["server_name"]; got != "warp.example" {
t.Errorf("server_name = %v", got)
}
// Flipping federation through the admin API must take effect on the
// gate, with no restart: PLAN.md section 3.
if rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server"); rec.Code != http.StatusNotFound {
t.Fatalf("with federation off, /.well-known/matrix/server = %d, want 404", rec.Code)
}
req := httptest.NewRequest(http.MethodPut, "/_warp/admin/v1/federation",
bytes.NewReader([]byte(`{"enabled":true}`)))
req.Header.Set("Authorization", "Bearer "+session.AccessToken)
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT /federation = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if !srv.Settings().FederationEnabled() {
t.Error("the federation switch did not take effect")
}
metrics := get(t, srv, http.MethodGet, "/metrics").Body.String()
if !strings.Contains(metrics, `route="GET /_warp/admin/v1/stats"`) {
t.Error("admin routes are not labelled in the metrics")
}
}
// TestRegistrationIsOffByDefault is the posture a new install starts in:
// private, with no way in until the operator opens one.
func TestRegistrationIsOffByDefault(t *testing.T) {
srv := newTestServer(t, nil)
rec := post(t, srv, "/_matrix/client/v3/register",
map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}, "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 with registration off (body: %s)", rec.Code, rec.Body.String())
}
if code := decode[map[string]string](t, rec)["errcode"]; code != "M_FORBIDDEN" {
t.Errorf("errcode = %q, want M_FORBIDDEN", code)
}
}
+8 -4
View File
@@ -11,9 +11,10 @@ import (
// SpecVersions is what GET /_matrix/client/versions reports.
//
// Keep it honest: a version belongs here once the endpoints a client
// actually uses from it work. Phase 0 serves only /versions and
// /.well-known, so the list stays minimal and grows as the C-S API lands in
// Phases 1 to 4.
// actually uses from it work. Phase 1 adds accounts, sessions, devices,
// profiles and account data; rooms and /sync are still missing, so the list
// is a promise about the identity half of the API only and grows as
// Phases 3 and 4 land.
var SpecVersions = []string{"v1.13", "v1.14"}
// UnstableFeatures advertises MSC support. Simplified Sliding Sync
@@ -46,7 +47,7 @@ type healthResponse struct {
Error string `json:"error,omitempty"`
}
// routes registers every endpoint Phase 0 serves.
// routes registers every endpoint this server serves.
func (s *Server) routes() *httputil.Router {
r := httputil.NewRouter(s.metrics.Instrument)
@@ -57,6 +58,9 @@ func (s *Server) routes() *httputil.Router {
// surface, so it goes through the gate and disappears with it.
r.Handle("GET /.well-known/matrix/server", s.federation.ProtectFunc(s.handleWellKnownServer))
s.clientAPI.RegisterRoutes(r)
s.adminAPI.RegisterRoutes(r)
r.HandleFunc("GET /healthz", s.handleHealth)
if s.cfg.Metrics.Enabled {
r.Handle("GET "+s.cfg.Metrics.Path, s.metrics.Handler())
+52
View File
@@ -13,7 +13,9 @@ import (
"sync/atomic"
"time"
"github.com/dariuszjaniak/warp/server/internal/adminapi"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
@@ -22,6 +24,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Server is a configured, not yet listening homeserver.
@@ -35,6 +38,10 @@ type Server struct {
federation *federation.Gate
signingKey *signing.KeyPair
users *userapi.API
clientAPI *clientapi.ClientAPI
adminAPI *adminapi.AdminAPI
handler http.Handler
// addr is the address Run is listening on; empty until then.
@@ -123,6 +130,47 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
log.Info("federation switch applied", "enabled", snap.FederationEnabled)
})
s.users, err = userapi.New(userapi.Options{
ServerName: cfg.Global.ServerName,
Store: s.db.Users(),
AccessTokenLifetime: cfg.ClientAPI.AccessTokenLifetime,
RefreshTokenLifetime: cfg.ClientAPI.RefreshTokenLifetime,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
s.clientAPI, err = clientapi.New(clientapi.Options{
ServerName: cfg.Global.ServerName,
ClientBaseURL: cfg.ClientBaseURL(),
Users: s.users,
Settings: settingsService,
Federation: s.federation,
Registration: cfg.Registration,
RateLimit: cfg.ClientAPI.RateLimit,
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
s.adminAPI, err = adminapi.New(adminapi.Options{
ServerName: cfg.Global.ServerName,
Users: s.users,
Settings: settingsService,
Federation: s.federation,
Database: s.db,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
s.handler = httputil.Chain(s.routes(),
httputil.Recoverer,
httputil.RequestLogger(log),
@@ -170,6 +218,10 @@ func (s *Server) Settings() *settings.Service { return s.settings }
// Database exposes the storage handle.
func (s *Server) Database() storage.Database { return s.db }
// Users exposes the userapi component, which `warp create-account` and the
// tests use to create accounts without going through HTTP.
func (s *Server) Users() *userapi.API { return s.users }
// SigningKey returns the server signing key, or nil when it was skipped.
func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey }
@@ -51,6 +51,9 @@ func (*Database) PendingMigrations(context.Context) ([]string, error) {
// Settings implements storage.Database.
func (*Database) Settings() storage.SettingsStore { return notImplementedSettings{} }
// Users implements storage.Database.
func (*Database) Users() storage.UserStore { return notImplementedUsers{} }
// Close implements storage.Database.
func (*Database) Close() error { return nil }
@@ -75,5 +78,93 @@ func (notImplementedSettings) Delete(context.Context, string) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interface.
var _ storage.Database = (*Database)(nil)
// notImplementedUsers satisfies storage.UserStore the same way: a
// half-wired PocketBase deployment must not accept a registration it cannot
// store, or hand out an access token it cannot look up again.
type notImplementedUsers struct{}
func (notImplementedUsers) CreateAccount(context.Context, storage.NewAccount) (storage.Account, error) {
return storage.Account{}, storage.ErrNotImplemented
}
func (notImplementedUsers) Account(context.Context, string) (storage.Account, error) {
return storage.Account{}, storage.ErrNotImplemented
}
func (notImplementedUsers) PasswordHash(context.Context, string) (string, error) {
return "", storage.ErrNotImplemented
}
func (notImplementedUsers) SetPasswordHash(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) SetAdmin(context.Context, string, bool) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) SetDisplayName(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) SetAvatarURL(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) ListAccounts(context.Context, storage.AccountFilter) ([]storage.Account, int, error) {
return nil, 0, storage.ErrNotImplemented
}
func (notImplementedUsers) Stats(context.Context) (storage.Stats, error) {
return storage.Stats{}, storage.ErrNotImplemented
}
func (notImplementedUsers) CreateSession(context.Context, storage.NewSession) (storage.Device, error) {
return storage.Device{}, storage.ErrNotImplemented
}
func (notImplementedUsers) RefreshSession(context.Context, storage.RefreshedSession) (storage.TokenInfo, error) {
return storage.TokenInfo{}, storage.ErrNotImplemented
}
func (notImplementedUsers) TokenInfo(context.Context, string) (storage.TokenInfo, error) {
return storage.TokenInfo{}, storage.ErrNotImplemented
}
func (notImplementedUsers) TouchDevice(context.Context, string, string, string, string, int64) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) Device(context.Context, string, string) (storage.Device, error) {
return storage.Device{}, storage.ErrNotImplemented
}
func (notImplementedUsers) Devices(context.Context, string) ([]storage.Device, error) {
return nil, storage.ErrNotImplemented
}
func (notImplementedUsers) SetDeviceDisplayName(context.Context, string, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) DeleteDevices(context.Context, string, []string) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedUsers) DeleteAllDevices(context.Context, string) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedUsers) AccountData(context.Context, string, string, string) ([]byte, error) {
return nil, storage.ErrNotImplemented
}
func (notImplementedUsers) SetAccountData(context.Context, string, string, string, []byte) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interfaces.
var (
_ storage.Database = (*Database)(nil)
_ storage.UserStore = notImplementedUsers{}
)
@@ -0,0 +1,44 @@
package postgres
import (
"errors"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgconn"
)
// uniqueViolation is the SQLSTATE class PostgreSQL raises for a duplicate
// key, whichever constraint caught it.
const uniqueViolation = "23505"
// isUniqueViolation reports whether err is PostgreSQL complaining about a
// duplicate key.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
}
// rebind turns the `?` placeholders the shared component stores are written
// with into PostgreSQL's numbered form. It is a straight scan rather than a
// SQL parser, which is safe because the statements it sees are constants in
// this repository: none of them contains a `?` inside a string literal, and
// none is built from user input beyond a run of placeholders.
func rebind(query string) string {
if !strings.Contains(query, "?") {
return query
}
var b strings.Builder
b.Grow(len(query) + 8)
n := 0
for i := range len(query) {
if query[i] != '?' {
b.WriteByte(query[i])
continue
}
n++
b.WriteByte('$')
b.WriteString(strconv.Itoa(n))
}
return b.String()
}
@@ -0,0 +1,51 @@
package postgres
import "testing"
// TestRebind covers the translation the shared component stores depend on.
// It runs without a database, which matters: it is the one part of the
// PostgreSQL driver that can be tested on a machine without PostgreSQL, and
// a mistake here breaks every statement at once.
func TestRebind(t *testing.T) {
for name, tc := range map[string]struct{ in, want string }{
"no placeholders": {
in: `SELECT COUNT(*) FROM user_accounts`,
want: `SELECT COUNT(*) FROM user_accounts`,
},
"one placeholder": {
in: `SELECT password_hash FROM user_accounts WHERE localpart = ?`,
want: `SELECT password_hash FROM user_accounts WHERE localpart = $1`,
},
"numbered in order": {
in: `INSERT INTO user_accounts (localpart, admin) VALUES (?, ?)`,
want: `INSERT INTO user_accounts (localpart, admin) VALUES ($1, $2)`,
},
"past nine": {
in: `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
want: `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
},
"leaves the escape clause alone": {
in: `WHERE localpart LIKE ? ESCAPE '!'`,
want: `WHERE localpart LIKE $1 ESCAPE '!'`,
},
} {
t.Run(name, func(t *testing.T) {
if got := rebind(tc.in); got != tc.want {
t.Errorf("rebind(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestIsUniqueViolationIgnoresOtherErrors(t *testing.T) {
if isUniqueViolation(nil) {
t.Error("nil is not a unique violation")
}
if isUniqueViolation(errOther{}) {
t.Error("an unrelated error was reported as a unique violation")
}
}
type errOther struct{}
func (errOther) Error() string { return "something else went wrong" }
@@ -0,0 +1,69 @@
-- Accounts, devices, tokens and account data: the userapi tables.
--
-- Timestamps are milliseconds since the Unix epoch, the unit the
-- Client-Server API uses on the wire, so values go in and out unconverted
-- and both SQL drivers store the same numbers.
--
-- Tokens are stored as SHA-256 hashes, never in the clear: they are bearer
-- credentials that by default never expire, and one hash per request is
-- cheap next to what a leaked database dump would otherwise hand over.
CREATE TABLE IF NOT EXISTS user_accounts (
localpart TEXT NOT NULL PRIMARY KEY,
-- An empty hash means the account has no password and cannot use
-- m.login.password; it is not a hash that anything can match.
password_hash TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
admin BOOLEAN NOT NULL DEFAULT FALSE,
deactivated BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_devices (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
device_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
last_seen_ms BIGINT NOT NULL DEFAULT 0,
last_seen_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
PRIMARY KEY (localpart, device_id)
);
CREATE TABLE IF NOT EXISTS user_access_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
-- 0 means the token never expires.
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
-- Revoking a device deletes its tokens by this index, not by table scan.
CREATE INDEX IF NOT EXISTS user_access_tokens_device_idx
ON user_access_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_refresh_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_refresh_tokens_device_idx
ON user_refresh_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_account_data (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
-- Empty for global account data; a room ID for per-room account data.
room_id TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL,
content TEXT NOT NULL,
PRIMARY KEY (localpart, room_id, data_type)
);
+9 -7
View File
@@ -69,13 +69,15 @@ func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Da
}
store, err := sqlstore.New(sqlstore.Options{
DriverName: storage.DriverPostgres,
DB: db,
Dialect: dialect,
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Logger: log,
DriverName: storage.DriverPostgres,
DB: db,
Dialect: dialect,
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Rebind: rebind,
IsUniqueViolation: isUniqueViolation,
Logger: log,
})
if err != nil {
_ = db.Close()
+31
View File
@@ -0,0 +1,31 @@
package sqlite
import (
"errors"
sqlite3 "modernc.org/sqlite"
)
// SQLite extended result codes for the two constraint failures that mean
// "that row is already there". The driver reports them numerically and does
// not re-export the constants, so they are named here rather than left as
// magic numbers at the call site.
const (
constraintUnique = 2067 // SQLITE_CONSTRAINT_UNIQUE
constraintPrimaryKey = 1555 // SQLITE_CONSTRAINT_PRIMARYKEY
)
// isUniqueViolation reports whether err is SQLite complaining about a
// duplicate key.
func isUniqueViolation(err error) bool {
var sqlErr *sqlite3.Error
if !errors.As(err, &sqlErr) {
return false
}
switch sqlErr.Code() {
case constraintUnique, constraintPrimaryKey:
return true
default:
return false
}
}
@@ -0,0 +1,60 @@
package sqlite
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
)
// TestIsUniqueViolation checks the error recognition against a real SQLite,
// because it turns on an extended result code the driver does not export as
// a constant: a version bump that changed it would otherwise turn "that name
// is taken" into a 500.
func TestIsUniqueViolation(t *testing.T) {
path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err := db.ExecContext(t.Context(),
`CREATE TABLE t (pk TEXT PRIMARY KEY, uniq TEXT UNIQUE)`); err != nil {
t.Fatal(err)
}
insert := func(pk, uniq string) error {
_, err := db.ExecContext(t.Context(), `INSERT INTO t (pk, uniq) VALUES (?, ?)`, pk, uniq)
return err
}
if err := insert("a", "one"); err != nil {
t.Fatal(err)
}
for name, err := range map[string]error{
"duplicate primary key": insert("a", "two"),
"duplicate unique key": insert("b", "one"),
} {
t.Run(name, func(t *testing.T) {
if err == nil {
t.Fatal("the insert should have failed")
}
if !isUniqueViolation(err) {
t.Errorf("isUniqueViolation(%v) = false, want true", err)
}
})
}
// Something that is an error but not a constraint failure.
_, err = db.ExecContext(t.Context(), `INSERT INTO nonexistent (x) VALUES (1)`)
if err == nil {
t.Fatal("inserting into a missing table should have failed")
}
if isUniqueViolation(err) {
t.Errorf("isUniqueViolation(%v) = true, want false", err)
}
if isUniqueViolation(nil) || isUniqueViolation(fmt.Errorf("unrelated")) {
t.Error("isUniqueViolation accepted something that is not a SQLite error")
}
}
@@ -0,0 +1,69 @@
-- Accounts, devices, tokens and account data: the userapi tables.
--
-- Timestamps are milliseconds since the Unix epoch, the unit the
-- Client-Server API uses on the wire, so values go in and out unconverted
-- and both SQL drivers store the same numbers.
--
-- Tokens are stored as SHA-256 hashes, never in the clear: they are bearer
-- credentials that by default never expire, and one hash per request is
-- cheap next to what a leaked database dump would otherwise hand over.
CREATE TABLE IF NOT EXISTS user_accounts (
localpart TEXT NOT NULL PRIMARY KEY,
-- An empty hash means the account has no password and cannot use
-- m.login.password; it is not a hash that anything can match.
password_hash TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
admin BOOLEAN NOT NULL DEFAULT 0,
deactivated BOOLEAN NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_devices (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
device_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
last_seen_ms BIGINT NOT NULL DEFAULT 0,
last_seen_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
PRIMARY KEY (localpart, device_id)
);
CREATE TABLE IF NOT EXISTS user_access_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
-- 0 means the token never expires.
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
-- Revoking a device deletes its tokens by this index, not by table scan.
CREATE INDEX IF NOT EXISTS user_access_tokens_device_idx
ON user_access_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_refresh_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_refresh_tokens_device_idx
ON user_refresh_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_account_data (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
-- Empty for global account data; a room ID for per-room account data.
room_id TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL,
content TEXT NOT NULL,
PRIMARY KEY (localpart, room_id, data_type)
);
+4 -1
View File
@@ -93,7 +93,10 @@ func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Da
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Logger: log,
// SQLite already uses the `?` placeholders the shared component
// stores are written with, so there is nothing to rebind.
IsUniqueViolation: isUniqueViolation,
Logger: log,
})
if err != nil {
_ = db.Close()
@@ -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()
+644
View File
@@ -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)
+3
View File
@@ -61,6 +61,9 @@ type Database interface {
// settings service (federation and registration switches).
Settings() SettingsStore
// Users is the userapi store: accounts, devices, tokens, account data.
Users() UserStore
// Close releases the connection pool.
Close() error
}
+226
View File
@@ -0,0 +1,226 @@
package storage
import (
"context"
"errors"
)
// Errors the user store returns for the cases a caller has to tell apart.
// Everything else comes back wrapped and becomes a 500.
var (
// ErrUserExists is returned by CreateAccount for a taken localpart.
ErrUserExists = errors.New("user already exists")
// ErrNoSuchUser is returned for an unknown localpart.
ErrNoSuchUser = errors.New("no such user")
// ErrNoSuchDevice is returned for an unknown device of a known user.
ErrNoSuchDevice = errors.New("no such device")
// ErrNoSuchToken is returned for an access or refresh token that is
// unknown or has already been rotated away.
ErrNoSuchToken = errors.New("no such token")
// ErrNoSuchAccountData is returned for an account data type the user
// has never set.
ErrNoSuchAccountData = errors.New("no such account data")
)
// Account is a local user.
//
// Only the localpart is stored: the server name is fixed for the lifetime of
// a deployment — it is baked into every ID — so repeating it on every row
// would buy nothing. Virtual hosting, if it ever happens, is a migration.
//
// Timestamps throughout the user store are milliseconds since the Unix
// epoch, the unit the Client-Server API uses on the wire. Nothing needs
// converting on the way in or out, and both SQL drivers hold the same value.
type Account struct {
Localpart string
DisplayName string
AvatarURL string
Admin bool
Deactivated bool
CreatedAtMS int64
}
// NewAccount describes an account to create.
type NewAccount struct {
Localpart string
// PasswordHash is an encoded argon2id hash, or empty for an account
// that cannot log in with a password.
PasswordHash string
DisplayName string
Admin bool
CreatedAtMS int64
}
// Device is one login session's device record.
type Device struct {
Localpart string
ID string
DisplayName string
CreatedAtMS int64
LastSeenMS int64
LastSeenIP string
UserAgent string
}
// NewSession is a device plus the tokens issued for it: one login or one
// registration. The store writes all of it in a single transaction, so a
// client never sees a device without a working token, or the reverse.
//
// Tokens are stored as hashes. They are bearer credentials that by default
// never expire; hashing costs one SHA-256 per request and keeps a leaked
// database dump from handing over live sessions.
type NewSession struct {
Localpart string
DeviceID string
DisplayName string
CreatedAtMS int64
AccessTokenHash string
// AccessTokenExpiresAtMS is 0 for a token that never expires.
AccessTokenExpiresAtMS int64
// RefreshTokenHash is empty when the client did not ask for one.
RefreshTokenHash string
RefreshTokenExpiresAtMS int64
LastSeenIP string
UserAgent string
}
// RefreshedSession rotates the token pair of an existing device.
type RefreshedSession struct {
// RefreshTokenHash identifies the token being spent.
RefreshTokenHash string
NewAccessTokenHash string
AccessTokenExpiresAtMS int64
NewRefreshTokenHash string
RefreshTokenExpiresAtMS int64
RefreshedAtMS int64
}
// TokenInfo is everything the auth middleware needs from an access token, in
// one query: who owns it, on which device, and whether the account may still
// be used.
type TokenInfo struct {
Localpart string
DeviceID string
Admin bool
Deactivated bool
// ExpiresAtMS is 0 for a token that never expires.
ExpiresAtMS int64
}
// AccountFilter narrows and pages the admin user list.
type AccountFilter struct {
// Search matches a substring of the localpart or the display name,
// case-insensitively. Empty matches everything.
Search string
// Limit caps the returned rows; 0 means the store's own default.
Limit int
// Offset skips rows, for paging.
Offset int
}
// Stats are the counts the admin dashboard shows.
type Stats struct {
Users int
Admins int
Deactivated int
Devices int
}
// UserStore persists accounts, devices, tokens and account data: the
// storage half of the userapi component.
//
// Every method takes a localpart the caller has already normalised
// (lower-cased and validated); the store does no interpretation of its own.
type UserStore interface {
// CreateAccount inserts an account, returning ErrUserExists when the
// localpart is taken.
CreateAccount(ctx context.Context, acc NewAccount) (Account, error)
// Account reads one account, returning ErrNoSuchUser if it is absent.
Account(ctx context.Context, localpart string) (Account, error)
// PasswordHash returns the stored argon2id hash, which is empty for an
// account without a password.
PasswordHash(ctx context.Context, localpart string) (string, error)
// SetPasswordHash replaces the password hash.
SetPasswordHash(ctx context.Context, localpart, hash string) error
// SetAdmin grants or revokes the admin flag.
SetAdmin(ctx context.Context, localpart string, admin bool) error
// SetDisplayName updates the profile display name.
SetDisplayName(ctx context.Context, localpart, displayName string) error
// SetAvatarURL updates the profile avatar.
SetAvatarURL(ctx context.Context, localpart, avatarURL string) error
// ListAccounts returns a page of accounts ordered by localpart, plus
// the total number of accounts matching the filter.
ListAccounts(ctx context.Context, filter AccountFilter) ([]Account, int, error)
// Stats returns the aggregate counts for the admin dashboard.
Stats(ctx context.Context) (Stats, error)
// CreateSession creates or replaces a device and issues its tokens.
// Re-using an existing device ID revokes the tokens that device had,
// which is what the spec requires of a login naming a device already
// in use.
CreateSession(ctx context.Context, s NewSession) (Device, error)
// RefreshSession spends a refresh token and issues a new pair,
// returning the owner of the device. It returns ErrNoSuchToken if the
// token is unknown or has already been spent.
RefreshSession(ctx context.Context, r RefreshedSession) (TokenInfo, error)
// TokenInfo resolves an access token hash to its owner, returning
// ErrNoSuchToken when there is no such token. An expired token is
// returned with its expiry rather than rejected here, so the caller can
// answer with the soft logout the spec asks for.
TokenInfo(ctx context.Context, tokenHash string) (TokenInfo, error)
// TouchDevice records the last-seen timestamp, address and user agent
// of a device. It runs on authenticated requests, so it has to stay a
// single cheap statement.
TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error
// Device reads one device, returning ErrNoSuchDevice if it is absent.
Device(ctx context.Context, localpart, deviceID string) (Device, error)
// Devices lists a user's devices, ordered by device ID.
Devices(ctx context.Context, localpart string) ([]Device, error)
// SetDeviceDisplayName renames a device, returning ErrNoSuchDevice if
// it is absent.
SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error
// DeleteDevices removes devices and every token issued to them,
// returning how many existed. Unknown device IDs are ignored.
DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error)
// DeleteAllDevices removes every device of a user and their tokens.
DeleteAllDevices(ctx context.Context, localpart string) (int, error)
// AccountData reads one account data blob; roomID is empty for global
// account data. It returns ErrNoSuchAccountData when unset.
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
// SetAccountData writes one account data blob, overwriting any previous
// value.
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
}
+494
View File
@@ -0,0 +1,494 @@
package storage_test
import (
"database/sql"
"errors"
"os"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// migratedTestDB opens a driver, brings its schema up to date and empties
// the userapi tables, which is what every user-store test needs before it
// can touch one.
func migratedTestDB(t *testing.T, driver string) storage.Database {
t.Helper()
db := openTestDB(t, driver)
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
resetUserTables(t, driver)
return db
}
// resetUserTables empties the userapi tables so that each test starts from a
// known state.
//
// SQLite gets a fresh file per test and needs nothing. The PostgreSQL tests
// all share the one database the environment points at, so without this the
// second test to create "alice" would collide with the first — and would do
// so only in CI, where PostgreSQL is available.
func resetUserTables(t *testing.T, driver string) {
t.Helper()
if driver != storage.DriverPostgres {
return
}
// The storage interface has no "delete an account", on purpose:
// deactivation is Phase 9 and deletion may never exist. Clearing the
// tables is a test concern, so the test does it directly.
db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
if err != nil {
t.Fatalf("open postgres to reset the user tables: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.ExecContext(t.Context(), `TRUNCATE
user_account_data, user_refresh_tokens, user_access_tokens,
user_devices, user_accounts`); err != nil {
t.Fatalf("reset the user tables: %v", err)
}
}
// newAccount is the minimum CreateAccount needs.
func newAccount(localpart string) storage.NewAccount {
return storage.NewAccount{
Localpart: localpart,
PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA",
CreatedAtMS: 1_700_000_000_000,
}
}
func TestUserAccounts(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.Account(ctx, "nobody"); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("Account of an unknown user = %v, want ErrNoSuchUser", err)
}
acc, err := users.CreateAccount(ctx, newAccount("alice"))
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" || acc.Admin || acc.Deactivated {
t.Errorf("CreateAccount returned %+v", acc)
}
// A taken localpart must be reported as such, not as a generic
// database failure: registration turns it into M_USER_IN_USE.
if _, err := users.CreateAccount(ctx, newAccount("alice")); !errors.Is(err, storage.ErrUserExists) {
t.Errorf("duplicate CreateAccount = %v, want ErrUserExists", err)
}
stored, err := users.Account(ctx, "alice")
if err != nil {
t.Fatalf("Account: %v", err)
}
if stored.CreatedAtMS != 1_700_000_000_000 {
t.Errorf("created_at_ms = %d, want 1700000000000", stored.CreatedAtMS)
}
hash, err := users.PasswordHash(ctx, "alice")
if err != nil || hash == "" {
t.Fatalf("PasswordHash = %q, %v", hash, err)
}
if err := users.SetPasswordHash(ctx, "alice", "newhash"); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if hash, _ = users.PasswordHash(ctx, "alice"); hash != "newhash" {
t.Errorf("PasswordHash after update = %q", hash)
}
if err := users.SetAdmin(ctx, "alice", true); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if err := users.SetDisplayName(ctx, "alice", "Alice"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
if err := users.SetAvatarURL(ctx, "alice", "mxc://warp.example/abc"); err != nil {
t.Fatalf("SetAvatarURL: %v", err)
}
stored, err = users.Account(ctx, "alice")
if err != nil {
t.Fatalf("Account: %v", err)
}
if !stored.Admin {
t.Error("the admin flag did not survive the round trip")
}
if stored.DisplayName != "Alice" || stored.AvatarURL != "mxc://warp.example/abc" {
t.Errorf("profile = %+v", stored)
}
// Updating an absent account is ErrNoSuchUser, so that callers
// do not have to look it up first.
if err := users.SetDisplayName(ctx, "nobody", "x"); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("SetDisplayName on an unknown user = %v, want ErrNoSuchUser", err)
}
})
}
}
func TestUserAccountListingAndStats(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
for _, localpart := range []string{"amy", "bob", "bobby", "zoe_x"} {
if _, err := users.CreateAccount(ctx, newAccount(localpart)); err != nil {
t.Fatalf("CreateAccount(%s): %v", localpart, err)
}
}
if err := users.SetAdmin(ctx, "amy", true); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if err := users.SetDisplayName(ctx, "zoe_x", "Zoe"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
all, total, err := users.ListAccounts(ctx, storage.AccountFilter{})
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if total != 4 || len(all) != 4 {
t.Fatalf("ListAccounts returned %d of %d, want 4 of 4", len(all), total)
}
if all[0].Localpart != "amy" || all[3].Localpart != "zoe_x" {
t.Errorf("accounts are not ordered by localpart: %v", all)
}
page, total, err := users.ListAccounts(ctx, storage.AccountFilter{Limit: 2, Offset: 1})
if err != nil {
t.Fatalf("ListAccounts paged: %v", err)
}
if total != 4 || len(page) != 2 || page[0].Localpart != "bob" {
t.Errorf("paged listing = %v (total %d)", page, total)
}
found, total, err := users.ListAccounts(ctx, storage.AccountFilter{Search: "BOB"})
if err != nil {
t.Fatalf("ListAccounts search: %v", err)
}
if total != 2 || len(found) != 2 {
t.Errorf("case-insensitive search for bob found %d, want 2", total)
}
// A display name is searchable too.
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "zoe"}); err != nil {
t.Fatalf("ListAccounts: %v", err)
} else if total != 1 {
t.Errorf("searching display names found %d, want 1", total)
}
// "_" is a LIKE wildcard; escaping it is what keeps this from
// matching every three-letter localpart.
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "e_x"}); err != nil {
t.Fatalf("ListAccounts: %v", err)
} else if total != 1 {
t.Errorf("searching for a literal underscore found %d, want 1", total)
}
stats, err := users.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Users != 4 || stats.Admins != 1 || stats.Deactivated != 0 || stats.Devices != 0 {
t.Errorf("Stats = %+v", stats)
}
})
}
}
func TestUserSessions(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
session := storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
DisplayName: "first login",
CreatedAtMS: 1000,
AccessTokenHash: "hash-a",
LastSeenIP: "10.0.0.1",
UserAgent: "warp-test",
}
device, err := users.CreateSession(ctx, session)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if device.ID != "DEV1" || device.DisplayName != "first login" {
t.Errorf("CreateSession returned %+v", device)
}
// A session for an unknown account must fail loudly rather than
// leave a device with no owner.
orphan := session
orphan.Localpart, orphan.AccessTokenHash = "nobody", "hash-orphan"
if _, err := users.CreateSession(ctx, orphan); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("CreateSession for an unknown user = %v, want ErrNoSuchUser", err)
}
info, err := users.TokenInfo(ctx, "hash-a")
if err != nil {
t.Fatalf("TokenInfo: %v", err)
}
if info.Localpart != "alice" || info.DeviceID != "DEV1" || info.ExpiresAtMS != 0 {
t.Errorf("TokenInfo = %+v", info)
}
if _, err := users.TokenInfo(ctx, "nope"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("TokenInfo of an unknown token = %v, want ErrNoSuchToken", err)
}
// Logging in again on the same device keeps the device and its
// display name, and revokes the token it had.
again := session
again.AccessTokenHash = "hash-b"
again.DisplayName = "ignored on an existing device"
again.CreatedAtMS = 2000
device, err = users.CreateSession(ctx, again)
if err != nil {
t.Fatalf("CreateSession (same device): %v", err)
}
if device.DisplayName != "first login" {
t.Errorf("display name = %q; initial_device_display_name applies to new devices only", device.DisplayName)
}
if device.CreatedAtMS != 1000 {
t.Errorf("created_at_ms = %d; re-login must not reset it", device.CreatedAtMS)
}
if _, err := users.TokenInfo(ctx, "hash-a"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("the previous access token of a re-used device was not revoked")
}
if _, err := users.TokenInfo(ctx, "hash-b"); err != nil {
t.Errorf("the new access token does not work: %v", err)
}
stored, err := users.Device(ctx, "alice", "DEV1")
if err != nil {
t.Fatalf("Device: %v", err)
}
if stored.LastSeenMS != 2000 || stored.LastSeenIP != "10.0.0.1" {
t.Errorf("device = %+v", stored)
}
if _, err := users.Device(ctx, "alice", "GONE"); !errors.Is(err, storage.ErrNoSuchDevice) {
t.Errorf("Device of an unknown device = %v, want ErrNoSuchDevice", err)
}
if err := users.TouchDevice(ctx, "alice", "DEV1", "10.0.0.2", "warp-test/2", 3000); err != nil {
t.Fatalf("TouchDevice: %v", err)
}
if stored, err = users.Device(ctx, "alice", "DEV1"); err != nil {
t.Fatal(err)
} else if stored.LastSeenMS != 3000 || stored.LastSeenIP != "10.0.0.2" {
t.Errorf("TouchDevice did not record the visit: %+v", stored)
}
if err := users.SetDeviceDisplayName(ctx, "alice", "DEV1", "renamed"); err != nil {
t.Fatalf("SetDeviceDisplayName: %v", err)
}
if err := users.SetDeviceDisplayName(ctx, "alice", "GONE", "x"); !errors.Is(err, storage.ErrNoSuchDevice) {
t.Errorf("renaming an unknown device = %v, want ErrNoSuchDevice", err)
}
second := storage.NewSession{
Localpart: "alice",
DeviceID: "DEV2",
CreatedAtMS: 4000,
AccessTokenHash: "hash-c",
}
if _, err := users.CreateSession(ctx, second); err != nil {
t.Fatalf("CreateSession (second device): %v", err)
}
devices, err := users.Devices(ctx, "alice")
if err != nil {
t.Fatalf("Devices: %v", err)
}
if len(devices) != 2 || devices[0].ID != "DEV1" || devices[1].ID != "DEV2" {
t.Errorf("Devices = %v", devices)
}
deleted, err := users.DeleteDevices(ctx, "alice", []string{"DEV1", "NEVER-EXISTED"})
if err != nil {
t.Fatalf("DeleteDevices: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteDevices reported %d, want 1", deleted)
}
if _, err := users.TokenInfo(ctx, "hash-b"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("deleting a device must revoke its access tokens")
}
deleted, err = users.DeleteAllDevices(ctx, "alice")
if err != nil {
t.Fatalf("DeleteAllDevices: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteAllDevices reported %d, want 1", deleted)
}
if _, err := users.TokenInfo(ctx, "hash-c"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("logout/all must revoke every token")
}
if devices, err = users.Devices(ctx, "alice"); err != nil {
t.Fatal(err)
} else if len(devices) != 0 {
t.Errorf("devices survived DeleteAllDevices: %v", devices)
}
})
}
}
func TestUserRefreshTokens(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.CreateSession(ctx, storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
CreatedAtMS: 1000,
AccessTokenHash: "access-1",
AccessTokenExpiresAtMS: 5000,
RefreshTokenHash: "refresh-1",
RefreshTokenExpiresAtMS: 0,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
info, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-2",
AccessTokenExpiresAtMS: 9000,
NewRefreshTokenHash: "refresh-2",
RefreshedAtMS: 4000,
})
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if info.Localpart != "alice" || info.DeviceID != "DEV1" {
t.Errorf("RefreshSession returned %+v", info)
}
// Both halves of the old pair are gone: a replayed refresh token
// is the signal that one of the two copies is a thief.
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-3",
NewRefreshTokenHash: "refresh-3",
RefreshedAtMS: 4100,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("replaying a spent refresh token = %v, want ErrNoSuchToken", err)
}
if _, err := users.TokenInfo(ctx, "access-1"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("refreshing must invalidate the previous access token")
}
if info, err = users.TokenInfo(ctx, "access-2"); err != nil {
t.Fatalf("the refreshed access token does not work: %v", err)
} else if info.ExpiresAtMS != 9000 {
t.Errorf("expires_at_ms = %d, want 9000", info.ExpiresAtMS)
}
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "nonexistent",
NewAccessTokenHash: "access-4",
RefreshedAtMS: 4200,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("unknown refresh token = %v, want ErrNoSuchToken", err)
}
})
}
}
func TestUserRefreshTokenExpiry(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.CreateSession(ctx, storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
CreatedAtMS: 1000,
AccessTokenHash: "access-1",
RefreshTokenHash: "refresh-1",
RefreshTokenExpiresAtMS: 2000,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-2",
RefreshedAtMS: 2001,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("expired refresh token = %v, want ErrNoSuchToken", err)
}
})
}
}
func TestUserAccountData(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.AccountData(ctx, "alice", "", "m.push_rules"); !errors.Is(err, storage.ErrNoSuchAccountData) {
t.Errorf("unset account data = %v, want ErrNoSuchAccountData", err)
}
global := []byte(`{"theme":"dark"}`)
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", global); err != nil {
t.Fatalf("SetAccountData: %v", err)
}
got, err := users.AccountData(ctx, "alice", "", "im.warp.prefs")
if err != nil {
t.Fatalf("AccountData: %v", err)
}
if string(got) != string(global) {
t.Errorf("account data came back as %s, want %s", got, global)
}
// Overwriting replaces rather than conflicting.
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", []byte(`{"theme":"light"}`)); err != nil {
t.Fatalf("SetAccountData (overwrite): %v", err)
}
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"theme":"light"}` {
t.Errorf("overwritten account data = %s", got)
}
// Room account data is a separate key space from global data of
// the same type.
if err := users.SetAccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs", []byte(`{"muted":true}`)); err != nil {
t.Fatalf("SetAccountData (room): %v", err)
}
if got, err = users.AccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"muted":true}` {
t.Errorf("room account data = %s", got)
}
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"theme":"light"}` {
t.Errorf("room account data overwrote the global value: %s", got)
}
})
}
}
+137
View File
@@ -0,0 +1,137 @@
package userapi
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. These are OWASP's recommended settings for argon2id
// (19 MiB, two passes, one lane): strong enough to make a stolen hash
// expensive to attack, cheap enough that a login does not become a denial of
// service against the server itself.
//
// The values are recorded in every hash, so raising them later is a matter
// of changing these constants; old hashes keep verifying with the parameters
// they were made with, and are rewritten the next time the password changes.
const (
argonMemoryKiB = 19 * 1024
argonTime = 2
argonLanes = 1
argonSaltLen = 16
argonKeyLen = 32
)
// MaxPasswordLength caps a password at 512 bytes. Argon2id does not care how
// long its input is, but accepting unbounded input means accepting unbounded
// work in the hash function.
const MaxPasswordLength = 512
// ErrPasswordTooLong is returned when a password exceeds MaxPasswordLength.
var ErrPasswordTooLong = fmt.Errorf("password must be at most %d bytes", MaxPasswordLength)
// ErrInvalidPasswordHash is returned when a stored hash cannot be parsed. It
// means the row is corrupt, not that the password was wrong.
var ErrInvalidPasswordHash = errors.New("stored password hash is malformed")
// HashPassword returns an encoded argon2id hash in the PHC string format:
//
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
//
// The format is the de facto standard for password hashes, so the parameters
// travel with the hash and an operator can tell at a glance what a row was
// written with.
func HashPassword(password string) (string, error) {
if len(password) > MaxPasswordLength {
return "", ErrPasswordTooLong
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemoryKiB, argonLanes, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoryKiB, argonTime, argonLanes,
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
// VerifyPassword reports whether password matches encoded. An unparseable
// hash is an error rather than a mismatch, so a corrupt row shows up in the
// logs instead of looking like a wrong password forever.
func VerifyPassword(encoded, password string) (bool, error) {
if len(password) > MaxPasswordLength {
return false, ErrPasswordTooLong
}
params, salt, want, err := decodeHash(encoded)
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.lanes, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// b64 is the unpadded base64 the PHC format uses.
var b64 = base64.RawStdEncoding
type argonParams struct {
memory uint32
time uint32
lanes uint8
}
// decodeHash parses a PHC argon2id string.
func decodeHash(encoded string) (argonParams, []byte, []byte, error) {
// "", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != "" {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if parts[1] != "argon2id" {
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported algorithm %q", ErrInvalidPasswordHash, parts[1])
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if version != argon2.Version {
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported argon2 version %d", ErrInvalidPasswordHash, version)
}
var p argonParams
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.lanes); err != nil {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if p.memory == 0 || p.time == 0 || p.lanes == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
salt, err := b64.DecodeString(parts[4])
if err != nil || len(salt) == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
key, err := b64.DecodeString(parts[5])
if err != nil || len(key) == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
return p, salt, key, nil
}
// dummyPasswordHash is verified against when the named account does not
// exist or has no password, so that a login attempt costs the same either
// way. Without it, the time a rejection takes would tell an attacker which
// user names are real.
var dummyPasswordHash = func() string {
hash, err := HashPassword("warp: this password hashes nothing")
if err != nil {
// HashPassword only fails on a short read from crypto/rand, which
// at init time means the process cannot work at all.
panic("userapi: cannot build the dummy password hash: " + err.Error())
}
return hash
}()
+160
View File
@@ -0,0 +1,160 @@
package userapi
import (
"errors"
"strings"
"testing"
"golang.org/x/crypto/argon2"
)
func TestHashAndVerifyPassword(t *testing.T) {
const password = "correct horse battery staple"
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$v=") {
t.Fatalf("hash %q is not in the PHC format", hash)
}
ok, err := VerifyPassword(hash, password)
if err != nil || !ok {
t.Fatalf("VerifyPassword of the right password = %v, %v", ok, err)
}
ok, err = VerifyPassword(hash, password+"!")
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Error("the wrong password verified")
}
}
// TestHashIsSalted is what makes a stolen table worth attacking one row at a
// time rather than all at once.
func TestHashIsSalted(t *testing.T) {
first, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
second, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Error("two hashes of the same password are identical, so the salt is not random")
}
}
// TestHashRecordsItsParameters is what lets the cost be raised later without
// invalidating every existing password.
func TestHashRecordsItsParameters(t *testing.T) {
hash, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
params, salt, key, err := decodeHash(hash)
if err != nil {
t.Fatalf("decodeHash of our own output: %v", err)
}
if params.memory != argonMemoryKiB || params.time != argonTime || params.lanes != argonLanes {
t.Errorf("recorded parameters = %+v, want the current constants", params)
}
if len(salt) != argonSaltLen || len(key) != argonKeyLen {
t.Errorf("salt is %d bytes and key %d bytes", len(salt), len(key))
}
}
// TestVerifyAgainstAnOlderCost proves the point of recording them: a hash
// made with weaker parameters still verifies.
func TestVerifyAgainstAnOlderCost(t *testing.T) {
const password = "from an older release"
// A hash as a previous, cheaper, configuration would have written it.
salt := []byte("0123456789abcdef")
key := argon2.IDKey([]byte(password), salt, 1, 8*1024, 1, 32)
hash := "$argon2id$v=19$m=8192,t=1,p=1$" + b64.EncodeToString(salt) + "$" + b64.EncodeToString(key)
ok, err := VerifyPassword(hash, password)
if err != nil || !ok {
t.Fatalf("VerifyPassword against older parameters = %v, %v", ok, err)
}
}
// TestVerifyRejectsMalformedHashes keeps a corrupt row from looking like a
// wrong password for ever.
func TestVerifyRejectsMalformedHashes(t *testing.T) {
good, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(good, "$")
for name, hash := range map[string]string{
"empty": "",
"not a hash": "hunter2",
"too few fields": "$argon2id$v=19$m=19456,t=2,p=1$" + parts[4],
"wrong algorithm": "$argon2i$v=19$" + strings.Join(parts[3:], "$"),
"unknown version": "$argon2id$v=16$" + strings.Join(parts[3:], "$"),
"unparseable params": "$argon2id$v=19$m=lots,t=2,p=1$" + parts[4] + "$" + parts[5],
"zero memory": "$argon2id$v=19$m=0,t=2,p=1$" + parts[4] + "$" + parts[5],
"bad base64 salt": "$argon2id$v=19$m=19456,t=2,p=1$not!base64$" + parts[5],
"empty key": "$argon2id$v=19$m=19456,t=2,p=1$" + parts[4] + "$",
} {
t.Run(name, func(t *testing.T) {
ok, err := VerifyPassword(hash, "x")
if ok {
t.Fatal("a malformed hash verified")
}
if !errors.Is(err, ErrInvalidPasswordHash) {
t.Errorf("error = %v, want ErrInvalidPasswordHash", err)
}
})
}
}
func TestPasswordLengthIsBounded(t *testing.T) {
long := strings.Repeat("x", MaxPasswordLength+1)
if _, err := HashPassword(long); !errors.Is(err, ErrPasswordTooLong) {
t.Errorf("HashPassword of an overlong password = %v, want ErrPasswordTooLong", err)
}
hash, err := HashPassword(strings.Repeat("x", MaxPasswordLength))
if err != nil {
t.Fatalf("a password at the limit must be accepted: %v", err)
}
if _, err := VerifyPassword(hash, long); !errors.Is(err, ErrPasswordTooLong) {
t.Errorf("VerifyPassword of an overlong password = %v, want ErrPasswordTooLong", err)
}
}
// TestNonASCIIPasswords: the hash takes bytes, so there is nothing to
// normalise, but a client sending one must not be refused.
func TestNonASCIIPasswords(t *testing.T) {
const password = "übers3kr1t 🔐"
hash, err := HashPassword(password)
if err != nil {
t.Fatal(err)
}
if ok, err := VerifyPassword(hash, password); err != nil || !ok {
t.Errorf("VerifyPassword = %v, %v", ok, err)
}
}
// TestDummyPasswordHashIsUsable: the dummy hash exists so that a login for
// an account that does not exist costs the same as one that does. If it ever
// stopped being a valid hash, that work would vanish and the timing would
// leak which user names are real.
func TestDummyPasswordHashIsUsable(t *testing.T) {
ok, err := VerifyPassword(dummyPasswordHash, "anything")
if err != nil {
t.Fatalf("the dummy hash is not verifiable: %v", err)
}
if ok {
t.Error("the dummy hash matched a password")
}
}
+100
View File
@@ -0,0 +1,100 @@
package userapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// Token prefixes. Matrix tokens are opaque to clients, so the prefix is for
// our side: it makes a token recognisable in a log or a bug report, it lets
// secret scanners match on something, and it means a client that sends its
// refresh token where the access token belongs gets a clear rejection
// instead of a puzzling one.
const (
accessTokenPrefix = "warp_at_"
refreshTokenPrefix = "warp_rt_"
)
// tokenEntropyBytes is the random part of a token: 256 bits, the same order
// as the session cookies of anything else worth attacking.
const tokenEntropyBytes = 32
// deviceIDLength is how many characters a generated device ID has. Ten
// uppercase letters is the shape clients and users are used to seeing, and
// 26^10 is far more than a user will ever have devices.
const deviceIDLength = 10
// deviceIDAlphabet is uppercase ASCII only: device IDs are read aloud during
// device verification, so digits and letters that look alike are a nuisance.
const deviceIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// GenerateAccessToken returns a new access token.
func GenerateAccessToken() (string, error) { return generateToken(accessTokenPrefix) }
// GenerateRefreshToken returns a new refresh token.
func GenerateRefreshToken() (string, error) { return generateToken(refreshTokenPrefix) }
func generateToken(prefix string) (string, error) {
raw := make([]byte, tokenEntropyBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
// URL-safe and unpadded: tokens travel in query strings as well as in
// the Authorization header.
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}
// HashToken returns the stored form of a token: lowercase hex SHA-256.
//
// The database holds hashes rather than tokens so that a dump cannot be
// replayed as a set of live sessions. SHA-256 with no salt or stretching is
// the right choice here, unlike for passwords: the input is 256 bits of
// uniform randomness, so there is no dictionary to try, and the hash is on
// the path of every authenticated request.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// GenerateDeviceID returns a random device ID for a login that did not name
// one.
func GenerateDeviceID() (string, error) {
raw := make([]byte, deviceIDLength)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate device ID: %w", err)
}
out := make([]byte, deviceIDLength)
for i, b := range raw {
// The alphabet has 26 letters and a byte has 256 values, so the
// modulo is very slightly biased towards the first four letters.
// For a name whose only job is to be unique among a user's handful
// of devices, that is not worth a rejection loop.
out[i] = deviceIDAlphabet[int(b)%len(deviceIDAlphabet)]
}
return string(out), nil
}
// MaxDeviceIDLength caps a client-supplied device ID. The spec sets no
// limit, but the column is a primary key and an unbounded one invites abuse.
const MaxDeviceIDLength = 255
// ValidateDeviceID checks a client-supplied device ID. Device IDs are opaque
// strings, so the only rules are that it is non-empty, short enough, and
// free of control characters that would mangle a log line.
func ValidateDeviceID(deviceID string) error {
if deviceID == "" {
return fmt.Errorf("%w: device_id must not be empty", ErrInvalidDeviceID)
}
if len(deviceID) > MaxDeviceIDLength {
return fmt.Errorf("%w: device_id must be at most %d bytes", ErrInvalidDeviceID, MaxDeviceIDLength)
}
for _, r := range deviceID {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("%w: device_id must not contain control characters", ErrInvalidDeviceID)
}
}
return nil
}
+527
View File
@@ -0,0 +1,527 @@
// Package userapi owns accounts, passwords, devices, tokens and account
// data: everything that identifies a local user and their sessions.
//
// It is the component the Client-Server API handlers call for anything
// to do with who is making a request. The HTTP shapes live in clientapi;
// what is here is the part that would be the same behind any transport —
// which is also what makes `warp create-account` able to create an account
// without starting a server.
//
// Nothing in this package knows about rooms. Devices get their encryption
// keys in Phase 7 (keyserver) and their push rules in Phase 8.
package userapi
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The storage sentinels are re-exported
// rather than translated: there is one error value with two names, so
// errors.Is works whichever package a caller reaches for.
var (
ErrUserExists = storage.ErrUserExists
ErrNoSuchUser = storage.ErrNoSuchUser
ErrNoSuchDevice = storage.ErrNoSuchDevice
ErrNoSuchToken = storage.ErrNoSuchToken
ErrNoSuchAccountData = storage.ErrNoSuchAccountData
// ErrTokenExpired is an access token that was valid and has timed out.
// It is distinct from ErrNoSuchToken because the spec answers it with a
// soft logout: the client may keep its encryption state and log in
// again.
ErrTokenExpired = errors.New("access token has expired")
// ErrAccountDeactivated is a token belonging to an account that has
// been deactivated.
ErrAccountDeactivated = errors.New("account has been deactivated")
// ErrWrongPassword is a failed password check. It is deliberately the
// same error whether the account exists or not, so that callers cannot
// turn it into a user-enumeration oracle by accident.
ErrWrongPassword = errors.New("invalid password")
// ErrNoPassword is an account that has no password set and so cannot
// use m.login.password.
ErrNoPassword = errors.New("account has no password")
// ErrInvalidDeviceID is a client-supplied device ID that is empty, too
// long or contains control characters.
ErrInvalidDeviceID = errors.New("invalid device ID")
)
// API is the userapi component.
type API struct {
serverName string
store storage.UserStore
log *slog.Logger
now func() time.Time
accessTokenLifetime time.Duration
refreshTokenLifetime time.Duration
// lastSeen throttles the last-seen bookkeeping; see RecordActivity.
lastSeen sync.Map
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name; it becomes the domain part of
// every user ID this component hands out.
ServerName string
// Store is the persistence layer.
Store storage.UserStore
// AccessTokenLifetime expires issued access tokens. Zero issues tokens
// that never expire.
AccessTokenLifetime time.Duration
// RefreshTokenLifetime bounds how long a refresh token stays usable.
// Zero means no expiry.
RefreshTokenLifetime time.Duration
Logger *slog.Logger
// Now is the clock, so tests can control token expiry. Nil means
// time.Now.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*API, error) {
if opts.ServerName == "" {
return nil, errors.New("userapi: ServerName must not be empty")
}
if opts.Store == nil {
return nil, errors.New("userapi: Store must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
log: opts.Logger,
now: opts.Now,
accessTokenLifetime: opts.AccessTokenLifetime,
refreshTokenLifetime: opts.RefreshTokenLifetime,
}, nil
}
// ServerName returns the server name user IDs are built with.
func (a *API) ServerName() string { return a.serverName }
// nowMS is the current time in the unit the store uses.
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
// NewAccountRequest describes an account to create.
type NewAccountRequest struct {
// Localpart is normalised and validated by CreateAccount.
Localpart string
// Password may be empty, which creates an account that cannot log in
// with a password. `warp create-account` uses that for a placeholder
// account; the admin API does not offer it.
Password string
DisplayName string
Admin bool
}
// CreateAccount validates the request, hashes the password and stores the
// account. It returns ErrUserExists if the name is taken and
// ErrInvalidLocalpart if the name is not usable.
func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) {
localpart := NormaliseLocalpart(req.Localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return storage.Account{}, err
}
var hash string
if req.Password != "" {
var err error
if hash, err = HashPassword(req.Password); err != nil {
return storage.Account{}, err
}
}
acc, err := a.store.CreateAccount(ctx, storage.NewAccount{
Localpart: localpart,
PasswordHash: hash,
DisplayName: req.DisplayName,
Admin: req.Admin,
CreatedAtMS: a.nowMS(),
})
if err != nil {
return storage.Account{}, err
}
a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin)
return acc, nil
}
// Account reads an account by normalised localpart.
func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) {
return a.store.Account(ctx, localpart)
}
// AccountExists reports whether a localpart is taken. It is the answer
// behind /register/available, and deliberately does not distinguish a
// deactivated account: the name stays reserved.
func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) {
switch _, err := a.store.Account(ctx, localpart); {
case err == nil:
return true, nil
case errors.Is(err, ErrNoSuchUser):
return false, nil
default:
return false, err
}
}
// SetAdmin grants or revokes the admin flag.
func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error {
return a.store.SetAdmin(ctx, localpart, admin)
}
// SetPassword replaces an account's password.
func (a *API) SetPassword(ctx context.Context, localpart, password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
return a.store.SetPasswordHash(ctx, localpart, hash)
}
// CheckPassword verifies a password against an account.
//
// Every failure path costs one argon2id hash, including an account that does
// not exist or has no password, so the time a rejection takes says nothing
// about which user names are real.
func (a *API) CheckPassword(ctx context.Context, localpart, password string) error {
hash, err := a.store.PasswordHash(ctx, localpart)
switch {
case errors.Is(err, ErrNoSuchUser):
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrWrongPassword
case err != nil:
return err
}
if hash == "" {
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrNoPassword
}
ok, err := VerifyPassword(hash, password)
switch {
case errors.Is(err, ErrPasswordTooLong):
return ErrWrongPassword
case err != nil:
// A malformed hash is an operational problem, not a wrong password.
a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err)
return err
case !ok:
return ErrWrongPassword
}
return nil
}
// SessionRequest asks for a new device and its tokens.
type SessionRequest struct {
// Localpart must already be normalised and must exist.
Localpart string
// DeviceID is the client's choice. Empty means generate one. Naming a
// device that already exists re-uses it and revokes its old tokens.
DeviceID string
// DisplayName is initial_device_display_name: used only when the device
// is created.
DisplayName string
// WithRefreshToken issues a refresh token alongside the access token.
WithRefreshToken bool
IP string
UserAgent string
}
// Session is the result of a login, a registration or a token refresh: what
// the client needs to start making requests.
type Session struct {
UserID string
Localpart string
DeviceID string
AccessToken string
// RefreshToken is empty unless the client asked for one.
RefreshToken string
// ExpiresInMS is how long the access token is good for, or 0 when it
// does not expire.
ExpiresInMS int64
}
// CreateSession issues a device and tokens for an existing account.
func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) {
deviceID := req.DeviceID
if deviceID == "" {
var err error
if deviceID, err = GenerateDeviceID(); err != nil {
return Session{}, err
}
} else if err := ValidateDeviceID(deviceID); err != nil {
return Session{}, err
}
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
var refreshToken string
if req.WithRefreshToken {
if refreshToken, err = GenerateRefreshToken(); err != nil {
return Session{}, err
}
}
now := a.nowMS()
session := storage.NewSession{
Localpart: req.Localpart,
DeviceID: deviceID,
DisplayName: req.DisplayName,
CreatedAtMS: now,
AccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
LastSeenIP: req.IP,
UserAgent: req.UserAgent,
}
if refreshToken != "" {
session.RefreshTokenHash = HashToken(refreshToken)
}
device, err := a.store.CreateSession(ctx, session)
if err != nil {
return Session{}, err
}
return Session{
UserID: a.UserID(req.Localpart),
Localpart: req.Localpart,
DeviceID: device.ID,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// RefreshSession spends a refresh token and issues a new pair. It returns
// ErrNoSuchToken if the token is unknown or already spent.
func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) {
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
newRefreshToken, err := GenerateRefreshToken()
if err != nil {
return Session{}, err
}
now := a.nowMS()
info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: HashToken(refreshToken),
NewAccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
NewRefreshTokenHash: HashToken(newRefreshToken),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
RefreshedAtMS: now,
})
if err != nil {
return Session{}, err
}
if info.Deactivated {
return Session{}, ErrAccountDeactivated
}
return Session{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// Authenticated is the identity behind a request: what the auth middleware
// resolves an access token into and what every authenticated handler is
// given.
type Authenticated struct {
UserID string
Localpart string
DeviceID string
Admin bool
}
// Authenticate resolves an access token.
//
// It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one
// that has timed out, and ErrAccountDeactivated for a token whose account is
// gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and
// M_USER_DEACTIVATED respectively.
func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) {
if accessToken == "" {
return Authenticated{}, ErrNoSuchToken
}
info, err := a.store.TokenInfo(ctx, HashToken(accessToken))
if err != nil {
return Authenticated{}, err
}
if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() {
return Authenticated{}, ErrTokenExpired
}
if info.Deactivated {
return Authenticated{}, ErrAccountDeactivated
}
return Authenticated{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
Admin: info.Admin,
}, nil
}
// lastSeenInterval is how stale the last-seen record of a device may get.
// Recording it on every request would double the write load of the whole C-S
// API to keep a field nothing reads more often than a user opens their
// device list.
const lastSeenInterval = time.Minute
// RecordActivity updates a device's last-seen timestamp, address and user
// agent, at most once per lastSeenInterval per device. A failure is logged
// and swallowed: the request it belongs to has already been authorised, and
// bookkeeping must not turn into a 500.
func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) {
key := auth.Localpart + "\x00" + auth.DeviceID
now := a.now()
if previous, ok := a.lastSeen.Load(key); ok {
if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval {
return
}
}
a.lastSeen.Store(key, now)
if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil {
a.log.Warn("recording device activity failed",
"user_id", auth.UserID, "device_id", auth.DeviceID, "error", err)
}
}
// Device reads one of a user's devices.
func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
return a.store.Device(ctx, localpart, deviceID)
}
// Devices lists a user's devices.
func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
return a.store.Devices(ctx, localpart)
}
// SetDeviceDisplayName renames a device.
func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
return a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName)
}
// DeleteDevices revokes devices and their tokens. It is what /logout,
// /delete_devices and DELETE /devices/{id} all end up calling.
func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error {
if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil {
return err
}
a.forgetLastSeen(localpart, deviceIDs...)
return nil
}
// DeleteAllDevices revokes every device of a user: /logout/all.
func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error {
if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil {
return err
}
a.forgetLastSeen(localpart)
return nil
}
// forgetLastSeen drops the throttle entries for devices that no longer
// exist, so the map does not grow with every logout. With no device IDs it
// clears every entry of the user.
func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) {
if len(deviceIDs) > 0 {
for _, id := range deviceIDs {
a.lastSeen.Delete(localpart + "\x00" + id)
}
return
}
prefix := localpart + "\x00"
a.lastSeen.Range(func(key, _ any) bool {
if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix {
a.lastSeen.Delete(key)
}
return true
})
}
// SetDisplayName updates a user's profile display name.
func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error {
return a.store.SetDisplayName(ctx, localpart, displayName)
}
// SetAvatarURL updates a user's profile avatar.
func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
return a.store.SetAvatarURL(ctx, localpart, avatarURL)
}
// AccountData reads one account data blob; roomID is empty for global data.
func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
return a.store.AccountData(ctx, localpart, roomID, dataType)
}
// SetAccountData writes one account data blob.
func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
return a.store.SetAccountData(ctx, localpart, roomID, dataType, content)
}
// ListAccounts returns a page of accounts for the admin API.
func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
return a.store.ListAccounts(ctx, filter)
}
// Stats returns the aggregate user counts for the admin API.
func (a *API) Stats(ctx context.Context) (storage.Stats, error) {
return a.store.Stats(ctx)
}
// expiryMS turns a lifetime into an absolute deadline in the store's unit,
// or 0 for "never".
func expiryMS(nowMS int64, lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return nowMS + lifetime.Milliseconds()
}
// lifetimeMS is the expires_in_ms a client is told, or 0 for "never".
func lifetimeMS(lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return lifetime.Milliseconds()
}
+323
View File
@@ -0,0 +1,323 @@
package userapi_test
import (
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// fixedClock is a clock a test can wind forward, so token expiry does not
// have to be waited for.
type fixedClock struct{ at time.Time }
func (c *fixedClock) now() time.Time { return c.at }
func (c *fixedClock) add(d time.Duration) { c.at = c.at.Add(d) }
// newTestAPI builds a userapi on a throwaway SQLite database.
func newTestAPI(t *testing.T, tweak func(*userapi.Options)) (*userapi.API, *fixedClock) {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
clock := &fixedClock{at: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
opts := userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
Now: clock.now,
}
if tweak != nil {
tweak(&opts)
}
api, err := userapi.New(opts)
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
return api, clock
}
func TestNewRejectsIncompleteOptions(t *testing.T) {
if _, err := userapi.New(userapi.Options{Store: nil, ServerName: "x"}); err == nil {
t.Error("a userapi without a store must not be built")
}
if _, err := userapi.New(userapi.Options{ServerName: ""}); err == nil {
t.Error("a userapi without a server name must not be built")
}
}
func TestCreateAccountNormalisesAndValidates(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
acc, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "Alice",
Password: "sUp3rs3kr1t",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" {
t.Errorf("localpart = %q, want alice", acc.Localpart)
}
if api.UserID(acc.Localpart) != "@alice:warp.example" {
t.Errorf("UserID = %q", api.UserID(acc.Localpart))
}
// Normalising on the way in is what makes the name really taken.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "ALICE", Password: "x",
}); !errors.Is(err, userapi.ErrUserExists) {
t.Errorf("creating @alice twice = %v, want ErrUserExists", err)
}
for name, localpart := range map[string]string{
"empty": "",
"illegal rune": "not valid",
"non-ASCII": "ünïcode",
"colon": "a:b",
"too long": strings.Repeat("a", 250),
} {
t.Run(name, func(t *testing.T) {
_, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: localpart, Password: "x"})
if !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("CreateAccount(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestCheckPasswordRejectsEveryWayTheSame keeps the login path from becoming
// a user directory.
func TestCheckPasswordRejectsEveryWayTheSame(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "sUp3rs3kr1t",
}); err != nil {
t.Fatal(err)
}
// An account with no password: `warp create-account` can make one.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "nopassword"}); err != nil {
t.Fatal(err)
}
if err := api.CheckPassword(ctx, "alice", "sUp3rs3kr1t"); err != nil {
t.Errorf("the right password was rejected: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "wrong"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("wrong password = %v, want ErrWrongPassword", err)
}
// An unknown account is ErrWrongPassword, not ErrNoSuchUser: the
// caller must not be able to tell the difference.
if err := api.CheckPassword(ctx, "nobody", "sUp3rs3kr1t"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("unknown account = %v, want ErrWrongPassword", err)
}
if err := api.CheckPassword(ctx, "nopassword", "anything"); !errors.Is(err, userapi.ErrNoPassword) {
t.Errorf("passwordless account = %v, want ErrNoPassword", err)
}
}
func TestSetPassword(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "first",
}); err != nil {
t.Fatal(err)
}
if err := api.SetPassword(ctx, "alice", "second"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "second"); err != nil {
t.Errorf("the new password does not work: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "first"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Error("the old password still works")
}
}
func TestAuthenticate(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "x", Admin: true,
}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if session.UserID != "@alice:warp.example" || session.DeviceID == "" || session.AccessToken == "" {
t.Fatalf("CreateSession returned %+v", session)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if auth.UserID != session.UserID || auth.DeviceID != session.DeviceID || !auth.Admin {
t.Errorf("Authenticate = %+v", auth)
}
for name, token := range map[string]string{"empty": "", "unknown": "warp_at_nope"} {
t.Run(name, func(t *testing.T) {
if _, err := api.Authenticate(ctx, token); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("Authenticate(%q) = %v, want ErrNoSuchToken", token, err)
}
})
}
}
// TestAccessTokenExpiry covers the lifetime configuration and the reason
// expiry is reported separately: it becomes a soft logout, not a hard one.
func TestAccessTokenExpiry(t *testing.T) {
api, clock := newTestAPI(t, func(o *userapi.Options) {
o.AccessTokenLifetime = time.Hour
})
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if session.ExpiresInMS != time.Hour.Milliseconds() {
t.Errorf("ExpiresInMS = %d, want one hour", session.ExpiresInMS)
}
if _, err := api.Authenticate(ctx, session.AccessToken); err != nil {
t.Fatalf("a fresh token must work: %v", err)
}
clock.add(time.Hour + time.Second)
if _, err := api.Authenticate(ctx, session.AccessToken); !errors.Is(err, userapi.ErrTokenExpired) {
t.Errorf("Authenticate of an expired token = %v, want ErrTokenExpired", err)
}
}
func TestRefreshSessionRequiresARefreshToken(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
// A session that did not ask for one cannot be refreshed.
plain, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if plain.RefreshToken != "" {
t.Error("a session that did not ask for a refresh token got one")
}
if _, err := api.RefreshSession(ctx, "warp_rt_nope"); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("RefreshSession with an unknown token = %v, want ErrNoSuchToken", err)
}
withRefresh, err := api.CreateSession(ctx, userapi.SessionRequest{
Localpart: "alice", WithRefreshToken: true,
})
if err != nil {
t.Fatal(err)
}
refreshed, err := api.RefreshSession(ctx, withRefresh.RefreshToken)
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if refreshed.DeviceID != withRefresh.DeviceID {
t.Errorf("refreshing changed the device: %s to %s", withRefresh.DeviceID, refreshed.DeviceID)
}
if refreshed.AccessToken == withRefresh.AccessToken {
t.Error("refreshing returned the same access token")
}
}
// TestRecordActivityIsThrottled covers the decision not to write to the
// database on every authenticated request.
func TestRecordActivityIsThrottled(t *testing.T) {
api, clock := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice", IP: "10.0.0.1"})
if err != nil {
t.Fatal(err)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatal(err)
}
api.RecordActivity(ctx, auth, "10.0.0.2", "first")
device, err := api.Device(ctx, "alice", session.DeviceID)
if err != nil {
t.Fatal(err)
}
firstSeen := device.LastSeenMS
if device.LastSeenIP != "10.0.0.2" {
t.Errorf("last_seen_ip = %q, want 10.0.0.2", device.LastSeenIP)
}
// A second visit a moment later is not written.
clock.add(time.Second)
api.RecordActivity(ctx, auth, "10.0.0.3", "second")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.2" || device.LastSeenMS != firstSeen {
t.Errorf("a visit one second later was written: %+v", device)
}
// A later one is.
clock.add(2 * time.Minute)
api.RecordActivity(ctx, auth, "10.0.0.4", "third")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.4" || device.LastSeenMS <= firstSeen {
t.Errorf("a visit two minutes later was not written: %+v", device)
}
}
func TestAccountExists(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
exists, err := api.AccountExists(ctx, "alice")
if err != nil || exists {
t.Fatalf("AccountExists on an empty database = %v, %v", exists, err)
}
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
if exists, err = api.AccountExists(ctx, "alice"); err != nil || !exists {
t.Fatalf("AccountExists = %v, %v", exists, err)
}
}
+119
View File
@@ -0,0 +1,119 @@
package userapi
import (
"errors"
"fmt"
"strings"
)
// MaxUserIDLength is the spec's cap on a user ID, sigil and server name
// included.
const MaxUserIDLength = 255
// Errors from parsing and validating identifiers. Callers map these onto
// M_INVALID_USERNAME or M_UNKNOWN depending on the endpoint.
var (
// ErrInvalidLocalpart means the localpart is empty, too long, or
// contains a character the spec does not allow.
ErrInvalidLocalpart = errors.New("invalid user name")
// ErrInvalidUserID means the string is not a well-formed user ID.
ErrInvalidUserID = errors.New("invalid user ID")
// ErrForeignServer means a well-formed user ID belongs to another
// homeserver. Answering for it needs federation.
ErrForeignServer = errors.New("user ID belongs to another server")
)
// NormaliseLocalpart lower-cases a localpart.
//
// The spec's grammar for the localpart of a user ID allows only lowercase
// letters, so a client sending "User-UPPER" is asking for "user-upper"
// rather than making a mistake. Both registration and login normalise, which
// is also what Synapse does and what Complement checks.
func NormaliseLocalpart(localpart string) string {
return strings.ToLower(localpart)
}
// ValidateLocalpart checks a normalised localpart against the user ID
// grammar of the Client-Server API: lowercase letters, digits, and the
// characters . _ = - / +.
//
// serverName is needed because the 255-byte limit applies to the whole user
// ID, so how long a localpart may be depends on where it lives.
func ValidateLocalpart(localpart, serverName string) error {
if localpart == "" {
return fmt.Errorf("%w: must not be empty", ErrInvalidLocalpart)
}
if n := len("@" + localpart + ":" + serverName); n > MaxUserIDLength {
return fmt.Errorf("%w: the resulting user ID would be %d bytes, the limit is %d",
ErrInvalidLocalpart, n, MaxUserIDLength)
}
for _, r := range localpart {
if !isLocalpartRune(r) {
return fmt.Errorf("%w: %q is not allowed; use lowercase letters, digits or . _ = - / +",
ErrInvalidLocalpart, r)
}
}
return nil
}
// isLocalpartRune reports whether r may appear in a user ID localpart. The
// range is deliberately byte-oriented: the grammar is ASCII only, so any
// multi-byte rune falls through to false.
func isLocalpartRune(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return true
case r == '.', r == '_', r == '=', r == '-', r == '/', r == '+':
return true
default:
return false
}
}
// UserID assembles "@localpart:serverName".
func (a *API) UserID(localpart string) string {
return "@" + localpart + ":" + a.serverName
}
// ParseLocalUserID turns a user identifier into a normalised localpart on
// this server.
//
// It accepts both forms clients send: a full "@user:server" ID and a bare
// localpart, which the login endpoint has always allowed. A full ID naming
// another server is ErrForeignServer — a distinct case from a malformed one,
// because that is the answer federation would change.
func (a *API) ParseLocalUserID(identifier string) (string, error) {
if identifier == "" {
return "", fmt.Errorf("%w: must not be empty", ErrInvalidUserID)
}
if !strings.HasPrefix(identifier, "@") {
localpart := NormaliseLocalpart(identifier)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return "", err
}
return localpart, nil
}
if len(identifier) > MaxUserIDLength {
return "", fmt.Errorf("%w: longer than %d bytes", ErrInvalidUserID, MaxUserIDLength)
}
localpart, serverName, ok := strings.Cut(identifier[1:], ":")
if !ok || serverName == "" {
return "", fmt.Errorf("%w: expected @localpart:servername", ErrInvalidUserID)
}
// Server names are compared case-insensitively for the same reason the
// federation allowlist is: every real deployment uses a DNS name, and
// treating "Warp.example" as somebody else would turn a client's
// capitalisation into a login failure.
if !strings.EqualFold(serverName, a.serverName) {
return "", fmt.Errorf("%w: %s", ErrForeignServer, serverName)
}
localpart = NormaliseLocalpart(localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return "", err
}
return localpart, nil
}
+203
View File
@@ -0,0 +1,203 @@
package userapi_test
import (
"errors"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
func TestNormaliseLocalpart(t *testing.T) {
for input, want := range map[string]string{
"alice": "alice",
"Alice": "alice",
"USER-UPPER": "user-upper",
"": "",
} {
if got := userapi.NormaliseLocalpart(input); got != want {
t.Errorf("NormaliseLocalpart(%q) = %q, want %q", input, got, want)
}
}
}
func TestValidateLocalpart(t *testing.T) {
// The grammar the Client-Server API gives for a user ID localpart.
for _, localpart := range []string{
"alice", "user-upper", "a", "0", "chrtestuserq", "chrtestuser3",
"chrtestuser.", "chrtestuser_", "chrtestuser=", "chrtestuser-",
"chrtestuser/", "chrtestuser+",
} {
if err := userapi.ValidateLocalpart(localpart, "warp.example"); err != nil {
t.Errorf("ValidateLocalpart(%q) = %v, want nil", localpart, err)
}
}
for name, localpart := range map[string]string{
"empty": "",
"uppercase": "Alice",
"space": "a b",
"exclamation": "a!b",
"quote": `a"b`,
"colon": "a:b",
"question": "a?b",
"backslash": `a\b`,
"at sign": "a@b",
"bracket": "a[b",
"brace": "a{b",
"pipe": "a|b",
"pound": "a£b",
"accent": "aéb",
"newline": "a\nb",
"apostrophe": "a'b",
"comma": "a,b",
} {
t.Run(name, func(t *testing.T) {
if err := userapi.ValidateLocalpart(localpart, "warp.example"); !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("ValidateLocalpart(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestValidateLocalpartLengthDependsOnTheServerName: the 255-byte limit is
// on the whole user ID, so a long server name leaves less room for a name.
func TestValidateLocalpartLengthDependsOnTheServerName(t *testing.T) {
const shortServer = "a.example"
// "@" + localpart + ":" + server must be at most 255 bytes.
longest := strings.Repeat("x", userapi.MaxUserIDLength-len("@:"+shortServer))
if err := userapi.ValidateLocalpart(longest, shortServer); err != nil {
t.Errorf("a localpart exactly at the limit was rejected: %v", err)
}
if err := userapi.ValidateLocalpart(longest+"x", shortServer); !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("a localpart one byte over the limit = %v, want ErrInvalidLocalpart", err)
}
// The same localpart on a longer server name no longer fits.
if err := userapi.ValidateLocalpart(longest, "a-much-longer-server-name.example"); err == nil {
t.Error("the limit must count the server name")
}
}
func TestParseLocalUserID(t *testing.T) {
api, _ := newTestAPI(t, nil)
for name, tc := range map[string]struct {
input string
want string
}{
"full user ID": {"@alice:warp.example", "alice"},
"bare localpart": {"alice", "alice"},
"uppercase localpart": {"@ALICE:warp.example", "alice"},
"uppercase bare": {"Alice", "alice"},
"uppercase server name": {"@alice:WARP.Example", "alice"},
} {
t.Run(name, func(t *testing.T) {
got, err := api.ParseLocalUserID(tc.input)
if err != nil {
t.Fatalf("ParseLocalUserID(%q) = %v", tc.input, err)
}
if got != tc.want {
t.Errorf("ParseLocalUserID(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
// A user ID on another server is its own case: it is well formed, and
// federation is what would change the answer.
if _, err := api.ParseLocalUserID("@bob:other.example"); !errors.Is(err, userapi.ErrForeignServer) {
t.Errorf("a remote user ID = %v, want ErrForeignServer", err)
}
for name, input := range map[string]string{
"empty": "",
"no server name": "@alice",
"empty server": "@alice:",
"illegal rune": "@not valid:warp.example",
"bare illegal": "not valid",
} {
t.Run(name, func(t *testing.T) {
if _, err := api.ParseLocalUserID(input); err == nil {
t.Errorf("ParseLocalUserID(%q) returned no error", input)
}
})
}
}
func TestValidateDeviceID(t *testing.T) {
for _, deviceID := range []string{"ABCDEFGHIJ", "my_device_id", "device with spaces", "日本語"} {
if err := userapi.ValidateDeviceID(deviceID); err != nil {
t.Errorf("ValidateDeviceID(%q) = %v, want nil (device IDs are opaque)", deviceID, err)
}
}
for name, deviceID := range map[string]string{
"empty": "",
"too long": strings.Repeat("x", userapi.MaxDeviceIDLength+1),
"newline": "a\nb",
"control character": "a\x00b",
} {
t.Run(name, func(t *testing.T) {
if err := userapi.ValidateDeviceID(deviceID); !errors.Is(err, userapi.ErrInvalidDeviceID) {
t.Errorf("ValidateDeviceID(%q) = %v, want ErrInvalidDeviceID", deviceID, err)
}
})
}
}
func TestGenerateDeviceID(t *testing.T) {
seen := map[string]bool{}
for range 100 {
id, err := userapi.GenerateDeviceID()
if err != nil {
t.Fatalf("GenerateDeviceID: %v", err)
}
if err := userapi.ValidateDeviceID(id); err != nil {
t.Fatalf("a generated device ID is invalid: %v", err)
}
if seen[id] {
t.Fatalf("GenerateDeviceID repeated %q within 100 calls", id)
}
seen[id] = true
}
}
func TestTokensAreDistinctAndHashed(t *testing.T) {
access, err := userapi.GenerateAccessToken()
if err != nil {
t.Fatal(err)
}
refresh, err := userapi.GenerateRefreshToken()
if err != nil {
t.Fatal(err)
}
// The prefixes exist so that a client sending the wrong one gets a
// clear rejection, and so a token is recognisable in a log.
if !strings.HasPrefix(access, "warp_at_") {
t.Errorf("access token %q has no recognisable prefix", access)
}
if !strings.HasPrefix(refresh, "warp_rt_") {
t.Errorf("refresh token %q has no recognisable prefix", refresh)
}
hash := userapi.HashToken(access)
if hash == access || len(hash) != 64 {
t.Errorf("HashToken(%q) = %q, want a 64-character hex digest", access, hash)
}
if userapi.HashToken(access) != hash {
t.Error("HashToken is not deterministic")
}
if userapi.HashToken(refresh) == hash {
t.Error("two different tokens hashed the same")
}
second, err := userapi.GenerateAccessToken()
if err != nil {
t.Fatal(err)
}
if second == access {
t.Error("two generated access tokens are identical")
}
}