Files
Warp/server/internal/clientapi/login.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

272 lines
8.7 KiB
Go

package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Login identifier types. Third-party identifiers (email, phone) need an
// identity server, which is a non-goal for now.
const (
identifierUser = "m.id.user"
)
// loginIdentifier is the `identifier` object of a login or a password stage.
type loginIdentifier struct {
Type string `json:"type"`
User string `json:"user"`
}
// loginRequest is the body of POST /login.
type loginRequest struct {
Type string `json:"type"`
Identifier loginIdentifier `json:"identifier"`
// User is the deprecated identifier form, still sent by older clients
// and by some test harnesses.
User string `json:"user"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
InitialDeviceDisplayName string `json:"initial_device_display_name"`
RefreshToken bool `json:"refresh_token"`
}
// loginFlow is one entry of GET /login.
//
// The spec defines a flow as having `stages`, but for login every flow has
// exactly one, and clients have always read a flat `type`. Both are sent:
// `type` for the clients, `stages` for a strict reading of the spec.
type loginFlow struct {
Type string `json:"type"`
Stages []string `json:"stages,omitempty"`
}
type loginFlowsResponse struct {
Flows []loginFlow `json:"flows"`
}
// loginResponse is the body of a successful login.
type loginResponse struct {
UserID string `json:"user_id"`
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
// HomeServer is deprecated but still read by older clients.
HomeServer string `json:"home_server,omitempty"`
WellKnown *wellKnownDiscovery `json:"well_known,omitempty"`
}
// wellKnownDiscovery mirrors /.well-known/matrix/client, which the spec
// allows a login response to carry so a client does not have to fetch it.
type wellKnownDiscovery struct {
Homeserver wellKnownBaseURL `json:"m.homeserver"`
}
type wellKnownBaseURL struct {
BaseURL string `json:"base_url"`
}
// handleLoginFlows implements GET /_matrix/client/v3/login.
func (c *ClientAPI) handleLoginFlows(w http.ResponseWriter, _ *http.Request) error {
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
}
// handleLogin implements POST /_matrix/client/v3/login.
//
// Every rejection is the same M_FORBIDDEN, whether the account does not
// exist, has no password or the password is wrong, and userapi spends the
// same work on each. A login endpoint that distinguishes them is a user
// directory for anyone who asks.
func (c *ClientAPI) handleLogin(w http.ResponseWriter, r *http.Request) error {
var req loginRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Type != stagePassword && req.Type != loginAppService {
return httputil.Unknown(http.StatusBadRequest, "Login type %q is not supported", req.Type)
}
identifier := req.Identifier.User
switch {
case identifier != "" && req.Identifier.Type != identifierUser:
return httputil.InvalidParam("Login identifier type %q is not supported", req.Identifier.Type)
case identifier == "":
identifier = req.User
}
if identifier == "" {
return httputil.MissingParam("A user identifier is required")
}
localpart, err := c.users.ParseLocalUserID(identifier)
switch {
case errors.Is(err, userapi.ErrForeignServer):
// Not a federation matter: an account on another server has its
// password there, and no amount of federation changes that.
return httputil.Forbidden("Cannot log in as a user of another server")
case err != nil:
return httputil.Forbidden("Invalid username or password")
}
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)
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 {
return httputil.UserDeactivated()
}
if req.DeviceID != "" {
if err := userapi.ValidateDeviceID(req.DeviceID); err != nil {
return httputil.InvalidParam("%s", err.Error())
}
}
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 := loginResponse{
UserID: session.UserID,
AccessToken: session.AccessToken,
DeviceID: session.DeviceID,
RefreshToken: session.RefreshToken,
ExpiresInMS: session.ExpiresInMS,
HomeServer: c.serverName,
}
if c.clientBaseURL != "" {
resp.WellKnown = &wellKnownDiscovery{
Homeserver: wellKnownBaseURL{BaseURL: c.clientBaseURL},
}
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// refreshRequest is the body of POST /refresh.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// refreshResponse is its answer.
type refreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
}
// handleRefresh implements POST /_matrix/client/v3/refresh.
//
// It is unauthenticated on purpose: the refresh token *is* the credential,
// and the access token it replaces has usually already expired.
func (c *ClientAPI) handleRefresh(w http.ResponseWriter, r *http.Request) error {
var req refreshRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.RefreshToken == "" {
return httputil.MissingParam("refresh_token is required")
}
session, err := c.users.RefreshSession(r.Context(), req.RefreshToken)
switch {
case err == nil:
case errors.Is(err, userapi.ErrNoSuchToken):
// A soft logout: the device is probably still legitimate, it just
// cannot prove it any more.
return httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return httputil.UserDeactivated()
default:
return err
}
httputil.WriteJSON(w, http.StatusOK, refreshResponse{
AccessToken: session.AccessToken,
RefreshToken: session.RefreshToken,
ExpiresInMS: session.ExpiresInMS,
})
return nil
}
// handleLogout implements POST /_matrix/client/v3/logout. Logging out
// deletes the device, not only the token: that is what the spec says, and it
// is what keeps a user's device list from filling up with dead entries.
func (c *ClientAPI) handleLogout(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.users.DeleteDevices(r.Context(), auth.Localpart, []string{auth.DeviceID}); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleLogoutAll implements POST /_matrix/client/v3/logout/all.
func (c *ClientAPI) handleLogoutAll(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.users.DeleteAllDevices(r.Context(), auth.Localpart); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleWhoami implements GET /_matrix/client/v3/account/whoami.
func (c *ClientAPI) handleWhoami(w http.ResponseWriter, _ *http.Request, auth userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"user_id": auth.UserID,
"device_id": auth.DeviceID,
// Guest accounts are not implemented, so this is always false
// rather than omitted: clients branch on it.
"is_guest": false,
})
return nil
}