Skip to content

Commit

Permalink
PB-21872 - As LU, I can verify Duo callback to login using Duo as MFA
Browse files Browse the repository at this point in the history
vinpb committed Feb 24, 2023
1 parent 6f0063a commit 48fa791
Showing 14 changed files with 592 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -103,6 +103,11 @@
])
->setMethods(['POST']);

$routes->connect('/verify/duo/callback', [
'prefix' => 'Duo', 'controller' => 'DuoVerifyCallbackGet', 'action' => 'get',
])
->setMethods(['GET']);

$routes->connect('/verify/error', ['controller' => 'MfaVerifyAjaxError', 'action' => 'get'])
->setMethods(['GET', 'POST', 'PUT', 'DELETE']);

Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);

/**
* Passbolt ~ Open source password manager for teams
* Copyright (c) Passbolt SA (https://www.passbolt.com)
*
* Licensed under GNU Affero General Public License version 3 of the or any later version.
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
* @link https://www.passbolt.com Passbolt(tm)
* @since 3.11.0
*/
namespace Passbolt\MultiFactorAuthentication\Controller\Duo;

use App\Authenticator\SessionIdentificationServiceInterface;
use App\Error\Exception\FormValidationException;
use App\Model\Entity\AuthenticationToken;
use App\Utility\UserAccessControl;
use Cake\Http\Cookie\Cookie;
use Cake\Http\Exception\BadRequestException;
use Cake\Http\Exception\InternalErrorException;
use Cake\Validation\Validation;
use Duo\DuoUniversal\Client;
use Passbolt\MultiFactorAuthentication\Controller\MfaVerifyController;
use Passbolt\MultiFactorAuthentication\Form\Duo\DuoCallbackForm;
use Passbolt\MultiFactorAuthentication\Model\Dto\MfaDuoCallbackDto;
use Passbolt\MultiFactorAuthentication\Service\Duo\MfaDuoLoginService;
use Passbolt\MultiFactorAuthentication\Service\Duo\MfaDuoStateCookieService;
use Passbolt\MultiFactorAuthentication\Service\MfaVerifiedCookieService;
use Passbolt\MultiFactorAuthentication\Utility\MfaSettings;

