PasswordReset   A
last analyzed

Complexity

Total Complexity 21

Size/Duplication

Total Lines 300
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 138
c 1
b 0
f 0
dl 0
loc 300
rs 10
wmc 21

7 Methods

Rating   Name   Duplication   Size   Complexity  
A invalidMagicLink() 0 3 1
A setAuthState() 0 3 1
A validateMagicLink() 0 58 5
A __construct() 0 7 1
A setLogger() 0 3 1
B resetPassword() 0 82 7
B enterEmail() 0 66 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\ldapPasswordReset\Controller;
6
7
use SimpleSAML\Assert\Assert;
8
use SimpleSAML\{Auth, Configuration, Error, Logger, Module, Session};
9
use SimpleSAML\Module\ldapPasswordReset\MagicLink;
10
use SimpleSAML\Module\ldapPasswordReset\TokenStorage;
11
use SimpleSAML\Module\ldapPasswordReset\UserRepository;
12
use SimpleSAML\XHTML\Template;
13
use Symfony\Component\HttpFoundation\{RedirectResponse, Request};
14
15
use function date;
16
use function sprintf;
17
use function time;
18
19
/**
20
 * Controller class for the ldapPasswordReset module.
21
 *
22
 * This class serves the password reset code and error views available in the module.
23
 *
24
 * @package simplesamlphp/simplesamlphp-module-ldapPasswordReset
25
 */
