Provisioning the owner account did not stop anyone else signing up. Stoat ships open, with no captcha and no email verification, so a reachable instance still accepted walk-in registrations. Made a config option rather than hardcoded, because running an open community server is legitimate — but defaulting CLOSED, which is the opposite of the other registration toggle in the tree. Vaultwarden's SIGNUPS_ALLOWED defaults true for a reason that does not apply here: it has to let you register to get in at all, whereas Stoat's owner account is now created for you. Matrix's ENABLE_REGISTRATION already defaults false for the same shape of app. The section name is load-bearing and not guessable. invite_only under [features] or [api.security.authifier] is accepted in silence and does nothing — the API keeps reporting invite_only=false — so it goes under [api.registration], which was found by testing all three against a running instance. Anything but an explicit "false" closes registration, so a blank or misspelled value fails safe. Closing it broke LibrePortal's own tooling, which is the part worth noting: the API answers MissingInvite to create_account too. So account creation now mints a single-use invite and retries when it sees that. Reactive rather than reading the config, so it follows the instance's actual state — someone who edits Revolt.toml by hand gets the same behaviour. Stoat stamps the invite used/claimed_by as it consumes it, and a failed create deletes it, so no reusable invite is left behind; verified that the collection holds zero unused invites after two creations. Verified end to end: a fresh install reports invite_only=true, a walk-in signup is refused with MissingInvite, and the Create User Account tool still succeeds. Flipping the config to false and reinstalling flips the API to open, and the provisioning guard correctly reports "already has accounts" instead of trying to claim a second owner. The value is baked into Revolt.toml at install, so changing it needs a reinstall rather than a reload — now said in the config comment. Unrelated flake seen once during testing and not reproduced: an install left stoat-rabbit with no IP row, so the compose died on a literal IP_DATA_3. A straight uninstall/reinstall allocated all 16 cleanly. Untouched here — it is in the IP allocator, not this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
313 lines
14 KiB
Bash
313 lines
14 KiB
Bash
#!/bin/bash
|
|
|
|
# Stoat user management.
|
|
#
|
|
# Deliberately the thinnest set of the four, because Stoat gives the least to
|
|
# work with. Its service containers are distroless (no shell), it has no admin
|
|
# CLI, and its admin HTTP surface is not a stable documented API — so the only
|
|
# durable handles are the public auth endpoint and the database itself.
|
|
#
|
|
# What that rules out, and why:
|
|
#
|
|
# Role/permission editing — Stoat's permissions are per-server bitfields held
|
|
# on server_members, not a global admin flag. There is no single "make admin"
|
|
# to toggle; it is a server-by-server concept the app models properly and a
|
|
# button would misrepresent. Confirmed against a running instance: the user
|
|
# document holds only _id/username/discriminator, and GET /users/@me adds only
|
|
# relationship and online. There is no privileged bit to set.
|
|
#
|
|
# Password reset WAS excluded for the same reason — Stoat hashes with argon2
|
|
# through its own authifier layer, and a hand-rolled hash that is subtly wrong
|
|
# writes an account nothing can verify, with no error at the time. That objection
|
|
# is answered not by reimplementing the hash but by refusing to: authifier's own
|
|
# password_reset field takes a token, and its reset endpoint does the hashing.
|
|
# See authAdapter_stoat_setPassword.
|
|
|
|
# Host-local API base for this app, e.g. http://127.0.0.1:8860/api.
|
|
#
|
|
# 127.0.0.1 rather than the published LAN address: this only ever runs on the
|
|
# box itself, and the loopback route does not depend on which interface the
|
|
# advertised URL happens to name.
|
|
_stoatApiLocal() {
|
|
local app_name="${1:-stoat}"
|
|
local compose="$containers_dir$app_name/docker-compose.yml"
|
|
local ports external
|
|
ports=$(tagsManagerGetTagContent "$compose" "PORTS_TAG_1" 2>/dev/null)
|
|
external="${ports%%:*}"
|
|
[[ -z "$external" || "$external" == PORTS_DATA* ]] && return 1
|
|
printf 'http://127.0.0.1:%s/api' "$external"
|
|
}
|
|
|
|
# JSON-encode one value, so an apostrophe in a password cannot end the string.
|
|
_stoatJson() { printf '%s' "$1" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))'; }
|
|
|
|
# True when the instance already holds at least one account.
|
|
_stoatAccountExists() {
|
|
local n
|
|
n=$(_stoatMongo 'print(db.accounts.countDocuments({}))' | tr -dc '0-9')
|
|
[[ -n "$n" && "$n" != "0" ]]
|
|
}
|
|
|
|
# Register an account and finish onboarding, which is what actually makes it a
|
|
# usable identity: `accounts` holds the login, `users` the handle, and Stoat
|
|
# creates the second only when onboarding completes. An account left un-onboarded
|
|
# can sign in and then sits on a "pick a username" screen forever, and — worse for
|
|
# a first account — has not yet taken instance ownership.
|
|
#
|
|
# Done over HTTP rather than by writing Mongo directly because passwords go
|
|
# through Stoat's own argon2 layer. Reimplementing that in bash means matching
|
|
# its parameters exactly, and a subtly wrong hash writes an account nothing can
|
|
# verify: no error at the time, just a login that never works.
|
|
_stoatCreateAccount() {
|
|
local api="$1" email="$2" pass="$3" user="$4"
|
|
local body out token
|
|
|
|
body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass")}"
|
|
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \
|
|
-H 'Content-Type: application/json' -d "$body" 2>&1)
|
|
|
|
# On a closed instance the API answers MissingInvite. Rather than refuse, mint
|
|
# one: an admin creating an account here IS the authorisation, and requiring
|
|
# them to go and generate an invite by hand first would make CFG_STOAT_INVITE_ONLY
|
|
# a switch that breaks LibrePortal's own tooling.
|
|
#
|
|
# Reactive rather than reading the config, so this follows the instance's ACTUAL
|
|
# state — someone who edits Revolt.toml by hand, or flips it after install, gets
|
|
# the same behaviour without LibrePortal having to be told.
|
|
#
|
|
# The invite is single-use and consumed by this create: Stoat stamps it
|
|
# used/claimed_by, so it cannot become a spare key left under the mat.
|
|
if [[ "$out" == *MissingInvite* ]]; then
|
|
local code
|
|
code=$(_stoatMongo 'const c = "LP" + Math.random().toString(36).slice(2, 10).toUpperCase();
|
|
db.account_invites.insertOne({_id: c, used: false});
|
|
print(c);' | tr -dc 'A-Z0-9')
|
|
if [[ -z "$code" ]]; then
|
|
isError "Stoat is invite-only and an invite could not be created."
|
|
return 1
|
|
fi
|
|
body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass"),\"invite\":$(_stoatJson "$code")}"
|
|
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \
|
|
-H 'Content-Type: application/json' -d "$body" 2>&1)
|
|
# Do not leave an unused invite behind if the create still failed.
|
|
[[ -n "$out" ]] && _stoatMongo "db.account_invites.deleteOne({_id: $(_stoatJson "$code"), used: false})" >/dev/null 2>&1
|
|
fi
|
|
|
|
# A successful create returns 204 with no body; anything printed is an error.
|
|
if [[ -n "$out" && "$out" != *'"result"'* ]]; then
|
|
isError "Stoat account create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
|
|
return 1
|
|
fi
|
|
|
|
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/session/login" \
|
|
-H 'Content-Type: application/json' -d "$body" 2>&1)
|
|
token=$(printf '%s' "$out" | python3 -c 'import sys,json
|
|
try: print(json.load(sys.stdin).get("token",""))
|
|
except Exception: print("")' 2>/dev/null)
|
|
if [[ -z "$token" ]]; then
|
|
isError "Stoat login after create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
|
|
return 1
|
|
fi
|
|
|
|
out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/onboard/complete" \
|
|
-H 'Content-Type: application/json' -H "X-Session-Token: ${token}" \
|
|
-d "{\"username\":$(_stoatJson "$user")}" 2>&1)
|
|
if [[ "$out" == *'"type"'*'"error"'* || "$out" == *UsernameTaken* || "$out" == *InvalidUsername* ]]; then
|
|
isError "Stoat onboarding failed for '${user}': $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
_stoatMongo() {
|
|
runFileOp docker exec -i stoat-database mongosh revolt --quiet --eval "$1" 2>&1
|
|
}
|
|
|
|
_stoatMongoFailed() {
|
|
local out="$1" what="$2"
|
|
# mongosh reports failures as a thrown error rather than a non-zero exit in
|
|
# --eval mode, so match on the text.
|
|
if [[ "$out" == *MongoServerError* || "$out" == *ReferenceError* || "$out" == *TypeError* ]]; then
|
|
isError "$what failed: $(printf '%s' "$out" | head -2 | tr '\n' ' ')"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Accounts and profiles are separate collections sharing an _id: `accounts` holds
|
|
# the login (email, disabled flag), `users` the profile (username, discriminator).
|
|
# Neither alone is a useful view, so join them.
|
|
authAdapter_stoat_listUsers() {
|
|
local out
|
|
out=$(_stoatMongo '
|
|
const users = db.users.find({}, {username:1, discriminator:1, display_name:1}).toArray();
|
|
const byId = {};
|
|
db.accounts.find({}, {email:1, disabled:1}).toArray().forEach(a => byId[a._id] = a);
|
|
users.forEach(u => {
|
|
const a = byId[u._id] || {};
|
|
const handle = u.username + (u.discriminator ? "#" + u.discriminator : "");
|
|
const state = a.disabled ? " (disabled)" : "";
|
|
// EZ_USER<TAB>identifier<TAB>display<TAB>roles, as the WebUI modal expects.
|
|
// Email first where there is one: it is the field a row action prefills.
|
|
print("EZ_USER\t" + (a.email || handle) + "\t" + handle + "\t" + (a.disabled ? "disabled" : "user"));
|
|
});
|
|
print("LP_TOTAL:" + users.length);
|
|
')
|
|
_stoatMongoFailed "$out" "Listing users" && return 1
|
|
|
|
local line total=0
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
EZ_USER*) IFS=$'\t' read -r _ ident handle state <<< "$line"
|
|
# The marker line has to reach the task log for the WebUI
|
|
# user-list modal to build its rows from.
|
|
printf '%s\n' "$line"
|
|
printf ' %-30s %-24s %s\n' "$ident" "$handle" "$state" ;;
|
|
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
|
|
esac
|
|
done <<< "$out"
|
|
|
|
if [[ "$total" == "0" ]]; then
|
|
isNotice "No Stoat accounts yet — the first person to register becomes the instance owner."
|
|
return 0
|
|
fi
|
|
isSuccessful "$total Stoat account(s)."
|
|
}
|
|
|
|
# Disabling is Stoat's own reversible state, not a hand-rolled hack: the account
|
|
# stays intact and its messages stay readable, the holder just cannot log in.
|
|
_stoatSetDisabled() {
|
|
local who="$1" disabled="$2" verb="$3"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
|
|
local out
|
|
out=$(_stoatMongoWho "$who" "$disabled")
|
|
_stoatMongoFailed "$out" "$verb $who" && return 1
|
|
[[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; }
|
|
[[ "$out" != *LP_OK* ]] && { isError "$verb $who failed: $out"; return 1; }
|
|
return 0
|
|
}
|
|
|
|
# Kept separate so the account lookup (by email on `accounts`, or by username on
|
|
# `users`) lives in one place.
|
|
_stoatMongoWho() {
|
|
local who="$1" disabled="$2"
|
|
_stoatMongo "
|
|
const who = $(printf '%s' "$who" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))');
|
|
let acct = null;
|
|
if (who.includes('@')) {
|
|
acct = db.accounts.findOne({email: who});
|
|
} else {
|
|
const u = db.users.findOne({username: who});
|
|
if (u) acct = db.accounts.findOne({_id: u._id});
|
|
}
|
|
if (!acct) { print('LP_MISSING'); } else {
|
|
db.accounts.updateOne({_id: acct._id}, {\$set: {disabled: $disabled}});
|
|
print('LP_OK');
|
|
}
|
|
"
|
|
}
|
|
|
|
authAdapter_stoat_deleteUser() {
|
|
local who="$1"
|
|
_stoatSetDisabled "$who" "true" "Disabling" || return 1
|
|
isSuccessful "Stoat account '$who' disabled — they can no longer sign in. Re-enable it with the Enable tool."
|
|
isNotice "Their existing sessions are not revoked by this; restart the app to force everyone to re-authenticate."
|
|
}
|
|
|
|
authAdapter_stoat_enableUser() {
|
|
local who="$1"
|
|
_stoatSetDisabled "$who" "false" "Enabling" || return 1
|
|
isSuccessful "Stoat account '$who' re-enabled."
|
|
}
|
|
|
|
authAdapter_stoat_createUser() {
|
|
local email="$1" pass="$2" user="$3"
|
|
[[ -z "$email" ]] && { isError "An email is required."; return 1; }
|
|
[[ -z "$pass" ]] && pass=$(generateRandomPassword)
|
|
# Stoat needs a handle as well as a login. Derive one from the email's local
|
|
# part when the caller did not supply it, so the generic "create user" form
|
|
# (which only asks for email + password) still produces a usable account
|
|
# rather than one stuck on the pick-a-username screen.
|
|
if [[ -z "$user" ]]; then
|
|
user="${email%%@*}"
|
|
user="${user//[^a-zA-Z0-9_.]/}"
|
|
fi
|
|
|
|
local api
|
|
api=$(_stoatApiLocal "${CFG_STOAT_APP_NAME:-stoat}") || {
|
|
isError "Could not work out Stoat's local API address."; return 1; }
|
|
|
|
_stoatCreateAccount "$api" "$email" "$pass" "$user" || return 1
|
|
|
|
# Keep the config truthful when this IS the configured owner: an install that
|
|
# could not reach the API leaves those fields describing an account that does
|
|
# not exist, and creating it by hand afterwards should reconcile the two.
|
|
if [[ "$email" == "${CFG_STOAT_ADMIN_EMAIL:-}" ]]; then
|
|
authPersistCfg stoat ADMIN_PASSWORD "$pass"
|
|
authPersistCfg stoat ADMIN_USERNAME "$user"
|
|
fi
|
|
isSuccessful "Stoat account created — Handle: $user — Email: $email — Password: $pass"
|
|
}
|
|
|
|
# Reset a password WITHOUT touching the hash ourselves.
|
|
#
|
|
# authifier already owns a reset flow: an account carries a password_reset token,
|
|
# and PATCH /auth/account/reset_password trades that token for a new password —
|
|
# hashing it with exactly the parameters the verifier expects, because it is the
|
|
# same code that verifies. So the only thing written directly is the token, which
|
|
# is inert on its own; Stoat does the part that has to be right.
|
|
#
|
|
# The alternative, writing an argon2 string into the account document, would mean
|
|
# reproducing $argon2i$v=19$m=4096,t=3,p=1 by hand — and a near miss there is
|
|
# silent, locking the holder out with no error at the time.
|
|
authAdapter_stoat_setPassword() {
|
|
local who="$1" pass="$2"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
[[ -z "$pass" ]] && pass=$(generateRandomPassword)
|
|
|
|
local api
|
|
api=$(_stoatApiLocal "${CFG_STOAT_APP_NAME:-stoat}") || {
|
|
isError "Could not work out Stoat's local API address."; return 1; }
|
|
|
|
# Short-lived and single-use: the reset endpoint consumes it, and the expiry
|
|
# bounds the window if the endpoint is never reached.
|
|
local token
|
|
token="lp$(tr -dc 'a-z0-9' </dev/urandom | head -c 40)"
|
|
|
|
local out
|
|
out=$(_stoatMongo "
|
|
const who = $(_stoatJson "$who");
|
|
let acct = null;
|
|
if (who.includes('@')) {
|
|
acct = db.accounts.findOne({email: who});
|
|
} else {
|
|
const u = db.users.findOne({username: who});
|
|
if (u) acct = db.accounts.findOne({_id: u._id});
|
|
}
|
|
if (!acct) { print('LP_MISSING'); } else {
|
|
db.accounts.updateOne({_id: acct._id}, {\$set: {password_reset: {token: $(_stoatJson "$token"), expiry: new Date(Date.now() + 600000).toISOString()}}});
|
|
print('LP_OK');
|
|
}
|
|
")
|
|
_stoatMongoFailed "$out" "Password reset for $who" && return 1
|
|
[[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; }
|
|
[[ "$out" != *LP_OK* ]] && { isError "Password reset for $who failed: $out"; return 1; }
|
|
|
|
out=$(runFileOp curl -sS --max-time 20 -X PATCH "${api}/auth/account/reset_password" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"token\":$(_stoatJson "$token"),\"password\":$(_stoatJson "$pass")}" 2>&1)
|
|
if [[ -n "$out" ]]; then
|
|
# Clear the token so a failed attempt does not leave a live reset behind.
|
|
_stoatMongo "db.accounts.updateOne({\$or:[{email:$(_stoatJson "$who")}]}, {\$unset:{password_reset:''}})" >/dev/null 2>&1
|
|
isError "Stoat rejected the new password: $(printf '%s' "$out" | tr -d '\n' | head -c 200)"
|
|
return 1
|
|
fi
|
|
|
|
if [[ "$who" == "${CFG_STOAT_ADMIN_EMAIL:-}" || "$who" == "${CFG_STOAT_ADMIN_USERNAME:-}" ]]; then
|
|
authPersistCfg stoat ADMIN_PASSWORD "$pass"
|
|
fi
|
|
isSuccessful "Stoat password set for $who — New password: $pass"
|
|
isNotice "Existing sessions stay valid; restart the app to force a re-login."
|
|
}
|