Add a guarded way to retire the plaintext shared password

settings.json can still hold the old plaintext login_password after
accounts exist, where it's ignored but sitting in cleartext. Removing it
by hand has a sharp edge: with no accounts AND no login_password, the
login route reads "nothing configured" as dev mode and lets anyone in
without a password, so a tidy-up can quietly open the dashboard.

`manage_users.py retire-shared-password` removes the key only when at
least one account exists, and says so plainly when it won't. It also
warns if LOGIN_PASSWORD is still set in the environment, since that's a
second copy people forget.

Needed delete_setting() in app.py — save_settings() only ever merges, so
it had no way to take a key away.

Verified: refuses with exit 1 when no accounts exist and leaves the file
untouched; removes the key while preserving timezone and users; is
idempotent; warns about the env var; and after removal only the account
password signs in, with the retired shared password and the env value
both rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Barely Removable 2026-08-27 13:06:31 -07:00
parent 63ad19eb12
commit 2f73367c6b
2 changed files with 66 additions and 1 deletions

View file

@ -12,10 +12,17 @@ Run it inside the container, where Werkzeug and the real settings.json are:
docker exec -it arr-summary python3 manage_users.py add <username>
docker exec -it arr-summary python3 manage_users.py passwd <username>
docker exec -it arr-summary python3 manage_users.py remove <username>
docker exec -it arr-summary python3 manage_users.py retire-shared-password
The first `add` switches the dashboard from its single shared password to
per-user logins. Until then the old LOGIN_PASSWORD keeps working, so you
can't lock yourself out by deploying this.
Once accounts exist and you've confirmed they sign in,
`retire-shared-password` deletes the old plaintext login_password from
settings.json. It refuses to run while no accounts exist, because with
nothing configured at all the dashboard treats that as dev mode and lets
anyone in without a password.
"""
import sys
@ -24,7 +31,10 @@ import getpass
from app import (
SETTINGS_FILE,
PASSWORD_HASH_METHOD,
LOGIN_PASSWORD,
save_settings,
delete_setting,
load_settings,
load_users,
generate_password_hash,
)
@ -122,7 +132,49 @@ def cmd_remove(args):
return 0
COMMANDS = {"list": cmd_list, "add": cmd_add, "passwd": cmd_passwd, "remove": cmd_remove}
def cmd_retire_shared_password(_args):
"""Delete the plaintext login_password from settings.json.
Guarded, because removing it while nothing else is configured makes
get_active_password() empty — and the login route reads "no credentials
at all" as dev mode and lets anyone straight in.
"""
users = load_users()
if not users:
print("Refusing: no accounts exist yet, so the shared password is still",
file=sys.stderr)
print("the only way in. Run `add <username>` first and check you can",
file=sys.stderr)
print("sign in with it, then run this again.", file=sys.stderr)
return 1
if "login_password" not in load_settings():
print("Nothing to do — settings.json has no login_password.")
if LOGIN_PASSWORD:
print()
print("Note: LOGIN_PASSWORD is still set in the environment. It's")
print("ignored while accounts exist, but clear it from .env too if")
print("you want the old shared password gone entirely.")
return 0
delete_setting("login_password")
print(f"Removed the plaintext login_password from {SETTINGS_FILE}.")
print(f"{len(users)} account(s) remain in use: {', '.join(sorted(users))}")
if LOGIN_PASSWORD:
print()
print("LOGIN_PASSWORD is still set in the environment. It's ignored while")
print("accounts exist, but clear it from .env and restart to be rid of it.")
print("Don't clear it before you've confirmed the accounts work.")
return 0
COMMANDS = {
"list": cmd_list,
"add": cmd_add,
"passwd": cmd_passwd,
"remove": cmd_remove,
"retire-shared-password": cmd_retire_shared_password,
}
def main(argv):