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 }