Passed
Push — master ( 7e81b0...7eb007 )
by Robbie
12:39 queued 11s
created

src/Authenticator/ChangePasswordHandler.php (6 issues)

1
<?php declare(strict_types=1);
2
3
namespace SilverStripe\MFA\Authenticator;
4
5
use LogicException;
6
use Psr\Log\LoggerInterface;
7
use SilverStripe\Control\HTTPRequest;
8
use SilverStripe\Control\HTTPResponse;
9
use SilverStripe\Core\Injector\Injector;
10
use SilverStripe\MFA\Exception\InvalidMethodException;
11
use SilverStripe\MFA\Extension\MemberExtension;
12
use SilverStripe\MFA\RequestHandler\BaseHandlerTrait;
13
use SilverStripe\MFA\RequestHandler\VerificationHandlerTrait;
14
use SilverStripe\MFA\Service\MethodRegistry;
15
use SilverStripe\MFA\Service\SchemaGenerator;
16
use SilverStripe\MFA\Store\StoreInterface;
17
use SilverStripe\Security\Member;
18
use SilverStripe\Security\MemberAuthenticator\ChangePasswordHandler as BaseChangePasswordHandler;
19
use Throwable;
20
21
/**
22
 * Extends the "MemberAuthenticator version of the ChangePasswordHandler in order to allow MFA to be
23
 * inserted into the flow when an AutoLoginHash is being used  - that is when the user has clicked a
24
 * reset password link in an email after using the "forgot password" functionality.
25
 * When an "auto login" is not being used (a user is already logged in), it is existing functionality
26
 * to ask a user for their password before allowing a change - so this flow does not require MFA.
27
 */