26
class PasswordReset
27
{
28
    /** @var \SimpleSAML\Configuration */
29
    protected Configuration $config;
30
31
    /** @var \SimpleSAML\Logger */
32
    protected Logger $logger;
33
34
    /** @var \SimpleSAML\Configuration */
35
    protected Configuration $moduleConfig;
36
37
    /** @var \SimpleSAML\Session */
38
    protected Session $session;
39
40
    /** @var \SimpleSAML\Module\ldapPasswordReset\UserRepository */
41
    protected UserRepository $userRepository;
42
43
    /**
44
     * @var \SimpleSAML\Auth\State|string
45
     * @psalm-var \SimpleSAML\Auth\State|class-string
46
     */
47
    protected $authState = Auth\State::class;
48
49
50
    /**
51
     * Password reset Controller constructor.
52
     *
53
     * @param \SimpleSAML\Configuration $config The configuration to use.
54
     * @param \SimpleSAML\Session $session The current user session.
55
     */
56
    public function __construct(Configuration $config, Session $session)
57
    {
58
        $this->config = $config;
59
        $this->logger = new Logger();
60
        $this->moduleConfig = Configuration::getConfig('module_ldapPasswordReset.php');
61
        $this->session = $session;
62
        $this->userRepository = new UserRepository();
63
    }
64
65
66
    /**
67
     * Inject the \SimpleSAML\Logger dependency.
68
     *
69
     * @param \SimpleSAML\Logger $logger
70
     */
71
    public function setLogger(Logger $logger): void
72
    {
73
        $this->logger = $logger;
74
    }
75
76
77
    /**
78
     * Inject the \SimpleSAML\Auth\State dependency.
79
     *
80
     * @param \SimpleSAML\Auth\State $authState
81
     */
82
    public function setAuthState(Auth\State $authState): void
83
    {
84
        $this->authState = $authState;
85
    }
86
87
88
    /**
89
     * Display the page where the EMail address should be entered.
90
     *
91
     * @return \SimpleSAML\XHTML\Template
92
     */
93
    public function enterEmail(Request $request): Template
94
    {
95
        $t = new Template($this->config, 'ldapPasswordReset:enterEmail.twig');
96
        $t->data = [
97
            'mailSent' => false,
98
        ];
99
100
        $state = [];
101
        if ($request->request->has('submit_button')) {
102
            /** @psalm-var string|null $id */
103
            $id = $request->query->get('AuthState', null);
104
            if ($id === null) {
105
                throw new Error\BadRequest('Missing AuthState parameter.');
106
            }
107
108
            $t->data['mailSent'] = true;
109
110
            /** @psalm-var string $email */
111
            $email = $request->request->get('email');
112
            Assert::stringNotEmpty($email);
113
114
            /** @var array<mixed> $state */
115
            $state = $this->authState::loadState($id, 'ldapPasswordReset:request', false);
116
117
            $user = $this->userRepository->findUserByEmail($email);
118
            if ($user !== null) {
119
                $this->logger::info(sprintf('ldapPasswordReset: a password reset was requested for user %s', $email));
120
121
                $tokenStorage = new TokenStorage($this->config);
122
                $token = $tokenStorage->generateToken();
123
                $session = $this->session->getTrackID();
124
                $validUntil = time() + ($this->moduleConfig->getOptionalInteger('magicLinkExpiration', 15) * 60);
125
126
                $tokenStorage->storeToken(
127
                    $token,
128
                    $email,
129
                    $session,
130
                    $validUntil,
131
                    $state['ldapPasswordReset:referer'] ?? null
132
                );
133
                $this->logger::debug(sprintf('ldapPasswordReset: token %s was stored for %s', $token, $email));
134
135
                $mailer = new MagicLink($this->config);
136
                $mailer->sendMagicLink($email, $token, $validUntil);
137
                $this->logger::info(sprintf(
138
                    'ldapPasswordReset: token %s was e-mailed to user %s (valid until %s)',
139
                    $token,
140
                    $email,
141
                    date(DATE_RFC2822, $validUntil)
142
                ));
143
            } else {
144
                $this->logger::warning(sprintf(
145
                    'ldapPasswordReset: a password reset was requested for non-existing user %s',
146
                    $email
147
                ));
148
            }
149
        } else {
150
            $state = [];
151
152
            if ($request->server->has('HTTP_REFERER')) {
153
                $state['ldapPasswordReset:referer'] = $request->server->get('HTTP_REFERER');
154
            }
155
        }
156
157
        $t->data['AuthState'] = $this->authState::saveState($state, 'ldapPasswordReset:request');
158
        return $t;
159
    }
160
161
162
    /**
163
     * Process a received magic link.
164
     *
165
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
166
     */
167
    public function validateMagicLink(Request $request): RedirectResponse
168
    {
169
        if (!$request->query->has('t')) {
170
            throw new Error\BadRequest('Missing token.');
171
        }
172
173
        /** @psalm-var string $t */
174
        $t = $request->query->get('t');
175
        Assert::uuid($t, 'Invalid token provided.', Error\BadRequest::class);
176
177
        $tokenStorage = new TokenStorage($this->config);
178
        $token = $tokenStorage->retrieveToken($t);
179
180
        Assert::nullOrIsArray($token);
181
182
        if ($token !== null) {
183
            Assert::keyExists($token, 'mail');
184
            Assert::keyExists($token, 'session');
185
            Assert::keyExists($token, 'referer');
186
187
            if (
188
                $this->moduleConfig->getOptionalBoolean('lockBrowserSession', true)
189
                && $token['session'] !== $this->session->getTrackID()
190
            ) {
191
                $this->logger::warning(sprintf(
192
                    "Token '%s' was used in a different browser session then where it was requested from.",
193
                    $t,
194
                ));
195
            } else {
196
                $this->logger::info(sprintf(
197
                    "ldapPasswordReset: pre-conditions for token '%s' were met. User '%s' may change it's password.",
198
                    $t,
199
                    $token['mail']
200
                ));
201
202
                // All pre-conditions met - Allow user to change password
203
                $state = [
204
                    'ldapPasswordReset:magicLinkValidated' => true,
205
                    'ldapPasswordReset:subject' => $token['mail'],
206
                    'ldapPasswordReset:session' => $token['session'],
207
                    'ldapPasswordReset:token' => $t,
208
                    'ldapPasswordReset:referer' => $token['referer'],
209
                ];
210
211
                // Invalidate token - It may be used only once to reset a password
212
//                $tokenStorage->deleteToken($t);
213
214
                $id = $this->authState::saveState($state, 'ldapPasswordReset:request');
215
                return new RedirectResponse(
216
                    Module::getModuleURL('ldapPasswordReset/resetPassword', ['AuthState' => $id])
217
                );
218
            }
219
        } else {
220
            $this->logger::warning(sprintf("ldapPasswordReset: Could not find token '%s' in token storage.", $t));
221
        }
222
223
        $this->logger::debug(sprintf("ldapPasswordReset: an invalid magic link was used: %s", $t));
224
        return new RedirectResponse(Module::getModuleURL('ldapPasswordReset/invalidMagicLink'));
225
    }
226
227
228
    /**
229
     * Display an error message when an invalid magic link was used.
230
     *
231
     * @return \SimpleSAML\XHTML\Template
232
     */
233
    public function invalidMagicLink(Request $request): Template
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

233
    public function invalidMagicLink(/** @scrutinizer ignore-unused */ Request $request): Template

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
234
    {
235
        return new Template($this->config, 'ldapPasswordReset:invalidMagicLink.twig');
236
    }
237
238
239
    /**
240
     * Display the page where the user can set a new password.
241
     *
242
     * @return \SimpleSAML\XHTML\Template
243
     */
244
    public function resetPassword(Request $request): Template
245
    {
246
        /** @psalm-var string|null $id */
247
        $id = $request->query->get('AuthState', null);
248
        if ($id === null) {
249
            throw new Error\BadRequest('Missing AuthState parameter.');
250
        }
251
252
        /** @var array<mixed> $state */
253
        $state = $this->authState::loadState($id, 'ldapPasswordReset:request', false);
254
255
        $t = new Template($this->config, 'ldapPasswordReset:resetPassword.twig');
256
        $t->data = [
257
            'AuthState' => $id,
258
            'passwordMismatch' => false,
259
            'emailAddress' => $state['ldapPasswordReset:subject'],
260
        ];
261
262
        // Check if the submit-button was hit, or whether this is a first visit
263
        if ($request->request->has('submit_button')) {
264
            $this->logger::debug(sprintf(
265
                'ldapPasswordReset: a new password was entered for user %s',
266
                $state['ldapPasswordReset:subject']
267
            ));
268
269
            // See if the submitted passwords match
270
            /** @psalm-var string $newPassword */
271
            $newPassword = $request->request->get('new-password');
272
            /** @psalm-var string $retypePassword */
273
            $retypePassword = $request->request->get('password');
274
275
            if (strcmp($newPassword, $retypePassword) === 0) {
276
                $this->logger::debug(sprintf(
277
                    'ldapPasswordReset: new matching passwords were entered for user %s',
278
                    $state['ldapPasswordReset:subject']
279
                ));
280
281
                $user = $this->userRepository->findUserByEmail($state['ldapPasswordReset:subject']);
282
                Assert::notNull($user); // Must exist
283
284
                /** @psalm-var \Symfony\Component\Ldap\Entry $user */
285
                $result = $this->userRepository->updatePassword($user, $newPassword);
0 ignored issues
show
Bug introduced by
It seems like $user can also be of type null; however, parameter $user of SimpleSAML\Module\ldapPa...itory::updatePassword() does only seem to accept Symfony\Component\Ldap\Entry, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

285
                $result = $this->userRepository->updatePassword(/** @scrutinizer ignore-type */ $user, $newPassword);
Loading history...
286
                if ($result === true) {
287
                    $this->logger::info(sprintf(
288
                        'Password was reset for user: %s',
289
                        $state['ldapPasswordReset:subject']
290
                    ));
291
292
                    $t = new Template($this->config, 'ldapPasswordReset:passwordChanged.twig');
293
                    if (
294
                        isset($state['ldapPasswordReset:referer'])
295
                        && ($state['session'] === $this->session->getTrackID())
296
                    ) {
297
                        // If this isn't the same browser, it makes no sense to get back to the
298
                        // previous authentication-flow. It will fail relentlessly
299
                        $t->data['referer'] = $state['ldapPasswordReset:referer'];
300
                    }
301
                    $t->data['passwordChanged'] = true;
302
303
                    // Invalidate token - It may be used only once to reset a password
304
                    $tokenStorage = new TokenStorage($this->config);
305
                    $tokenStorage->deleteToken($state['ldapPasswordReset:token']);
306
307
                    return $t;
308
                } else {
309
                    $this->logger::warning(sprintf(
310
                        'Password reset has failed for user: %s',
311
                        $state['ldapPasswordReset:subject']
312
                    ));
313
314
                    $t->data['passwordChanged'] = false;
315
                }
316
            } else {
317
                $this->logger::debug(sprintf(
318
                    'ldapPasswordReset: mismatching passwords were entered for user %s',
319
                    $state['ldapPasswordReset:subject']
320
                ));
321
                $t->data['passwordMismatch'] = true;
322
            }
323
        }
324
325
        return $t;
326
    }
327
}
328