Skip to content

refactor: honor the Mezzio authentication repository contract - #100

Merged
tyrsson merged 6 commits into
1.0.xfrom
refactor/mezzio-auth-contract
Oct 15, 2026
Merged

tyrsson merged 6 commits into
1.0.xfrom
refactor/mezzio-auth-contract

Conversation

@tyrsson

@tyrsson tyrsson commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Closes #28

What this does

UserRepositoryInterface extends Mezzio\Authentication\UserRepositoryInterface and narrows
authenticate(). The side effects (security audit) and the account-state policy move off the
persistence layer onto the bus, so the repository verifies and the handler decides.

Field-name fixes

Two defects in the field-name handling that landed with this PR, fixed in the final commit.

authentication.username carries one meaning across three readers: Mezzio's form field name, the POST body key LoginMiddleware reads, and the SQL column UserRepositoryFactory hands to UserRepository. Two readers disagreed with the rest.

The login template hardcoded name="email" and name="password" because LoginHandler never passed the configured names. Set the key to a non-email value and the form posted a field nothing read, so the middleware fell through with no error and no flash message. The handler now resolves both names through Configuration and passes them to the template.

UserRepositoryFactory read the key raw, without the fallback Configuration::getUsernameFieldName applies when the value is absent, empty, or not a string. ConfigurationTest asserted that fallback while the SQL-building path ignored it. The factory now goes through the accessor. Analyze had baselined the resulting possibly-undefined-array-key finding; it no longer fires and the entry is removed.

Verified end to end against the running app with MySQL and Mailpit:

  • Shipped config renders the credential input as name=email type=email with an Email label.
  • With authentication.username set to user_name and authentication.password set to pass, the same page renders name=user_name type=text autocomplete=username and a Username label, with the password input renamed to pass.
  • A real login POST against a throwaway account returns 302 to / and writes one security log row, confirming the template and the middleware agree at runtime.

Audit logging for the set-password and verification steps

Both steps changed state with no audit row. Setting a password wrote a new hash, activated the account, cleared the verification token and cleared the password-required flag. Following a verification link activated an account in the registration flow. Neither reached the log table.

Each step now records one security-channel entry per attempt, matching the doctrine AuthenticateUserHandler already documents. The dispatcher is injected into ProcessSetPasswordMiddleware and ProcessVerifyEmailMiddleware, and the account under change goes in the context.

The identity column is filled from the request principal by webware-log's middleware, and the visitor here holds a token rather than a session, so user_identifier is normally Guest and the subject of the change is in the context JSON.

The redirect from a verification link to the set-password form is deliberately not audited. It changes no state, and a mail client may prefetch the link, which would fill the table with clicks nobody made.

Verified against the running app with the log table emptied and the whole workflow driven over HTTP:

step result
admin signs in 1 security row
POST /admin/user/create 1 app row, user_identifier is the admin
GET /user/verify.email/{token} 302 to the set-password route, no row
POST /user/set.password/{token} 1 security row naming the account, 302 to sign-in
the new user signs in 1 security row

The set-password row reads Probe SetPw set a password and activated their account. with {"email":"...","userId":13} in the context, and the account ends active=1, passwordSetRequired=0.

The same commit raises the webware/webware-log floor to ^2.0.0-alpha.4. That release resolves the identity field against the authentication configuration and prefers webware's user contract over mezzio's; usermanager supplies that configuration, so it has to ship against a version that reads it.

Commits

Three commits, in order:

  1. refactor: honor the Mezzio authentication repository contract - the work described below.
  2. chore(deps): bump webware/webware-tools to 1.0.0-rc.6 - raises the floor to the release that pins Mago 1.57.0 and drops the centre override of preserve-breaking-member-access-chain-first-method-on-same-line.
  3. chore: apply mago 1.57.0 formatting - the mechanical output of that formatter across 86 files, 6 in src and 80 in test.

Reading 2 and 3 first is the quickest route in. Neither changes behavior, and the formatting commit is whitespace only: with every whitespace character stripped, each file compares identical to its previous revision. The 1.57.0 formatter needs two passes on LoginMiddleware before format --check is clean, which is the upstream member-access chain idempotency family, mago #2475 and #2529.

Tooling side, reviewed and released separately: webinertia/webware-tools#57, tagged 1.0.0-rc.6.

The contract

