Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 75 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ admitted by an allowlist of addresses and domains organized into named groups.
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] can be held to the same allowlist; staff accounts
* Single sign-on logins through [OpenIDConnect] can be held to the same allowlist; 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.
* Nothing gives the member list away: a member's account is named after nobody, 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.

Expand Down Expand Up @@ -52,17 +53,21 @@ can ask for another code, which replaces the one before it, or go back and enter
address. Asking for another is counted by the same rate limits as asking for the first; once they
are spent the offer is withdrawn, and the code already sent goes on working.

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.
Entering the right code logs the visitor in, and the first time also creates their account: it is
named after nobody, 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.

### Usernames

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's account is named `Member` and six characters drawn at random, `Member A7K2M4` for
instance, which says nothing about who holds it. Everywhere MediaWiki names an account is a place a
member could be recognised, which a name that identifies nobody makes harmless.

The address is on the account as its confirmed email and in the roster, which is what joins an
address to an account: a code login goes to the account the roster names for that address. Nothing
about the address constrains the name, so an address that could never have been a username is
admitted like any other.

### Passwords

Expand All @@ -79,10 +84,18 @@ address checked is the one recorded when they were admitted, so removing their e
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. An account that carries the reader group without being on the roster is no staff account
but a forgotten member account — a removed member's parked account, or one left behind by a failed
but a forgotten member account — a removed member's closed account, or one left behind by a failed
provisioning — and is refused rather than exempted. A refusal is final: no other handler of the same
hook can hand the login back. Without PluggableAuth the check never runs.

The account is created by the identity provider's plugin, which settles on its name before the
extension is asked anything. [OpenIDConnect] offers a say over that name, which the extension takes
for the logins the allowlist admits, so that a member is named after nobody here as well. A
processor the wiki configured itself is kept and runs first, and decides the name of every login
that is not a member's. A plugin offering no such say would name the account itself, so a login the
allowlist admits arriving through one is refused, and the refusal is written to the log channel.
Holding single sign-on to the allowlist therefore works with OpenIDConnect and no other plugin.

### Deactivation and removal

Deactivating a member blocks their account sitewide and indefinitely; removing their allowlist entry
Expand All @@ -95,13 +108,12 @@ A block placed by hand, for some other reason, is neither replaced when the memb
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
code 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. An identity provider that recorded the account still
points at the parked one, so a removed member's single sign-on logins arrive there and are refused
rather than reaching a fresh account.
Removing a member makes the roster forget them and closes their account: the address goes off it
and the open sessions with it, so their address is free again and reaches a new account at the next
code login. What is removed is the member, not their admission: the allowlist entry that admits
them stays, and a deactivation block stays behind on the closed account rather than reaching the new
one. An identity provider that recorded the account still points at the closed one, so a removed
member's single sign-on logins arrive there and are refused rather than reaching a fresh account.

### Rate limits and logging

Expand All @@ -112,15 +124,15 @@ address hashed.

### The roster

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.
The special pages that list or resolve accounts are closed to the reader group as well, and
transcluding `Special:ListUsers` renders it empty for everyone, since what a transclusion renders
is not kept to the reader who asked for it. `Special:Redirect` is closed whole, so members lose its
other lookups too, and `Special:FilePath`, which redirects through it.
A member's name gives nothing away, so what is left to keep is that they exist at all. 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 holds
what members were called before the update that gave them opaque names. Restricting a log type also
keeps it out of recent changes. The special pages that list or resolve accounts are closed to the
reader group as well, and transcluding `Special:ListUsers` renders it empty for everyone, since what
a transclusion renders is not kept to the reader who asked for it. `Special:Redirect` is closed
whole, so members lose its other lookups too, and `Special:FilePath`, which redirects through it.

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.
Expand Down Expand Up @@ -173,19 +185,25 @@ Whatever the login routes are set to, loading the extension:
information or preferences, which closes `Special:ChangeEmail` to them;
* sets `$wgBlockDisablesLogin`, so blocking a member keeps them out of a private wiki;
* restricts the `newusers`, `block` and `renameuser` logs to the `memberaccess-manage` right, unless
the wiki already restricted them;
the wiki already restricted them, so that who joined, who was deactivated, and what members were
called before the update renamed them stay out of view;
* reserves the username `MemberAccess`, which the update that renames members records its renames as,
so that no real account can be there for it to take over;
* refuses members a password, whatever the routes: setting one and having a temporary one mailed
stay refused;
* closes the account-listing API modules to the reader group;
* closes the special pages that list or resolve accounts to the reader group, and transcluding
`Special:ListUsers` to everyone;
* removes `@` from `$wgInvalidUsernameCharacters`, and changes `$wgUserrightsInterwikiDelimiter` from
`@` to `@@`, so that staff can use `Special:UserRights` on an account named after an address.
`Special:ListUsers` to everyone.

