Files
Warp/server/internal/clientapi/uia.go
T
tajniak81andClaude Opus 5.5 a94794b866
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 9: application services and admin completion
Implements PLAN.md Phase 9. internal/appservice reads registration files
(appservice.config_files), matches namespaces the way Synapse does
(anchored at the start), and delivers events to each service as
transactions read from the roomserver's event stream at a cursor kept in
appservice_state. A transaction in flight is recorded, so after a restart
it is resent under the same ID; failures back off from 2s to 5m, and a
successful ping cuts the backoff short.

In the Client-Server API an as_token acts as the service's sender or, with
?user_id=, as any existing user in its namespace. Services register and
log in their users with m.login.application_service; exclusive user and
alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown
aliases and users in a namespace are asked of the service first. The
ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED
and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when
rate_limited is false, skip the rate limiter.

Users can change their password (POST /account/password) and deactivate
their account (POST /account/deactivate), both behind the password.
Deactivation revokes tokens, deletes devices, optionally erases the
profile and leaves every room through a new userapi.AccountObserver.

The admin API is complete: user detail, the admin flag, deactivation and
reactivation, password resets, devices, media quotas
(media.max_user_storage_bytes plus per-user overrides), registration
tokens with use limits and expiry (registration.require_token), server
notices (internal/servernotices, server_notices.*), an editor for the
federation allow/deny lists persisted in server_settings, a delivery view
of the application services, and an audit log of every change.

Migration 0011 adds the registration token, server notice room, media
quota, audit log and appservice cursor tables. Policy lists are now
returned sorted. The Complement entrypoint loads the registrations
Complement mounts in /complement/appservice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:58:28 +02:00

321 lines
9.6 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"
// loginAppService is not an interactive stage but a login and
// registration type: an application service's own token is the
// credential.
loginAppService = "m.login.application_service"
)
// 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
// registrationToken is the token a registration's
// m.login.registration_token stage was passed with. It is spent when
// the account is created, so it has to be remembered until then.
registrationToken string
}
// 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,
registrationToken: session.registrationToken,
}
}
// setRegistrationToken remembers the token a session's registration token
// stage was passed with.
func (s *uiaStore) setRegistrationToken(id, token string) {
s.mu.Lock()
defer s.mu.Unlock()
if session, ok := s.sessions[id]; ok {
session.registrationToken = token
}
}
// 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
}