MediaWiki extension for members-only wikis: readers log in with an email one-time code, admitted by an allowlist of addresses and domains organized into named groups.
- A member never has a password, and a login is remembered for about a month.
- Members can read and nothing else: everything that would let them change the wiki or see behind the scenes is revoked.
- Accounts create themselves at first login. Removing an allowlist entry ends access at the next login; deactivating a member blocks them at once.
- Single sign-on logins through PluggableAuth are held to the same allowlist by default; staff accounts are exempt.
- Nothing gives the member list away: code and password-reset requests answer the same for every address, and account listings and the logs that record members are restricted.
- Groups, allowlist entries and the member roster are managed over a REST API.
- It does not make the wiki private: restricting who may read stays a wiki configuration decision.
- Introduction to the extension
- Usage documentation
- How it works
- What loading the extension changes on the wiki
- Installation
- Management API
- Configuration
- Development
- Release notes
Get professional support for this extension via Professional Wiki, its creators and maintainers. We provide MediaWiki Development, MediaWiki Hosting, and MediaWiki Consulting services.
What follows describes the defaults. Two settings change them: see Login routes.
A visitor asks for a login code by entering their email address in the login form's username field. Whether one is sent depends on the allowlist, where each entry belongs to exactly one group. A code is eight digits, valid for ten minutes and usable once. The response to a code request is the same either way, so it never reveals who is on the list.
Entering the right code logs the visitor in, and the first time also creates their account: the username is their email address, they are placed in the reader group, and the address is recorded as confirmed. The allowlist is consulted again at that point, so removing an entry ends access at the next login. A code never opens an account that was created some other way.
The username is the address lowercased and then put through MediaWiki's username rules: the first
letter is capitalized and underscores become spaces, so John_Doe@Example.com logs in as
John doe@example.com. Addresses that cannot become a username, and addresses whose username is
already taken by an account that is not that member, are refused.
A member never has a password: setting one is refused, and so is having a temporary one mailed by a password reset. Both stay open to accounts that were not admitted through the allowlist. Asking for a reset of a member's address answers exactly as it does for an address that was never admitted.
Single sign-on logins are held to the same allowlist. With PluggableAuth configured, the address the identity provider returns has to match an entry or the login is refused, and a first login that matches is provisioned exactly like a code login. For an account that is already a member, the address checked is the one recorded when they were admitted, so removing their entry ends this route too. Accounts that are not members are exempt, so staff signing in through the identity provider are unaffected; when such a login uses an address the allowlist would not admit, it is written to the log channel. A refusal is final: no other handler of the same hook can hand the login back. Without PluggableAuth the check never runs.
Deactivating a member blocks their account sitewide and indefinitely; removing their allowlist entry alone leaves the account and its open session intact. The block is an ordinary one, so it appears in the block log and can be undone by hand. A deactivated member asking for a login code gets no mail, and the same answer as an address that was never admitted. Reactivating lifts the block and leaves the account otherwise as it was.
A block placed by hand, for some other reason, is neither replaced when the member is deactivated nor lifted when they are reactivated. Deactivating is refused while such a block would not keep the member out by itself, because it runs out or is only partial.
Removing a member makes the roster forget them and renames their account to
Removed member <userId>, so their address is free again and reaches a new account at the next
login. The rename ends the account's open sessions, but not the member's admission: the allowlist
entry that admits them stays, and a deactivation block stays behind on the renamed account rather
than reaching the new one.
Code requests are rate limited per email address and per client IP, with both a burst and a daily
limit. Codes are stored hashed and are burned after five wrong entries. Every issue, success,
failure and rate-limit hit is logged through the MemberAccess log channel, with the email
address hashed.
A member's username is their email address, so anything that names accounts names the roster. The action API query modules whose purpose is enumerating accounts are closed to the reader group, and three logs are closed to anyone who cannot manage members: the new user log, where every member's account creation is recorded, the block log, where every deactivation is, and the rename log, which names what a removed member was called. Restricting a log type also keeps it out of recent changes. Hiding the matching special pages beyond that is a wiki configuration matter, for instance with Lockdown.
Page histories and recent changes still name whoever acted, which on a members-only wiki means the staff who edit: members cannot appear there, since they cannot change anything.
Two settings, one per login route, say what the allowlist governs there and whether the code route is offered at all.
$wgMemberAccessCodeLogin says whom the one-time code route admits:
| Value | What the route does |
|---|---|
allowlisted |
Admits the addresses an allowlist entry matches. The default |
open |
Admits every address. A matching entry still attributes the member to its group; without a match they have no group |
off |
Is not offered: no button on the login form, and no code is issued |
An unrecognized value is read as allowlisted, with a warning in the log.
$wgMemberAccessApplyAllowlistToSso holds single sign-on logins to the allowlist, and does so by
default. Set to false, it leaves that route alone: no login is refused, none is logged, and the
accounts that route creates are ordinary accounts rather than members. Setting it back to true
does not reach them. An account that is not a member is exempt, so everyone who signed in while the
switch was off keeps their account and the rights it carries, outside the allowlist, until an
administrator deals with the account by hand.
An open route is exactly that: anyone who can receive mail at the address they enter gets an account and a roster row, without an administrator having seen the address first. The per-address rate limits bound what can be aimed at one mailbox; an attacker who varies the address meets only the IP limits. The route suits a wiki with another gate in front of it, an internal network for instance, rather than one on the open internet.
The open route changes only the allowlist check; everything else still holds. A member whom no entry matched has no group until one does: their next login, over either route, writes that group down. The group a member already has is never moved.
Narrowing a route ends the access of everyone it no longer admits, at their next login: everyone on the code route, and every member on single sign-on.
With the code route off and single sign-on left alone, the allowlist governs nothing.
Loading the extension:
- revokes from the reader group everything that would let a reader change the wiki or see behind the
scenes: editing, commenting, moving, uploading, deleting, protecting, tagging, creating accounts,
sending email, reading the abuse filters and their log, and reading or changing their own private
information or preferences, which closes
Special:ChangeEmailto them; - sets
$wgBlockDisablesLogin, so blocking a member keeps them out of a private wiki; - restricts the
newusers,blockandrenameuserlogs to thememberaccess-manageright, unless the wiki already restricted them; - turns off ConfirmEdit's
badloginperusercaptcha trigger unless the code route is off, so failed logins no longer escalate to a captcha for the account they name, for everyone on the wiki and not only for members; the per-IPbadlogintrigger is left alone; - grants
autocreateaccountto anonymous visitors, since a member's account is created by logging in; - removes
@from$wgInvalidUsernameCharacters, and changes$wgUserrightsInterwikiDelimiterfrom@to@@, so thatSpecial:UserRightscan act on an account named after an address; - sets
$wgExtendedLoginCookieExpirationto$wgMemberAccessSessionDurationSeconds, which decides how long a remembered login lasts for everyone on the wiki, not only for members.
Apart from the captcha trigger, none of this depends on the login routes: setting the code route to off
narrows that route and nothing else.
Platform requirements:
- PHP 8.3 or later
- MediaWiki 1.43 or later
- MySQL, MariaDB or SQLite. No PostgreSQL schema is shipped
- Working outgoing email, since login codes are sent by mail
Clone into the wiki's extensions/ directory:
git clone git@github.com:ProfessionalWiki/MemberAccess.gitThen add to LocalSettings.php:
wfLoadExtension( 'MemberAccess' );Run php maintenance/run.php update --quick to create the extension's tables.
Groups, allowlist entries and the roster are managed over REST, under /rest.php/member-access/v0/.
Every endpoint requires the memberaccess-manage right, which sysops and bureaucrats have. Writes also require the
wiki's CSRF token in an X-CSRF-TOKEN header, unless the session provider is inherently CSRF-safe.
| Endpoint | What it does |
|---|---|
GET /groups |
Every group with its entry count and its total and active member counts |
POST /groups |
Creates a group. Body: name |
PUT /groups/{id} |
Renames a group. Body: name |
DELETE /groups/{id} |
Deletes a group. Refused while it still holds entries, or while members are attributed to it |
GET /groups/{id}/entries |
The group's allowlist entries |
POST /groups/{id}/entries |
Adds an entry. Body: value, an email address or @domain |
DELETE /entries/{id} |
Removes an allowlist entry |
GET /members |
The roster: each member's address, group, creation, last login and active flag, plus the totals overall and per group |
POST /members/{userId}/deactivate |
Ends a member's access. Also requires the block right, and refuses your own account |
POST /members/{userId}/reactivate |
Restores a member's access. Also requires the block right. The response's blocked says whether a block placed for another reason is still on the account |
DELETE /members/{userId} |
Removes a member, freeing their address for a new account. Refuses your own account |
A failure answers with the HTTP status and a body carrying a stable errorCode next to a
human-readable error: not_logged_in, permission_denied, invalid_csrf_token, invalid_group_name,
group_name_too_long, duplicate_group_name, group_not_found, group_not_empty, group_has_members,
invalid_entry_value, entry_value_too_long, duplicate_entry, entry_not_found, not_a_member,
cannot_deactivate_self, block_right_required, block_failed, unblock_failed,
cannot_remove_self, reserved_name_taken, removal_failed. A duplicate_entry also carries
conflictingGroupId and conflictingGroupName, naming the group that already admits the value.
Malformed requests are refused by MediaWiki's REST framework before reaching the extension, and
carry its error shape rather than this one.
| Variable | Type | Default | Description |
|---|---|---|---|
$wgMemberAccessCodeLogin |
string | 'allowlisted' |
Whom the one-time code route admits: allowlisted, open or off. See Login routes |
$wgMemberAccessApplyAllowlistToSso |
bool | true |
Whether single sign-on logins are held to the allowlist. See Login routes |
$wgMemberAccessReaderGroup |
string | 'reader' |
Name of the user group that members are placed in |
$wgMemberAccessCodeTtlSeconds |
int | 600 |
How long an issued login code stays valid, in seconds |
$wgMemberAccessCodeAttemptLimit |
int | 5 |
How many times a code may be entered before it is burned |
$wgMemberAccessEmailBurstLimit |
int | 3 |
Maximum code requests per email address within 15 minutes |
$wgMemberAccessEmailDailyLimit |
int | 10 |
Maximum code requests per email address within 24 hours |
$wgMemberAccessIpBurstLimit |
int | 10 |
Maximum code requests per client IP within 15 minutes |
$wgMemberAccessIpDailyLimit |
int | 50 |
Maximum code requests per client IP within 24 hours |
$wgMemberAccessSenderAddress |
?string | null |
Address login codes are sent from. Falls back to $wgPasswordSender |
$wgMemberAccessSessionDurationSeconds |
int | 2592000 |
How long a remembered login lasts, wiki-wide. Thirty days, against core's 180 days. 0 leaves $wgExtendedLoginCookieExpiration alone |
$wgMemberAccessBlockedApiModules |
string[] | [ 'allusers', 'users', 'blocks' ] |
Action API query submodules the reader group may not use |
Issued codes and rate-limit counters are held in the main object stash ($wgMainStash), which is
database-backed by default. Point it at Redis or Valkey to keep them out of the database.
Route the log channel to keep the audit trail:
$wgDebugLogGroups['MemberAccess'] = '/path/to/memberaccess.log';Install dependencies from the extension directory:
composer installRun all checks (PHPCS, PHPStan and PHPUnit) from a MediaWiki installation:
composer preflightAfter changing a table definition in sql/*.json, regenerate the SQL for both database types:
php maintenance/run.php generateSchemaSql --json extensions/MemberAccess/sql/<table>.json \
--sql extensions/MemberAccess/sql/mysql/<table>.sql --type mysql
php maintenance/run.php generateSchemaSql --json extensions/MemberAccess/sql/<table>.json \
--sql extensions/MemberAccess/sql/sqlite/<table>.sql --type sqliteInitial version for MediaWiki 1.43+ with these features:
- Login with an eight-digit code mailed to the member's address, valid for ten minutes and usable once, requested from the login form's username field
- An allowlist of email addresses and domains, organized into named groups, decides who is admitted
- Accounts create themselves at first login, into a reader group that may read and nothing else
- Single sign-on logins through PluggableAuth held to the same allowlist by default, with staff accounts exempt
- Settable login routes: the code route admits the addresses an allowlist entry matches, every address, or is not offered at all; single sign-on can be left outside the allowlist
- Members never have a password: setting one and having a temporary one mailed are both refused
- Deactivation blocks a member's account sitewide, reactivation lifts that block again, and removal frees their address for a new account
- Code requests rate limited per email address and per client IP, with a burst and a daily limit, and codes stored hashed and burned after five wrong entries
- Uniform responses, restricted account-listing API modules, and restricted new user, block and rename logs, so the member list is not given away
- Every code issue, login success, failure and rate-limit hit logged through the
MemberAccesslog channel, with the email address hashed - A REST API under
/rest.php/member-access/v0/for managing groups, allowlist entries and the roster