Dashboard

Component User Password Management

PBKDF2 storage, 24-hour reset tokens, five password entry flows, and the legacy controllers behind them

Raw
Revised:
2026-09-10
Revision:
1
Sources:
/Users/jxc755/projects/repositories/galaxy-brain/.ingest-dossiers/Component-User-Password-Management.md
Related Notes:
Component - CORS Handling, Component - UI Error Handling, Component - E2E Tests - Writing, Component - Tool Shed Data Model, Component - API Tests

User Password Management

Verified against origin/dev @ SHA 118e885c52cdf2c213f1277ffdfc0f6c7b408090.

Scope: all local password handling — logged-out reset by email token, logged-in change, forced change on expiry, admin reset, registration validation, policy config, and storage/hashing. Out of scope: OIDC/OAuth, LDAP/PAM/remote-user (named only where they bypass or gate local passwords), API key lifecycle, session cookie internals, general authorization.

Overview

Passwords have one shared write path (UserManager.__set_password) but five very different entry paths, and one of the five bypasses the shared path entirely. That asymmetry is the organizing fact of this component.

Three things are worth knowing before reading anything else:

  1. Nothing here has been migrated to the modern stack. Every password endpoint except admin user creation runs on the legacy Routes-mapper + @expose_api / @web.legacy_expose_api machinery. lib/galaxy/webapps/galaxy/services/users.py exists and contains no password logic at all. There is no Pydantic payload model for changing a password.
  2. The policy is one constant. PASSWORD_MIN_LEN = 6. No complexity rules, no denylist, no max length, no reuse or history check — and none of it is configurable.
  3. There is no rehash-on-login. The stored format carries its own parameters, so old hashes verify forever under their old cost factor. Users hashed under the pre-2020 COST_FACTOR=10000, or under the even older unsalted SHA-1, keep those hashes indefinitely while authenticating successfully every day.

Layer map

LayerFileOwns
Crypto primitivelib/galaxy/security/passwords.pyPBKDF2-HMAC hash/verify, legacy sha1 verify
Legacy digestlib/galaxy/util/hash_util.pynew_insecure_hash (sha1)
Token entropylib/galaxy/util/__init__.py:358unique_id()
Policylib/galaxy/security/validate_user_input.pyPASSWORD_MIN_LEN, validate_password_str, validate_password
Modellib/galaxy/model/__init__.pyUser.password, last_password_change, set_password_cleartext, set_random_password, check_password, PasswordResetToken
Model wiringlib/galaxy/model/mapping.pyinjects config.use_pbkdf2 into User.use_pbkdf2
Auth indirectionlib/galaxy/auth/__init__.py, auth/providers/localdb.py, auth/util.pycheck_password, check_change_password, allow-password-change
Orchestrationlib/galaxy/managers/users.pyregister, create, change_password, __set_password, send_reset_email, get_reset_token
Legacy controllerswebapps/galaxy/controllers/user.py, controllers/admin.pylogin, create, change_password, reset_password, reset_user_password
Legacy APIwebapps/galaxy/api/users.pyget_password / set_password on …/password/inputs
Basic authwebapps/galaxy/services/authenticate.pyverify password → API key
Clientclient/src/components/Login/, User/UserPreferencesModel.ts, Register/RegisterForm.vue, Grid/configs/adminUsers.tsall four UI entry points

Storage and hashing

lib/galaxy/security/passwords.py is 70 lines and frozen since 2022-02-02 (a formatting commit; last substantive change 2020).

SALT_LENGTH   = 12        # os.urandom bytes, b64-encoded to 16 chars
KEY_LENGTH    = 24
HASH_FUNCTION = "sha256"
COST_FACTOR   = 100000

Stored form is PBKDF2${fn}${cost}${salt}${b64(key)} (passwords.py:49) — parameters live in the hash, so check_password_PBKDF2 (L52) re-derives using the stored fn/cost, and old hashes verify under their own weaker settings. check_password (L26) dispatches on the PBKDF2 prefix; the else branch is an unsalted sha1(guess).hexdigest() comparison against the raw column — the pre-PBKDF2 format. Both use safe_str_cmp.

