Give members opaque usernames - #29
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #29 +/- ##
============================================
- Coverage 86.77% 86.74% -0.03%
- Complexity 594 647 +53
============================================
Files 67 71 +4
Lines 2072 2226 +154
============================================
+ Hits 1798 1931 +133
- Misses 274 295 +21 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JeroenDeDauw
force-pushed
the
feature/opaque-member-usernames
branch
from
September 1, 2026 18:24
f8079db to
529a5de
Compare
Member accounts were named after the address they were admitted with, so every enumeration defence in this extension worked around that name, and each release found another place where MediaWiki prints one. Members now get an opaque name: `Member` plus six characters drawn with `random_int` from a 32-symbol alphabet, checked for availability against the primary database rather than a replica, and redrawn on a collision. Names are never reused, since a removed member's account stays. The address lives only in the account's confirmed email and in the roster, which is already how the extension looks members up: a code login for a known address lands in the account the roster names, and an address the roster has never seen gets an account of its own. Where to look hardest: `MediaWikiUsernameMinter` (the primary read and the retry), the two wrappers in `RegistrationHandler::nameTheMembersSingleSignOnAdmits()`, the shape check in `SsoAuthorizationHandler::admitANewAccount()`, and the two selections in `OpaqueNameUpdate`. ### Single sign-on The PluggableAuth plugin settles on the username before this extension is consulted. OpenIDConnect gives a say over both the address and the name, in that order within one login: the extension wraps `OpenIDConnect_EmailProcessor` to learn the address the plugin resolved — not always in the token payload a processor is handed, since the plugin falls back to the userinfo endpoint — and `OpenIDConnect_PreferredUsernameProcessor` to mint an opaque name when the allowlist admits that address. Processors the wiki configured itself run first and are kept; a login the allowlist does not admit keeps the plugin's name. Other plugins offer no such hook, so a new allowlisted login arriving through one is refused rather than provisioned under an identifying name: the member sees PluggableAuth's authorization-failed page and the `MemberAccess` log channel says why. Members who already have an account are admitted by the roster as before, whatever the plugin. Holding single sign-on to the allowlist therefore requires OpenIDConnect; the README states this under platform requirements, with the settings that bypass the hook (`UseRandomUsernames`, a per-provider processor, `MigrateUsersByUserName`). `MigrateUsersByEmail` does not bypass it — it attaches the login to an existing account, which the roster then judges — and is what joins the code-route account and the single sign-on login of one address. ### Removed The `@` in `$wgInvalidUsernameCharacters`, the `$wgUserrightsInterwikiDelimiter` flip, and the rename step of member removal — removal is now: forget the roster row, invalidate the address, invalidate the sessions. `RemovalResult::ReservedNameTaken`, `RemovalFailed` and the `reserved_name_taken` and `removal_failed` REST error codes went with it; the admin panel, their only consumer, no longer receives them. ### Kept as defence in depth The reader-group revocations and provenance rule, the password veto and password-reset filtering, the blocked special pages and API modules, and the `newusers`, `block` and `renameuser` log restrictions — the last because the migration renames through `RenameuserSQL`, whose log entry names the old username. ### Migration A `LoadExtensionSchemaUpdates` callback, run by every `update.php`, renames roster members whose name the extension did not mint (single sign-on members carry their provider's name, with no `@` in it) and reader-group accounts with no roster row whose name holds an `@`; a reader-group account with neither is left alone, since a wiki may point the group at one it already had. Members cannot edit, so the rename touches the account, its actor row, its log entries and any block on it; the old name survives only in the restricted rename log, and nothing reverses it. It runs as a `MemberAccess` system user, taking over an existing account of that name and reserving it from then on; it logs by user id, and on failure by exception class alone, since a database error message carries the statement and with it the name. A second run finds nothing. `User:` pages keep their old titles and core logs both names at debug level; the README's `update.php` paragraph says both. ### Verification 846 tests, up from 763 on master; phpcs and phpstan clean; every new test seen failing against a mutation of the code it covers. In a browser on MediaWiki 1.43: a first login minted `Member LWDOHG` with the address confirmed and the reader group, the same address returned to it, `update.php` renamed two legacy accounts keeping address, confirmation and group, and `Special:ListUsers` stayed refused. ### Known limitation `memberaccess_member.mam_email` has no unique index. Two first logins for one address in flight — two browser tabs — each get an account and a roster row, as does an address admitted by code that later arrives by single sign-on without `MigrateUsersByEmail`. The index alone would leave the losing account roster-less and refused at every later login; fixing it also needs the `ignore()` dropped from the roster insert and a dedupe step in the updater. Also omitted: a configurable name prefix, forcing `MigrateUsersByUserName` off from code, and moving `User:` pages in the migration. ### Related Follows-up to #23 and #25. Issues #22, #24 and #26 stay open: each closes one place that prints a username, now defence in depth rather than the only defence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JeroenDeDauw
force-pushed
the
feature/opaque-member-usernames
branch
from
September 1, 2026 18:24
529a5de to
1d4b3e7
Compare
Core's RenameuserSQL::rename() opens an ATOMIC_CANCELABLE section that stays open when a failure escapes it. The update's catch-and-continue then ran the remaining renames on a connection holding that dangling section: depending on the failure class they all failed with DBTransactionStateError, or were reported renamed and rolled back wholesale when update.php's shutdown commit refused the still-open section. Either way the catch's documented purpose -- one account's failure leaves the rest to be renamed -- held only for failures thrown before the section opens, which is all the existing failure tests exercised (the RenameUserSQL hook fires in the constructor). Cancelling an outer section of our own in the catch discards the dangling inner one and restores the connection, the same shape MediaWikiMemberRemover already uses. The regression test fails inside the section through the RenameUserPreRename hook, which core fires right after opening it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The graceful paths around mintUsername() caught RuntimeException, but random_int() throws Random\RandomException, which is no RuntimeException. A failing random source therefore skipped both refusals: a code login was answered with MediaWiki's error page instead of memberaccess-auth-failed, and on single sign-on OpenIDConnect catches the exception and puts its string on the error screen for the visitor -- the outcome the catch exists to prevent. Both boundaries now catch Exception, per the conventions on catch blocks considering the whole call chain, and the minter interface documents the wider contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JeroenDeDauw
marked this pull request as ready for review
September 1, 2026 23:52
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR: we jumped through a lot of hoops to hide usernames since they contained emails which need to stay private. We even have some open issues and PRs since there is a large amount of places to block access to if we want usernames to be inaccessible to Members. This PR is the simpler approach of avoiding emails in usernames so we don't need all that other stuff. Users get a random name instead.
Member accounts were named after the address they were admitted with, so every enumeration defence in
this extension worked around that name, and each release found another place where MediaWiki prints
one.
Members now get an opaque name:
Memberplus six characters drawn withrandom_intfrom a 32-symbolalphabet, checked for availability against the primary database rather than a replica, and redrawn on
a collision. Names are never reused, since a removed member's account stays. The address lives only in
the account's confirmed email and in the roster, which is already how the extension looks members up:
a code login for a known address lands in the account the roster names, and an address the roster has
never seen gets an account of its own.
Where to look hardest:
MediaWikiUsernameMinter(the primary read and the retry), the two wrappers inRegistrationHandler::nameTheMembersSingleSignOnAdmits(), the shape check inSsoAuthorizationHandler::admitANewAccount(), and the two selections inOpaqueNameUpdate.Single sign-on
The PluggableAuth plugin settles on the username before this extension is consulted. OpenIDConnect
gives a say over both the address and the name, in that order within one login: the extension wraps
OpenIDConnect_EmailProcessorto learn the address the plugin resolved — not always in the tokenpayload a processor is handed, since the plugin falls back to the userinfo endpoint — and
OpenIDConnect_PreferredUsernameProcessorto mint an opaque name when the allowlist admits thataddress. Processors the wiki configured itself run first and are kept; a login the allowlist does not
admit keeps the plugin's name.
Other plugins offer no such hook, so a new allowlisted login arriving through one is refused rather
than provisioned under an identifying name: the member sees PluggableAuth's authorization-failed page
and the
MemberAccesslog channel says why. Members who already have an account are admitted by theroster as before, whatever the plugin. Holding single sign-on to the allowlist therefore requires
OpenIDConnect; the README states this under platform requirements, with the settings that bypass the
hook (
UseRandomUsernames, a per-provider processor,MigrateUsersByUserName).MigrateUsersByEmaildoes not bypass it — it attaches the login to an existing account, which the roster then judges — and
is what joins the code-route account and the single sign-on login of one address.
Removed
The
@in$wgInvalidUsernameCharacters, the$wgUserrightsInterwikiDelimiterflip, and the renamestep of member removal — removal is now: forget the roster row, invalidate the address, invalidate the
sessions.
RemovalResult::ReservedNameTaken,RemovalFailedand thereserved_name_takenandremoval_failedREST error codes went with it; the admin panel, their only consumer, no longerreceives them.
Kept as defence in depth
The reader-group revocations and provenance rule, the password veto and password-reset filtering, the
blocked special pages and API modules, and the
newusers,blockandrenameuserlog restrictions —the last because the migration renames through
RenameuserSQL, whose log entry names the old username.Migration
A
LoadExtensionSchemaUpdatescallback, run by everyupdate.php, renames roster members whose namethe extension did not mint (single sign-on members carry their provider's name, with no
@in it) andreader-group accounts with no roster row whose name holds an
@; a reader-group account with neitheris left alone, since a wiki may point the group at one it already had. Members cannot edit, so the
rename touches the account, its actor row, its log entries and any block on it; the old name survives
only in the restricted rename log, and nothing reverses it. It runs as a
MemberAccesssystem user,taking over an existing account of that name and reserving it from then on; it logs by user id, and on
failure by exception class alone, since a database error message carries the statement and with it
the name. A second run finds nothing.
User:pages keep their old titles and core logs both names atdebug level; the README's
update.phpparagraph says both.Verification
846 tests, up from 763 on master; phpcs and phpstan clean; every new test seen failing against a
mutation of the code it covers. In a browser on MediaWiki 1.43: a first login minted
Member LWDOHGwith the address confirmed and the reader group, the same address returned to it,
update.phprenamed two legacy accounts keeping address, confirmation and group, and
Special:ListUsersstayedrefused.
Known limitation
memberaccess_member.mam_emailhas no unique index. Two first logins for one address in flight — twobrowser tabs — each get an account and a roster row, as does an address admitted by code that later
arrives by single sign-on without
MigrateUsersByEmail. The index alone would leave the losingaccount roster-less and refused at every later login; fixing it also needs the
ignore()droppedfrom the roster insert and a dedupe step in the updater.
Also omitted: a configurable name prefix, forcing
MigrateUsersByUserNameoff from code, and movingUser:pages in the migration.Related
Follows-up to #23 and
#25. Issues
#22,
#24 and
#26 stay open: each closes one place that
prints a username, now defence in depth rather than the only defence.
Production notes
Design and the fix list by
Fable 5 (max); implementation and the fixes byOpus 5 (max)in delegated sessions; a reviewer-only pr-review pass (Opus 5 (max)) between the two, whose nine findings are the fixes. The description went through a blind judge in a fresh context. Browser verification ran on a standalone MediaWiki 1.43, not on this environment's dev wikis.