libervia-backend/libervia/backend/plugins/plugin_xep_0077.py
Goffi 6a7b7bc36a core: Replace legacy INI config system with Pydantic-based TOML configuration.
This is a huge configuration refactoring.

The configuration system now uses Pydantic settings, with better validation, better
support for environment variables and other goodies.

The main config files now use TOML instead of INI, this allow for better handling of
complex types, notably dict.

Plugins, components and frontends can declare their config settings in a class which will
be imported dynamically.
2025-11-30 12:27:35 +01:00

563 lines
21 KiB
Python

#!/usr/bin/env python3
# SAT plugin for managing xep-0077
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Awaitable, Callable, NamedTuple, cast
from twisted.internet import defer, reactor, ssl
from twisted.internet.interfaces import IReactorCore
from twisted.python.failure import Failure
from twisted.words.protocols.jabber import (
client,
error as jabber_error,
jid,
xmlstream as xmlstream_mod,
)
from twisted.words.xish import domish
from wokkel import data_form, disco, iwokkel
from zope.interface import implementer
from libervia.backend import G
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.core.xmpp import SatXMPPEntity
from libervia.backend.tools import xml_tools
from libervia.backend.tools.utils import as_deferred, ensure_deferred
log = getLogger(__name__)
NS_IQ_REGISTER = "jabber:iq:register"
IQ_REGISTER_REQUEST = f'{C.IQ_GET}/query[@xmlns="{NS_IQ_REGISTER}"]'
IQ_SUBMIT_REQUEST = f'{C.IQ_SET}/query[@xmlns="{NS_IQ_REGISTER}"]'
PLUGIN_INFO = {
C.PI_NAME: "In-Band Registration",
C.PI_IMPORT_NAME: "XEP-0077",
C.PI_TYPE: "XEP",
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_PROTOCOLS: ["XEP-0077"],
C.PI_DEPENDENCIES: [],
C.PI_MAIN: "XEP_0077",
C.PI_DESCRIPTION: _("""Implementation of in-band registration."""),
C.PI_HANDLER: C.BOOL_TRUE,
}
# FIXME: this implementation is incomplete
class RegistrationHandlers(NamedTuple):
form_handler: Callable
submit_handler: Callable
class RegisteringAuthenticator(xmlstream_mod.ConnectAuthenticator):
# FIXME: request IQ is not send to check available fields,
# while XEP recommand to use it
# FIXME: doesn't handle data form or oob
namespace = "jabber:client"
def __init__(self, jid_, password, email=None, check_certificate=True):
log.debug(_("Registration asked for {jid}").format(jid=jid_))
xmlstream_mod.ConnectAuthenticator.__init__(self, jid_.host)
self.jid = jid_
self.password = password
self.email = email
self.check_certificate = check_certificate
self.registered = defer.Deferred()
def associateWithStream(self, xmlstream):
xmlstream_mod.ConnectAuthenticator.associateWithStream(self, xmlstream)
xmlstream.addObserver(xmlstream_mod.STREAM_AUTHD_EVENT, self.register)
xmlstream.initializers = [client.CheckVersionInitializer(xmlstream)]
if self.check_certificate:
tls_required, configurationForTLS = True, None
else:
tls_required = False
configurationForTLS = ssl.CertificateOptions(trustRoot=None)
tls_init = xmlstream_mod.TLSInitiatingInitializer(
xmlstream, required=tls_required, configurationForTLS=configurationForTLS
)
xmlstream.initializers.append(tls_init)
def register(self, xmlstream):
log.debug(
_(
"Stream started with {server}, now registering".format(
server=self.jid.host
)
)
)
iq = XEP_0077.build_register_iq(
self.xmlstream, self.jid, self.password, self.email
)
d = iq.send(self.jid.host).addCallbacks(
self.registration_cb, self.registration_eb
)
d.chainDeferred(self.registered)
def registration_cb(self, answer):
log.debug(_("Registration answer: {}").format(answer.toXml()))
assert self.xmlstream is not None
self.xmlstream.sendFooter()
def registration_eb(self, failure_):
log.info(_("Registration failure: {}").format(str(failure_.value)))
assert self.xmlstream is not None
self.xmlstream.sendFooter()
raise failure_
class ServerRegister(xmlstream_mod.XmlStreamFactory):
def __init__(self, *args, **kwargs):
xmlstream_mod.XmlStreamFactory.__init__(self, *args, **kwargs)
self.addBootstrap(xmlstream_mod.STREAM_END_EVENT, self._disconnected)
def clientConnectionLost(self, connector, unused_reason):
connector.disconnect()
def _disconnected(self, reason):
if not self.authenticator.registered.called:
err = jabber_error.StreamError("Server unexpectedly closed the connection")
try:
if reason.value.args[0][0][2] == "certificate verify failed":
err = exceptions.InvalidCertificate()
except (IndexError, TypeError):
pass
self.authenticator.registered.errback(err)
class XEP_0077:
def __init__(self, host):
log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
self.host = host
host.register_namespace("iq-register", NS_IQ_REGISTER)
host.bridge.add_method(
"in_band_register",
".plugin",
in_sign="ss",
out_sign="",
method=self._in_band_register,
async_=True,
)
host.bridge.add_method(
"in_band_account_new",
".plugin",
in_sign="ssssi",
out_sign="",
method=self._register_new_account,
async_=True,
)
host.bridge.add_method(
"in_band_unregister",
".plugin",
in_sign="ss",
out_sign="",
method=self._unregister,
async_=True,
)
host.bridge.add_method(
"in_band_password_change",
".plugin",
in_sign="ss",
out_sign="",
method=self._change_password,
async_=True,
)
self.handlers = set()
def get_handler(self, client: SatXMPPEntity) -> xmlstream_mod.XMPPHandler:
return XEP_0077_handler(self)
def register_handler(
self,
form_handler: Callable[
[SatXMPPEntity, domish.Element],
Awaitable[tuple[bool, data_form.Form] | None]
| defer.Deferred[tuple[bool, data_form.Form] | None]
| tuple[bool, data_form.Form]
| None,
],
submit_handler: Callable[
[SatXMPPEntity, domish.Element, data_form.Form | None],
Awaitable[bool | None] | defer.Deferred[bool | None] | bool | None,
],
) -> None:
"""
Register a new handler.
Mostly useful for component which handle In-Band Registration.
@param form_handler: method to call on registration request to get a data form.
May be async.
The handler must return a data_form.Form instance if it can handle the
request, otherwise other handlers will be tried until one returns a data form.
@param submit_handler: method to call on registration request to submit the
handler.
In case of "unregister" request, None will be used instead of a
data_form.Form.
"""
self.handlers.add(RegistrationHandlers(form_handler, submit_handler))
def _on_register_request(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
defer.ensureDeferred(self._on_request(iq_elt, client, True))
def _on_submit_request(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
defer.ensureDeferred(self._on_request(iq_elt, client, False))
async def _on_request(
self, iq_elt: domish.Element, client: SatXMPPEntity, is_register: bool
) -> None:
"""Handle a register or submit request.
@param iq_elt: The IQ element of the request.
@param client: Client session.
@param is_register: Whether this is a register request (True) or a submit request
(False).
"""
iq_elt.handled = True
# Submit request must have a form with submitted values.
if is_register:
handler_type = "register"
submit_form = None
else:
handler_type = "submit"
remove_elt = next(iq_elt.query.elements(NS_IQ_REGISTER, "remove"), None)
if remove_elt is not None:
# This is a unregister request.
submit_form = None
else:
submit_form = data_form.findForm(iq_elt.query, NS_IQ_REGISTER)
if submit_form is None:
log.warning(f"Data form not found, invalid request: {iq_elt.toXml()}")
client.send(
jabber_error.StanzaError(
"bad-request", text="No data form found."
).toResponse(iq_elt)
)
return
# We look and run relevant handler.
for handlers in self.handlers:
if is_register:
handler = handlers.form_handler
handler_call = as_deferred(handler, client, iq_elt)
else:
handler = handlers.submit_handler
handler_call = as_deferred(handler, client, iq_elt, submit_form)
try:
callback_ret = await handler_call
except jabber_error.StanzaError as e:
iq_error_elt = e.toResponse(iq_elt)
client.send(iq_error_elt)
return
except exceptions.PasswordError as e:
log.warning("Invalid login or password while registering to service.")
iq_error_elt = jabber_error.StanzaError(
"forbidden", text=str(e)
).toResponse(iq_elt)
client.send(iq_error_elt)
return
except Exception:
log.exception(
f"Error while handling {handler_type} request with {handler}, "
"ignoring it."
)
continue
if callback_ret is not None:
if is_register:
try:
registered, registration_form = callback_ret
assert isinstance(registered, bool)
assert isinstance(registration_form, data_form.Form)
except (TypeError, ValueError, AssertionError) as e:
log.warning(
f"Invalid return value from {handler}, ignoring it: {e}"
)
continue
# We need to be sure to have the right namespace for the form.
registration_form.formNamespace = NS_IQ_REGISTER
iq_result_elt = xmlstream_mod.toResponse(iq_elt, "result")
query_elt = iq_result_elt.addElement((NS_IQ_REGISTER, "query"))
if registered:
# The requestor is already registered, we indicate it.
query_elt.addElement("registered")
query_elt.addChild(registration_form.toElement())
client.send(iq_result_elt)
else:
if callback_ret is True:
if submit_form is None:
log.info(f"User {iq_elt['from']} successfully unregistered.")
else:
log.info(f"User {iq_elt['from']} successfully registered.")
else:
log.error(
f"Unexpected return value from {handler}, was expecting "
'"True".'
)
client.send(
jabber_error.StanzaError(
"internal-server-error", text="Error in request handler."
).toResponse(iq_elt)
)
return
iq_result_elt = xmlstream_mod.toResponse(iq_elt, "result")
client.send(iq_result_elt)
break
else:
log.warning(
f"No handler found for in-band registration {handler_type} request: "
f"{iq_elt.toXml()}."
)
iq_error_elt = jabber_error.StanzaError("service-unavailable").toResponse(
iq_elt
)
client.send(iq_error_elt)
return
@staticmethod
def build_register_iq(xmlstream, jid_, password, email=None):
iq_elt = xmlstream_mod.IQ(xmlstream, "set")
iq_elt["to"] = jid_.host
query_elt = iq_elt.addElement(("jabber:iq:register", "query"))
username_elt = query_elt.addElement("username")
username_elt.addContent(jid_.user)
password_elt = query_elt.addElement("password")
password_elt.addContent(password)
if email is not None:
email_elt = query_elt.addElement("email")
email_elt.addContent(email)
return iq_elt
def _in_band_register(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
client = self.host.get_client(profile_key)
return defer.ensureDeferred(self.in_band_register(client, jid.JID(to_jid_s)))
async def in_band_register(
self,
client: SatXMPPEntity,
to_jid: jid.JID,
post_treat_cb: Callable | None = None,
) -> xml_tools.XMLUI:
"""Register to a service.
Send an IQ request to register with the given service and return the registration
form as XML UI.
@param client: client session.
@param to_jid: The JID of the service to register to.
@param post_treat_cb: A callback function to handle the registration result, if
provided.
@return: The registration form as XML UI.
"""
# FIXME: this post_treat_cb arguments seems wrong, check it
log.debug(_("Asking registration for {}").format(to_jid.full()))
reg_request = client.IQ("get")
reg_request["from"] = client.jid.full()
reg_request["to"] = to_jid.full()
reg_request.addElement("query", NS_IQ_REGISTER)
try:
iq_result_elt = await reg_request.send(to_jid.full())
except Exception as e:
log.warning(_("Registration failure: {}").format(e))
raise e
else:
try:
query_elt = next(iq_result_elt.elements(NS_IQ_REGISTER, "query"))
except StopIteration:
raise exceptions.DataError(
f"Can't find expected query element: {iq_result_elt.toXml()}"
)
try:
x_elem = next(query_elt.elements(data_form.NS_X_DATA, "x"))
except StopIteration:
# XXX: it seems we have an old service which doesn't manage data forms
log.warning("Can't find data form: {iq_result_elt.toXml()}")
raise exceptions.DataError(
_(
"This gateway is outdated and can't be managed by Libervia, "
"sorry :("
)
)
form = data_form.Form.fromElement(x_elem)
xml_ui = xml_tools.data_form_2_xmlui(form, "")
d = xml_tools.deferred_ui(self.host, xml_ui)
d.addCallback(
self._on_xml_ui_cb,
client,
iq_result_elt["id"],
iq_result_elt["from"],
post_treat_cb,
)
d.addErrback(self._on_xml_ui_eb)
d.addTimeout(600, cast(IReactorCore, reactor))
return xml_ui
@ensure_deferred
async def _on_xml_ui_cb(
self,
data: dict,
client: SatXMPPEntity,
stanza_id: str,
to_: str,
post_treat_cb: Callable | None,
) -> None:
"""Handle the XML UI result of a registration form.
Process the filled registration form (from frontend) and send it back to complete
the registration process.
@param data: The filled registration form as a XMLUI Form result.
@param client: Client session.
@param stanza_id: The ID of the IQ stanza of the registration process.
@param to_: The JID of the service that sent the registration form.
@param post_treat_cb: A callback function to handle the registration result, if
provided.
"""
form_elt = xml_tools.xmlui_result_to_elt(data, NS_IQ_REGISTER)
iq_elt = client.IQ()
iq_elt["id"] = stanza_id
iq_elt["to"] = to_
query_elt = iq_elt.addElement("query", NS_IQ_REGISTER)
query_elt.addChild(form_elt)
try:
answer = await iq_elt.send()
log.debug(_("registration answer: %s") % answer.toXml())
if post_treat_cb is not None:
post_treat_cb(jid.JID(answer["from"]), client.profile)
except jabber_error.StanzaError as e:
log.info(_("Registration failure: {}").format(e))
if e.condition == "conflict":
raise exceptions.ConflictError(
_("Username already exists, please choose another one.")
)
raise e
def _on_xml_ui_eb(self, failure_: Failure) -> None:
"""Handle error during handling of registration form by frontend."""
log.warning(f"Error while handling registration form to frontend: {failure_}")
def _register_new_account(self, jid_, password, email, host, port):
kwargs = {}
if email:
kwargs["email"] = email
if host:
kwargs["host"] = host
if port:
kwargs["port"] = port
return self.register_new_account(jid.JID(jid_), password, **kwargs)
def register_new_account(
self,
jid_: jid.JID,
password: str,
email: str | None = None,
host: str | None = None,
port: int = C.XMPP_C2S_PORT,
):
"""Register a new account on a XMPP server.
@param jid_: request jid to register
@param password: password of the account
@param email: email of the account
@param host: host of the server to register to
@param port: port of the server to register to
"""
if host is None:
host = G.config.common.xmpp_domain
check_certificate = host != "127.0.0.1"
authenticator = RegisteringAuthenticator(
jid_, password, email, check_certificate=check_certificate
)
registered_d = authenticator.registered
server_register = ServerRegister(authenticator)
reactor.connectTCP(host, port, server_register)
return registered_d
def _change_password(self, new_password, profile_key):
client = self.host.get_client(profile_key)
return self.change_password(client, new_password)
def change_password(self, client, new_password):
iq_elt = self.build_register_iq(client.xmlstream, client.jid, new_password)
d = iq_elt.send(client.jid.host)
d.addCallback(
lambda __: self.host.memory.param_set(
"Password", new_password, "Connection", profile_key=client.profile
)
)
return d
def _unregister(self, to_jid_s, profile_key):
client = self.host.get_client(profile_key)
return self.unregister(client, jid.JID(to_jid_s))
def unregister(self, client: SatXMPPEntity, to_jid: jid.JID) -> defer.Deferred:
"""Remove registration from a server/service.
BEWARE! if you remove registration from profile own server, this will
DELETE THE XMPP ACCOUNT WITHOUT WARNING
@param to_jid: jid of the service or server
None to delete client's account (DANGEROUS!)
"""
iq_elt = client.IQ()
if to_jid is not None:
iq_elt["to"] = to_jid.full()
query_elt = iq_elt.addElement((NS_IQ_REGISTER, "query"))
query_elt.addElement("remove")
d = iq_elt.send()
if not to_jid or to_jid == jid.JID(client.jid.host):
d.addCallback(lambda __: client.entity_disconnect())
return d
@implementer(iwokkel.IDisco)
class XEP_0077_handler(xmlstream_mod.XMPPHandler):
def __init__(self, plugin_parent: XEP_0077) -> None:
self.plugin_parent = plugin_parent
def connectionInitialized(self):
client = cast(SatXMPPEntity, self.parent)
if client.is_component:
self.xmlstream.addObserver(
IQ_REGISTER_REQUEST,
self.plugin_parent._on_register_request,
client=client,
)
self.xmlstream.addObserver(
IQ_SUBMIT_REQUEST,
self.plugin_parent._on_submit_request,
client=client,
)
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
return [disco.DiscoFeature(NS_IQ_REGISTER)]
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
return []