The narrowed return is (Webware\Core\UserInterface&User)|null. Webware\Core\UserInterface
already extends Mezzio\Authentication\UserInterface, so the narrowing is a true subtype and is
legal covariance. This restores the shape the interface carried before 8c106ff in the IMS
application, where the docblock read "Narrows the return type from the parent interface".

ConfigProvider aliases Mezzio\Authentication\UserRepositoryInterface to UserRepository.
Mezzio\Authentication\ConfigProvider declares no service for that name, so the alias competes
with nothing. A host can then type against either interface and drop a hand-rolled Mezzio
repository without touching type hints, while this component replaces the authentication workflow.

Where each responsibility landed

Concern Before After
Credential verification UserRepository::authenticate() unchanged, still the repository
Security audit (4 LogEvent dispatches) UserRepository AuthenticateUserHandler
Account-state policy UserRepository AuthenticateUserHandler
Result mapping handler, pass-through handler owns it
Request-body field names hardcoded email / password in LoginMiddleware authentication config via Configuration
Failure logging duplicated (repository + middleware) one record, one channel

UserRepository no longer takes a dispatcher; AuthenticateUserHandlerFactory resolves it instead.
mezzio/mezzio-authentication becomes a direct require. It was already resolved transitively, so
the lock records it as direct with no version change.

Behavior changes, in full

  1. An inactive account with a wrong password now reports InvalidCredentials, not NotActive.
    The state check runs after verification, so the ordering is verify-then-policy. The previous
    ordering returned NotActive for any password against a deactivated account, which confirmed
    account state without proving the password. A correct password on an inactive account still yields
    NotActive and still shows the activation hint. This is the only change to an existing outcome.
  2. authenticate() rejects a null or empty password before querying. password_hash('') is a
    real hash and password_verify('', $hash) returns true, so an account stored with an empty
    password would otherwise authenticate on an empty password. The guard also means a null password
    costs no query. Mezzio's contract permits a credential-only token lookup; this implementation does
    not provide one, and the interface docblock now says so.
  3. MessageStatus::Failure marks every attempt that must not establish a session, including a
    denied inactive account whose password verified. AuthenticationResult::status carries the reason.
    Success now means a session may be written. Previously every outcome reported Success, so a
    failed login was indistinguishable from a successful one at the status level.
  4. LoginMiddleware lost its RequestMethodInterface::METHOD_POST guard. The route is registered
    with $routeCollector->post(), so no other verb can reach it.
  5. POST body field names come from authentication.username and authentication.password.
    These keys have shipped since the beginning and had no runtime reader until now. webware-log's
    PhpDbHandlerFactory reads authentication.username independently, so that key also feeds the
    log user_identifier column.

Explicitly not changed

  • AuthenticationStatus and AuthenticationResult keep their shape and stay the payload contract.
  • checkStatus() and the CheckUserActiveQuery identity path are untouched.
  • The session payload contract and toArray() / populate() round trip are untouched.
  • authentication.redirect still has no reader. Wiring PhpSession would mean implementing
    Mezzio\Authentication\AuthenticationInterface, which is a new class and out of scope here.

Verification

Gate Result
mago fmt clean
mago lint no issues, 0 new baseline entries
mago analyze no issues, 0 new baseline entries
mago guard no issues
phpunit unit 440 tests, 1339 assertions, OK
phpunit integration 6 tests, 12 assertions, OK

Both baseline files are unchanged. The unit count rose from 439 to 440 with the null-password query
assertion, which checks preparedSqlObjects is empty rather than inferring "no query" from the return
value.

Related

UserRepositoryInterface extends the Mezzio authentication repository contract and
narrows authenticate(), and the authentication side effects and account-state
policy move off the persistence layer onto the bus.

- UserRepositoryInterface extends Mezzio\Authentication\UserRepositoryInterface
  and narrows authenticate() to (Webware\Core\UserInterface&User)|null, the same
  narrowing the interface carried before 8c106ff in the IMS application.
- ConfigProvider aliases Mezzio\Authentication\UserRepositoryInterface to
  UserRepository, whose factory no longer takes a dispatcher.
- UserRepository::authenticate() is now verification only: it rejects a null or
  empty password before querying, looks the row up, and compares the hash. No
  event dispatch, no account-state check.
- AuthenticateUserHandler takes the dispatcher and owns the policy: one
  security-channel LogEvent per attempt, AuthenticationStatus for the reason,
  and MessageStatus::Failure for every attempt that must not establish a session.
- LoginMiddleware drops its request-method guard (the route is POST-only), its
  logger, and its hardcoded field names. It reads authentication.username and
  authentication.password through Configuration, and reads only the query result
  payload.