class DuoVerifyCallbackGetController extends MfaVerifyController
{
/**
* Handle Duo setup callback GET request. Redirect the user if the auth token associated to the callback
* contains a redirect property. It is usually the case when a user authenticates to duo on the web application.
*
* @param \App\Authenticator\SessionIdentificationServiceInterface $sessionIdentificationService session ID service
* @param \Duo\DuoUniversal\Client|null $duoSdkClient Duo SDK Client
* @return \Cake\Http\Response|void
*/
public function get(
SessionIdentificationServiceInterface $sessionIdentificationService,
?Client $duoSdkClient = null
) {
$this->_assertRequestNotJson();
$this->_handleVerifiedNotRequired($sessionIdentificationService);
$redirect = $this->_handleInvalidSettings(MfaSettings::PROVIDER_DUO);
if ($redirect) {
return $redirect;
}

$uac = $this->User->getAccessControl();
$mfaDuoCallbackDto = $this->getAndAssertMfaDuoCallbackData();
$cookieToken = $this->consumeAndAssertCookieToken();

$authenticationToken = (new MfaDuoLoginService($duoSdkClient))->login(
$uac,
$mfaDuoCallbackDto,
$cookieToken
);
$this->addMfaVerifiedCookieToResponse($uac, $sessionIdentificationService);

$this->disableAutoRender();
$this->redirectIfDefinedInToken($authenticationToken);
}

/**
* Get the Mfa Duo Callback data from the query and assert them.
*
* @throws \App\Error\Exception\FormValidationException If the data provided on the query does not validate
* @throws \Cake\Http\Exception\BadRequestException If Duo was not able to authenticate the user and provided error details
* @return \Passbolt\MultiFactorAuthentication\Model\Dto\MfaDuoCallbackDto
*/
private function getAndAssertMfaDuoCallbackData(): MfaDuoCallbackDto
{
$mfaDuoCallbackData = $this->getRequest()->getQueryParams();
$mfaDuoCallbackForm = new DuoCallbackForm();
$isValid = $mfaDuoCallbackForm->execute($mfaDuoCallbackData);
$mfaDuoCallbackDto = new MfaDuoCallbackDto($mfaDuoCallbackForm->getData());

if ($mfaDuoCallbackDto->hasError()) {
$msg = __('Unable to authenticate to Duo.');
$msg .= " {$mfaDuoCallbackDto->formatError()}";
throw new BadRequestException($msg);
}

if (!$isValid) {
$msg = __('Unable to validate the Duo callback data.');
throw new FormValidationException($msg, $mfaDuoCallbackForm);
}

return $mfaDuoCallbackDto;
}

/**
* Consume the duo state cookie containing the user authentication token id and assert the format this one.
*
* @return string The token id stored in the cookie
* @throws \Cake\Http\Exception\BadRequestException if the cookie is not defined
* @throws \Cake\Http\Exception\BadRequestException if the cookie value is not a string
* @throws \Cake\Http\Exception\BadRequestException if the cookie value is not a valid uuid
*/
private function consumeAndAssertCookieToken(): string
{
$cookieToken = (new MfaDuoStateCookieService())->readDuoStateCookieValue($this->getRequest());
if (is_null($cookieToken)) {
throw new BadRequestException(__('A Duo state cookie is required.'));
}
$cookieToExpire = new Cookie(MfaDuoStateCookieService::MFA_COOKIE_DUO_STATE);
$this->setResponse($this->getResponse()->withExpiredCookie($cookieToExpire));

if (!is_string($cookieToken)) {
throw new BadRequestException(__('The Duo state cookie value should be a string.'));
} elseif (!Validation::uuid($cookieToken)) {
throw new BadRequestException(__('The Duo state cookie should be a valid UUID.'));
}

return $cookieToken;
}

/**
* Add to the response the MFA verified cookie.
*
* @param \App\Utility\UserAccessControl $uac User access control
* @param \App\Authenticator\SessionIdentificationServiceInterface $sessionIdentificationService session ID service
* @return void
* @throws \Cake\Http\Exception\InternalErrorException if it cannot create MFA cookie
*/
private function addMfaVerifiedCookieToResponse(
UserAccessControl $uac,
SessionIdentificationServiceInterface $sessionIdentificationService
): void {
try {
$cookie = (new MfaVerifiedCookieService())->createDuoMfaVerifiedCookie(
$uac,
$sessionIdentificationService,
$this->getRequest()
);
} catch (\Throwable $e) {
throw new InternalErrorException('Could not create MFA verified cookie.', null, $e);
}

$this->setResponse($this->getResponse()->withCookie($cookie));
}

/**
* Redirect the user if the authentication token contains a redirect path.
*
* @param \App\Model\Entity\AuthenticationToken $authenticationToken The authentication token
* @return void
*/
private function redirectIfDefinedInToken(AuthenticationToken $authenticationToken): void
{
$redirect = $authenticationToken->getDataValue('redirect');
if (!empty($redirect)) {
$this->redirect($redirect);
}
}
}
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@ abstract class MfaVerifyController extends MfaController
{
/**
* @return void
* @throws \Exception if loadComponent failed
*/
public function initialize(): void
{
Original file line number Diff line number Diff line change
@@ -105,6 +105,7 @@ public function services(
$concrete = DuoSetupForm::class;
break;
case 'setup/duo/callback':
case 'verify/duo/callback':
$concrete = DuoCallbackForm::class;
break;
case 'verify/duo':
Original file line number Diff line number Diff line change
@@ -56,12 +56,12 @@ public function consumeAndVerifyAuthenticationToken(
string $duoState
): AuthenticationToken {
if (!Validation::uuid($token)) {
throw new \InvalidArgumentException(__('The authentication token should be a valid UUID.'));
throw new \InvalidArgumentException('The authentication token should be a valid UUID.');
}
if (!Validation::inList($tokenType, MfaDuoCallbackAuthenticationTokenService::$ALLOWED_TOKEN_TYPES)) {
$readableAllowedTokenTypes = implode(', ', MfaDuoCallbackAuthenticationTokenService::$ALLOWED_TOKEN_TYPES);
$msg = __('The authentication token type should be one of the following: {0}.', $readableAllowedTokenTypes);
throw new \InvalidArgumentException($msg);
$msg = 'The authentication token type should be one of the following: ' . $readableAllowedTokenTypes . '.';
throw new \InvalidArgumentException($msg);
}

$authToken = $this->consumeAuthenticationTokenOrFail($uac, $tokenType, $token);
Original file line number Diff line number Diff line change
@@ -49,7 +49,8 @@ public function __construct(?Client $client = null)
{
try {
$this->duoClient = $client ?? (new MfaDuoGetSdkClientService())->getOrFail(
new MfaOrgSettingsDuoService(MfaOrgSettings::get()->getSettings())
new MfaOrgSettingsDuoService(MfaOrgSettings::get()->getSettings()),
AuthenticationToken::TYPE_MFA_SETUP
);
} catch (\Throwable $th) {
$msg = __('Could not enable Duo MFA provider.');
@@ -76,16 +77,18 @@ public function enable(
string $token
): AuthenticationToken {
if (!Validation::uuid($token)) {
throw new \InvalidArgumentException(__('The authentication token should be a valid UUID.'));
throw new \InvalidArgumentException('The authentication token should be a valid UUID.');
}
$authenticationTokenType = AuthenticationToken::TYPE_MFA_SETUP;
$authenticationToken = (new MfaDuoCallbackAuthenticationTokenService())
->consumeAndVerifyAuthenticationToken(
$uac,
AuthenticationToken::TYPE_MFA_SETUP,
$authenticationTokenType,
$token,
$duoCallbackDto->state
);
(new MfaDuoVerifyDuoCodeService($this->duoClient))->verify($uac, $duoCallbackDto->duoCode);
(new MfaDuoVerifyDuoCodeService($authenticationTokenType, $this->duoClient))
->verify($uac, $duoCallbackDto->duoCode);
$this->enableProvider($uac);

return $authenticationToken;
Original file line number Diff line number Diff line change
@@ -17,8 +17,10 @@

namespace Passbolt\MultiFactorAuthentication\Service\Duo;

use App\Model\Entity\AuthenticationToken;
use Cake\Http\Exception\InternalErrorException;
use Cake\Routing\Router;
use Cake\Validation\Validation;
use Duo\DuoUniversal\Client;
use Duo\DuoUniversal\DuoException;
use Passbolt\MultiFactorAuthentication\Service\MfaOrgSettings\MfaOrgSettingsDuoService;
@@ -32,21 +34,41 @@ class MfaDuoGetSdkClientService
* Get the Duo Sdk Client object or fail.
*
* @param \Passbolt\MultiFactorAuthentication\Service\MfaOrgSettings\MfaOrgSettingsDuoService $settings Duo org settings
* @param string $tokenType Authentication token type -- used to know whether the callback is for the setup or verify flow
* @return \Duo\DuoUniversal\Client
* @throws \Cake\Http\Exception\InternalErrorException If it cannot instantiate the Duo Sdk client.
*/
public function getOrFail(MfaOrgSettingsDuoService $settings): Client
public function getOrFail(MfaOrgSettingsDuoService $settings, string $tokenType): Client
{
try {
return new Client(
$settings->getDuoClientId(),
$settings->getDuoClientSecret(),
$settings->getDuoApiHostname(),
Router::url('/mfa/setup/duo/callback', true),
$this->getCallbackRedirectUrl($tokenType),
true,
);
} catch (DuoException $e) {
throw new InternalErrorException(__('Could not validate the Duo settings.'), null, $e);
}
}

/**
* Get the callback redirect URL to redirect the user from Duo back to Passbolt
*
* @param string $tokenType Authentication token type, which determines which endpoint to redirect users to
* @return string
*/
public function getCallbackRedirectUrl(string $tokenType): string
{
if (!Validation::inList($tokenType, MfaDuoCallbackAuthenticationTokenService::$ALLOWED_TOKEN_TYPES)) {
$readableAllowedTokenTypes = implode(', ', MfaDuoCallbackAuthenticationTokenService::$ALLOWED_TOKEN_TYPES);
$msg = 'The authentication token type should be one of the following: ' . $readableAllowedTokenTypes . '.';
throw new \InvalidArgumentException($msg);
}
$path = $tokenType === AuthenticationToken::TYPE_MFA_SETUP ? 'setup' : 'verify';
$url = '/mfa/' . $path . '/duo/callback';

return Router::url($url, true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);

/**
* Passbolt ~ Open source password manager for teams
* Copyright (c) Passbolt SA (https://www.passbolt.com)
*
* Licensed under GNU Affero General Public License version 3 of the or any later version.
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Passbolt SA (https://www.passbolt.com)
* @license https://opensource.org/licenses/AGPL-3.0 AGPL License
* @link https://www.passbolt.com Passbolt(tm)
* @since 3.11.0
*/

namespace Passbolt\MultiFactorAuthentication\Service\Duo;

use App\Model\Entity\AuthenticationToken;
use App\Utility\UserAccessControl;
use Cake\Http\Exception\InternalErrorException;
use Cake\Validation\Validation;
use Duo\DuoUniversal\Client;
use Passbolt\MultiFactorAuthentication\Model\Dto\MfaDuoCallbackDto;
use Passbolt\MultiFactorAuthentication\Service\MfaOrgSettings\MfaOrgSettingsDuoService;
use Passbolt\MultiFactorAuthentication\Utility\MfaOrgSettings;

/**
* Class MfaDuoLoginService
*/
class MfaDuoLoginService
{
/**
* @var \Duo\DuoUniversal\Client
*/
protected $duoClient;

/**
* MfaDuoLoginService constructor.
*
* @param \Duo\DuoUniversal\Client|null $client Duo SDK Client
* @return void
* @throws \Cake\Http\Exception\InternalErrorException If it cannot create the Duo Sdk Client
*/
public function __construct(?Client $client = null)
{
try {
$this->duoClient = $client ?? (new MfaDuoGetSdkClientService())->getOrFail(
new MfaOrgSettingsDuoService(MfaOrgSettings::get()->getSettings()),
AuthenticationToken::TYPE_MFA_VERIFY
);
} catch (\Throwable $th) {
$msg = __('Could not login using Duo MFA provider.');
throw new InternalErrorException($msg, null, $th);
}
}

/**
* Login using Duo for the operator.
*
* @param \App\Utility\UserAccessControl $uac The user access control
* @param \Passbolt\MultiFactorAuthentication\Model\Dto\MfaDuoCallbackDto $duoCallbackDto The Duo callback data
* @param string $token The authentication token.
* @return \App\Model\Entity\AuthenticationToken
* @throws \InvalidArgumentException if the provided token is not a UUID
* @throws \Cake\Http\Exception\UnauthorizedException If no active Duo callback authentication can be found.
* @throws \Cake\Http\Exception\UnauthorizedException If the duo state cannot be verified.
* @throws \Cake\Http\Exception\UnauthorizedException If the Duo code cannot be verified.
*/
public function login(
UserAccessControl $uac,
MfaDuoCallbackDto $duoCallbackDto,
string $token
): AuthenticationToken {
if (!Validation::uuid($token)) {
throw new \InvalidArgumentException('The authentication token should be a valid UUID.');
}
$authenticationTokenType = AuthenticationToken::TYPE_MFA_VERIFY;
$authenticationToken = (new MfaDuoCallbackAuthenticationTokenService())
->consumeAndVerifyAuthenticationToken(
$uac,
$authenticationTokenType,
$token,
$duoCallbackDto->state
);
(new MfaDuoVerifyDuoCodeService($authenticationTokenType, $this->duoClient))
->verify($uac, $duoCallbackDto->duoCode);

return $authenticationToken;
}
}
Loading

0 comments on commit 48fa791

Please sign in to comment.