Repository navigation
refactor: honor the Mezzio authentication repository contract - #100
Merged
Merged
Conversation
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 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.
…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.
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.
Closes #28
What this does
UserRepositoryInterfaceextendsMezzio\Authentication\UserRepositoryInterfaceand narrowsauthenticate(). The side effects (security audit) and the account-state policy move off thepersistence 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.usernamecarries one meaning across three readers: Mezzio's form field name, the POST body keyLoginMiddlewarereads, and the SQL columnUserRepositoryFactoryhands toUserRepository. Two readers disagreed with the rest.The login template hardcoded
name="email"andname="password"becauseLoginHandlernever 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 throughConfigurationand passes them to the template.UserRepositoryFactoryread the key raw, without the fallbackConfiguration::getUsernameFieldNameapplies when the value is absent, empty, or not a string.ConfigurationTestasserted 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:
name=email type=emailwith anEmaillabel.authentication.usernameset touser_nameandauthentication.passwordset topass, the same page rendersname=user_name type=text autocomplete=usernameand aUsernamelabel, with the password input renamed topass.302to/and writes onesecuritylog 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
AuthenticateUserHandleralready documents. The dispatcher is injected intoProcessSetPasswordMiddlewareandProcessVerifyEmailMiddleware, 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_identifieris normallyGuestand 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:
securityrowPOST /admin/user/createapprow,user_identifieris the adminGET /user/verify.email/{token}POST /user/set.password/{token}securityrow naming the account, 302 to sign-insecurityrowThe set-password row reads
Probe SetPw set a password and activated their account.with{"email":"...","userId":13}in the context, and the account endsactive=1,passwordSetRequired=0.The same commit raises the
webware/webware-logfloor 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:
refactor: honor the Mezzio authentication repository contract- the work described below.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 ofpreserve-breaking-member-access-chain-first-method-on-same-line.chore: apply mago 1.57.0 formatting- the mechanical output of that formatter across 86 files, 6 insrcand 80 intest.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
LoginMiddlewarebeforeformat --checkis 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\UserInterfacealready extends
Mezzio\Authentication\UserInterface, so the narrowing is a true subtype and islegal covariance. This restores the shape the interface carried before
8c106ffin the IMSapplication, where the docblock read "Narrows the return type from the parent interface".
ConfigProvideraliasesMezzio\Authentication\UserRepositoryInterfacetoUserRepository.Mezzio\Authentication\ConfigProviderdeclares no service for that name, so the alias competeswith 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
UserRepository::authenticate()LogEventdispatches)UserRepositoryAuthenticateUserHandlerUserRepositoryAuthenticateUserHandleremail/passwordinLoginMiddlewareauthenticationconfig viaConfigurationUserRepositoryno longer takes a dispatcher;AuthenticateUserHandlerFactoryresolves it instead.mezzio/mezzio-authenticationbecomes a direct require. It was already resolved transitively, sothe lock records it as direct with no version change.
Behavior changes, in full
InvalidCredentials, notNotActive.The state check runs after verification, so the ordering is verify-then-policy. The previous
ordering returned
NotActivefor any password against a deactivated account, which confirmedaccount state without proving the password. A correct password on an inactive account still yields
NotActiveand still shows the activation hint. This is the only change to an existing outcome.authenticate()rejects a null or empty password before querying.password_hash('')is areal hash and
password_verify('', $hash)returnstrue, so an account stored with an emptypassword 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.
MessageStatus::Failuremarks every attempt that must not establish a session, including adenied inactive account whose password verified.
AuthenticationResult::statuscarries the reason.Successnow means a session may be written. Previously every outcome reportedSuccess, so afailed login was indistinguishable from a successful one at the status level.
LoginMiddlewarelost itsRequestMethodInterface::METHOD_POSTguard. The route is registeredwith
$routeCollector->post(), so no other verb can reach it.authentication.usernameandauthentication.password.These keys have shipped since the beginning and had no runtime reader until now.
webware-log'sPhpDbHandlerFactoryreadsauthentication.usernameindependently, so that key also feeds thelog
user_identifiercolumn.Explicitly not changed
AuthenticationStatusandAuthenticationResultkeep their shape and stay the payload contract.checkStatus()and theCheckUserActiveQueryidentity path are untouched.toArray()/populate()round trip are untouched.authentication.redirectstill has no reader. WiringPhpSessionwould mean implementingMezzio\Authentication\AuthenticationInterface, which is a new class and out of scope here.Verification
mago fmtmago lintmago analyzemago guardBoth baseline files are unchanged. The unit count rose from 439 to 440 with the null-password query
assertion, which checks
preparedSqlObjectsis empty rather than inferring "no query" from the returnvalue.
Related
HttpMethodProcessorTraitis redundant on three single-verb admin middlewares. Foundwhile auditing this work; deliberately not folded in.
SetPasswordDataFilterdeclaresMINIMUM_LENGTH = 12yet accepts an empty password, because Laminas validators skip empty input.Configuring the minimum is filed separately.