-
Notifications
You must be signed in to change notification settings - Fork 511
User: Add TOTP 2FA authentication - refs #4431 #5836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ae664de
1968ed2
c8437d8
fd605e3
62ff965
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ | |
use Chamilo\CoreBundle\ServiceHelper\UserHelper; | ||
use Chamilo\CoreBundle\Settings\SettingsManager; | ||
use Chamilo\CoreBundle\Traits\ControllerTrait; | ||
use OTPHP\TOTP; | ||
use Security; | ||
use Symfony\Component\Form\FormError; | ||
use Symfony\Component\HttpFoundation\RedirectResponse; | ||
|
@@ -24,6 +25,10 @@ | |
use Symfony\Component\Security\Csrf\CsrfToken; | ||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; | ||
use Symfony\Contracts\Translation\TranslatorInterface; | ||
use Endroid\QrCode\Builder\Builder; | ||
use Endroid\QrCode\Encoding\Encoding; | ||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; | ||
use Endroid\QrCode\Writer\PngWriter; | ||
|
||
/** | ||
* @author Julio Montoya <[email protected]> | ||
|
@@ -89,15 +94,36 @@ public function edit(Request $request, UserRepository $userRepository, Illustrat | |
#[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])] | ||
public function changePassword(Request $request, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response | ||
{ | ||
/* @var User $user */ | ||
$user = $this->getUser(); | ||
|
||
if (!\is_object($user) || !$user instanceof UserInterface) { | ||
throw $this->createAccessDeniedException('This user does not have access to this section'); | ||
} | ||
|
||
$form = $this->createForm(ChangePasswordType::class); | ||
$form = $this->createForm(ChangePasswordType::class, [ | ||
'enable2FA' => $user->getMfaEnabled(), | ||
]); | ||
$form->handleRequest($request); | ||
|
||
$qrCodeBase64 = null; | ||
if ($user->getMfaEnabled() && $user->getMfaService() === 'TOTP' && $user->getMfaSecret()) { | ||
$decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']); | ||
$totp = TOTP::create($decryptedSecret); | ||
$totp->setLabel($user->getEmail()); | ||
|
||
$qrCodeResult = Builder::create() | ||
->writer(new PngWriter()) | ||
->data($totp->getProvisioningUri()) | ||
->encoding(new Encoding('UTF-8')) | ||
->errorCorrectionLevel(new ErrorCorrectionLevelHigh()) | ||
->size(300) | ||
->margin(10) | ||
->build(); | ||
|
||
$qrCodeBase64 = base64_encode($qrCodeResult->getString()); | ||
} | ||
|
||
if ($form->isSubmitted() && $form->isValid()) { | ||
$submittedToken = $request->request->get('_token'); | ||
|
||
|
@@ -107,33 +133,86 @@ public function changePassword(Request $request, UserRepository $userRepository, | |
$currentPassword = $form->get('currentPassword')->getData(); | ||
$newPassword = $form->get('newPassword')->getData(); | ||
$confirmPassword = $form->get('confirmPassword')->getData(); | ||
$enable2FA = $form->get('enable2FA')->getData(); | ||
|
||
if ($enable2FA && !$user->getMfaSecret()) { | ||
$totp = TOTP::create(); | ||
$totp->setLabel($user->getEmail()); | ||
$encryptedSecret = $this->encryptTOTPSecret($totp->getSecret(), $_ENV['APP_SECRET']); | ||
$user->setMfaSecret($encryptedSecret); | ||
$user->setMfaEnabled(true); | ||
$user->setMfaService('TOTP'); | ||
$userRepository->updateUser($user); | ||
|
||
$qrCodeResult = Builder::create() | ||
->writer(new PngWriter()) | ||
->data($totp->getProvisioningUri()) | ||
->encoding(new Encoding('UTF-8')) | ||
->errorCorrectionLevel(new ErrorCorrectionLevelHigh()) | ||
->size(300) | ||
->margin(10) | ||
->build(); | ||
|
||
$qrCodeBase64 = base64_encode($qrCodeResult->getString()); | ||
|
||
return $this->render('@ChamiloCore/Account/change_password.html.twig', [ | ||
'form' => $form->createView(), | ||
'qrCode' => $qrCodeBase64, | ||
'user' => $user | ||
]); | ||
} elseif (!$enable2FA) { | ||
$user->setMfaEnabled(false); | ||
$user->setMfaSecret(null); | ||
$userRepository->updateUser($user); | ||
$this->addFlash('success', '2FA disabled successfully.'); | ||
} | ||
|
||
if (!$userRepository->isPasswordValid($user, $currentPassword)) { | ||
$form->get('currentPassword')->addError(new FormError($this->translator->trans('Current password is incorrect.'))); | ||
} elseif ($newPassword !== $confirmPassword) { | ||
$form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match.'))); | ||
} else { | ||
$errors = $this->validatePassword($newPassword); | ||
if (\count($errors) > 0) { | ||
foreach ($errors as $error) { | ||
$form->get('newPassword')->addError(new FormError($error)); | ||
} | ||
if ($newPassword || $confirmPassword || $currentPassword) { | ||
if (!$userRepository->isPasswordValid($user, $currentPassword)) { | ||
$form->get('currentPassword')->addError(new FormError($this->translator->trans('The current password is incorrect'))); | ||
} elseif ($newPassword !== $confirmPassword) { | ||
$form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match'))); | ||
} else { | ||
$user->setPlainPassword($newPassword); | ||
$userRepository->updateUser($user); | ||
$this->addFlash('success', $this->translator->trans('Password changed successfully.')); | ||
|
||
return $this->redirectToRoute('chamilo_core_account_home'); | ||
$this->addFlash('success', 'Password updated successfully'); | ||
} | ||
} | ||
|
||
return $this->redirectToRoute('chamilo_core_account_home'); | ||
} | ||
} | ||
|
||
return $this->render('@ChamiloCore/Account/change_password.html.twig', [ | ||
'form' => $form->createView(), | ||
'qrCode' => $qrCodeBase64, | ||
'user' => $user | ||
]); | ||
} | ||
|
||
/** | ||
* Encrypts the TOTP secret using AES-256-CBC encryption. | ||
*/ | ||
private function encryptTOTPSecret(string $secret, string $encryptionKey): string | ||
{ | ||
$cipherMethod = 'aes-256-cbc'; | ||
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod)); | ||
$encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv); | ||
|
||
return base64_encode($iv . '::' . $encryptedSecret); | ||
} | ||
|
||
/** | ||
* Decrypts the TOTP secret using AES-256-CBC decryption. | ||
*/ | ||
private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string | ||
{ | ||
$cipherMethod = 'aes-256-cbc'; | ||
list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2); | ||
|
||
return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv); | ||
} | ||
|
||
/** | ||
* Validate the password against the same requirements as the client-side validation. | ||
*/ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ | |
use DateTime; | ||
use Doctrine\ORM\EntityManager; | ||
use Doctrine\ORM\EntityManagerInterface; | ||
use OTPHP\TOTP; | ||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||
use Symfony\Component\HttpFoundation\JsonResponse; | ||
use Symfony\Component\HttpFoundation\Request; | ||
|
@@ -63,6 +64,23 @@ public function loginJson(Request $request, EntityManager $entityManager, Settin | |
return $this->json(['error' => $message], 401); | ||
} | ||
|
||
if ($user->getMfaEnabled()) { | ||
$totpCode = null; | ||
$data = json_decode($request->getContent(), true); | ||
if (isset($data['totp'])) { | ||
$totpCode = $data['totp']; | ||
} | ||
|
||
if (null === $totpCode || !$this->isTOTPValid($user, $totpCode)) { | ||
$tokenStorage->setToken(null); | ||
$request->getSession()->invalidate(); | ||
|
||
return $this->json([ | ||
'requires2FA' => true, | ||
], 200); | ||
} | ||
} | ||
|
||
if (null !== $user->getExpirationDate() && $user->getExpirationDate() <= new DateTime()) { | ||
$message = $translator->trans('Your account has expired.'); | ||
|
||
|
@@ -128,4 +146,33 @@ public function checkSession(): JsonResponse | |
|
||
throw $this->createAccessDeniedException(); | ||
} | ||
|
||
/** | ||
* Validates the provided TOTP code for the given user. | ||
*/ | ||
private function isTOTPValid($user, string $totpCode): bool | ||
{ | ||
$decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']); | ||
$totp = TOTP::create($decryptedSecret); | ||
|
||
return $totp->verify($totpCode); | ||
} | ||
|
||
/** | ||
* Decrypts the stored TOTP secret. | ||
*/ | ||
private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string | ||
{ | ||
$cipherMethod = 'aes-256-cbc'; | ||
|
||
try { | ||
list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2); | ||
$decryptedSecret = openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv); | ||
|
||
return $decryptedSecret; | ||
} catch (\Exception $e) { | ||
error_log("Exception caught during decryption: " . $e->getMessage()); | ||
return ''; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing blank line before return statement |
||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -699,6 +699,21 @@ class User implements UserInterface, EquatableInterface, ResourceInterface, Reso | |
#[ORM\OneToMany(mappedBy: 'user', targetEntity: SocialPostFeedback::class, orphanRemoval: true)] | ||
private Collection $socialPostsFeedbacks; | ||
|
||
#[ORM\Column(name: 'mfa_enabled', type: 'boolean', options: ['default' => false])] | ||
ywarnier marked this conversation as resolved.
Show resolved
Hide resolved
|
||
protected bool $mfaEnabled = false; | ||
ywarnier marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
#[ORM\Column(name: 'mfa_service', type: 'string', length: 255, nullable: true)] | ||
ywarnier marked this conversation as resolved.
Show resolved
Hide resolved
|
||
protected ?string $mfaService = null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line indented incorrectly; expected 8 spaces, found 4 |
||
|
||
#[ORM\Column(name: 'mfa_secret', type: 'string', length: 255, nullable: true)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perl-style comments are not allowed. Use "// Comment." or "/* comment */" instead. |
||
protected ?string $mfaSecret = null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line indented incorrectly; expected 8 spaces, found 4 |
||
|
||
#[ORM\Column(name: 'mfa_backup_codes', type: 'text', nullable: true)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perl-style comments are not allowed. Use "// Comment." or "/* comment */" instead. |
||
protected ?string $mfaBackupCodes = null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Line indented incorrectly; expected 8 spaces, found 4 |
||
|
||
#[ORM\Column(name: 'mfa_last_used', type: 'datetime', nullable: true)] | ||
protected ?\DateTimeInterface $mfaLastUsed = null; | ||
|
||
/** | ||
* @var Collection<int, UserAuthSource> | ||
*/ | ||
|
@@ -2495,4 +2510,59 @@ public function removeAuthSource(UserAuthSource $authSource): static | |
|
||
return $this; | ||
} | ||
|
||
public function getMfaEnabled(): bool | ||
{ | ||
return $this->mfaEnabled; | ||
} | ||
|
||
public function setMfaEnabled(bool $mfaEnabled): self | ||
{ | ||
$this->mfaEnabled = $mfaEnabled; | ||
return $this; | ||
} | ||
|
||
public function getMfaService(): ?string | ||
{ | ||
return $this->mfaService; | ||
} | ||
|
||
public function setMfaService(?string $mfaService): self | ||
{ | ||
$this->mfaService = $mfaService; | ||
return $this; | ||
} | ||
|
||
public function getMfaSecret(): ?string | ||
{ | ||
return $this->mfaSecret; | ||
} | ||
|
||
public function setMfaSecret(?string $mfaSecret): self | ||
{ | ||
$this->mfaSecret = $mfaSecret; | ||
return $this; | ||
} | ||
|
||
public function getMfaBackupCodes(): ?string | ||
{ | ||
return $this->mfaBackupCodes; | ||
} | ||
|
||
public function setMfaBackupCodes(?string $mfaBackupCodes): self | ||
{ | ||
$this->mfaBackupCodes = $mfaBackupCodes; | ||
return $this; | ||
} | ||
|
||
public function getMfaLastUsed(): ?\DateTimeInterface | ||
{ | ||
return $this->mfaLastUsed; | ||
} | ||
|
||
public function setMfaLastUsed(?\DateTimeInterface $mfaLastUsed): self | ||
{ | ||
$this->mfaLastUsed = $mfaLastUsed; | ||
return $this; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a comma after each item in a multi-line array