Phase 9: application services and admin completion
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 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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-17 12:58:28 +02:00
1 parent b507753172
commit a94794b866
69 files changed
+6922 -124

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
package clientapi
import (
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// changePasswordRequest is the body of POST /account/password.
type changePasswordRequest struct {
NewPassword string `json:"new_password"`
// LogoutDevices defaults to true, which is why it is a pointer: a
// client that says nothing gets every other session logged out.
LogoutDevices *bool `json:"logout_devices"`
Auth *uiaRequest `json:"auth"`
}
// handleChangePassword implements POST /_matrix/client/v3/account/password.
//
// The current password is asked for again first, through the same
// interactive authentication as deleting a device, and for the same
// reason: a stolen access token must not be enough to lock the owner out.
// The session the change is made from stays logged in; with
// logout_devices, every other one is logged out.
//
// An application service acting as one of its users skips the
// re-authentication: its users have no password to ask for, and the
// service's token is already the strongest credential involved.
func (c *ClientAPI) handleChangePassword(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req changePasswordRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
sessionID := ""
if auth.AppService == "" {
flows := []uiaFlow{{Stages: []string{stagePassword}}}
var done bool
var err error
sessionID, done, err = c.beginOrAdvanceUIA(w, flows, req.Auth,
func(_ string, stage *uiaRequest) (*httputil.MatrixError, error) {
return c.passwordStage(r, auth, stage)
})
if err != nil || !done {
return err
}
}
if req.NewPassword == "" {
return httputil.MissingParam("new_password is required")
}
if len(req.NewPassword) > userapi.MaxPasswordLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword,
"Password must be at most %d bytes", userapi.MaxPasswordLength)
}
logout := req.LogoutDevices == nil || *req.LogoutDevices
if err := c.users.ChangePassword(r.Context(), auth.Localpart, req.NewPassword, logout, auth.DeviceID); err != nil {
return err
}
c.uia.remove(sessionID)
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// deactivateRequest is the body of POST /account/deactivate.
type deactivateRequest struct {
// Erase asks for the account's profile to be forgotten as well.
Erase bool `json:"erase"`
Auth *uiaRequest `json:"auth"`
}
// deactivateResponse is its answer. Warp keeps no third-party identifiers
// and talks to no identity server, so there was nothing to unbind.
type deactivateResponse struct {
IDServerUnbindResult string `json:"id_server_unbind_result"`
}
// handleDeactivate implements POST /_matrix/client/v3/account/deactivate:
// the user closing their own account, behind a re-entered password.
func (c *ClientAPI) handleDeactivate(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req deactivateRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
sessionID := ""
if auth.AppService == "" {
flows := []uiaFlow{{Stages: []string{stagePassword}}}
var done bool
var err error
sessionID, done, err = c.beginOrAdvanceUIA(w, flows, req.Auth,
func(_ string, stage *uiaRequest) (*httputil.MatrixError, error) {
return c.passwordStage(r, auth, stage)
})
if err != nil || !done {
return err
}
}
if err := c.users.DeactivateAccount(r.Context(), auth.Localpart, req.Erase); err != nil {
return err
}
c.uia.remove(sessionID)
httputil.WriteJSON(w, http.StatusOK, deactivateResponse{IDServerUnbindResult: "no-support"})
return nil
}
+161
View File
@@ -0,0 +1,161 @@
package clientapi
import (
"context"
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/appservice"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// The Client-Server half of the Application Service API: a service's
// as_token as an access token, acting as one of its users with
// ?user_id=, and the namespaces other users are kept out of.
// authenticateAppService turns a request carrying a service's as_token
// into an identity: the service's sender, or the user ?user_id= names.
//
// The user has to be the sender or in one of the service's user
// namespaces — exclusive or not, the spec does not distinguish — and has
// to exist: a service registers its users first, and acting as a user
// nobody created would hand out an identity with no account behind it.
func (c *ClientAPI) authenticateAppService(r *http.Request, svc *appservice.Service) (userapi.Authenticated, error) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
userID = svc.SenderUserID()
}
localpart, err := c.users.ParseLocalUserID(userID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
return userapi.Authenticated{}, httputil.Forbidden("An application service cannot act as a user of another server")
case err != nil:
return userapi.Authenticated{}, httputil.InvalidParam("%s is not a valid user ID", userID)
}
canonical := c.users.UserID(localpart)
if !svc.OwnsUser(canonical) {
return userapi.Authenticated{}, httputil.Forbidden("%s is not in the namespace of application service %s", canonical, svc.ID)
}
account, err := c.users.Account(r.Context(), localpart)
switch {
case errors.Is(err, userapi.ErrNoSuchUser):
return userapi.Authenticated{}, httputil.Forbidden("Application service %s has not registered %s", svc.ID, canonical)
case err != nil:
return userapi.Authenticated{}, err
case account.Deactivated:
return userapi.Authenticated{}, httputil.UserDeactivated()
}
return userapi.Authenticated{
UserID: canonical,
Localpart: localpart,
Admin: account.Admin,
AppService: svc.ID,
}, nil
}
// rateLimitExempt lets an application service's own requests past the
// rate limiter, and the requests of the users it acts as when its
// registration says rate_limited: false.
//
// A bridge puppeting a thousand users from one address would otherwise
// share one bucket among all of them. The check is a map lookup on the
// token, so it is cheap enough to run ahead of the limiter.
func (c *ClientAPI) rateLimitExempt(r *http.Request) bool {
svc := c.appservices.Registry().ByToken(accessTokenFrom(r))
if svc == nil {
return false
}
userID := r.URL.Query().Get("user_id")
return userID == "" || userID == svc.SenderUserID() || !svc.RateLimitedUsers()
}
// checkExclusiveUser refuses a user ID in a namespace an application
// service has claimed, unless the request comes from that service.
func (c *ClientAPI) checkExclusiveUser(userID, asking string) error {
if owner := c.appservices.Registry().ExclusiveUserOwner(userID); owner != nil && owner.ID != asking {
return httputil.Exclusive("%s is reserved by an application service", userID)
}
return nil
}
// checkExclusiveAlias is checkExclusiveUser for a room alias.
func (c *ClientAPI) checkExclusiveAlias(alias string, auth userapi.Authenticated) error {
if owner := c.appservices.Registry().ExclusiveAliasOwner(alias); owner != nil && owner.ID != auth.AppService {
return httputil.Exclusive("%s is reserved by an application service", alias)
}
return nil
}
// resolveAlias resolves an alias, asking the application services whose
// namespace it is in when this server does not know it — which is how a
// bridge creates a portal room the first time somebody looks for it.
func (c *ClientAPI) resolveAlias(ctx context.Context, alias string) (roomserver.ResolvedAlias, error) {
resolved, err := c.rooms.ResolveAlias(ctx, alias)
if !errors.Is(err, roomserver.ErrNoSuchAlias) {
return resolved, err
}
if !c.appservices.QueryAlias(ctx, alias) {
return resolved, err
}
return c.rooms.ResolveAlias(ctx, alias)
}
// ensureAppServiceUser gives the application services a chance to create
// a local user that does not exist, before something that needs the user
// gives up on it. It reports whether the user exists afterwards.
func (c *ClientAPI) ensureAppServiceUser(ctx context.Context, localpart string) (bool, error) {
exists, err := c.users.AccountExists(ctx, localpart)
if err != nil || exists {
return exists, err
}
if !c.appservices.QueryUser(ctx, c.users.UserID(localpart)) {
return false, nil
}
return c.users.AccountExists(ctx, localpart)
}
// pingRequest is the body of the ping endpoint.
type pingRequest struct {
TransactionID string `json:"transaction_id"`
}
// handleAppServicePing implements
// POST /_matrix/client/v1/appservice/{appserviceId}/ping: a service asking
// this server to ping it back, to check the two can reach each other.
func (c *ClientAPI) handleAppServicePing(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
svc := c.appservices.Registry().ByID(r.PathValue("appserviceID"))
if svc == nil || auth.AppService != svc.ID {
return httputil.Forbidden("Only the application service itself may ask for its ping")
}
var req pingRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
took, err := c.appservices.Ping(r.Context(), svc, req.TransactionID)
var status *appservice.BadStatusError
switch {
case err == nil:
case errors.Is(err, appservice.ErrNoURL):
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeURLNotSet,
"Application service %s has no URL", svc.ID)
case errors.As(err, &status):
e := httputil.NewError(http.StatusBadGateway, httputil.ErrCodeBadStatus,
"The application service answered the ping with %d", status.Status)
e.RemoteStatus, e.RemoteBody = status.Status, status.Body
return e
case errors.Is(err, appservice.ErrConnectionTimeout):
return httputil.NewError(http.StatusGatewayTimeout, httputil.ErrCodeConnectionTimeout,
"The application service did not answer the ping in time")
case errors.Is(err, appservice.ErrConnectionFailed):
return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeConnectionFailed,
"Could not connect to the application service")
default:
return err
}
httputil.WriteJSON(w, http.StatusOK, map[string]int64{"duration_ms": took.Milliseconds()})
return nil
}
+7 -1
View File
@@ -22,7 +22,10 @@ func (c *ClientAPI) withAuth(h AuthedHandler) httputil.Handler {
if err != nil {
return err
}
c.users.RecordActivity(r.Context(), auth, c.clientIP(r), r.UserAgent())
// An application service has no device to record a visit on.
if auth.AppService == "" {
c.users.RecordActivity(r.Context(), auth, c.clientIP(r), r.UserAgent())
}
return h(w, r, auth)
}
}
@@ -33,6 +36,9 @@ func (c *ClientAPI) authenticate(r *http.Request) (userapi.Authenticated, error)
if token == "" {
return userapi.Authenticated{}, httputil.MissingToken()
}
if svc := c.appservices.Registry().ByToken(token); svc != nil {
return c.authenticateAppService(r, svc)
}
auth, err := c.users.Authenticate(r.Context(), token)
switch {
+4 -4
View File
@@ -58,9 +58,9 @@ type capabilities struct {
// 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.
// Third-party identifiers need an identity server, which Warp does not
// talk to, 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{
@@ -68,7 +68,7 @@ func (c *ClientAPI) handleCapabilities(w http.ResponseWriter, _ *http.Request, _
Default: DefaultRoomVersion,
Available: availableRoomVersions(),
},
ChangePassword: booleanCapability{Enabled: false},
ChangePassword: booleanCapability{Enabled: true},
SetDisplayName: booleanCapability{Enabled: true},
SetAvatarURL: booleanCapability{Enabled: true},
ThreePIDChanges: booleanCapability{Enabled: false},
+37 -4
View File
@@ -17,6 +17,7 @@ import (
"log/slog"
"time"
"github.com/dariuszjaniak/warp/server/internal/appservice"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
@@ -44,7 +45,8 @@ type ClientAPI struct {
settings *settings.Service
federation *federation.Gate
remoteProfile RemoteProfiles
registration config.Registration
regTokens *userapi.RegistrationTokens
appservices *appservice.API
log *slog.Logger
now func() time.Time
@@ -127,6 +129,15 @@ type Options struct {
// Registration is the file configuration of the registration flow.
Registration config.Registration
// RegistrationTokens answers the m.login.registration_token stage.
// Nil builds one from Registration's static tokens alone.
RegistrationTokens *userapi.RegistrationTokens
// AppServices is the application service component: who may act as
// whom, the exclusive namespaces, and the queries. Nil means no
// application services are registered.
AppServices *appservice.API
// RateLimit configures the per-endpoint buckets.
RateLimit config.RateLimit
@@ -159,6 +170,20 @@ func New(opts Options) (*ClientAPI, error) {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.RegistrationTokens == nil {
now := opts.Now
opts.RegistrationTokens = userapi.NewRegistrationTokens(userapi.RegistrationTokensOptions{
Static: opts.Registration.Tokens,
Required: opts.Registration.RequireToken,
NowMS: func() int64 { return now().UnixMilli() },
})
}
if opts.AppServices == nil {
var err error
if opts.AppServices, err = appservice.New(appservice.Options{Logger: opts.Logger}); err != nil {
return nil, err
}
}
c := &ClientAPI{
serverName: opts.ServerName,
@@ -173,7 +198,8 @@ func New(opts Options) (*ClientAPI, error) {
settings: opts.Settings,
federation: opts.Federation,
remoteProfile: opts.RemoteProfiles,
registration: opts.Registration,
regTokens: opts.RegistrationTokens,
appservices: opts.AppServices,
log: opts.Logger,
now: opts.Now,
uia: newUIAStore(uiaSessionTTL, opts.Now),
@@ -232,6 +258,13 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
// Account.
c.authed(r, "GET "+v3+"/account/whoami", c.limitDefault, c.handleWhoami)
c.authed(r, "POST "+v3+"/account/password", c.limitLogin, c.handleChangePassword)
c.authed(r, "POST "+v3+"/account/deactivate", c.limitLogin, c.handleDeactivate)
// Application services. The ping is the service checking that this
// server can reach it, so it is authenticated with the service's own
// token.
c.authed(r, "POST "+v1+"/appservice/{appserviceID}/ping", c.limitDefault, c.handleAppServicePing)
c.authed(r, "GET "+v3+"/capabilities", c.limitDefault, c.handleCapabilities)
// Devices.
@@ -372,7 +405,7 @@ func (c *ClientAPI) registerRoomRoutes(r *httputil.Router) {
// 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)))
r.Handle(pattern, limiter.MiddlewareUnless(c.clientIP, c.rateLimitExempt)(httputil.Wrap(h)))
}
// authed registers an endpoint that needs one.
@@ -381,5 +414,5 @@ func (c *ClientAPI) public(r *httputil.Router, pattern string, limiter *ratelimi
// 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))))
r.Handle(pattern, limiter.MiddlewareUnless(c.clientIP, c.rateLimitExempt)(httputil.Wrap(c.withAuth(h))))
}
+4 -1
View File
@@ -25,7 +25,7 @@ func (c *ClientAPI) handleGetAlias(w http.ResponseWriter, r *http.Request) error
if err != nil {
return err
}
resolved, err := c.rooms.ResolveAlias(r.Context(), alias)
resolved, err := c.resolveAlias(r.Context(), alias)
if err != nil {
return roomError(err)
}
@@ -54,6 +54,9 @@ func (c *ClientAPI) handlePutAlias(w http.ResponseWriter, r *http.Request, auth
if req.RoomID == "" {
return httputil.MissingParam("A room ID is required")
}
if err := c.checkExclusiveAlias(alias, auth); err != nil {
return err
}
if err := c.rooms.CreateAlias(r.Context(), alias, req.RoomID, auth.UserID); err != nil {
return roomError(err)
+33 -11
View File
@@ -77,9 +77,11 @@ type wellKnownBaseURL struct {
// 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}}},
})
flows := []loginFlow{{Type: stagePassword, Stages: []string{stagePassword}}}
if !c.appservices.Registry().Empty() {
flows = append(flows, loginFlow{Type: loginAppService, Stages: []string{loginAppService}})
}
httputil.WriteJSON(w, http.StatusOK, loginFlowsResponse{Flows: flows})
return nil
}
@@ -94,7 +96,7 @@ func (c *ClientAPI) handleLogin(w http.ResponseWriter, r *http.Request) error {
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Type != stagePassword {
if req.Type != stagePassword && req.Type != loginAppService {
return httputil.Unknown(http.StatusBadRequest, "Login type %q is not supported", req.Type)
}
@@ -119,16 +121,36 @@ func (c *ClientAPI) handleLogin(w http.ResponseWriter, r *http.Request) error {
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
if req.Type == loginAppService {
// The credential is the application service's token, and the
// user has to be one of its own.
svc := c.appservices.Registry().ByToken(accessTokenFrom(r))
switch {
case svc == nil && accessTokenFrom(r) == "":
return httputil.MissingToken()
case svc == nil:
return httputil.UnknownToken(false)
case !svc.OwnsUser(c.users.UserID(localpart)):
return httputil.Forbidden("%s is not in the namespace of application service %s",
c.users.UserID(localpart), svc.ID)
}
} else {
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 {
switch {
case errors.Is(err, userapi.ErrNoSuchUser):
// Only reachable for an application service login: a password
// check has already failed for a user who does not exist.
return httputil.Forbidden("%s does not exist", c.users.UserID(localpart))
case err != nil:
return err
}
if account.Deactivated {
+10 -1
View File
@@ -45,7 +45,7 @@ func (c *ClientAPI) handleJoinRoom(w http.ResponseWriter, r *http.Request, auth
return c.join(w, r, auth, target)
case strings.HasPrefix(target, "#"):
resolved, err := c.rooms.ResolveAlias(r.Context(), target)
resolved, err := c.resolveAlias(r.Context(), target)
if err != nil {
return roomError(err)
}
@@ -129,6 +129,15 @@ func (c *ClientAPI) handleInvite(w http.ResponseWriter, r *http.Request, auth us
"Third-party invites are not supported by this server")
}
// A user of this server that does not exist may be one an
// application service creates on demand: a bridge's puppet for
// somebody on the other network is invited before it is registered.
if localpart, err := c.users.ParseLocalUserID(req.UserID); err == nil {
if _, err := c.ensureAppServiceUser(r.Context(), localpart); err != nil {
return err
}
}
if err := c.rooms.InviteUser(r.Context(), roomserver.InviteRequest{
RoomID: roomID,
Sender: auth.UserID,
+3
View File
@@ -88,6 +88,9 @@ func (c *ClientAPI) lookupProfile(r *http.Request) (storage.Account, error) {
return storage.Account{}, httputil.InvalidParam("%s is not a valid user ID", userID)
}
if _, err := c.ensureAppServiceUser(r.Context(), localpart); err != nil {
return storage.Account{}, err
}
account, err := c.users.Account(r.Context(), localpart)
switch {
case errors.Is(err, userapi.ErrNoSuchUser):
+120 -5
View File
@@ -11,6 +11,10 @@ import (
// registerRequest is the body of POST /register.
type registerRequest struct {
// Type is m.login.application_service for an application service
// registering one of its users, and otherwise absent.
Type string `json:"type"`
Username string `json:"username"`
Password string `json:"password"`
@@ -65,6 +69,12 @@ func (c *ClientAPI) handleRegister(w http.ResponseWriter, r *http.Request) error
return httputil.InvalidParam("Unknown registration kind %q", kind)
}
// An application service registering one of its users is not a
// sign-up: it needs neither the registration switch nor the flow.
if req.Type == loginAppService || (req.Auth != nil && req.Auth.Type == loginAppService) {
return c.registerAppServiceUser(w, r, req)
}
if !c.settings.RegistrationEnabled() {
return httputil.Forbidden("Registration is disabled on this server")
}
@@ -81,10 +91,16 @@ func (c *ClientAPI) handleRegister(w http.ResponseWriter, r *http.Request) error
if taken {
return httputil.UserInUse()
}
if err := c.checkExclusiveUser(c.users.UserID(localpart), ""); err != nil {
return err
}
}
flows := c.registrationFlows()
sessionID, done, err := c.beginOrAdvanceUIA(w, flows, req.Auth, c.registrationStage)
sessionID, done, err := c.beginOrAdvanceUIA(w, flows, req.Auth,
func(stage string, auth *uiaRequest) (*httputil.MatrixError, error) {
return c.registrationStage(r, stage, auth)
})
if err != nil || !done {
return err
}
@@ -111,10 +127,30 @@ func (c *ClientAPI) handleRegister(w http.ResponseWriter, r *http.Request) error
}
}
// The token is spent now that an account is about to exist, not when
// the stage was passed: a sign-up abandoned halfway costs no use. A
// token that ran out in between is refused here.
release := func() {}
if c.regTokens.Required() {
var token string
if session := c.uia.get(sessionID); session != nil {
token = session.registrationToken
}
release, err = c.regTokens.Spend(r.Context(), token)
switch {
case errors.Is(err, userapi.ErrNoSuchRegistrationToken):
c.uia.remove(sessionID)
return httputil.Forbidden("The registration token is no longer valid")
case err != nil:
return err
}
}
if _, err := c.users.CreateAccount(r.Context(), userapi.NewAccountRequest{
Localpart: localpart,
Password: req.Password,
}); err != nil {
release()
switch {
case errors.Is(err, userapi.ErrUserExists):
// Lost the race against another registration of the same name
@@ -167,14 +203,14 @@ func (c *ClientAPI) handleRegister(w http.ResponseWriter, r *http.Request) error
// 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() {
if c.regTokens.Required() {
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) {
func (c *ClientAPI) registrationStage(r *http.Request, stage string, auth *uiaRequest) (*httputil.MatrixError, error) {
switch stage {
case stageDummy:
return nil, nil
@@ -182,9 +218,14 @@ func (c *ClientAPI) registrationStage(stage string, auth *uiaRequest) (*httputil
if auth.Token == "" {
return httputil.MissingParam("A registration token is required"), nil
}
if !c.registration.TokenValid(auth.Token) {
valid, err := c.regTokens.Valid(r.Context(), auth.Token)
if err != nil {
return nil, err
}
if !valid {
return httputil.Forbidden("Invalid registration token"), nil
}
c.uia.setRegistrationToken(auth.Session, auth.Token)
return nil, nil
default:
// beginOrAdvanceUIA only calls this for stages it offered, so
@@ -212,6 +253,9 @@ func (c *ClientAPI) handleRegisterAvailable(w http.ResponseWriter, r *http.Reque
if taken {
return httputil.UserInUse()
}
if err := c.checkExclusiveUser(c.users.UserID(localpart), ""); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"available": true})
return nil
@@ -226,7 +270,78 @@ func (c *ClientAPI) handleRegistrationTokenValidity(w http.ResponseWriter, r *ht
if token == "" {
return httputil.MissingParam("The token query parameter is required")
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"valid": c.registration.TokenValid(token)})
valid, err := c.regTokens.Valid(r.Context(), token)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"valid": valid})
return nil
}
// registerAppServiceUser registers a user for the application service
// whose token the request carries.
//
// The user has to be in the service's user namespace. There is no
// password: an application service's users are only ever used through
// the service, which needs none, or through an m.login.application_service
// login, which needs the service's token rather than a password.
func (c *ClientAPI) registerAppServiceUser(w http.ResponseWriter, r *http.Request, req registerRequest) error {
token := accessTokenFrom(r)
if token == "" {
return httputil.MissingToken()
}
svc := c.appservices.Registry().ByToken(token)
if svc == nil {
return httputil.UnknownToken(false)
}
if req.Username == "" {
return httputil.MissingParam("An application service has to name the user it registers")
}
localpart := userapi.NormaliseLocalpart(req.Username)
if err := userapi.ValidateLocalpart(localpart, c.serverName); err != nil {
return httputil.InvalidUsername("%s", err.Error())
}
userID := c.users.UserID(localpart)
if !svc.OwnsUser(userID) {
return httputil.Exclusive("%s is not in the namespace of application service %s", userID, svc.ID)
}
if req.DeviceID != "" {
if err := userapi.ValidateDeviceID(req.DeviceID); err != nil {
return httputil.InvalidParam("%s", err.Error())
}
}
_, err := c.users.CreateAccount(r.Context(), userapi.NewAccountRequest{Localpart: localpart})
switch {
case errors.Is(err, userapi.ErrUserExists):
return httputil.UserInUse()
case errors.Is(err, userapi.ErrInvalidLocalpart):
return httputil.InvalidUsername("%s", err.Error())
case err != nil:
return err
}
resp := registerResponse{UserID: userID, 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
}
+6
View File
@@ -100,6 +100,12 @@ func (c *ClientAPI) handleCreateRoom(w http.ResponseWriter, r *http.Request, aut
"Third-party invites are not supported by this server")
}
if req.RoomAliasName != "" {
if err := c.checkExclusiveAlias("#"+req.RoomAliasName+":"+c.serverName, auth); err != nil {
return err
}
}
resp, err := c.rooms.CreateRoom(r.Context(), roomserver.CreateRoomRequest{
Creator: auth.UserID,
Version: req.RoomVersion,
+26 -1
View File
@@ -19,6 +19,11 @@ 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.
@@ -54,6 +59,11 @@ 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.
@@ -115,7 +125,22 @@ func (s *uiaStore) get(id string) *uiaSession {
}
// 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}
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.