While the code route is turned on, it also turns off ConfirmEdit's `badloginperuser` captcha trigger,
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-IP `badlogin` trigger is left alone.

While the allowlist governs single sign-on, it also sets `$wgOpenIDConnect_PreferredUsernameProcessor`,
so that the accounts that route creates for members are named after nobody, and
`$wgOpenIDConnect_EmailProcessor`, so that the address the plugin resolved is the one the allowlist is
asked about. Processors the wiki configured itself are kept and run first.

While any route can log a member in — the code route turned on, or the allowlist governing single
sign-on — it also:

Expand All @@ -194,10 +212,10 @@ sign-on — it also:
* sets `$wgExtendedLoginCookieExpiration` to `$wgMemberAccessSessionDurationSeconds`, which decides
how long a remembered login lasts for everyone on the wiki, not only for members.

A wiki with the code route off and single sign-on left alone gets the first list and nothing else: what
an anonymous visitor may do, what ConfirmEdit does, and how long a remembered login lasts are left as
the wiki has them. That is a wiki that has just loaded the extension, since neither route is offered
until a setting says so.
A wiki with the code route off and single sign-on left alone gets the first list and nothing else:
what an anonymous visitor may do, what ConfirmEdit does, how single sign-on names the accounts it
creates, and how long a remembered login lasts are left as the wiki has them. That is a wiki that has
just loaded the extension, since neither route is offered until a setting says so.

## Installation

Expand All @@ -207,6 +225,14 @@ Platform requirements:
* [MediaWiki] 1.43 or later
* MySQL, MariaDB or SQLite. No PostgreSQL schema is shipped
* Working outgoing email while the code route is offered, since login codes are sent by mail
* [OpenIDConnect] 8.3 or later while single sign-on is held to the allowlist, with
`$wgOpenIDConnect_UseRandomUsernames` left off and its preferred username processor left to this
extension rather than set per provider. Every other way of naming a single sign-on account is one
the extension cannot make opaque, and a member's login is then refused.
`$wgOpenIDConnect_MigrateUsersByUserName` has to be off as well, since it hands a login the account
whose name it presents as its `preferred_username`, which is a member's account claimed without
their address. `$wgOpenIDConnect_MigrateUsersByEmail` is what joins a single sign-on login to the
account the code route created for the same address; without it that address gets a second account

Clone into the wiki's `extensions/` directory:

Expand Down Expand Up @@ -234,6 +260,13 @@ Run `php maintenance/run.php update --quick` to create the extension's tables. U
warning on the `MemberAccess` log channel says so, and anything that reads them fails with a database
error.

The same command gives an opaque name to every member the extension did not name, which is what
earlier versions left them under. It is recorded in the rename log, which is why that log is
restricted, and on the `MemberAccess` log channel by user id alone. It does not move a `User:` or
`User talk:` page titled after the old name, so a wiki that has any moves them by hand, without
leaving a redirect. Core's rename code names both the old and the new name at debug level, so run it
without `$wgDebugLogFile` pointing at a file you keep. Running it again has nothing left to rename.

## Management API

Groups, allowlist entries and the roster are managed over REST, under `/rest.php/member-access/v0/`.
Expand Down Expand Up @@ -289,9 +322,8 @@ human-readable `error`: `not_logged_in`, `permission_denied`, `invalid_csrf_toke
`invalid_request_body`, `invalid_group_name`, `group_name_too_long`, `duplicate_group_name`,
`group_not_found`, `group_not_empty`, `group_has_members`, `too_many_entry_values`, `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 request the REST framework refuses
first — an id that is not a number, a body it cannot read — carries MediaWiki's error shape rather
than this one.
`cannot_remove_self`. A request the REST framework refuses first — an id that is not a number, a
body it cannot read — carries MediaWiki's error shape rather than this one.

## Configuration

