Skip to content

Show whether an account has a local password and which source verifies it #38906

Description

@lunny

Problem

Nothing in the UI tells an administrator whether an account holds a local password. The admin user
detail page shows the authentication source and little else
(templates/admin/user/view_details.tmpl:17-19, rendering Local when the source ID is 0), and the
user list has no such column or filter (templates/admin/user/list.tmpl:62-76).

That would be tolerable if "auth source = Local" and "has a local password" meant the same thing, but
they do not:

  • LDAP accounts have no local password (services/auth/source/ldap/source_authenticate.go:82-90).
  • SMTP and PAM accounts do have one — a copy of the remote password captured at auto-registration
    (services/auth/source/smtp/source_authenticate.go:73,
    services/auth/source/pam/source_authenticate.go:57) — which is never used for verification.
  • OAuth2 accounts may or may not have one, and when they do it is the password they actually sign in
    with, because the OAuth2 source delegates verification to the DB authenticator
    (services/auth/source/oauth2/source_authenticate.go:15). The link-register form deliberately leaves
    it empty for external-only registration (routers/web/auth/linkaccount.go:213-219).
  • Local accounts can have an empty password too, in which case they cannot sign in with one at all.

So today the question "does this account have a local password, and who verifies it?" can only be
answered by querying the database directly. IsLocal() (models/user/user.go:238) does not answer it,
and neither does the source name shown in the UI.

Why this should land before the purge

#38905 proposes clearing the copied remote password hashes for
LDAP/SMTP/PAM accounts. That migration should not be the first time an administrator can see the state
it is about to change:

  • Before upgrading, an administrator should be able to enumerate exactly which accounts hold a local
    password hash, and cross-check it against the migration's report.
  • After the migration, IsPasswordSet() flips to false for those accounts. Without a visible
    explanation of what a local password is and who verifies this account's password, that looks like
    data loss rather than an intended cleanup.
  • The same visibility is what makes the one intentional behaviour change in SMTP and PAM auto-registration stores a copy of the user's remote password locally #38905 — switching such an
    account to the local source no longer silently re-enables the old remote password — understandable
    instead of surprising.

Proposal

  1. Precise predicates in models/user, replacing inference from IsLocal():
    • HasLocalPassword() — a local password hash exists.
    • PasswordVerifiedLocally() — a submitted password is checked against that hash, which is true for
      local and OAuth2 accounts today.
      These make the distinction expressible in templates and reusable by the doctor check.
  2. Admin user detail (templates/admin/user/view_details.tmpl): two rows next to the existing
    auth-source row —
    • Local password: ✓ / ✗
    • Password verified by: Local / <source name>
      For an OAuth2 account the second row reads Local, which is what actually happens and is currently
      invisible.
  3. Admin user list filter: add HasLocalPassword optional.Option[bool] alongside the existing
    options (models/user/search.go:53-57) and a status_filter[has_local_password] entry
    (routers/web/admin/users.go:54 and :75), following the is_2fa_enabled precedent
    (models/user/search.go:143-151). This is what lets an administrator enumerate affected accounts
    from the UI rather than with SQL.
  4. Own account page: replace the generic settings.password_change_disabled message
    (templates/user/settings/account.tmpl:31) with one that names the source managing the password, so
    a user who cannot change their password learns why. New strings go in
    options/locale/locale_en-US.json only.
  5. Doctor check reporting counts per class — remote-verified accounts holding a local password,
    local accounts with no password — shared with Step 1 of
    Proposal: refactor the user and authentication system #38904 so the CLI and the UI agree.

Deliberately not part of this: exposing password state through the public user API
(modules/structs/user.go). This is administrative information and stays on admin-only surfaces plus
the doctor output.

Tests

  • Unit test for the new search filter over fixtures covering local-with-password,
    local-without-password, LDAP, SMTP-with-captured-hash and OAuth2 accounts.
  • Integration test asserting the admin detail page renders both states correctly, including the OAuth2
    case where the source is external but verification is local.
  • Assertion that the new predicates and the doctor counts agree on the same fixture set.

Context

Prerequisite for #38905, and Step 1a of
#38904. It stands alone: no schema change, no behaviour change,
only visibility.


Assisted-by: Codet:claude-opus-4-6

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions