-
Notifications
You must be signed in to change notification settings - Fork 823
FIX Don't set session value in endpoint accessed from mail link. #11686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
FIX Don't set session value in endpoint accessed from mail link. #11686
Conversation
c248c7d
to
544faba
Compare
// If a member or password reset token was included on its own, the URL was invalid. | ||
if ($token !== null || $member !== null) { | ||
return $this->getInvalidTokenResponse(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This condition wasn't part of the original PRs and handles an edge case where only one of ?t
or ?m
is included in the URL (won't happen normally but can happen if someone copies a URL incorrectly for example)
private function getInvalidTokenResponse(): array | ||
{ | ||
return [ | ||
'Content' => DBField::create_field( | ||
'HTMLFragment', | ||
_t( | ||
Security::class . '.NOTERESETLINKINVALID', | ||
'<p>The password reset link is invalid or expired.</p>' | ||
. '<p>You can request a new one <a href="{link1}">here</a> or change your password after' | ||
. ' you <a href="{link2}">log in</a>.</p>', | ||
[ | ||
'link1' => Security::lost_password_url(), | ||
'link2' => Security::login_url(), | ||
] | ||
) | ||
), | ||
]; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This returns a message that used to only be returned in one place - but is now used to give consistent message any time the token is found to be invalid or expired at any point during the process.
*/ | ||
protected function setSessionToken($member, $token) | ||
protected function setSessionToken(Member $member, string $token, bool $alreadyEncrypted = false): void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've opted to keep the existing method name with the same default behaviour it has in CMS 5 so we don't have to deprecate it.
'SilverStripe\\Security\\Security.ENTERNEWPASSWORD', | ||
Security::class . '.ENTERNEWPASSWORD', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unrelated refactoring - happens a few times in this PR.
$autoLoginTempHash = $this->createAutoLoginTempHash(); | ||
$member->AutoLoginTempHash = $autoLoginTempHash; | ||
$member->write(); | ||
$response = $this->redirect(Controller::join_links($this->link, '?th=' . $autoLoginTempHash)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Controller::join_links()
here to catch the (unlikely) edge case where the link already has some query string. join_links()
will merge the query strings together so we don't end up with some/path?var=1?th=123
9f7f93f
to
eafec7d
Compare
I approve this fix |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should not include the 2nd commit, it's an enhancement basically unrelated to the original issue it doesn't belong in this bugfix PR. Also the default lifetime of the token is 2 days, since it's so long it's questionable if it's even worth making a friendly message at all.
Also you'll need to create a changelog PR telling people with custom authenicators to update the 'changepassword' method names
Usually the distinction of enhancement vs bug fix is to determine whether a change should be included in a patch release or a minor release - which doesn't apply here. I'm not sure how that distinction is helpful in this context? The second commit provides standardised messaging when a reset password token expires. Due to the way it changes the response return flow, I would hesitate to include this change in a minor release when we have the opportunity now to include it in a major release. The second commit was part of the change I explained to Jenn and was approved, so from a "are we allowed to include it" perspective - yes we are.
That value is configurable, and we both were surprised by how long it is by default. I wouldn't be surprised to find projects updating it to be only valid for a couple of hours at most. That's plenty of time to step away, have a meeting or go for lunch or any number of things, and then submit the form when you come back. tl;dr I'm not sure what your reasoning is for not including the second commit. If there's a problem with it that I'm not seeing, I'll be happy to exclude it, but I need to know what that problem is first. |
I don't like the 2nd commit for several reasons:
|
I had considered doing it separately, but since I was hesitant to include it in a minor release and it is in-line with the other related changes to the standardisation of the message that I'm including in commit 1, it seemed like a good fit for this PR.
It was already approved - and like I said, I'd be hesitant to do this in a minor release.
It's in a second commit so you can look at the first commit in isolation from the second commit. I recommend you look at the first commit, ensure you're happy with that, then look at the (very narrow scope very small) second commit after you're happy with the first one. I think you'll find the overhead that second commit adds is negligible. It sounds like you don't actually have a problem with the code or the change itself, which should be the primary concern IMO. |
If you still find it hard to review for some reason with two commits, let me know and I will split the second commit off as a separate PR in this issue with the understanding it will be reviewed and merged into 6.0 after this one |
Yup 2nd PR would be helpful thanks Also remember to:
|
Done, please see link in issue |
eafec7d
to
57907c7
Compare
Second commit has been separated out into #11689 |
57907c7
to
44568de
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ?th hash for the redirect is non-predictable so gets updated multiple times if there are multiple authenticators. Presumably that processChangePasswordUrlVars() gets called multiple times.
Debugging on line 866 of Security::delegateToMultipleHandlers() - you can see there are 2x 302 responses with different th hashes.
I think what's written to the Member table isn't matching the redirect the browser makes. Means that that you end up with a The password reset link is invalid or expired.
when you click the email link to reset password
Perhaps use an existing tempory hash on the member table if there is one rather than creating a new one?
# app/_config/myconfig.yml
SilverStripe\Core\Injector\Injector:
SilverStripe\Security\Security:
properties:
Authenticators:
hello: '%$HelloAuthenticator'
<?php
// app/src/HelloAuthenticator.php
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
class HelloAuthenticator extends MemberAuthenticator
{
public function getChangePasswordHandler($link)
{
return HelloChangePasswordHandler::create($link, $this);
}
}
<?php
// app/src/HelloChangePasswordHandler.php
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\MemberAuthenticator\ChangePasswordHandler;
class HelloChangePasswordHandler extends ChangePasswordHandler
{
private static $allowed_actions = [
'changepassword',
];
protected function createChangePasswordResponse(): array|HTTPResponse
{
return new HTTPResponse('Hello from HelloChangePasswordHandler');
}
}
Can you please write some step-by-step instructions to get into that many-redirects state? I don't quite understand what you mean so being able to reproduce it myself will help a lot. |
|
44568de
to
e57834e
Compare
Good catch! I've updated to reuse the existing temp hash if multiple authenticators try to set it in the same request. |
e57834e
to
5740835
Compare
use SilverStripe\Control\Session; | ||
use SilverStripe\Core\Config\Config; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Config class not used
CI failures are unrelated and will be fixed in silverstripe/.github#389 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Latest changes do not appear to work using the HelloAuthenticator code supplied, tried on a user with an MFA enabled and one without, same result, get redirected to the ?th url and I get a message that the token is invalid
I've debugged the reason to here ChangePasswordHandler::processChangePasswordUrlVars()
:
$tempToken = $request->getVar('th');
if ($tempToken !== null) {
$member = Member::get()->find('AutoLoginTempHash', $tempToken);
if (!$member) {
return $this->getInvalidTokenResponse(); // <<<<<<< 2nd time
}
// Delete the temp token from the member so that it cannot be used again
// This prevents the browser history from being used to access the change password form
$member->AutoLoginTempHash = ''; // <<<<<<< 1st time
$member->write();
// ...
What's happening the ?th
code blocks works correctly the first time and sets AutoLoginTempHash
to blank string. However the 2nd time around (because of the 2nd authenticator) it hits this code again and it cannot find a corresponding member
Can you please show me a video (or in person) what that looks like from a user perspective? It worked for me locally but I may be doing something slightly different to what you're doing. |
I don't think we need a video - it's exactly same flow as the replication steps, I followed those steps and the invalid token method was triggered i.e. the user is not shown the new + confirm password fields
<?php
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\MemberAuthenticator\ChangePasswordHandler;
class HelloChangePasswordHandler extends ChangePasswordHandler
{
private static $allowed_actions = [
'changepassword',
];
protected function createChangePasswordResponse(): array|HTTPResponse
{
// return new HTTPResponse('Hello from HelloChangePasswordHandler');
return [];
}
} |
That change to the What was happening before: I had removed the implementation of
Now they're both arrays. The problem is that the implementation in Potential solutions
I lean towards 1, mostly because this happening is very edge-casey but partly because I think any valid implementation won't hit this problem. That said, I don't feel at all strongly about it, so defer to you to decide which of these (or something else I didn't think of) to do. |
Add the following code to /**
* Keep track of whether a temporary hash is already processed during this request cycle.
* @internal
*/
private static bool $tempHashAlreadyProcessed = false;
// processChangePasswordUrlVars()
// ...
$tempToken = $request->getVar('th');
if ($tempToken !== null && !ChangePasswordHandler::$tempHashAlreadyProcessed) { // <<<< update this
// ...
$this->setSessionToken($member, $encryptedToken, true);
ChangePasswordHandler::$tempHashAlreadyProcessed = true; // <<<< add this
} |
Modified option 2 it is 👍 |
5740835
to
86a56f2
Compare
Rebased on 6.0 to clear out those unrelated CI failures |
86a56f2
to
b83de35
Compare
Added new behaviour to ignore invalid handler results if there are valid results from another handler, including test. |
$response = $security->changepassword(); | ||
$this->assertInstanceOf(DBHTMLText::class, $response); | ||
$this->assertStringNotContainsString('The password reset link is invalid or expired', $response->getValue()); | ||
$this->assertStringContainsString('Please enter a new password', $response->getValue()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would have checked for the presence of the form, except there isn't one due to silverstripe/startup-theme#17
Not sure how other tests in here that rely on the form are passing 😅
@@ -655,7 +655,7 @@ public static function clearSessionMessage() | |||
* | |||
* @param null|HTTPRequest $request | |||
* @param int $service | |||
* @return HTTPResponse|string Returns the "login" page as HTML code. | |||
* @return HTTPResponse|RequestHandler|DBHTMLText|string Returns the "login" page as HTML code. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated PHPDocs in this class match return type hint for delegateToMultipleHandlers()
which is the value all of these methods return.
@@ -884,7 +884,7 @@ function (RequestHandler $handler) { | |||
* @param RequestHandler $handler | |||
* @param string $title The title of the form | |||
* @param array $templates | |||
* @return array|HTTPResponse|RequestHandler|DBHTMLText|string | |||
* @return HTTPResponse|RequestHandler|DBHTMLText|string | |||
*/ | |||
protected function delegateToHandler(RequestHandler $handler, $title, array $templates = []) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and delegateToMultipleHandlers()
explicitly cannot return an array, because they transform arrays with a call to renderWrappedController()
b83de35
to
b5a3574
Compare
With the session cookie's "SameSite" value set to "Strict", we can't set session values when a request is the result of clicking a link from another website. When a user clicks the reset password link from an email within a web client (e.g. Gmail), this results in not being able to reset their password. This change sets the session value _after_ the redirect, while avoiding putting the reset token into the browser history which could allow a bad actor to access it. This change also allows that flow to work for silverstripe/mfa which needs to alter the second part of the flow, but detects that currently using the session value's presence or absence.
b5a3574
to
a7a962e
Compare
With the session cookie's "SameSite" value set to "Strict", we can't set session values when a request is the result of clicking a link from another website.
When a user clicks the reset password link from an email within a web client (e.g. Gmail), this results in not being able to reset their password.
This change sets the session value after the redirect, while avoiding putting the reset token into the browser history which could allow a bad actor to access it.
This change also allows that flow to work for silverstripe/mfa which needs to alter the second part of the flow, but detects that currently using the session value's presence or absence.
This PR is based off #11668 and #11676 though it does have some differences.
Issue