Files
Warp/server/internal/clientapi/devices.go
T
tajniak81andClaude Opus 5 6004bb01de
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
Phase 1: accounts and sessions
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>
2026-10-15 18:18:37 +02:00

190 lines
6.3 KiB
Go

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
}
}