Model side (lib/galaxy/model/__init__.py): set_password_cleartext (L998) validates via validate_password_str, branches on User.use_pbkdf2 between hash_password (L1005) and new_insecure_hash (L1007), and stamps last_password_change = now(). use_pbkdf2 is a class attribute overwritten at startup by mapping._configure_model from config. set_random_password (L1010) correctly uses random.SystemRandom().

use_pbkdf2: false exists for ProFTPD, which reads galaxy_user.password directly (doc/source/admin/special_topics/ftp.md:41).

Legacy compatibility is deliberate and permanent: test/unit/data/security/test_passwords.py:31-40 pins both Py2/Py3 10000-iteration PBKDF2 hashes and raw sha1 hashes as must-keep-verifying fixtures.

Policy

lib/galaxy/security/validate_user_input.py:

  • PASSWORD_MIN_LEN = 6 (L67).
  • validate_password_str (L86) — length only.
  • validate_password(trans, password, confirm) (L195) — confirm-match, then delegates.

The layering matters: model.User.set_password_cleartext calls validate_password_str itself, so the length rule cannot be routed around by any write path. The confirm-match check exists only at manager/controller level. This makes validate_password_str the single choke point for any future policy work.

The reset token

lib/galaxy/model/__init__.py:1411-1425. The token is the primary key (String(32)). TTL is a hard-coded 24 hours, not configurable. Entropy comes from galaxy.util.unique_id() — md5(str(random.getrandbits(128))) over the stdlib module-level Mersenne Twister, not SystemRandom/secrets (see Known issues).

Single-use is implemented by back-dating expiration_time = now() against a strict > check, not by deleting the row — and only on the success path, so a failed submit leaves the token live (desirable UX). No cleanup job exists anywhere in lib/ or scripts/; the table grows monotonically. It appears in no alembic revision — it predates the baseline and is created from model metadata.

The five flows

A — logged-out reset by email

LoginForm.vue:141-153 POSTs /user/reset_password; the link is gated on enable_account_interface. That resolves through buildapp’s catch-all /{controller}/{action} to UserController.reset_password (controllers/user.py:355), which always returns the enumeration-safe “If an account exists for this email address…” unless the manager handed back an error.

UserManager.send_reset_email (managers/users.py:623) refuses without smtp_server, validates the email, then get_reset_token looks the user up by email and retries case-insensitively (the legacy of 8dec13c376f), skipping deleted users. The mail body is a module-level %-formatted constant PASSWORD_RESET_TEMPLATE (L71-80) — notably not a file under templates/mail/, unlike send_activation_email.

The link points at /login/start?token=…. Resolution is worth tracing because it explains a bug later: the server mapper is consulted first and matches controller="login", but there is no controllers/login.py — _resolve_map_match raises HTTPNotFound and handle_request (web/framework/base.py:248-259) falls back to the clientside mapper, which serves the SPA. Login.vue:14-16 then renders ChangePassword.vue when query.token || query.expired_user is set.

Submit POSTs /user/change_password; the token branch of UserManager.change_password (managers/users.py:504-514) validates expiry, sets the password, back-dates the token, and the controller calls trans.handle_user_login(user) — a successful token reset logs you straight in.

B — logged-in change via preferences

A FormGeneric card (UserPreferencesModel.ts:47-60) GETs and PUTs /api/users/{id}/password/inputs, routed by explicit mapper.connect calls at buildapp.py:727-741 to get_password / set_password (api/users.py:1046, :1060). The GET returns a static three-field spec; the PUT delegates straight to change_password’s id branch, which runs auth_manager.check_change_password(user, current, request) before __set_password.

__set_password (managers/users.py:531-557) is the shared write path and the only place that:

  • runs validate_password,
  • calls set_password_cleartext,
  • invalidates the user’s other GalaxySession rows (all is_valid sessions except trans.galaxy_session.id),
  • writes trans.log_event("User change password").