28
class ChangePasswordHandler extends BaseChangePasswordHandler
29
{
30
    use BaseHandlerTrait;
31
    use VerificationHandlerTrait;
0 ignored issues
show
The trait SilverStripe\MFA\Request...erificationHandlerTrait requires the property $DefaultRegisteredMethod which is not provided by SilverStripe\MFA\Authent...r\ChangePasswordHandler.
Loading history...
32
33
    /**
34
     * Session key used to track whether multi-factor authentication has been verified during a change password
35
     * request flow.
36
     *
37
     * @var string
38
     */
39
    const MFA_VERIFIED_ON_CHANGE_PASSWORD = 'MultiFactorAuthenticated';
40
41
    private static $url_handlers = [
0 ignored issues
show
The private property $url_handlers is not used, and could be removed.
Loading history...
42
        'GET mfa/schema' => 'getSchema', // Provides details about existing registered methods, etc.
43
        'GET mfa/login/$Method' => 'startMFACheck', // Initiates login process for $Method
44
        'POST mfa/login/$Method' => 'verifyMFACheck', // Verifies login via $Method
45
        'GET mfa' => 'mfa', // Renders the MFA Login Page to init the app
46
    ];
47
48
    private static $allowed_actions = [
0 ignored issues
show
The private property $allowed_actions is not used, and could be removed.
Loading history...
49
        'changepassword',
50
        'mfa',
51
        'getSchema',
52
        'startMFACheck',
53
        'verifyMFACheck',
54
    ];
55
56
    private static $dependencies = [
0 ignored issues
show
The private property $dependencies is not used, and could be removed.
Loading history...
57
        'Logger' => '%$' . LoggerInterface::class . '.mfa',
58
    ];
59
60
    /**
61
     * @var LoggerInterface
62
     */
63
    protected $logger;
64
65
    /**
66
     * Respond with the given array as a JSON response
67
     *
68
     * @param array $response
69
     * @param int $code The HTTP response code to set on the response
70
     * @return HTTPResponse
71
     */
72
    protected function jsonResponse(array $response, int $code = 200): HTTPResponse
73
    {
74
        return HTTPResponse::create(json_encode($response))
75
            ->addHeader('Content-Type', 'application/json')
76
            ->setStatusCode($code);
77
    }
78
79
    /**
80
     * Supply JavaScript application configuration details, required for an MFA check
81
     *
82
     * @return HTTPResponse
83
     */
84
    public function getSchema(): HTTPResponse
85
    {
86
        try {
87
            $member = $this->getStore()->getMember();
88
            $schema = SchemaGenerator::create()->getSchema($member);
89
            return $this->jsonResponse(
90
                array_merge($schema, [
91
                    'endpoints' => [
92
                        'verify' => $this->Link('mfa/login/{urlSegment}'),
93
                        'complete' => $this->Link(),
94
                    ],
95
                    'shouldRedirect' => false,
96
                ])
97
            );
98
        } catch (Throwable $exception) {
99
            $this->logger->debug($exception->getMessage());
100
            // If we don't have a valid member we shouldn't be here...
101
            return $this->redirectBack();
102
        }
103
    }
104
105
    /**
106
     * Render the JavaScript app responsible for initiating an MFA check
107
     *
108
     * @return HTTPResponse|array
109
     */
110
    public function mfa()
111
    {
112
        $store = $this->getStore();
113
        if (!$store || !$store->getMember()) {
0 ignored issues
show
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
114
            return $this->redirectBack();
115
        }
116
117
        $this->applyRequirements();
118
119
        return [
120
            'Form' => $this->renderWith($this->getViewerTemplates()),
121
            'ClassName' => 'mfa',
122
        ];
123
    }
124
125
    /**
126
     * Initiates the session for the user attempting to log in, in preparation for an MFA check
127
     *
128
     * @param HTTPRequest $request
129
     * @return HTTPResponse
130
     * @throws LogicException when no store is available
131
     */
132
    public function startMFACheck(HTTPRequest $request): HTTPResponse
133
    {
134
        $store = $this->getStore();
135
        if (!$store) {
0 ignored issues
show
$store is of type SilverStripe\MFA\Store\StoreInterface, thus it always evaluated to true.
Loading history...
136
            throw new LogicException('Store not found, please create one first.');
137
        }
138
        $member = $store->getMember();
139
140
        // If we don't have a valid member we shouldn't be here...
141
        if (!$member) {
142
            return $this->jsonResponse(['message' => 'Forbidden'], 403);
143
        }
144
145
        $method = $request->param('Method');
146
147
        if (empty($method)) {
148
            return $this->jsonResponse(['message' => 'Invalid request: method not present'], 400);
149
        }
150
151
        // Use the provided trait method for handling login
152
        $response = $this->createStartVerificationResponse(
153
            $store,
154
            Injector::inst()->get(MethodRegistry::class)->getMethodByURLSegment($method)
155
        );
156
157
        // Ensure detail is saved to the store
158
        $store->save($request);
159
160
        return $response;
161
    }
162
163
    /**
164
     * Checks the MFA JavaScript app input to validate the user attempting to log in
165
     *
166
     * @param HTTPRequest $request
167
     * @return HTTPResponse
168
     */
169
    public function verifyMFACheck(HTTPRequest $request): HTTPResponse
170
    {
171
        $store = $this->getStore();
172
173
        try {
174
            $result = $this->completeVerificationRequest($store, $request);
175
        } catch (InvalidMethodException $exception) {
176
            // Invalid method usually means a timeout. A user might be trying to verify before "starting"
177
            $this->logger->debug($exception->getMessage());
178
            return $this->jsonResponse(['message' => 'Forbidden'], 403);
179
        }
180
181
        if (!$result->isSuccessful()) {
182
            return $this->jsonResponse([
183
                'message' => $result->getMessage(),
184
            ], 401);
185
        }
186
187
        if (!$this->isVerificationComplete($store)) {
188
            return $this->jsonResponse([
189
                'message' => 'Additional authentication required',
190
            ], 202);
191
        }
192
193
        $request->getSession()->set(self::MFA_VERIFIED_ON_CHANGE_PASSWORD, true);
194
        $store->clear($request);
195
196
        return $this->jsonResponse([
197
            'message' => 'Multi-factor authenticated',
198
        ], 200);
199
    }
200
201
    public function changepassword()
202
    {
203
        $session = $this->getRequest()->getSession();
204
        $hash = $session->get('AutoLoginHash');
205
        /** @var Member&MemberExtension $member */
206
        $member = Member::member_from_autologinhash($hash);
207
208
        if ($hash
209
            && $member
210
            && $member->RegisteredMFAMethods()->exists()
211
            && !$session->get(self::MFA_VERIFIED_ON_CHANGE_PASSWORD)
212
        ) {
213
            Injector::inst()->create(StoreInterface::class, $member)->save($this->getRequest());
214
            return $this->mfa();
215
        }
216
217
        return parent::changepassword();
218
    }
219
220
    /**
221
     * @param LoggerInterface $logger
222
     * @return $this
223
     */
224
    public function setLogger(LoggerInterface $logger): ChangePasswordHandler
225
    {
226
        $this->logger = $logger;
227
        return $this;
228
    }
229
}
230