authelia: allow password reset writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-22 13:19:58 +01:00
parent c57ca59f25
commit 8fe9e98300
3 changed files with 46 additions and 13 deletions

View file

@ -1,5 +1,6 @@
"""Self-check for guildhall-join.py: invites, the append-only users file, and """Self-check for guildhall-join.py: invites, the append-only users file (also
that Authelia accepts the password hashes it writes. after Authelia rewrote it for a password reset), and that Authelia accepts the
password hashes it writes.
Run it against the same python the service uses, with authelia on PATH: Run it against the same python the service uses, with authelia on PATH:
@ -78,6 +79,23 @@ check("display name survives quoting", users["alex"]["displayname"] == 'Alex "th
check("invite is spent", j.open_invite(token) is None) check("invite is spent", j.open_invite(token) is None)
check("spent invite refused", refused(j.sign_up, token, "sam", "", "longpassword", "longpassword")) check("spent invite refused", refused(j.sign_up, token, "sam", "", "longpassword", "longpassword"))
# What Authelia's yaml writer leaves behind after a password reset.
REWRITTEN = """users:
fred:
password: $argon2id$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA
displayname: Fred
email: fred@example.org
groups:
- household
address: null
extra: {}
"""
j.USERS_FILE.write_text(REWRITTEN)
second = j.mint("after a reset")
j.sign_up(second, "sam", "", "correct horse battery", "correct horse battery")
rewritten = yaml.safe_load(j.USERS_FILE.read_text())["users"]
check("signs up after authelia rewrote the file", set(rewritten) == {"fred", "sam"} and rewritten["sam"]["groups"] == ["guildhall"])
expired = j.mint("old", now=1.0) expired = j.mint("old", now=1.0)
check("expired invite is closed", j.open_invite(expired) is None) check("expired invite is closed", j.open_invite(expired) is None)

View file

@ -113,18 +113,26 @@ def write_atomic(path: Path, text: str, mode: int, gid: int = -1) -> None:
raise raise
def user_indent(text: str) -> str:
"""The indent of the existing user keys. Authelia rewrites the whole file
with four spaces when someone resets their password; by hand it's two."""
match = re.search(r"^users:[ \t]*\n(?:[ \t]*(?:#.*)?\n)*( +)\S", text, re.M)
return match.group(1) if match else " "
def add_user(path: Path, username: str, display: str, password_hash: str) -> None: def add_user(path: Path, username: str, display: str, password_hash: str) -> None:
"""Append one user; refuse unless every existing user parses back unchanged.""" """Append one user; refuse unless every existing user parses back unchanged."""
text = path.read_text() text = path.read_text()
before = yaml.safe_load(text)["users"] before = yaml.safe_load(text)["users"]
if username.lower() in (name.lower() for name in before): if username.lower() in (name.lower() for name in before):
raise Refused("That username is taken.") raise Refused("That username is taken.")
i = user_indent(text)
block = ( block = (
f" {username}:\n" f"{i}{username}:\n"
f" displayname: {json.dumps(display)}\n" f"{i * 2}displayname: {json.dumps(display)}\n"
f" password: {json.dumps(password_hash)}\n" f"{i * 2}password: {json.dumps(password_hash)}\n"
f" groups:\n" f"{i * 2}groups:\n"
f" - {GROUP}\n" f"{i * 3}- {GROUP}\n"
) )
new_text = text.rstrip("\n") + "\n\n" + block new_text = text.rstrip("\n") + "\n\n" + block
after = yaml.safe_load(new_text)["users"] after = yaml.safe_load(new_text)["users"]

View file

@ -2,8 +2,9 @@
# Secrets live in /var/secrets/authelia (root:authelia-main, 640) — see readme. # Secrets live in /var/secrets/authelia (root:authelia-main, 640) — see readme.
# #
# Users live in /var/lib/authelia-users so guildhall-join (services/ # Users live in /var/lib/authelia-users so guildhall-join (services/
# guildhall-join.nix) can add invited players; Authelia only reads it and # guildhall-join.nix) can add invited players. Authelia reloads it on change
# reloads on change. Groups decide reach: `household` gets the shared home # and writes it back in place when someone resets their password, which only
# works for users with an `email` (the link lands in notification.txt). Groups decide reach: `household` gets the shared home
# sites, `guildhall` gets the game and nothing else. # sites, `guildhall` gets the game and nothing else.
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
@ -13,7 +14,8 @@ let
usersFile = "${usersDir}/users_database.yml"; usersFile = "${usersDir}/users_database.yml";
# One-time move out of Authelia's private state dir, then the same # One-time move out of Authelia's private state dir, then the same
# ownership on every start: guildhall-join writes, authelia-users reads. # ownership on every start: guildhall-join appends, authelia-users reads and
# saves password resets.
prepareUsers = pkgs.writeShellScript "authelia-users-prepare" '' prepareUsers = pkgs.writeShellScript "authelia-users-prepare" ''
set -eu set -eu
${pkgs.coreutils}/bin/install -d -m 0750 -o guildhall-join -g authelia-users ${usersDir} ${pkgs.coreutils}/bin/install -d -m 0750 -o guildhall-join -g authelia-users ${usersDir}
@ -21,7 +23,7 @@ let
${pkgs.coreutils}/bin/cp /var/lib/authelia-main/users_database.yml ${usersFile} ${pkgs.coreutils}/bin/cp /var/lib/authelia-main/users_database.yml ${usersFile}
fi fi
${pkgs.coreutils}/bin/chown guildhall-join:authelia-users ${usersFile} ${pkgs.coreutils}/bin/chown guildhall-join:authelia-users ${usersFile}
${pkgs.coreutils}/bin/chmod 0640 ${usersFile} ${pkgs.coreutils}/bin/chmod 0660 ${usersFile}
''; '';
in in
{ {
@ -94,7 +96,12 @@ in
}; };
}; };
# First, before the module's validate-config pre-start. systemd.services.authelia-main.serviceConfig = {
systemd.services.authelia-main.serviceConfig.ExecStartPre = lib.mkBefore [ "+${prepareUsers}" ]; # First, before the module's validate-config pre-start.
ExecStartPre = lib.mkBefore [ "+${prepareUsers}" ];
# The directory, not the file: guildhall-join replaces the file, which
# would leave a file bind mount pointing at the old one.
ReadWritePaths = [ usersDir ];
};
}; };
} }