Skip to content

Commit 2196570

Browse files
author
Nicolas Heist
committed
FEATURE: Initial implementation of Two-Factor-Authentication
1 parent 3491cd0 commit 2196570

8 files changed

Lines changed: 534 additions & 1 deletion
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
namespace Yeebase\TwoFactorAuthentication\Controller;
3+
4+
use Neos\Flow\Annotations as Flow;
5+
use Neos\Flow\Security\Authentication\Controller\AbstractAuthenticationController;
6+
7+
abstract class AbstractTwoFactorAuthenticationController extends AbstractAuthenticationController
8+
{
9+
10+
public function insertSecretAction()
11+
{
12+
}
13+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
namespace Yeebase\TwoFactorAuthentication\Controller;
3+
4+
use Neos\Flow\Annotations as Flow;
5+
use Neos\Error\Messages\Message;
6+
use Neos\Flow\Mvc\Controller\ActionController;
7+
use Yeebase\TwoFactorAuthentication\Service\TwoFactorAuthenticationService;
8+
9+
abstract class AbstractTwoFactorAuthenticationManagementController extends ActionController
10+
{
11+
12+
/**
13+
* @var TwoFactorAuthenticationService
14+
* @Flow\Inject
15+
*/
16+
protected $twoFactorAuthenticationService;
17+
18+
public function configureAction()
19+
{
20+
$account = $this->securityContext->getAccount();
21+
$enabled = $this->twoFactorAuthenticationService->hasTwoFactorAuthenticationEnabled($account);
22+
23+
if (! $enabled) {
24+
$activationQrCode = $this->twoFactorAuthenticationService->createActivationQrCode($account);
25+
$this->view->assign('activationQrCode', $activationQrCode);
26+
}
27+
}
28+
29+
public function enableAction(string $secret)
30+
{
31+
$account = $this->securityContext->getAccount();
32+
33+
if ($this->twoFactorAuthenticationService->validateSecret($secret, $account, true)) {
34+
$this->twoFactorAuthenticationService->enableTwoFactorAuthentication($account);
35+
$this->addFlashMessage('Zwei-Faktor-Authentisierung aktiviert.');
36+
} else {
37+
$this->addFlashMessage('Falsches Secret.', Message::SEVERITY_ERROR);
38+
}
39+
40+
$this->redirect('configure');
41+
}
42+
43+
public function disableAction(string $secret)
44+
{
45+
$account = $this->securityContext->getAccount();
46+
47+
if ($this->twoFactorAuthenticationService->validateSecret($secret, $account)) {
48+
$this->twoFactorAuthenticationService->disableTwoFactorAuthentication($account);
49+
$this->addFlashMessage('Zwei-Faktor-Authentisierung deaktiviert.');
50+
} else {
51+
$this->addFlashMessage('Falsches Secret.', Message::SEVERITY_ERROR);
52+
}
53+
54+
$this->redirect('configure');
55+
}
56+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
namespace Yeebase\TwoFactorAuthentication\Domain\Dto;
3+
4+
class TwoFactorAuthenticationCredentialsSource
5+
{
6+
7+
/**
8+
* @var string
9+
*/
10+
public $credentialsSource;
11+
12+
/**
13+
* @var bool
14+
*/
15+
public $enabled;
16+
17+
/**
18+
* @var string
19+
*/
20+
public $secret;
21+
22+
/**
23+
* @var string
24+
*/
25+
public $pendingSecret;
26+
27+
public function __construct(string $credentialsSource, bool $enabled, string $secret, string $pendingSecret)
28+
{
29+
$this->credentialsSource = $credentialsSource;
30+
$this->enabled = $enabled;
31+
$this->secret = $secret;
32+
$this->pendingSecret = $pendingSecret;
33+
}
34+
35+
/**
36+
* @param string $jsonString
37+
* @return TwoFactorAuthenticationCredentialsSource
38+
*/
39+
public static function fromJsonString($jsonString)
40+
{
41+
$jsonArray = json_decode($jsonString, true);
42+
43+
return new self(
44+
$jsonArray['credentialsSource'],
45+
$jsonArray['enabled'],
46+
$jsonArray['secret'],
47+
$jsonArray['pendingSecret']
48+
);
49+
}
50+
51+
public function toJsonString(): string
52+
{
53+
return json_encode([
54+
'credentialsSource' => $this->credentialsSource,
55+
'enabled' => $this->enabled,
56+
'secret' => $this->secret,
57+
'pendingSecret' => $this->pendingSecret
58+
]);
59+
}
60+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
namespace Yeebase\TwoFactorAuthentication\Security\Authentication\Provider;
3+
4+
use Neos\Flow\Annotations as Flow;
5+
use Neos\Flow\Core\Bootstrap;
6+
use Neos\Flow\Http\HttpRequestHandlerInterface;
7+
use Neos\Flow\Persistence\PersistenceManagerInterface;
8+
use Neos\Flow\Security\Account;
9+
use Neos\Flow\Security\AccountRepository;
10+
use Neos\Flow\Security\Authentication\EntryPoint\WebRedirect;
11+
use Neos\Flow\Security\Authentication\Provider\AbstractProvider;
12+
use Neos\Flow\Security\Authentication\TokenInterface;
13+
use Neos\Flow\Security\Context;
14+
use Neos\Flow\Security\Cryptography\HashService;
15+
use Neos\Flow\Security\Exception\UnsupportedAuthenticationTokenException;
16+
use Yeebase\TwoFactorAuthentication\Security\Authentication\Token\TwoFactorUsernamePasswordToken;
17+
use Yeebase\TwoFactorAuthentication\Service\TwoFactorAuthenticationService;
18+
19+
/**
20+
* An authentication provider that adds an additional layer of security by validating a Two-Factor-Authentication token.
21+
*/
22+
class TwoFactorAuthenticationProvider extends AbstractProvider
23+
{
24+
/**
25+
* @var TwoFactorAuthenticationService
26+
* @Flow\Inject
27+
*/
28+
protected $twoFactorAuthenticationService;
29+
30+
/**
31+
* @var AccountRepository
32+
* @Flow\Inject
33+
*/
34+
protected $accountRepository;
35+
36+
/**
37+
* @var Context
38+
* @Flow\Inject
39+
*/
40+
protected $securityContext;
41+
42+
/**
43+
* @var HashService
44+
* @Flow\Inject
45+
*/
46+
protected $hashService;
47+
48+
/**
49+
* @var PersistenceManagerInterface
50+
* @Flow\Inject
51+
*/
52+
protected $persistenceManager;
53+
54+
/**
55+
* @var Bootstrap
56+
* @Flow\Inject
57+
*/
58+
protected $bootstrap;
59+
60+
/**
61+
* @var array
62+
* @Flow\InjectConfiguration("authenticationEntryPoint")
63+
*/
64+
protected $entryPointConfiguration;
65+
66+
/**
67+
* Returns the class names of the tokens this provider can authenticate.
68+
*
69+
* @return array
70+
*/
71+
public function getTokenClassNames()
72+
{
73+
return [TwoFactorUsernamePasswordToken::class];
74+
}
75+
76+
/**
77+
* Checks the given token for validity and sets the token authentication status
78+
* accordingly (success, wrong credentials or no credentials given).
79+
*
80+
* @param TokenInterface $authenticationToken The token to be authenticated
81+
* @return void
82+
* @throws UnsupportedAuthenticationTokenException
83+
*/
84+
public function authenticate(TokenInterface $authenticationToken)
85+
{
86+
if (! in_array(get_class($authenticationToken), $this->getTokenClassNames())) {
87+
throw new UnsupportedAuthenticationTokenException('This provider cannot authenticate the given token.', 1217339840);
88+
}
89+
90+
$alreadyAuthenticated = false;
91+
if ($authenticationToken->getAuthenticationStatus() !== TokenInterface::AUTHENTICATION_SUCCESSFUL) {
92+
$authenticationToken->setAuthenticationStatus(TokenInterface::NO_CREDENTIALS_GIVEN);
93+
} else {
94+
$alreadyAuthenticated = true;
95+
}
96+
97+
// Username-Password-Authentication
98+
99+
$credentials = $authenticationToken->getCredentials();
100+
if (!is_array($credentials) || !isset($credentials[TwoFactorUsernamePasswordToken::CREDENTIALS_USERNAME]) || !isset($credentials[TwoFactorUsernamePasswordToken::CREDENTIALS_PASSWORD])) {
101+
// no username/password credentials given -> authentication failed
102+
return;
103+
}
104+
105+
$authenticationToken->setAuthenticationStatus(TokenInterface::WRONG_CREDENTIALS);
106+
107+
$account = $this->retrieveAccountForToken($authenticationToken);
108+
$givenPassword = $credentials[TwoFactorUsernamePasswordToken::CREDENTIALS_PASSWORD];
109+
110+
if ($account === null) {
111+
// account for username not found -> authentication failed
112+
$this->hashService->validatePassword($givenPassword, 'bcrypt=>$2a$14$DummySaltToPreventTim,.ingAttacksOnThisProvider');
113+
return;
114+
}
115+
116+
$existingPasswordHash = $this->twoFactorAuthenticationService->getPasswordCredentialsSource($account);
117+
if (! $this->hashService->validatePassword($givenPassword, $existingPasswordHash)) {
118+
// invalid password for given username -> authentication failed
119+
$account->authenticationAttempted(TokenInterface::WRONG_CREDENTIALS);
120+
$this->accountRepository->update($account);
121+
$this->persistenceManager->whitelistObject($account);
122+
return;
123+
}
124+
125+
if (! $this->twoFactorAuthenticationService->hasTwoFactorAuthenticationEnabled($account) || $alreadyAuthenticated) {
126+
// Two-Factor-Authentication is disabled or has been completed already -> authentication succeeded
127+
$account->authenticationAttempted(TokenInterface::AUTHENTICATION_SUCCESSFUL);
128+
$authenticationToken->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
129+
$authenticationToken->setAccount($account);
130+
$this->accountRepository->update($account);
131+
$this->persistenceManager->whitelistObject($account);
132+
return;
133+
}
134+
135+
// Authentication of Two-Factor-Authentication secret
136+
137+
$authenticationToken->setAuthenticationStatus(TokenInterface::NO_CREDENTIALS_GIVEN);
138+
139+
$twoFactorSecret = $authenticationToken->getCredentials()[TwoFactorUsernamePasswordToken::CREDENTIALS_TWO_FACTOR_SECRET];
140+
if (empty($twoFactorSecret)) {
141+
// No secret given yet -> forward to insertSecret action
142+
$this->configureRedirectToInsertSecretAction();
143+
return;
144+
}
145+
146+
$authenticationToken->setAuthenticationStatus(TokenInterface::WRONG_CREDENTIALS);
147+
148+
if ($this->twoFactorAuthenticationService->validateSecret($twoFactorSecret, $account)) {
149+
// Secret evaluated correctly -> authentication succeeded
150+
$account->authenticationAttempted(TokenInterface::AUTHENTICATION_SUCCESSFUL);
151+
$authenticationToken->setAuthenticationStatus(TokenInterface::AUTHENTICATION_SUCCESSFUL);
152+
$authenticationToken->setAccount($account);
153+
} else {
154+
// Invalid secret -> authentication failed
155+
$account->authenticationAttempted(TokenInterface::WRONG_CREDENTIALS);
156+
}
157+
$this->accountRepository->update($account);
158+
$this->persistenceManager->whitelistObject($account);
159+
}
160+
161+
protected function retrieveAccountForToken(TokenInterface $authenticationToken): ?Account
162+
{
163+
$account = null;
164+
165+
$username = $authenticationToken->getCredentials()[TwoFactorUsernamePasswordToken::CREDENTIALS_USERNAME];
166+
$providerName = $this->name;
167+
$accountRepository = $this->accountRepository;
168+
$this->securityContext->withoutAuthorizationChecks(function () use ($username, $providerName, $accountRepository, &$account) {
169+
$account = $accountRepository->findActiveByAccountIdentifierAndAuthenticationProviderName($username, $providerName);
170+
});
171+
172+
return $account;
173+
}
174+
175+
protected function configureRedirectToInsertSecretAction()
176+
{
177+
/* @var HttpRequestHandlerInterface $requestHandler */
178+
$requestHandler = $this->bootstrap->getActiveRequestHandler();
179+
$request = $requestHandler->getHttpRequest();
180+
$response = $requestHandler->getHttpResponse();
181+
182+
$webRedirect = new WebRedirect();
183+
$webRedirect->setOptions(['routeValues' => [
184+
'@package' => $this->entryPointConfiguration['package'],
185+
'@controller' => $this->entryPointConfiguration['controller'],
186+
'@action' => $this->entryPointConfiguration['action'],
187+
'@format' => 'html'
188+
]]);
189+
190+
$webRedirect->startAuthentication($request, $response);
191+
}
192+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
namespace Yeebase\TwoFactorAuthentication\Security\Authentication\Token;
3+
4+
use Neos\Flow\Mvc\ActionRequest;
5+
use Neos\Flow\Security\Authentication\Token\AbstractToken;
6+
use Neos\Utility\ObjectAccess;
7+
8+
/**
9+
* An authentication token used for two-factor-authentication with username and password as well as a secret.
10+
*/
11+
class TwoFactorUsernamePasswordToken extends AbstractToken
12+
{
13+
const CREDENTIALS_USERNAME = 'username';
14+
const CREDENTIALS_PASSWORD = 'password';
15+
const CREDENTIALS_TWO_FACTOR_SECRET = 'twoFactorSecret';
16+
17+
protected $credentials = [
18+
self::CREDENTIALS_USERNAME => '',
19+
self::CREDENTIALS_PASSWORD => '',
20+
self::CREDENTIALS_TWO_FACTOR_SECRET => ''
21+
];
22+
23+
/**
24+
* In a first request you need to send the username and password in these two POST parameters:
25+
* __authentication[Yeebase][TwoFactorAuthentication][Security][Authentication][Token][TwoFactorUsernamePasswordToken][username]
26+
* and __authentication[Yeebase][TwoFactorAuthentication][Security][Authentication][Token][TwoFactorUsernamePasswordToken][password]
27+
*
28+
* In a second request you need to send the respective Two-Factor-Authentication token in this POST parameter:
29+
* __authentication[Yeebase][TwoFactorAuthentication][Security][Authentication][Token][TwoFactorUsernamePasswordToken][twoFactorSecret]
30+
*
31+
* @param ActionRequest $actionRequest The current action request
32+
* @return void
33+
*/
34+
public function updateCredentials(ActionRequest $actionRequest): void
35+
{
36+
$httpRequest = $actionRequest->getHttpRequest();
37+
if ($httpRequest->getMethod() !== 'POST') {
38+
return;
39+
}
40+
41+
$arguments = $actionRequest->getInternalArguments();
42+
$username = ObjectAccess::getPropertyPath($arguments, '__authentication.Yeebase.TwoFactorAuthentication.Security.Authentication.Token.TwoFactorUsernamePasswordToken.' . self::CREDENTIALS_USERNAME);
43+
$password = ObjectAccess::getPropertyPath($arguments, '__authentication.Yeebase.TwoFactorAuthentication.Security.Authentication.Token.TwoFactorUsernamePasswordToken.' . self::CREDENTIALS_PASSWORD);
44+
$twoFactorSecret = ObjectAccess::getPropertyPath($arguments, '__authentication.Yeebase.TwoFactorAuthentication.Security.Authentication.Token.TwoFactorUsernamePasswordToken.' . self::CREDENTIALS_TWO_FACTOR_SECRET);
45+
46+
if (!empty($username) && !empty($password)) {
47+
$this->credentials[self::CREDENTIALS_USERNAME] = $username;
48+
$this->credentials[self::CREDENTIALS_PASSWORD] = $password;
49+
$this->credentials[self::CREDENTIALS_TWO_FACTOR_SECRET] = '';
50+
$this->setAuthenticationStatus(self::AUTHENTICATION_NEEDED);
51+
}
52+
53+
if (!empty($twoFactorSecret)) {
54+
$this->credentials[self::CREDENTIALS_TWO_FACTOR_SECRET] = $twoFactorSecret;
55+
$this->setAuthenticationStatus(self::AUTHENTICATION_NEEDED);
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)