-
Notifications
You must be signed in to change notification settings - Fork 10
/
TwoFactorFormAuthenticator.php
239 lines (213 loc) · 7.77 KB
/
TwoFactorFormAuthenticator.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
<?php
declare(strict_types=1);
namespace TwoFactorAuth\Authenticator;
use ArrayAccess;
use Authentication\Authenticator\FormAuthenticator as CakeFormAuthenticator;
use Authentication\Authenticator\ResultInterface;
use Authentication\Identifier\AbstractIdentifier;
use Authentication\UrlChecker\UrlCheckerTrait;
use Cake\Utility\Hash;
use Exception;
use Psr\Http\Message\ServerRequestInterface;
use RobThree\Auth\Algorithm;
use RobThree\Auth\TwoFactorAuth;
/**
* Two Factor Form Authenticator
*
* Authenticates an identity based on the POST data of the request.
*/
class TwoFactorFormAuthenticator extends CakeFormAuthenticator
{
use UrlCheckerTrait;
protected ?TwoFactorAuth $_tfa = null;
/**
* Default config for this object.
* - `fields` The fields to use to identify a user by.
* - `loginUrl` Login URL or an array of URLs.
* - `urlChecker` Url checker config.
* - `userSessionKey` Session key to store user after 1ss factor auth
* - `secretProperty` User model property containing user's 2FA secret key
* - `codeField` Request field containing one-time code
* - `issuer` Will be displayed in the app as issuer name.
* - `digits` The number of digits the resulting codes will be.
* - `period` The number of seconds a code will be valid.
* - `algorithm` The algorithm used.
* - `qrcodeprovider` QR-code provider.
* - `rngprovider` Random Number Generator provider.
* - `timeprovider` Time provider.
*
* @var array
*/
protected array $_defaultConfig = [
'loginUrl' => null,
'userSessionKey' => 'TwoFactorAuth.user',
'urlChecker' => 'Authentication.Default',
'fields' => [
AbstractIdentifier::CREDENTIAL_USERNAME => 'username',
AbstractIdentifier::CREDENTIAL_PASSWORD => 'password',
],
'codeField' => 'code',
'secretProperty' => 'secret',
'issuer' => null,
'digits' => 6,
'period' => 30,
'algorithm' => Algorithm::Sha1,
'qrcodeprovider' => null,
'rngprovider' => null,
'timeprovider' => null,
];
/**
* Authenticates the identity contained in a request. Will use the `config.userModel`, and `config.fields`
* to find POST data that is used to find a matching record in the `config.userModel`. Will return false if
* there is no post data, either username or password is missing, or if the scope conditions have not been met.
*
* @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
* @return \Authentication\Authenticator\ResultInterface
*/
public function authenticate(ServerRequestInterface $request): ResultInterface
{
if (!$this->_checkUrl($request)) {
return $this->_buildLoginUrlErrorResult($request);
}
$code = Hash::get($request->getParsedBody(), $this->getConfig('codeField'));
if (!is_null($code)) {
return $this->authenticateCode($request, (string)$code);
} else {
return $this->authenticateCredentials($request);
}
}
/**
* 2nd factor authentication
*
* @param \Psr\Http\Message\ServerRequestInterface $request Request object
* @param string $code One-time code
* @return \Authentication\Authenticator\ResultInterface
*/
protected function authenticateCode(ServerRequestInterface $request, string $code): ResultInterface
{
$user = $this->_getSessionUser($request);
if (!$user) {
// User hasn't passed 1st factor auth
return new Result(null, Result::FAILURE_CREDENTIALS_MISSING);
}
if (!$this->_verifyCode($this->_getUserSecret($user), $code)) {
// 2nd factor auth code is invalid
return new Result(null, Result::TWO_FACTOR_AUTH_FAILED);
}
$this->_unsetSessionUser($request);
return new Result($user, Result::SUCCESS);
}
/**
* 1st factor authentication
*
* @param \Psr\Http\Message\ServerRequestInterface $request Request object
* @return \Authentication\Authenticator\ResultInterface
*/
protected function authenticateCredentials(ServerRequestInterface $request): ResultInterface
{
$result = parent::authenticate($request);
if (
!$result->isValid()
|| !$this->_getUser2faEnabledStatus($result->getData())
|| !$this->_getUserSecret($result->getData())
) {
// The user is invalid or the 2FA secret is not enabled/present
return $result;
}
$user = $result->getData();
// Store user authenticated with 1 factor
$this->_setSessionUser($request, $user);
return new Result(null, Result::TWO_FACTOR_AUTH_REQUIRED);
}
/**
* Verify 2FA code
*
* @param string $secret Secret
* @param string $code One-time code
* @return bool
*/
protected function _verifyCode(string $secret, string $code): bool
{
try {
return $this->getTfa()->verifyCode($secret, $code);
} catch (Exception $e) {
return false;
}
}
/**
* Get pre-authenticated user from the session
*
* @param \Psr\Http\Message\ServerRequestInterface $request Request object
* @return \ArrayAccess|null
*/
protected function _getSessionUser(ServerRequestInterface $request): ?ArrayAccess
{
/** @var \Cake\Http\Session $session */
$session = $request->getAttribute('session');
return $session->read($this->getConfig('userSessionKey'));
}
/**
* Store pre-authenticated user in the session
*
* @param \Psr\Http\Message\ServerRequestInterface $request Request object
* @param \ArrayAccess $user User
*/
protected function _setSessionUser(ServerRequestInterface $request, ArrayAccess $user): void
{
/** @var \Cake\Http\Session $session */
$session = $request->getAttribute('session');
$session->write($this->getConfig('userSessionKey'), $user);
}
/**
* Clear pre-authenticated user from the session
*
* @param \Psr\Http\Message\ServerRequestInterface $request Request object
*/
protected function _unsetSessionUser(ServerRequestInterface $request): void
{
/** @var \Cake\Http\Session $session */
$session = $request->getAttribute('session');
$session->delete($this->getConfig('userSessionKey'));
}
/**
* Get user's 2FA secret
*
* @param \ArrayAccess $user User
* @return string|null
*/
protected function _getUserSecret(ArrayAccess $user): ?string
{
return Hash::get($user, $this->getConfig('secretProperty'));
}
/**
* Check if 2FA is enabled for the given user
*
* @param \ArrayAccess|array $user User
* @return bool
*/
protected function _getUser2faEnabledStatus(array|ArrayAccess $user): bool
{
return (bool)Hash::get($user, $this->getConfig('isEnabled2faProperty', $this->getConfig('secretProperty')));
}
/**
* Get RobThree\Auth\TwoFactorAuth object
*
* @return \RobThree\Auth\TwoFactorAuth
* @throws \RobThree\Auth\TwoFactorAuthException
*/
public function getTfa(): TwoFactorAuth
{
if (!$this->_tfa) {
$this->_tfa = new TwoFactorAuth(
$this->getConfig('issuer'),
$this->getConfig('digits'),
$this->getConfig('period'),
$this->getConfig('algorithm'),
$this->getConfig('qrcodeprovider'),
$this->getConfig('rngprovider'),
$this->getConfig('timeprovider')
);
}
return $this->_tfa;
}
}