There is no “must differ from current” rule — test_new_password_same_password asserts re-setting the same password succeeds.

C — forced change on expiry

password_expiration_period (config schema L1556, default 0 days) is converted to a timedelta at config/__init__.py:897. The check lives in controllers/user.py:197-211, inside the else: branch of __validate_login. On expiry the controller returns expired_user: <encoded id>; the “expiring soon” warning fires in the last 10% of the period, hard-coded.

LoginForm.vue:112-113 reacts by navigating to /root/login?expired_user=…. ChangePassword.vue:52 renders the extra “Current Password” field only when expiredUser is set — which is why current is required here but not in the token flow. See Known issues #1 and #2; both live in this flow.

D — admin reset

A per-row “Reset Password” action in adminUsers.ts:88-95 → FormGeneric on /admin/reset_user_password → controllers/admin.py:772-796 (@web.legacy_expose_api + @web.require_admin). It accepts util.listify(kwd.get("id")), so it is a bulk operation setting the same password on N users.

It calls set_password_cleartext directly and therefore bypasses __set_password — no session invalidation, no log_event, no allow-password-change consultation. Given that admin reset is the standard response to a compromised account, the missing session invalidation is the sharpest edge in this component.

E — registration

RegisterForm.vue POSTs /user/create (CSRF-checked). UserManager.register (managers/users.py:100-136) gates on allow_local_account_creation, then joins the messages from validate_email + validate_password + validate_publicname — all three always run. UserManager.create (L138-174) falls back to set_random_password() when no password is supplied; that is the “account with no usable local password” case used by autoregistration, get_or_create_remote_user, and PSA/OIDC account creation.

Auth-provider indirection

No controller calls User.check_password directly. Everything goes through AuthManager (lib/galaxy/auth/__init__.py), which iterates configured authenticators — check_password (L78) for login and Basic-auth, check_change_password (L91) for the preferences flow, check_registration_allowed (L26) for signup. LocalDB.authenticate_user (auth/providers/localdb.py:25) is a three-line wrapper; LocalDB.authenticate() always returns failure, since localdb can never auto-create.

Operator footgun: allow-password-change defaults to False in AuthManager code (L101), but auth/util.py:25-34 synthesizes true when no auth_config_file exists. An admin who hand-writes a minimal auth_conf.xml silently disables change-password with the message “Password change not supported.”

user.external == True blocks login outright regardless of stored password. use_remote_user / disable_local_accounts / enable_account_interface gate only the client — see Known issues #7.

Endpoints

Method + pathHandlerAuthNotes
POST /user/logincontrollers/user.py:143anon+sessionless, CSRFreturns expired_user on expiry
POST /user/createcontrollers/user.py:267anon+sessionless, CSRFregistration
POST /user/reset_passwordcontrollers/user.py:355anon+sessionless, no CSRFrequest reset email
POST /user/change_passwordcontrollers/user.py:335anon+sessionless, no CSRFtoken or id+current
GET /api/users/{id}/password/inputsapi/users.py:1046@expose_apistatic field spec
PUT /api/users/{id}/password/inputsapi/users.py:1060@expose_api, no ownership checkdelegates to manager
GET|PUT /admin/reset_user_passwordcontrollers/admin.py:773@web.require_adminbulk; bypasses __set_password
POST /api/usersapi/users.py:587 (FastAPI)require_admin=Trueonly Pydantic-typed password endpoint
GET /api/authenticate/baseauthservices/authenticate.py:33Basic authverify password → API key

Routing mechanics (web/framework/base.py:219-259): two Routes mappers exist and map_match = self.mapper.match(...) or client_match — the server mapper wins, with the clientside match used only as a fallback when controller resolution raises HTTPNotFound. Because buildapp.py:109 connects the catch-all /{controller}/{action} before the clientside routes at L191+, every /<x>/<y> URL is offered to a server controller first. This is the same dual-mapper stack described in Component - CORS Handling, and it is the direct cause of Known issue #1.