Expand Down Expand Up @@ -356,15 +388,16 @@ Initial version for MediaWiki 1.43+ with these features:
* A code screen that names the address the code went to, offers another code in its place, and
offers a way back to enter a different address
* 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
* Accounts create themselves at first login, into a reader group that may read and nothing else,
under a name that identifies nobody
* Single sign-on logins through [PluggableAuth] can be held to the same allowlist, with staff
accounts exempt
* Settable login routes, neither offered until a setting says so: the code route admits the addresses
an allowlist entry matches, every address, or nobody; single sign-on is held to the allowlist or
left alone
* 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
removal closes the account and frees their address for a new one
* 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 special pages, and restricted new
Expand All @@ -380,3 +413,4 @@ Initial version for MediaWiki 1.43+ with these features:
[MediaWiki Consulting]: https://professional.wiki/en/mediawiki-consulting-services
[PHP]: https://www.php.net
[PluggableAuth]: https://www.mediawiki.org/wiki/Extension:PluggableAuth
[OpenIDConnect]: https://www.mediawiki.org/wiki/Extension:OpenID_Connect
2 changes: 1 addition & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"memberaccess-special-page-denied": "This special page is not available to members.",
"memberaccess-block-reason": "Membership ended",
"memberaccess-unblock-reason": "Membership resumed",
"memberaccess-rename-reason": "Member removed",
"memberaccess-opaque-name-reason": "Email addresses are no longer used as usernames",
"right-memberaccess-manage": "Manage the member allowlist and roster",
"action-memberaccess-manage": "manage the member allowlist and roster",
"group-reader": "Readers",
Expand Down
4 changes: 2 additions & 2 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@
"memberaccess-auth-code-outstanding": "Shown on the code entry screen when no new code was sent, so the one already sent is what to enter. Parameters:\n* $1 - the email address the code was sent to",
"memberaccess-auth-code-expired": "Error shown when the code is used up, expired, or out of attempts.",
"memberaccess-auth-not-authorized": "Error shown when a correct code was entered but the address is not on the allowlist.",
"memberaccess-auth-failed": "Error shown when logging in cannot continue: the session lost track of the code request, the address cannot become a username, or that username belongs to a different account. Deliberately vague; only the first case is fixable by requesting a new code.",
"memberaccess-auth-failed": "Error shown when a code login cannot continue, for any of several internal reasons. Deliberately vague, so keep the translation unspecific rather than naming a cause.",
"memberaccess-auth-password-refused": "Error shown when setting or resetting the password of a member account is attempted.",
"memberaccess-api-module-denied": "Action API error shown when a member asks for a query module that lists the wiki's accounts.\n\nParameters:\n* $1 - the name of the query submodule, for example allusers",
"memberaccess-special-page-denied": "Error page shown when a member opens a special page that lists or resolves the wiki's accounts.",
"memberaccess-block-reason": "Reason recorded in the block log when a member is deactivated. Reactivation recognises the extension's own block by this text in the wiki's content language: after a rewording, blocks placed under the old wording are reported as foreign instead of lifted.",
"memberaccess-unblock-reason": "Reason recorded in the block log when a member is reactivated.",
"memberaccess-rename-reason": "Reason recorded in the rename log when a member is removed.",
"memberaccess-opaque-name-reason": "Reason recorded in the rename log when the update that gives members opaque usernames renames a member account.",
"right-memberaccess-manage": "{{doc-right|memberaccess-manage}}",
"action-memberaccess-manage": "{{doc-action|memberaccess-manage}}",
"group-reader": "{{doc-group|reader}}",
Expand Down
19 changes: 10 additions & 9 deletions src/Application/MemberRemover.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@

namespace ProfessionalWiki\MemberAccess\Application;

use Throwable;

/**
* Forgets a member: the roster row goes, the account gives up their address, and the username the
* address maps to is freed by parking the account under a reserved name.
* Forgets a member: the roster row goes and the account gives up their address and their sessions.
*
* Freeing the name is what a removal is for. A roster row deleted on its own would leave the
* account holding the name the address maps to, which is where every later login with that address
* arrives, so the address would be refused for good.
* The account itself stays, holding nothing of the member and no longer reachable by either login
* route. The address is free again and reaches a new account at the next login, since the roster
* is what joins an address to an account.
*/
interface MemberRemover {

/**
* Either all of it happens or none does: an account holding a name that admits nobody is the
* state a removal exists to undo, so it is never one a removal leaves behind.
* Either all of it happens or none does: a forgotten roster row whose account kept the address
* is a way back in, so it is never a state a removal leaves behind.
*
* @return RemovalResult Never NotAMember: whether the account is a member is the caller's to know
* @throws Throwable Whatever the writes throw, having left nothing of the removal behind
*/
public function removeMember( int $userId, int $performerId ): RemovalResult;
public function removeMember( int $userId ): void;

}
Loading
Loading