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>
296 lines
8.8 KiB
Go
296 lines
8.8 KiB
Go
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
|
|
}
|