Also live: webapps/base/webapp.py:705-724 keeps an allowed_paths allowlist so reset_password and change_password still work when require_login is on. It carries a TODO to eliminate itself and still lists four UserController actions that no longer exist.

Config

OptionDefaultEffect
use_pbkdf2truefalse → new passwords stored as unsalted hex sha1
password_expiration_period0 daysforced change on login; 10%-of-period warning
enable_account_interfacetruehides preferences card + reset link — client-side only
disable_local_accountsfalseforces allow_local_account_creation=false; hides card client-side
allow_local_account_creationtruegates register and POST /api/users
use_remote_userfalsehides card client-side; remote users get a random password
smtp_serverunsetsend_reset_email refuses without it
auth_config_fileauth_conf.xml<allow-password-change> per authenticator

No config option exists for minimum length, token TTL, complexity, history, or rate limiting.

Tests and gaps

FileCovers
test/unit/data/security/test_passwords.pyround-trip, unicode, pinned legacy hash fixtures
test/unit/app/managers/test_UserManager.py:157-191change_password — missing args, wrong current, mismatch, success, token, expired token
test/unit/webapps/test_login.py:35-99login controller incl. all three expiry outcomes; case-insensitive get_reset_token
test/unit/auth/test_auth.pyLocalDB.authenticate_user; policy rejection
lib/galaxy_test/selenium/test_change_password.py6 browser tests over the preferences flow; still @selenium_only

Gaps — no API-level test of either password endpoint (lib/galaxy_test/api/test_users.py never mentions passwords beyond the last_password_change key); no test of admin reset; no end-to-end reset-email test; no test of the expiry redirect (only the controller’s JSON); no test that session invalidation happens; no integration test with password_expiration_period set. Closing the API gap would use the plumbing in Component - API Tests; the Selenium test’s @selenium_only status is a live instance of the migration gap tracked in Component - E2E Tests - Writing, with its selectors in navigation.yml:69, 130-132.

Tool Shed’s parallel implementation

lib/tool_shed/webapp/model/__init__.py defines its own User and PasswordResetToken and does not use galaxy.security.passwords. Shed passwords are unsalted hex SHA-1 for all users unconditionally — no PBKDF2 branch, no use_pbkdf2 knob — and check_password (L171) uses a plain == rather than safe_str_cmp. It does share validate_password_str and galaxy.auth.AuthManager. Its password_expiration_period is typed plain int with no timedelta conversion. Schema context in Component - Tool Shed Data Model.

