mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 17:42:16 -05:00
Adds `python -m spoolman.cli`, the way back in when nobody can sign in: a
forgotten owner password, a disabled account, an owner who has left. This is
why the design has no per-user recovery codes.
docker exec -it spoolman python -m spoolman.cli auth status
Commands: auth status; user list/create/reset-password/set-level/enable/
disable; session revoke-all. Generated passwords are printed once.
Module form rather than a console script, because the Docker image runs
Spoolman from source and there is no installed distribution to hang an entry
point on. argparse rather than a dependency: eight subcommands do not
justify adding click or typer to the runtime image.
It never runs migrations. If the auth tables are missing it says so and
exits 2, because the fix is to start the server once and let it migrate --
having two things that can migrate the schema is how databases get hurt.
It deliberately works while SPOOLMAN_AUTH_ENABLED is unset, since the
intended first run is to create an owner and only then turn auth on.
Fixes a bug found by running it: "user create <name> --owner" produced an
owner at level read, because the --level default won over the flag. An owner
who cannot change anything is useless and would have sent someone straight
back to the CLI. --admin and --owner now imply manage, and an explicit lower
--level is reported rather than silently ignored.
Adds database.get_engine(), since the schema check needs the engine and the
Database instance is module-private.
Verified against a real database: refuses to run before migrations (exit 2);
reports an unclaimed instance; creates owner, edit and admin accounts with
the right levels; refuses a second owner and a duplicate username; refuses
to disable the owner; reset-password ends existing sessions and sets
must_change_password. Most importantly, end to end: a password generated by
the CLI signs in through the API, arrives with must_change_password set, and
carries the owner's manage level for both reads and writes.
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Command line administration for authentication.
|
|
|
|
Run as ``python -m spoolman.cli``, rather than as an installed console script, because
|
|
the Docker image runs Spoolman from source:
|
|
|
|
docker exec -it spoolman python -m spoolman.cli auth status
|
|
|
|
This is the way back in when nobody can sign in -- a forgotten owner password, a
|
|
disabled account, an instance whose owner has left. It is the reason no per-user
|
|
recovery codes exist.
|
|
|
|
It talks to the database directly using the same environment configuration as the
|
|
server, and never runs migrations: if the schema is missing it says so and stops, since
|
|
the fix is to start the server once and let it migrate.
|
|
|
|
It also works while SPOOLMAN_AUTH_ENABLED is unset, which is deliberate -- the intended
|
|
first run is to create an owner and only then turn authentication on.
|
|
"""
|
|
|
|
# ruff: noqa: T201
|
|
|
|
import argparse
|
|
import asyncio
|
|
import secrets
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
import sqlalchemy
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.auth.hashing import hash_password
|
|
from spoolman.auth.levels import Level, parse_level
|
|
from spoolman.database import auth_session as auth_session_db
|
|
from spoolman.database import auth_user as auth_user_db
|
|
from spoolman.database import database, models
|
|
|
|
# Long enough that a generated password is not worth attacking, short enough to retype
|
|
# from a terminal into a browser.
|
|
GENERATED_PASSWORD_BYTES = 12
|
|
|
|
EXIT_OK = 0
|
|
EXIT_ERROR = 1
|
|
EXIT_NO_SCHEMA = 2
|
|
|
|
|
|
async def _require_schema() -> None:
|
|
"""Stop unless the auth tables exist."""
|
|
|
|
def _tables(connection: sqlalchemy.Connection) -> list[str]:
|
|
return sqlalchemy.inspect(connection).get_table_names()
|
|
|
|
async with database.get_engine().connect() as connection:
|
|
tables = await connection.run_sync(_tables)
|
|
|
|
if "auth_user" not in tables:
|
|
print(
|
|
"Spoolman's authentication tables are missing. Start the Spoolman server "
|
|
"once so it can migrate the database, then run this command again.",
|
|
)
|
|
raise SystemExit(EXIT_NO_SCHEMA)
|
|
|
|
|
|
def _generate_password() -> str:
|
|
"""Make a password that is safe to hand to someone once."""
|
|
return secrets.token_urlsafe(GENERATED_PASSWORD_BYTES)
|
|
|
|
|
|
def _describe(user: models.AuthUser) -> str:
|
|
"""Format a user as one line for `user list`."""
|
|
flags = []
|
|
if user.is_owner:
|
|
flags.append("owner")
|
|
if user.is_admin:
|
|
flags.append("admin")
|
|
if not user.is_active:
|
|
flags.append("disabled")
|
|
if user.must_change_password:
|
|
flags.append("must-change-password")
|
|
if user.locked_until is not None and user.locked_until > datetime.utcnow():
|
|
flags.append(f"locked until {user.locked_until:%Y-%m-%d %H:%M} UTC")
|
|
last = f"{user.last_login:%Y-%m-%d %H:%M}" if user.last_login else "never"
|
|
return f" {user.username:<24} {user.level:<7} last login: {last:<17} {' '.join(flags)}"
|
|
|
|
|
|
async def _cmd_auth_status(args: argparse.Namespace, db: AsyncSession) -> int: # noqa: ARG001
|
|
"""Report the state of authentication on this instance."""
|
|
from spoolman import env # noqa: PLC0415
|
|
|
|
users, total = await auth_user_db.find(db=db)
|
|
owners = [u for u in users if u.is_owner]
|
|
|
|
print(f"Authentication enabled : {'yes' if env.is_auth_enabled() else 'no (SPOOLMAN_AUTH_ENABLED is not set)'}")
|
|
print(f"User accounts : {total}")
|
|
print(f"Owner : {owners[0].username if owners else 'none - the instance is unclaimed'}")
|
|
if total == 0:
|
|
print()
|
|
print("Nobody can sign in yet. Either create an account here, or start the server")
|
|
print("with authentication enabled and claim it from the web interface.")
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_user_list(args: argparse.Namespace, db: AsyncSession) -> int: # noqa: ARG001
|
|
"""List every account."""
|
|
users, total = await auth_user_db.find(db=db)
|
|
if total == 0:
|
|
print("No user accounts exist.")
|
|
return EXIT_OK
|
|
print(f"{total} user account(s):")
|
|
for user in users:
|
|
print(_describe(user))
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_user_create(args: argparse.Namespace, db: AsyncSession) -> int:
|
|
"""Create an account and print its generated password."""
|
|
existing = await auth_user_db.get_by_username(db, args.username)
|
|
if existing is not None:
|
|
print(f"A user named {existing.username!r} already exists.")
|
|
return EXIT_ERROR
|
|
|
|
if args.owner:
|
|
existing_users, _ = await auth_user_db.find(db=db)
|
|
owners = [u for u in existing_users if u.is_owner]
|
|
if owners:
|
|
print(
|
|
f"This instance is already owned by {owners[0].username!r}. There is exactly one owner; "
|
|
"transfer ownership from the web interface instead.",
|
|
)
|
|
return EXIT_ERROR
|
|
|
|
# Administering other users implies being able to manage data, so an owner or admin
|
|
# is always at manage. Without this, "user create <name> --owner" would take the
|
|
# default level and produce an owner who cannot change anything.
|
|
is_admin = args.admin or args.owner
|
|
level = parse_level(args.level) if args.level is not None else Level.READ
|
|
if is_admin and level is not Level.MANAGE:
|
|
if args.level is not None:
|
|
print(f"Ignoring --level {level}: an admin or owner is always at {Level.MANAGE}.")
|
|
level = Level.MANAGE
|
|
|
|
password = _generate_password()
|
|
user = await auth_user_db.create(
|
|
db=db,
|
|
username=args.username,
|
|
password_hash=hash_password(password),
|
|
level=level,
|
|
is_admin=is_admin,
|
|
is_owner=args.owner,
|
|
display_name=args.display_name,
|
|
)
|
|
print(f"Created {user.username!r} with level {user.level}.")
|
|
print(f" Password: {password}")
|
|
print(" This is shown once. Give it to the user and have them change it.")
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_user_reset_password(args: argparse.Namespace, db: AsyncSession) -> int:
|
|
"""Give an account a new one-time password."""
|
|
user = await auth_user_db.get_by_username(db, args.username)
|
|
if user is None:
|
|
print(f"No user named {args.username!r}.")
|
|
return EXIT_ERROR
|
|
|
|
password = _generate_password()
|
|
await auth_user_db.set_password(
|
|
db=db,
|
|
user=user,
|
|
password_hash=hash_password(password),
|
|
must_change=True,
|
|
)
|
|
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
|
print(f"Reset the password for {user.username!r}.")
|
|
print(f" Password: {password}")
|
|
print(f" They must change it at next sign-in. {revoked} existing session(s) were ended.")
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_user_set_level(args: argparse.Namespace, db: AsyncSession) -> int:
|
|
"""Change an account's permission level."""
|
|
user = await auth_user_db.get_by_username(db, args.username)
|
|
if user is None:
|
|
print(f"No user named {args.username!r}.")
|
|
return EXIT_ERROR
|
|
level = parse_level(args.level)
|
|
user.level = str(level)
|
|
await db.commit()
|
|
print(f"{user.username!r} is now at level {level}.")
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_user_enable(args: argparse.Namespace, db: AsyncSession) -> int:
|
|
"""Re-enable an account and clear any lockout."""
|
|
user = await auth_user_db.get_by_username(db, args.username)
|
|
if user is None:
|
|
print(f"No user named {args.username!r}.")
|
|
return EXIT_ERROR
|
|
user.is_active = True
|
|
user.failed_logins = 0
|
|
user.locked_until = None
|
|
await db.commit()
|
|
print(f"{user.username!r} is enabled, and any lockout has been cleared.")
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_user_disable(args: argparse.Namespace, db: AsyncSession) -> int:
|
|
"""Disable an account and end its sessions."""
|
|
user = await auth_user_db.get_by_username(db, args.username)
|
|
if user is None:
|
|
print(f"No user named {args.username!r}.")
|
|
return EXIT_ERROR
|
|
if user.is_owner:
|
|
print("The owner cannot be disabled. Transfer ownership first.")
|
|
return EXIT_ERROR
|
|
user.is_active = False
|
|
await db.commit()
|
|
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
|
print(f"{user.username!r} is disabled. {revoked} session(s) were ended.")
|
|
return EXIT_OK
|
|
|
|
|
|
async def _cmd_session_revoke_all(args: argparse.Namespace, db: AsyncSession) -> int:
|
|
"""Sign out one user, or everyone."""
|
|
if args.user:
|
|
user = await auth_user_db.get_by_username(db, args.user)
|
|
if user is None:
|
|
print(f"No user named {args.user!r}.")
|
|
return EXIT_ERROR
|
|
revoked = await auth_session_db.revoke_all_for_user(db, user.id)
|
|
print(f"Ended {revoked} session(s) for {user.username!r}.")
|
|
return EXIT_OK
|
|
|
|
users, _ = await auth_user_db.find(db=db)
|
|
revoked = 0
|
|
for user in users:
|
|
revoked += await auth_session_db.revoke_all_for_user(db, user.id)
|
|
print(f"Ended {revoked} session(s) across all users.")
|
|
return EXIT_OK
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
"""Build the argument parser."""
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m spoolman.cli",
|
|
description=(
|
|
"Administer Spoolman authentication from the host. Use this to create the "
|
|
"first account, or to get back in when nobody can sign in."
|
|
),
|
|
)
|
|
commands = parser.add_subparsers(dest="group", required=True)
|
|
|
|
auth = commands.add_parser("auth", help="Inspect authentication state.")
|
|
auth_sub = auth.add_subparsers(dest="command", required=True)
|
|
auth_sub.add_parser("status", help="Show whether auth is on, and who owns the instance.").set_defaults(
|
|
func=_cmd_auth_status,
|
|
)
|
|
|
|
user = commands.add_parser("user", help="Manage user accounts.")
|
|
user_sub = user.add_subparsers(dest="command", required=True)
|
|
|
|
user_sub.add_parser("list", help="List all accounts.").set_defaults(func=_cmd_user_list)
|
|
|
|
create = user_sub.add_parser("create", help="Create an account and print a generated password.")
|
|
create.add_argument("username")
|
|
create.add_argument(
|
|
"--level",
|
|
default=None,
|
|
choices=[str(level) for level in Level],
|
|
help=f"Permission level. Defaults to {Level.READ}, or {Level.MANAGE} with --admin or --owner.",
|
|
)
|
|
create.add_argument("--admin", action="store_true", help="Let this user administer other users.")
|
|
create.add_argument("--owner", action="store_true", help="Make this user the instance owner. Implies --admin.")
|
|
create.add_argument("--display-name", default=None)
|
|
create.set_defaults(func=_cmd_user_create)
|
|
|
|
reset = user_sub.add_parser("reset-password", help="Set a new one-time password and end their sessions.")
|
|
reset.add_argument("username")
|
|
reset.set_defaults(func=_cmd_user_reset_password)
|
|
|
|
set_level = user_sub.add_parser("set-level", help="Change an account's permission level.")
|
|
set_level.add_argument("username")
|
|
set_level.add_argument("level", choices=[str(level) for level in Level])
|
|
set_level.set_defaults(func=_cmd_user_set_level)
|
|
|
|
enable = user_sub.add_parser("enable", help="Re-enable an account and clear any lockout.")
|
|
enable.add_argument("username")
|
|
enable.set_defaults(func=_cmd_user_enable)
|
|
|
|
disable = user_sub.add_parser("disable", help="Disable an account and end its sessions.")
|
|
disable.add_argument("username")
|
|
disable.set_defaults(func=_cmd_user_disable)
|
|
|
|
session = commands.add_parser("session", help="Manage login sessions.")
|
|
session_sub = session.add_subparsers(dest="command", required=True)
|
|
revoke = session_sub.add_parser("revoke-all", help="Sign out everyone, or one user.")
|
|
revoke.add_argument("--user", default=None, help="Limit to this username.")
|
|
revoke.set_defaults(func=_cmd_session_revoke_all)
|
|
|
|
return parser
|
|
|
|
|
|
async def _run(args: argparse.Namespace) -> int:
|
|
"""Connect to the database and dispatch the command."""
|
|
database.setup_db(database.get_connection_url())
|
|
await _require_schema()
|
|
|
|
generator = database.get_db_session()
|
|
db = await anext(generator)
|
|
try:
|
|
return await args.func(args, db)
|
|
finally:
|
|
await generator.aclose()
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the command line interface."""
|
|
args = _build_parser().parse_args()
|
|
try:
|
|
sys.exit(asyncio.run(_run(args)))
|
|
except SystemExit:
|
|
raise
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}")
|
|
sys.exit(EXIT_ERROR)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|