- Configuration gains getUsernameFieldName() and getPasswordFieldName(), the
  first two readers of those long-ignored keys.
- mezzio/mezzio-authentication becomes a direct require; it was already resolved
  transitively, so the lock records the package as direct with no version change.

Tests updated for the new signatures and the status semantics. Both baseline
files are unchanged: no new lint or analyze entries were needed.

Note: an inactive account submitted with a wrong password now reports
InvalidCredentials rather than NotActive, because the state check runs after
verification. The previous ordering returned NotActive for any password against
a deactivated account, which confirmed account state without proving the
password. A correct password on an inactive account still yields NotActive and
still shows the activation hint.
@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Raise the constraint floor so the release carrying the Mago 1.57.0 pin is
required, not merely admitted by the previous `^1.0.0-rc.5` range.

`1.0.0-rc.6` pins Mago 1.57.0 and deletes the centre override of
`preserve-breaking-member-access-chain-first-method-on-same-line`. A preserved
member-access chain therefore puts every link on its own line rather than gluing
the first link onto the receiver, which is Mago's own default.
The mechanical output of the 1.57.0 formatter under the `1.0.0-rc.6` centre
config, across 86 files: 6 in `src` and 80 in `test`.

The change is whitespace only. With every whitespace character removed, each
formatted file compares identical to its previous revision, so no token moved.

The visible effect is that the first link of a broken member-access chain takes
its own line, so a receiver no longer carries a glued `->method(` while later
links continue below. In `AuthenticateUserHandler`:

    $this->dispatcher->dispatch(new LogEvent(LogChannel::Security, Level::Info)
        ->setMessage('Failed login attempt.')
        ->setContext(['credential' => $query->credential]));

and in `LoginMiddleware`:

    $result = $this->messageBus
        ->handle(
            new AuthenticateUserQuery(
                credential: $credential,
                password  : $password,
            ),
        )
        ->getResult();

Verified locally: `mago format --check`, `lint`, `analyze` and `guard` all report
no issues under Mago 1.57.0, and the unit suite passes at 440 tests and 1339
assertions.

One file needs two passes. The first pass leaves `$this->messageBus->handle(` with
the opening link glued, and the second moves it onto its own line, after which the
file is stable and `format --check` is clean. This is the upstream member-access
chain idempotency family, mago issues #2475 and #2529.
@tyrsson tyrsson self-assigned this Oct 15, 2026
…tory

configuration.username is one key with one meaning: Mezzio reads it as its
form field name, LoginMiddleware reads the POST body under it, and
UserRepositoryFactory handed it to UserRepository as the SQL column the
credential is matched against.

LoginHandler passed no field names to the template, so login.phtml
hardcoded name="email" and name="password". Point the key at a non-email
value and the form posted a field the middleware never looked for, which
fell through silently with no flash message. The handler now resolves both
names through Configuration and passes them to the template, which renders
the credential input as type text with a username autocomplete when the
field is not an email address.

UserRepositoryFactory read the same key raw with no fallback, while
Configuration::getUsernameFieldName falls back to the shipped default when
the value is absent, empty or not a string. The fallback was asserted by
ConfigurationTest but not applied on the path that builds the SQL. The
factory now resolves through that accessor, so the reader and the accessor
agree. Analyze had baselined a possibly-undefined-array-key finding for the
raw read; the finding is gone and the baseline entry with it.
2.0.0-alpha.4 resolves the identity field against the authentication
configuration and prefers webware's user contract over mezzio's. usermanager
supplies that configuration, so it must ship against a version that reads it.
Both steps changed state with no audit row. Setting a password wrote a new
hash, activated the account, cleared the verification token and cleared the
password-required flag. Following a verification link activated an account in
the registration flow. None of it reached the log table.

Each step now records one security-channel entry per attempt, matching the
doctrine AuthenticateUserHandler already documents. The account under change
goes in the context: the identity column is filled from the request principal
by webware-log''s middleware, and the visitor here holds a token rather than a
session.

The redirect from a verification link to the set-password form is
deliberately not audited. It changes no state, and a mail client may prefetch
the link, which would fill the table with clicks nobody made.
@tyrsson
tyrsson merged commit fe3d100 into 1.0.x Oct 15, 2026
12 checks passed
@tyrsson
tyrsson deleted the refactor/mezzio-auth-contract branch October 15, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: move the authentication definition off UserRepository onto the bus

1 participant