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:
- 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_apimachinery.lib/galaxy/webapps/galaxy/services/users.pyexists and contains no password logic at all. There is no Pydantic payload model for changing a password. - 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. - 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
| Layer | File | Owns |
|---|---|---|
| Crypto primitive | lib/galaxy/security/passwords.py | PBKDF2-HMAC hash/verify, legacy sha1 verify |
| Legacy digest | lib/galaxy/util/hash_util.py | new_insecure_hash (sha1) |
| Token entropy | lib/galaxy/util/__init__.py:358 | unique_id() |
| Policy | lib/galaxy/security/validate_user_input.py | PASSWORD_MIN_LEN, validate_password_str, validate_password |
| Model | lib/galaxy/model/__init__.py | User.password, last_password_change, set_password_cleartext, set_random_password, check_password, PasswordResetToken |
| Model wiring | lib/galaxy/model/mapping.py | injects config.use_pbkdf2 into User.use_pbkdf2 |
| Auth indirection | lib/galaxy/auth/__init__.py, auth/providers/localdb.py, auth/util.py | check_password, check_change_password, allow-password-change |
| Orchestration | lib/galaxy/managers/users.py | register, create, change_password, __set_password, send_reset_email, get_reset_token |
| Legacy controllers | webapps/galaxy/controllers/user.py, controllers/admin.py | login, create, change_password, reset_password, reset_user_password |
| Legacy API | webapps/galaxy/api/users.py | get_password / set_password on …/password/inputs |
| Basic auth | webapps/galaxy/services/authenticate.py | verify password → API key |
| Client | client/src/components/Login/, User/UserPreferencesModel.ts, Register/RegisterForm.vue, Grid/configs/adminUsers.ts | all 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
GalaxySessionrows (allis_validsessions excepttrans.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 + path | Handler | Auth | Notes |
|---|---|---|---|
POST /user/login | controllers/user.py:143 | anon+sessionless, CSRF | returns expired_user on expiry |
POST /user/create | controllers/user.py:267 | anon+sessionless, CSRF | registration |
POST /user/reset_password | controllers/user.py:355 | anon+sessionless, no CSRF | request reset email |
POST /user/change_password | controllers/user.py:335 | anon+sessionless, no CSRF | token or id+current |
GET /api/users/{id}/password/inputs | api/users.py:1046 | @expose_api | static field spec |
PUT /api/users/{id}/password/inputs | api/users.py:1060 | @expose_api, no ownership check | delegates to manager |
GET|PUT /admin/reset_user_password | controllers/admin.py:773 | @web.require_admin | bulk; bypasses __set_password |
POST /api/users | api/users.py:587 (FastAPI) | require_admin=True | only Pydantic-typed password endpoint |
GET /api/authenticate/baseauth | services/authenticate.py:33 | Basic auth | verify 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
| Option | Default | Effect |
|---|---|---|
use_pbkdf2 | true | false → new passwords stored as unsalted hex sha1 |
password_expiration_period | 0 days | forced change on login; 10%-of-period warning |
enable_account_interface | true | hides preferences card + reset link — client-side only |
disable_local_accounts | false | forces allow_local_account_creation=false; hides card client-side |
allow_local_account_creation | true | gates register and POST /api/users |
use_remote_user | false | hides card client-side; remote users get a random password |
smtp_server | unset | send_reset_email refuses without it |
auth_config_file | auth_conf.xml | <allow-password-change> per authenticator |
No config option exists for minimum length, token TTL, complexity, history, or rate limiting.
Tests and gaps
| File | Covers |
|---|---|
test/unit/data/security/test_passwords.py | round-trip, unicode, pinned legacy hash fixtures |
test/unit/app/managers/test_UserManager.py:157-191 | change_password — missing args, wrong current, mismatch, success, token, expired token |
test/unit/webapps/test_login.py:35-99 | login controller incl. all three expiry outcomes; case-insensitive get_reset_token |
test/unit/auth/test_auth.py | LocalDB.authenticate_user; policy rejection |
lib/galaxy_test/selenium/test_change_password.py | 6 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
- The expired-password forced-change redirect appears broken.
LoginForm.vue:113navigates to/root/login?expired_user=…, but/root/loginis not a registered clientside route (buildapp.py:194registers only/root) and the server catch-all is consulted first, so it resolves toRootController.login. That method ends (controllers/root.py:79) withsend_redirect(url_for(controller="login", action="start", redirect=redirect))—expired_userlands in**kwdand is dropped. The user should arrive at/login/startwith 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. - The expiry check sits in the wrong branch.
controllers/user.py:184-211— whenuser_activation_onis true and the user is inactive but within the grace period, login proceeds at L192 inside the activation branch, which never evaluatespassword_expiration_period. Unactivated-but-in-grace users bypass expiry entirely. - Reset-token entropy uses a non-cryptographic PRNG.
unique_id()is md5 over the stdlib Mersenne Twister. Galaxy already usesSystemRandomforset_random_passwordandos.urandomfor salts, so the habit exists — this call site predates it.secrets.token_hex(16)is a drop-in for theString(32)column. - No rate limiting on the auth surface. Login, change-password and reset-password are all
@expose_api_anonymous_and_sessionlesswith no throttle. ALimiterexists (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_passwordreturns a distinguishable “Invalid current password.” change_passwordandreset_passwordskip the CSRF check their siblingsloginandcreateperform. Deliberate for the token flow (no session), but the authenticated id+current path is unprotected too.ChangePassword.vuecorrespondingly sends nosession_csrf_token, unlikeLoginForm.vueandRegisterForm.vue.set_passwordskips the ownership guard its siblings use —api/users.py:1060omits the_get_usercheck at L1219-1224. Mitigated in practice by thecurrent-password requirement, but it invites a regression ifcurrentever becomes optional.disable_local_accounts/enable_account_interface/use_remote_userare client-side only. The server endpoints stay live; on an OIDC-only instance local password endpoints remain reachable.- 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. password_reset_tokenrows are never deleted — single-use is back-dating, and no cleanup script exists.- Admin bulk reset diverges from the shared write path — no session invalidation, no audit event, no
allow-password-changecheck. See flow D. set_password_cleartextraises a bareException(model/__init__.py:1003) rather than agalaxy.exceptionstype, 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.active_authenticatorsevals a config-supplied<filter>(auth/__init__.py:118) with{"__builtins__": None}— admin-controlled, so not user-facing, but it is anevalin the login hot path.allow-password-changedefault mismatch between code (False) and synthesized config (true).
Extension points
- Policy —
validate_password_strinvalidate_user_input.pyis the single choke point (the model calls it too, so nothing can route around it). Making it config-driven means threading config in;validate_passwordalready takestrans,validate_password_strdoes not. - KDF — the stored
PBKDF2$fn$cost$salt$keyformat already carries its parameters, so a new prefix (ARGON2$…) can coexist.check_password’selsebranch is the sha1 catch-all and would need reordering into a prefix dispatch. - Rehash-on-login —
LocalDB.authenticate_userhas the user, the cleartext and the result, making it the natural site; it currently has no session to commit with.AuthManager.check_passwordis 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_passwordonto FastAPI with Pydantic payloads inservices/users.py(today empty of password code), which would also bring them under the existinglimiter. 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.