jmri_server.auth.manager

AuthManager — the runtime auth surface.

Wraps the ~jmri_server.auth.store.UsersFile behind a small API the middleware + FastAPI routes call into. Adds:

  • Password login (returns matched user or None).
  • Bearer-token login (constant-time compare against every user's personal token).
  • Signed-session-cookie mint + verify — uses ~itsdangerous.URLSafeTimedSerializer so a tampered cookie can't impersonate a user and expired cookies fall out on verify. The signing key is derived from the settings-root path (stable across restarts) unless the operator sets JMRI_AUTH_SECRET explicitly.
  • Bootstrap: with auth enabled + no users.yaml present, mint an admin account with a random password + token, persist the password to <settings-root>/admin_password.txt (mode 0600), and log a WARNING pointing the operator at the file. This is the path a fresh install takes — subsequent starts leave it alone.

The manager is deliberately synchronous: password hashing is CPU work, not I/O, and putting it under async would just hide the CPU spin behind a threadpool anyway. Callers await into the middleware, then the middleware calls into these plain methods.

  1"""``AuthManager`` — the runtime auth surface.
  2
  3Wraps the :class:`~jmri_server.auth.store.UsersFile` behind a small
  4API the middleware + FastAPI routes call into. Adds:
  5
  6* Password login (returns matched user or None).
  7* Bearer-token login (constant-time compare against every user's
  8  personal token).
  9* Signed-session-cookie mint + verify — uses
 10  :class:`~itsdangerous.URLSafeTimedSerializer` so a tampered
 11  cookie can't impersonate a user and expired cookies fall out on
 12  verify. The signing key is derived from the settings-root path
 13  (stable across restarts) unless the operator sets
 14  ``JMRI_AUTH_SECRET`` explicitly.
 15* Bootstrap: with auth enabled + no ``users.yaml`` present, mint an
 16  ``admin`` account with a random password + token, persist the
 17  password to ``<settings-root>/admin_password.txt`` (mode 0600),
 18  and log a WARNING pointing the operator at the file. This is the
 19  path a fresh install takes — subsequent starts leave it alone.
 20
 21The manager is deliberately synchronous: password hashing is CPU
 22work, not I/O, and putting it under ``async`` would just hide the
 23CPU spin behind a threadpool anyway. Callers await into the
 24middleware, then the middleware calls into these plain methods.
 25"""
 26from __future__ import annotations
 27
 28import logging
 29import os
 30import pathlib
 31import secrets
 32from dataclasses import dataclass
 33
 34from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
 35
 36from jmri_server.auth.models import Role, User
 37from jmri_server.auth.passwords import hash_password, verify_password
 38from jmri_server.auth.store import UsersFile, load_users_file, save_users_file
 39
 40_log = logging.getLogger(__name__)
 41
 42USERS_FILE_NAME = "users.yaml"
 43ADMIN_PASSWORD_FILE = "admin_password.txt"
 44ADMIN_TOKEN_ENV = "JMRI_AUTH_ADMIN_TOKEN"
 45SECRET_ENV = "JMRI_AUTH_SECRET"
 46EPHEMERAL_SECRET_ENV = "JMRI_AUTH_EPHEMERAL_SECRET"
 47
 48# One week — matches JMRI mobile clients that stay logged in
 49# between operating sessions but forces re-auth after long gaps.
 50SESSION_MAX_AGE_SECONDS = 7 * 24 * 3600
 51
 52
 53@dataclass(frozen=True, slots=True)
 54class LoginResult:
 55    """A successful login carries the matched user.
 56
 57    Kept as a distinct type so ``if login(...):`` reads naturally
 58    without confusing ``User``-vs-``None`` at the call site."""
 59    user: User
 60
 61
 62class AuthManager:
 63    """Load users, verify credentials, mint session cookies.
 64
 65    One instance per server lifetime. Constructed by
 66    :func:`build_auth_manager` which handles the bootstrap +
 67    users-file discovery — direct instantiation is fine for tests
 68    that seed a specific set of users.
 69    """
 70
 71    def __init__(
 72        self,
 73        users: UsersFile,
 74        *,
 75        users_path: pathlib.Path,
 76        secret_key: str,
 77    ) -> None:
 78        self._users = users
 79        self._users_path = users_path
 80        self._serializer = URLSafeTimedSerializer(
 81            secret_key, salt="jmri-session-v1",
 82        )
 83
 84    # ---- user lookup + password flow ----------------------------------
 85
 86    def login_password(
 87        self, username: str, password: str,
 88    ) -> LoginResult | None:
 89        """Verify *password* for *username*. Returns the login
 90        result on success, ``None`` on any failure (unknown user,
 91        wrong password, malformed hash). Never leaks which of the
 92        two failed via return value — the log line records it for
 93        the operator's audit."""
 94        user = self._users.by_username(username)
 95        if user is None:
 96            _log.info("auth: login failed for unknown user %r", username)
 97            return None
 98        if not verify_password(password, user.password_hash):
 99            _log.info("auth: bad password for user %r", username)
100            return None
101        return LoginResult(user=user)
102
103    def login_token(self, token: str) -> LoginResult | None:
104        """Match a Bearer token against every user's personal
105        token. Returns the login result on success, ``None`` on
106        no match. Empty token → ``None`` without querying the
107        store."""
108        user = self._users.by_token(token)
109        if user is None:
110            return None
111        return LoginResult(user=user)
112
113    # ---- session cookies ----------------------------------------------
114
115    def mint_session_cookie(self, user: User) -> str:
116        """Encode *user* as a signed cookie value. The value round-
117        trips through :meth:`verify_session_cookie`; anything else
118        (tampered value, expired timestamp) returns ``None`` there."""
119        return self._serializer.dumps({"username": user.username})
120
121    def verify_session_cookie(self, cookie_value: str) -> User | None:
122        """Decode + validate. Returns the current :class:`User` for
123        the encoded username, or ``None`` for tampered / expired /
124        deleted-user cases.
125
126        Deleted-user handling matters: a user removed from
127        ``users.yaml`` should be locked out immediately, not on
128        session expiry. Re-checking the store on every verify is
129        cheap (dict lookup) and closes that hole."""
130        if not cookie_value:
131            return None
132        try:
133            payload = self._serializer.loads(
134                cookie_value, max_age=SESSION_MAX_AGE_SECONDS,
135            )
136        except SignatureExpired:
137            return None
138        except BadSignature:
139            return None
140        if not isinstance(payload, dict):
141            return None
142        username = payload.get("username")
143        if not isinstance(username, str):
144            return None
145        # Re-fetch so a role change (or deletion) after cookie
146        # mint takes effect immediately.
147        return self._users.by_username(username)
148
149    # ---- introspection -------------------------------------------------
150
151    @property
152    def users(self) -> list[User]:
153        """Read-only snapshot of the current user list. Mutations
154        go through :meth:`add_user` / :meth:`remove_user` so the
155        on-disk file stays in sync."""
156        return list(self._users.users)
157
158    def has_users(self) -> bool:
159        return bool(self._users.users)
160
161    def by_username(self, username: str) -> User | None:
162        return self._users.by_username(username)
163
164    # ---- mutations ------------------------------------------------------
165
166    def add_user(
167        self,
168        username: str,
169        password: str,
170        *,
171        role: Role = Role.VIEWER,
172        token: str | None = None,
173    ) -> User:
174        """Create a new user. Persists to disk atomically.
175
176        ``token`` defaults to a fresh random one — a client (mobile
177        throttle, etc.) can start using it immediately. Pass an
178        explicit empty string to create a login-only account.
179        """
180        if not username:
181            raise ValueError("username must be non-empty")
182        if self._users.by_username(username) is not None:
183            raise ValueError(f"user {username!r} already exists")
184        actual_token = (
185            token if token is not None else secrets.token_urlsafe(32)
186        )
187        user = User(
188            username=username,
189            password_hash=hash_password(password),
190            role=role,
191            token=actual_token,
192        )
193        self._users.users.append(user)
194        self._persist()
195        return user
196
197    def remove_user(self, username: str, *, current_username: str) -> None:
198        """Delete *username*. Two guards:
199
200        * Cannot delete yourself — refuses when
201          ``username == current_username`` so an admin can't
202          accidentally lock the whole install out.
203        * Cannot delete the last admin — refuses when *username*
204          is the sole entry with :data:`Role.ADMIN`. Otherwise a
205          botched cleanup would leave the install with no admin
206          user, which no future user-CRUD flow could recover from.
207        """
208        if username == current_username:
209            raise ValueError("cannot delete your own account")
210        target = self._users.by_username(username)
211        if target is None:
212            raise LookupError(f"no user {username!r}")
213        if target.role is Role.ADMIN:
214            admins = [u for u in self._users.users if u.role is Role.ADMIN]
215            if len(admins) <= 1:
216                raise ValueError(
217                    "cannot delete the last admin — promote someone else first",
218                )
219        self._users.users = [
220            u for u in self._users.users if u.username != username
221        ]
222        self._persist()
223
224    def set_password(self, username: str, new_password: str) -> None:
225        """Update *username*'s password hash. Existing session
226        cookies stay valid (they encode username only) — a
227        password change without a forced-logout is the JMRI
228        Java convention and matches "remember me" behaviour."""
229        user = self._users.by_username(username)
230        if user is None:
231            raise LookupError(f"no user {username!r}")
232        user.password_hash = hash_password(new_password)
233        self._persist()
234
235    def set_role(
236        self, username: str, new_role: Role, *, current_username: str,
237    ) -> None:
238        """Change *username*'s role. Same last-admin guard as
239        :meth:`remove_user` — demoting the only admin would
240        strand the install."""
241        if username == current_username and new_role is not Role.ADMIN:
242            raise ValueError("cannot demote your own account")
243        user = self._users.by_username(username)
244        if user is None:
245            raise LookupError(f"no user {username!r}")
246        if user.role is Role.ADMIN and new_role is not Role.ADMIN:
247            admins = [u for u in self._users.users if u.role is Role.ADMIN]
248            if len(admins) <= 1:
249                raise ValueError(
250                    "cannot demote the last admin — promote someone else first",
251                )
252        user.role = new_role
253        self._persist()
254
255    def regenerate_token(self, username: str) -> str:
256        """Mint a new token for *username*, replacing the old.
257        Returns the new value. Any mobile client using the old
258        token is immediately locked out — that's the point of
259        this endpoint (a leaked token gets cycled)."""
260        user = self._users.by_username(username)
261        if user is None:
262            raise LookupError(f"no user {username!r}")
263        user.token = secrets.token_urlsafe(32)
264        self._persist()
265        return user.token
266
267    def _persist(self) -> None:
268        """Write the current users file to disk. Called after
269        every mutation so an operator hand-editing users.yaml
270        alongside the running server sees a coherent file."""
271        save_users_file(self._users_path, self._users)
272
273
274# ---- bootstrap ----------------------------------------------------------
275
276
277def build_auth_manager(settings_root: pathlib.Path) -> AuthManager:
278    """Load ``<settings-root>/users.yaml`` — or mint a default
279    admin if missing — and return a fully-configured
280    :class:`AuthManager`.
281
282    Called once at server startup when
283    ``JMRI_AUTH_ENABLED=1``. With auth off, callers should not
284    invoke this; the server sets ``ctx.auth = None`` instead and
285    the middleware becomes a no-op.
286    """
287    users_path = settings_root / USERS_FILE_NAME
288    users = load_users_file(users_path)
289    if not users.users:
290        users = _bootstrap_default_admin(settings_root)
291        save_users_file(users_path, users)
292
293    secret = _resolve_secret(settings_root)
294    return AuthManager(users, users_path=users_path, secret_key=secret)
295
296
297def _resolve_secret(settings_root: pathlib.Path) -> str:
298    """Determine the session signing key. Precedence:
299
300    1. ``JMRI_AUTH_SECRET`` — explicit operator override. Always wins.
301       Rotating this value invalidates every outstanding session.
302    2. ``JMRI_AUTH_EPHEMERAL_SECRET=1`` — mint a fresh random secret
303       at every server boot. All sessions die on restart, which is
304       what you want in dev / when testing auth flows / on a shared
305       machine where you don't want the "convenience persistence"
306       across reboots.
307    3. Derived stable key from the settings root — the default.
308       Sessions survive restarts (matches "remember me" semantics).
309    """
310    explicit = os.environ.get(SECRET_ENV)
311    if explicit:
312        return explicit
313    if _get_bool_env(EPHEMERAL_SECRET_ENV):
314        _log.info(
315            "auth: %s=1 — sessions will invalidate on every restart",
316            EPHEMERAL_SECRET_ENV,
317        )
318        return secrets.token_hex(32)
319    return _derive_secret_from_root(settings_root)
320
321
322def _get_bool_env(name: str) -> bool:
323    """Same truthy set as :mod:`jmri_core.env` — kept local to avoid
324    a circular import when the auth module is used standalone."""
325    raw = os.environ.get(name, "")
326    return raw.lower() in ("1", "true", "yes", "on")
327
328
329def _bootstrap_default_admin(settings_root: pathlib.Path) -> UsersFile:
330    """Mint an ``admin`` user with random password + token; write
331    the plaintext password to ``<settings-root>/admin_password.txt``
332    (mode 0600) so the operator can retrieve it on first start.
333
334    On subsequent starts the users file already has entries, so
335    this path never runs — the ``admin_password.txt`` sticks
336    around as a record of the initial secret until the operator
337    deletes it or changes the admin password."""
338    password = secrets.token_urlsafe(24)
339    token = os.environ.get(ADMIN_TOKEN_ENV) or secrets.token_urlsafe(32)
340    admin = User(
341        username="admin",
342        password_hash=hash_password(password),
343        role=Role.ADMIN,
344        token=token,
345    )
346    _write_admin_password_file(settings_root, password, token)
347    _log.warning(
348        "auth bootstrap: no users.yaml found. Default admin user created; "
349        "password written to %s (mode 0600). Log in as 'admin' and change "
350        "the password via a future user CRUD flow, or hand-edit users.yaml.",
351        settings_root / ADMIN_PASSWORD_FILE,
352    )
353    return UsersFile(users=[admin])
354
355
356def _write_admin_password_file(
357    settings_root: pathlib.Path, password: str, token: str,
358) -> None:
359    """Persist the bootstrap admin credentials so the operator can
360    read them on first login. Includes the API token too — mobile
361    clients need it to connect without a browser round-trip."""
362    settings_root.mkdir(parents=True, exist_ok=True)
363    path = settings_root / ADMIN_PASSWORD_FILE
364    body = (
365        "# JMRI auth bootstrap credentials — first-run only.\n"
366        "# Delete this file after you've copied the values into a\n"
367        "# password manager. Regeneration requires deleting the users\n"
368        "# entry as well (there's no re-bootstrap flow yet).\n"
369        f"admin_password: {password}\n"
370        f"admin_token: {token}\n"
371    )
372    path.write_text(body, encoding="utf-8")
373    try:
374        os.chmod(path, 0o600)
375    except (OSError, PermissionError) as e:  # pragma: no cover
376        _log.debug("chmod on %s ignored: %s", path, e)
377
378
379def _derive_secret_from_root(settings_root: pathlib.Path) -> str:
380    """Derive a stable session-signing secret from the settings
381    root path + host hostname when ``JMRI_AUTH_SECRET`` isn't set.
382
383    Stable across restarts (so sessions survive), unique per
384    install (so a leaked cookie from one Pi doesn't unlock
385    another). For a production deployment with real users the
386    operator should set ``JMRI_AUTH_SECRET`` explicitly — this
387    fallback exists so single-Pi hobby installs don't need an
388    extra config step."""
389    import hashlib  # noqa: PLC0415
390    import socket  # noqa: PLC0415
391    seed = f"{settings_root.resolve()}::{socket.gethostname()}"
392    return hashlib.sha256(seed.encode("utf-8")).hexdigest()
USERS_FILE_NAME = 'users.yaml'
ADMIN_PASSWORD_FILE = 'admin_password.txt'
ADMIN_TOKEN_ENV = 'JMRI_AUTH_ADMIN_TOKEN'
SECRET_ENV = 'JMRI_AUTH_SECRET'
EPHEMERAL_SECRET_ENV = 'JMRI_AUTH_EPHEMERAL_SECRET'
SESSION_MAX_AGE_SECONDS = 604800
@dataclass(frozen=True, slots=True)
class LoginResult:
54@dataclass(frozen=True, slots=True)
55class LoginResult:
56    """A successful login carries the matched user.
57
58    Kept as a distinct type so ``if login(...):`` reads naturally
59    without confusing ``User``-vs-``None`` at the call site."""
60    user: User

A successful login carries the matched user.

Kept as a distinct type so if login(...): reads naturally without confusing User-vs-None at the call site.

LoginResult(user: jmri_server.auth.User)
class AuthManager:
 63class AuthManager:
 64    """Load users, verify credentials, mint session cookies.
 65
 66    One instance per server lifetime. Constructed by
 67    :func:`build_auth_manager` which handles the bootstrap +
 68    users-file discovery — direct instantiation is fine for tests
 69    that seed a specific set of users.
 70    """
 71
 72    def __init__(
 73        self,
 74        users: UsersFile,
 75        *,
 76        users_path: pathlib.Path,
 77        secret_key: str,
 78    ) -> None:
 79        self._users = users
 80        self._users_path = users_path
 81        self._serializer = URLSafeTimedSerializer(
 82            secret_key, salt="jmri-session-v1",
 83        )
 84
 85    # ---- user lookup + password flow ----------------------------------
 86
 87    def login_password(
 88        self, username: str, password: str,
 89    ) -> LoginResult | None:
 90        """Verify *password* for *username*. Returns the login
 91        result on success, ``None`` on any failure (unknown user,
 92        wrong password, malformed hash). Never leaks which of the
 93        two failed via return value — the log line records it for
 94        the operator's audit."""
 95        user = self._users.by_username(username)
 96        if user is None:
 97            _log.info("auth: login failed for unknown user %r", username)
 98            return None
 99        if not verify_password(password, user.password_hash):
100            _log.info("auth: bad password for user %r", username)
101            return None
102        return LoginResult(user=user)
103
104    def login_token(self, token: str) -> LoginResult | None:
105        """Match a Bearer token against every user's personal
106        token. Returns the login result on success, ``None`` on
107        no match. Empty token → ``None`` without querying the
108        store."""
109        user = self._users.by_token(token)
110        if user is None:
111            return None
112        return LoginResult(user=user)
113
114    # ---- session cookies ----------------------------------------------
115
116    def mint_session_cookie(self, user: User) -> str:
117        """Encode *user* as a signed cookie value. The value round-
118        trips through :meth:`verify_session_cookie`; anything else
119        (tampered value, expired timestamp) returns ``None`` there."""
120        return self._serializer.dumps({"username": user.username})
121
122    def verify_session_cookie(self, cookie_value: str) -> User | None:
123        """Decode + validate. Returns the current :class:`User` for
124        the encoded username, or ``None`` for tampered / expired /
125        deleted-user cases.
126
127        Deleted-user handling matters: a user removed from
128        ``users.yaml`` should be locked out immediately, not on
129        session expiry. Re-checking the store on every verify is
130        cheap (dict lookup) and closes that hole."""
131        if not cookie_value:
132            return None
133        try:
134            payload = self._serializer.loads(
135                cookie_value, max_age=SESSION_MAX_AGE_SECONDS,
136            )
137        except SignatureExpired:
138            return None
139        except BadSignature:
140            return None
141        if not isinstance(payload, dict):
142            return None
143        username = payload.get("username")
144        if not isinstance(username, str):
145            return None
146        # Re-fetch so a role change (or deletion) after cookie
147        # mint takes effect immediately.
148        return self._users.by_username(username)
149
150    # ---- introspection -------------------------------------------------
151
152    @property
153    def users(self) -> list[User]:
154        """Read-only snapshot of the current user list. Mutations
155        go through :meth:`add_user` / :meth:`remove_user` so the
156        on-disk file stays in sync."""
157        return list(self._users.users)
158
159    def has_users(self) -> bool:
160        return bool(self._users.users)
161
162    def by_username(self, username: str) -> User | None:
163        return self._users.by_username(username)
164
165    # ---- mutations ------------------------------------------------------
166
167    def add_user(
168        self,
169        username: str,
170        password: str,
171        *,
172        role: Role = Role.VIEWER,
173        token: str | None = None,
174    ) -> User:
175        """Create a new user. Persists to disk atomically.
176
177        ``token`` defaults to a fresh random one — a client (mobile
178        throttle, etc.) can start using it immediately. Pass an
179        explicit empty string to create a login-only account.
180        """
181        if not username:
182            raise ValueError("username must be non-empty")
183        if self._users.by_username(username) is not None:
184            raise ValueError(f"user {username!r} already exists")
185        actual_token = (
186            token if token is not None else secrets.token_urlsafe(32)
187        )
188        user = User(
189            username=username,
190            password_hash=hash_password(password),
191            role=role,
192            token=actual_token,
193        )
194        self._users.users.append(user)
195        self._persist()
196        return user
197
198    def remove_user(self, username: str, *, current_username: str) -> None:
199        """Delete *username*. Two guards:
200
201        * Cannot delete yourself — refuses when
202          ``username == current_username`` so an admin can't
203          accidentally lock the whole install out.
204        * Cannot delete the last admin — refuses when *username*
205          is the sole entry with :data:`Role.ADMIN`. Otherwise a
206          botched cleanup would leave the install with no admin
207          user, which no future user-CRUD flow could recover from.
208        """
209        if username == current_username:
210            raise ValueError("cannot delete your own account")
211        target = self._users.by_username(username)
212        if target is None:
213            raise LookupError(f"no user {username!r}")
214        if target.role is Role.ADMIN:
215            admins = [u for u in self._users.users if u.role is Role.ADMIN]
216            if len(admins) <= 1:
217                raise ValueError(
218                    "cannot delete the last admin — promote someone else first",
219                )
220        self._users.users = [
221            u for u in self._users.users if u.username != username
222        ]
223        self._persist()
224
225    def set_password(self, username: str, new_password: str) -> None:
226        """Update *username*'s password hash. Existing session
227        cookies stay valid (they encode username only) — a
228        password change without a forced-logout is the JMRI
229        Java convention and matches "remember me" behaviour."""
230        user = self._users.by_username(username)
231        if user is None:
232            raise LookupError(f"no user {username!r}")
233        user.password_hash = hash_password(new_password)
234        self._persist()
235
236    def set_role(
237        self, username: str, new_role: Role, *, current_username: str,
238    ) -> None:
239        """Change *username*'s role. Same last-admin guard as
240        :meth:`remove_user` — demoting the only admin would
241        strand the install."""
242        if username == current_username and new_role is not Role.ADMIN:
243            raise ValueError("cannot demote your own account")
244        user = self._users.by_username(username)
245        if user is None:
246            raise LookupError(f"no user {username!r}")
247        if user.role is Role.ADMIN and new_role is not Role.ADMIN:
248            admins = [u for u in self._users.users if u.role is Role.ADMIN]
249            if len(admins) <= 1:
250                raise ValueError(
251                    "cannot demote the last admin — promote someone else first",
252                )
253        user.role = new_role
254        self._persist()
255
256    def regenerate_token(self, username: str) -> str:
257        """Mint a new token for *username*, replacing the old.
258        Returns the new value. Any mobile client using the old
259        token is immediately locked out — that's the point of
260        this endpoint (a leaked token gets cycled)."""
261        user = self._users.by_username(username)
262        if user is None:
263            raise LookupError(f"no user {username!r}")
264        user.token = secrets.token_urlsafe(32)
265        self._persist()
266        return user.token
267
268    def _persist(self) -> None:
269        """Write the current users file to disk. Called after
270        every mutation so an operator hand-editing users.yaml
271        alongside the running server sees a coherent file."""
272        save_users_file(self._users_path, self._users)

Load users, verify credentials, mint session cookies.

One instance per server lifetime. Constructed by build_auth_manager() which handles the bootstrap + users-file discovery — direct instantiation is fine for tests that seed a specific set of users.

AuthManager( users: jmri_server.auth.UsersFile, *, users_path: pathlib._local.Path, secret_key: str)
72    def __init__(
73        self,
74        users: UsersFile,
75        *,
76        users_path: pathlib.Path,
77        secret_key: str,
78    ) -> None:
79        self._users = users
80        self._users_path = users_path
81        self._serializer = URLSafeTimedSerializer(
82            secret_key, salt="jmri-session-v1",
83        )
def login_password( self, username: str, password: str) -> LoginResult | None:
 87    def login_password(
 88        self, username: str, password: str,
 89    ) -> LoginResult | None:
 90        """Verify *password* for *username*. Returns the login
 91        result on success, ``None`` on any failure (unknown user,
 92        wrong password, malformed hash). Never leaks which of the
 93        two failed via return value — the log line records it for
 94        the operator's audit."""
 95        user = self._users.by_username(username)
 96        if user is None:
 97            _log.info("auth: login failed for unknown user %r", username)
 98            return None
 99        if not verify_password(password, user.password_hash):
100            _log.info("auth: bad password for user %r", username)
101            return None
102        return LoginResult(user=user)

Verify password for username. Returns the login result on success, None on any failure (unknown user, wrong password, malformed hash). Never leaks which of the two failed via return value — the log line records it for the operator's audit.

def login_token(self, token: str) -> LoginResult | None:
104    def login_token(self, token: str) -> LoginResult | None:
105        """Match a Bearer token against every user's personal
106        token. Returns the login result on success, ``None`` on
107        no match. Empty token → ``None`` without querying the
108        store."""
109        user = self._users.by_token(token)
110        if user is None:
111            return None
112        return LoginResult(user=user)

Match a Bearer token against every user's personal token. Returns the login result on success, None on no match. Empty token → None without querying the store.

users: list[jmri_server.auth.User]
152    @property
153    def users(self) -> list[User]:
154        """Read-only snapshot of the current user list. Mutations
155        go through :meth:`add_user` / :meth:`remove_user` so the
156        on-disk file stays in sync."""
157        return list(self._users.users)

Read-only snapshot of the current user list. Mutations go through add_user() / remove_user() so the on-disk file stays in sync.

def has_users(self) -> bool:
159    def has_users(self) -> bool:
160        return bool(self._users.users)
def by_username(self, username: str) -> jmri_server.auth.User | None:
162    def by_username(self, username: str) -> User | None:
163        return self._users.by_username(username)
def add_user( self, username: str, password: str, *, role: jmri_server.auth.Role = <Role.VIEWER: 'viewer'>, token: str | None = None) -> jmri_server.auth.User:
167    def add_user(
168        self,
169        username: str,
170        password: str,
171        *,
172        role: Role = Role.VIEWER,
173        token: str | None = None,
174    ) -> User:
175        """Create a new user. Persists to disk atomically.
176
177        ``token`` defaults to a fresh random one — a client (mobile
178        throttle, etc.) can start using it immediately. Pass an
179        explicit empty string to create a login-only account.
180        """
181        if not username:
182            raise ValueError("username must be non-empty")
183        if self._users.by_username(username) is not None:
184            raise ValueError(f"user {username!r} already exists")
185        actual_token = (
186            token if token is not None else secrets.token_urlsafe(32)
187        )
188        user = User(
189            username=username,
190            password_hash=hash_password(password),
191            role=role,
192            token=actual_token,
193        )
194        self._users.users.append(user)
195        self._persist()
196        return user

Create a new user. Persists to disk atomically.

token defaults to a fresh random one — a client (mobile throttle, etc.) can start using it immediately. Pass an explicit empty string to create a login-only account.

def remove_user(self, username: str, *, current_username: str) -> None:
198    def remove_user(self, username: str, *, current_username: str) -> None:
199        """Delete *username*. Two guards:
200
201        * Cannot delete yourself — refuses when
202          ``username == current_username`` so an admin can't
203          accidentally lock the whole install out.
204        * Cannot delete the last admin — refuses when *username*
205          is the sole entry with :data:`Role.ADMIN`. Otherwise a
206          botched cleanup would leave the install with no admin
207          user, which no future user-CRUD flow could recover from.
208        """
209        if username == current_username:
210            raise ValueError("cannot delete your own account")
211        target = self._users.by_username(username)
212        if target is None:
213            raise LookupError(f"no user {username!r}")
214        if target.role is Role.ADMIN:
215            admins = [u for u in self._users.users if u.role is Role.ADMIN]
216            if len(admins) <= 1:
217                raise ValueError(
218                    "cannot delete the last admin — promote someone else first",
219                )
220        self._users.users = [
221            u for u in self._users.users if u.username != username
222        ]
223        self._persist()

Delete username. Two guards:

  • Cannot delete yourself — refuses when username == current_username so an admin can't accidentally lock the whole install out.
  • Cannot delete the last admin — refuses when username is the sole entry with Role.ADMIN. Otherwise a botched cleanup would leave the install with no admin user, which no future user-CRUD flow could recover from.
def set_password(self, username: str, new_password: str) -> None:
225    def set_password(self, username: str, new_password: str) -> None:
226        """Update *username*'s password hash. Existing session
227        cookies stay valid (they encode username only) — a
228        password change without a forced-logout is the JMRI
229        Java convention and matches "remember me" behaviour."""
230        user = self._users.by_username(username)
231        if user is None:
232            raise LookupError(f"no user {username!r}")
233        user.password_hash = hash_password(new_password)
234        self._persist()

Update username's password hash. Existing session cookies stay valid (they encode username only) — a password change without a forced-logout is the JMRI Java convention and matches "remember me" behaviour.

def set_role( self, username: str, new_role: jmri_server.auth.Role, *, current_username: str) -> None:
236    def set_role(
237        self, username: str, new_role: Role, *, current_username: str,
238    ) -> None:
239        """Change *username*'s role. Same last-admin guard as
240        :meth:`remove_user` — demoting the only admin would
241        strand the install."""
242        if username == current_username and new_role is not Role.ADMIN:
243            raise ValueError("cannot demote your own account")
244        user = self._users.by_username(username)
245        if user is None:
246            raise LookupError(f"no user {username!r}")
247        if user.role is Role.ADMIN and new_role is not Role.ADMIN:
248            admins = [u for u in self._users.users if u.role is Role.ADMIN]
249            if len(admins) <= 1:
250                raise ValueError(
251                    "cannot demote the last admin — promote someone else first",
252                )
253        user.role = new_role
254        self._persist()

Change username's role. Same last-admin guard as remove_user() — demoting the only admin would strand the install.

def regenerate_token(self, username: str) -> str:
256    def regenerate_token(self, username: str) -> str:
257        """Mint a new token for *username*, replacing the old.
258        Returns the new value. Any mobile client using the old
259        token is immediately locked out — that's the point of
260        this endpoint (a leaked token gets cycled)."""
261        user = self._users.by_username(username)
262        if user is None:
263            raise LookupError(f"no user {username!r}")
264        user.token = secrets.token_urlsafe(32)
265        self._persist()
266        return user.token

Mint a new token for username, replacing the old. Returns the new value. Any mobile client using the old token is immediately locked out — that's the point of this endpoint (a leaked token gets cycled).

def build_auth_manager( settings_root: pathlib._local.Path) -> AuthManager:
278def build_auth_manager(settings_root: pathlib.Path) -> AuthManager:
279    """Load ``<settings-root>/users.yaml`` — or mint a default
280    admin if missing — and return a fully-configured
281    :class:`AuthManager`.
282
283    Called once at server startup when
284    ``JMRI_AUTH_ENABLED=1``. With auth off, callers should not
285    invoke this; the server sets ``ctx.auth = None`` instead and
286    the middleware becomes a no-op.
287    """
288    users_path = settings_root / USERS_FILE_NAME
289    users = load_users_file(users_path)
290    if not users.users:
291        users = _bootstrap_default_admin(settings_root)
292        save_users_file(users_path, users)
293
294    secret = _resolve_secret(settings_root)
295    return AuthManager(users, users_path=users_path, secret_key=secret)

Load <settings-root>/users.yaml — or mint a default admin if missing — and return a fully-configured AuthManager.

Called once at server startup when JMRI_AUTH_ENABLED=1. With auth off, callers should not invoke this; the server sets ctx.auth = None instead and the middleware becomes a no-op.