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
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:
1 parent
b4fb60a8ab
commit
6004bb01de
68 files changed
+9729
-66
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user