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

+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")
}
}