From 160c552809224131ca285f7853471ae37dab88d3 Mon Sep 17 00:00:00 2001 From: casjay Date: Sun, 8 Nov 2026 10:34:11 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Implement=20ENABLE=5FREGISTRATION?= =?UTF-8?q?=20via=20user=5Fapi=20module=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/rootfs/usr/local/share/prosody-plugins/mod_user_api.lua: new POST /user-api/register endpoint (JSON, validation, reserved names, per-IP rate limit, refuses when AUTH_TYPE is not internal) - docker/rootfs/usr/local/bin/entrypoint.sh: read ENABLE_REGISTRATION, load user_api on the main host only when ENABLE_AUTH is on, warn otherwise - docker/compose.yml: add ENABLE_REGISTRATION - README.md: document registration --- README.md | 9 +++ docker/compose.yml | 1 + docker/rootfs/usr/local/bin/entrypoint.sh | 11 +++ .../share/prosody-plugins/mod_user_api.lua | 77 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 docker/rootfs/usr/local/share/prosody-plugins/mod_user_api.lua diff --git a/README.md b/README.md index de90a4c..90740fb 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,12 @@ buildx Service accounts (`focus`, `jvb`, `jibri`, `jigasi`) always use local `internal_hashed`. Only SASL PLAIN is offered with `openfire`, since an external check cannot do SCRAM. + +## Registration + +| Variable | Default | Meaning | +|---|---|---| +| `ENABLE_REGISTRATION` | `0` | Serve `POST /user-api/register` (used by the jitsi-web `/register` page) | + +Requires `ENABLE_AUTH=1` and `AUTH_TYPE=internal`; otherwise it is ignored (with a warning) or refused (403). +Rate limited to 5 attempts per IP per hour; reserved names (`admin`, `focus`, `jvb`, ...) are rejected. diff --git a/docker/compose.yml b/docker/compose.yml index 4a67b5e..1b7dcbb 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -27,6 +27,7 @@ services: ENABLE_XMPP_WEBSOCKET: "1" LOG_LEVEL: info # internal = local accounts only; openfire = also accept Openfire accounts + ENABLE_REGISTRATION: "0" AUTH_TYPE: internal OPENFIRE_AUTH_ENABLED: "0" OPENFIRE_AUTH_HOST: 172.17.0.1 diff --git a/docker/rootfs/usr/local/bin/entrypoint.sh b/docker/rootfs/usr/local/bin/entrypoint.sh index c068920..23429e6 100644 --- a/docker/rootfs/usr/local/bin/entrypoint.sh +++ b/docker/rootfs/usr/local/bin/entrypoint.sh @@ -39,6 +39,7 @@ set -euo pipefail [[ "${AUTH_TYPE}" == "openfire" ]] && OPENFIRE_AUTH_ENABLED=1 export OPENFIRE_AUTH_ENABLED : "${DISABLE_POLLS:=0}" +: "${ENABLE_REGISTRATION:=0}" # Derived: AUTH_TYPE selects the authentication module for user accounts # internal -> internal_hashed (local accounts only) @@ -239,6 +240,16 @@ VirtualHost "${XMPP_DOMAIN}" "features_identity"; SITE_START + # Registration endpoint used by the jitsi-web /register page + # Needs ENABLE_AUTH: anonymous auth has nowhere to store accounts + if [[ "${ENABLE_REGISTRATION}" == "1" ]] || [[ "${ENABLE_REGISTRATION}" == "true" ]]; then + if [[ "${main_auth}" == "jitsi-anonymous" ]]; then + echo "WARN: ENABLE_REGISTRATION ignored: requires ENABLE_AUTH=1" >&2 + else + printf ' "user_api";\n' >> "${PROSODY_SITE_CFG}" + fi + fi + # Optional XMPP_MODULES if [[ -n "${XMPP_MODULES:-}" ]]; then while IFS=',' read -ra mods; do diff --git a/docker/rootfs/usr/local/share/prosody-plugins/mod_user_api.lua b/docker/rootfs/usr/local/share/prosody-plugins/mod_user_api.lua new file mode 100644 index 0000000..1d865a5 --- /dev/null +++ b/docker/rootfs/usr/local/share/prosody-plugins/mod_user_api.lua @@ -0,0 +1,77 @@ +-- Account registration endpoint for the jitsi-web /register page. +-- POST /user-api/register with JSON {"username": "...", "password": "..."} +-- creates a local account on this virtual host. Loaded only when +-- ENABLE_REGISTRATION is true; refuses when accounts live in Openfire. +module:depends("http"); + +local json = require "prosody.util.json"; +local nodeprep = require "prosody.util.encodings".stringprep.nodeprep; +local usermanager = require "prosody.core.usermanager"; + +local host = module.host; +local auth_type = (os.getenv("AUTH_TYPE") or "internal"):lower(); +local reserved = {}; +for name in ("admin,administrator,root,system,guest," + .. (os.getenv("OPENFIRE_AUTH_EXCLUDE") or "focus,jvb,jibri,recorder") + .. ",jigasi,transcriber"):gmatch("[^,%s]+") do + reserved[name:lower()] = true; +end + +local max_per_hour = module:get_option_number("user_api_max_per_hour", 5); +local attempts = {}; + +local function reply(response, code, body) + response.status_code = code; + response.headers.content_type = "application/json"; + return json.encode(body); +end + +local function rate_limited(ip) + local now = os.time(); + local entry = attempts[ip]; + if not entry or now - entry.start > 3600 then + entry = { start = now, count = 0 }; + attempts[ip] = entry; + end + entry.count = entry.count + 1; + return entry.count > max_per_hour; +end + +local function register(event) + local request, response = event.request, event.response; + if auth_type ~= "internal" then + return reply(response, 403, { error = "Registration is handled by the XMPP server" }); + end + if rate_limited(request.ip or "unknown") then + return reply(response, 429, { error = "Too many attempts, try again later" }); + end + local data = request.body and json.decode(request.body); + if type(data) ~= "table" or type(data.username) ~= "string" or type(data.password) ~= "string" then + return reply(response, 400, { error = "username and password are required" }); + end + local username = nodeprep(data.username:lower()); + if not username or not username:match("^[a-z0-9][a-z0-9._-]*$") or #username > 64 then + return reply(response, 400, { error = "Invalid username" }); + end + if reserved[username] then + return reply(response, 403, { error = "This username is not allowed" }); + end + if #data.password < 8 or #data.password > 256 then + return reply(response, 400, { error = "Password must be 8 to 256 characters" }); + end + if usermanager.user_exists(username, host) then + return reply(response, 409, { error = "Username already taken" }); + end + local ok, err = usermanager.create_user(username, data.password, host); + if not ok then + module:log("warn", "Registration of %s failed: %s", username, tostring(err)); + return reply(response, 500, { error = "Registration failed" }); + end + module:log("info", "Registered account %s@%s", username, host); + return reply(response, 201, { status = "created" }); +end + +module:provides("http", { + default_path = "/user-api"; + route = { ["POST /register"] = register }; +});