Known issues

  1. The expired-password forced-change redirect appears broken. LoginForm.vue:113 navigates to /root/login?expired_user=…, but /root/login is not a registered clientside route (buildapp.py:194 registers only /root) and the server catch-all is consulted first, so it resolves to RootController.login. That method ends (controllers/root.py:79) with send_redirect(url_for(controller="login", action="start", redirect=redirect)) — expired_user lands in **kwd and is dropped. The user should arrive at /login/start with no query params and see the ordinary login form, never the change-password form. Needs empirical confirmation before filing; the unit test only asserts the controller’s JSON, so it would not catch this.
  2. The expiry check sits in the wrong branch. controllers/user.py:184-211 — when user_activation_on is true and the user is inactive but within the grace period, login proceeds at L192 inside the activation branch, which never evaluates password_expiration_period. Unactivated-but-in-grace users bypass expiry entirely.
  3. Reset-token entropy uses a non-cryptographic PRNG. unique_id() is md5 over the stdlib Mersenne Twister. Galaxy already uses SystemRandom for set_random_password and os.urandom for salts, so the habit exists — this call site predates it. secrets.token_hex(16) is a drop-in for the String(32) column.
  4. No rate limiting on the auth surface. Login, change-password and reset-password are all @expose_api_anonymous_and_sessionless with no throttle. A Limiter exists (fast_app.py:341-350) but is applied at exactly one unrelated call site, and these are legacy WSGI routes FastAPI middleware does not cover. check_change_password returns a distinguishable “Invalid current password.”
  5. change_password and reset_password skip the CSRF check their siblings login and create perform. Deliberate for the token flow (no session), but the authenticated id+current path is unprotected too. ChangePassword.vue correspondingly sends no session_csrf_token, unlike LoginForm.vue and RegisterForm.vue.
  6. set_password skips the ownership guard its siblings use — api/users.py:1060 omits the _get_user check at L1219-1224. Mitigated in practice by the current-password requirement, but it invites a regression if current ever becomes optional.
  7. disable_local_accounts / enable_account_interface / use_remote_user are client-side only. The server endpoints stay live; on an OIDC-only instance local password endpoints remain reachable.
  8. No rehash-on-successful-login. Cost went 10000 → 100000 in 2020 (eeaf748c8c4), but users who never change their password keep the old parameters forever. The verify path already knows the stored parameters, so this is a contained change.
  9. password_reset_token rows are never deleted — single-use is back-dating, and no cleanup script exists.
  10. Admin bulk reset diverges from the shared write path — no session invalidation, no audit event, no allow-password-change check. See flow D.
  11. set_password_cleartext raises a bare Exception (model/__init__.py:1003) rather than a galaxy.exceptions type, so callers reaching the model backstop directly cannot turn it into a clean 400. Error surfacing otherwise follows the pattern in Component - UI Error Handling.
  12. active_authenticators evals a config-supplied <filter> (auth/__init__.py:118) with {"__builtins__": None} — admin-controlled, so not user-facing, but it is an eval in the login hot path.
  13. allow-password-change default mismatch between code (False) and synthesized config (true).

Extension points

  • Policy — validate_password_str in validate_user_input.py is the single choke point (the model calls it too, so nothing can route around it). Making it config-driven means threading config in; validate_password already takes trans, validate_password_str does not.
  • KDF — the stored PBKDF2$fn$cost$salt$key format already carries its parameters, so a new prefix (ARGON2$…) can coexist. check_password’s else branch is the sha1 catch-all and would need reordering into a prefix dispatch.
  • Rehash-on-login — LocalDB.authenticate_user has the user, the cleartext and the result, making it the natural site; it currently has no session to commit with. AuthManager.check_password is the alternative and has the same problem.
  • Any new write path must go through UserManager.__set_password — it is the only place that invalidates sibling sessions and logs the event.
  • Modernization — the highest-value refactor is moving get_password / set_password / change_password / reset_password onto FastAPI with Pydantic payloads in services/users.py (today empty of password code), which would also bring them under the existing limiter. Component - Workflow API documents the target shape.

Recent activity

passwords.py is frozen since 2022-02-02 (formatting); last substantive change 2020-04-22 (eeaf748c8c4, cost factor bump). Recent churn in managers/users.py is all adjacent — OIDC token purge (2026-09-02), activation transaction handling (2026-07-16), configurable OIDC activation (2026-07-13), utcnow() → now() (2026-05-03, which does touch PasswordResetToken expiry semantics). The Login/ client cluster is 2026-01/02 test-infrastructure work plus a session-cookie fix (2026-04-25).

Read: the crypto and flow logic are stable and untouched; all motion is OIDC, activation, and client test plumbing. Nothing in flight would conflict with a hardening pass.

Incoming References (5)

  • Component Api Tests related note — API test plumbing: ApiTestCase base class, populators, fixtures, decorators, assertions, user context switching
  • Component Cors Handling related note — Five CORS mechanisms — global origin-validated middleware, per-route reflected-origin landings, OPTIONS catch-all, legacy WSGI path, display-app proxy
  • Component E2e Tests Writing related note — Layered test infrastructure: SeleniumTestCase, NavigatesGalaxy helpers, smart component system, Selenium/Playwright
  • Component Tool Shed Data Model related note — Shed-side SQLAlchemy schema — 14 tables, hg-keyed RepositoryMetadata over a denormalized JSON blob, dead flags, two-revision alembic history
  • Component Ui Error Handling related note — Backend MessageException serialization to JSON and frontend parsing via simple-error.ts