pull/5836/merge
christianbeeznest 9 months ago committed by GitHub
commit 37e8a301d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      assets/vue/components/Login.vue
  2. 4
      assets/vue/composables/auth/login.js
  3. 12
      assets/vue/services/securityService.js
  4. 1
      composer.json
  5. 107
      src/CoreBundle/Controller/AccountController.php
  6. 47
      src/CoreBundle/Controller/SecurityController.php
  7. 70
      src/CoreBundle/Entity/User.php
  8. 11
      src/CoreBundle/Form/ChangePasswordType.php
  9. 40
      src/CoreBundle/Migrations/Schema/V200/Version20241002002830.php
  10. 74
      src/CoreBundle/Resources/views/Account/change_password.html.twig

@ -28,6 +28,14 @@
/>
</div>
<div v-if="requires2FA" class="field">
<InputText
v-model="totp"
:placeholder="t('Enter TOTP code')"
type="text"
/>
</div>
<div class="field login-section__remember-me">
<InputSwitch
v-model="remember"
@ -43,7 +51,7 @@
<div class="field login-section__buttons">
<Button
:label="t('Sign in')"
:label="requires2FA ? t('Submit TOTP') : t('Sign in')"
:loading="isLoading"
type="submit"
/>
@ -91,15 +99,24 @@ const { redirectNotAuthenticated, performLogin, isLoading } = useLogin()
const login = ref("")
const password = ref("")
const totp = ref("")
const remember = ref(false)
const requires2FA = ref(false)
redirectNotAuthenticated()
function onSubmitLoginForm() {
performLogin({
async function onSubmitLoginForm() {
const response = await performLogin({
login: login.value,
password: password.value,
totp: requires2FA.value ? totp.value : null,
_remember_me: remember.value,
})
if (response.requires2FA) {
requires2FA.value = true
} else {
router.replace({ name: "Home" })
}
}
</script>

@ -32,6 +32,10 @@ export function useLogin() {
try {
const responseData = await securityService.login(payload)
if (responseData.requires2FA) {
return { success: true, requires2FA: true };
}
if (route.query.redirect) {
// Check if 'redirect' is an absolute URL
if (isValidHttpUrl(route.query.redirect.toString())) {

@ -6,12 +6,18 @@ import baseService from "./baseService";
* @param {boolean} _remember_me
* @returns {Promise<Object>}
*/
async function login({ login, password, _remember_me }) {
return await baseService.post("/login_json", {
async function login({ login, password, _remember_me, totp = null }) {
const payload = {
username: login,
password,
_remember_me,
});
}
if (totp) {
payload.totp = totp
}
return await baseService.post("/login_json", payload)
}
/**

@ -117,6 +117,7 @@
"sensio/framework-extra-bundle": "~6.1",
"simpod/doctrine-utcdatetime": "^0.1.2",
"sonata-project/exporter": "^2.2",
"spomky-labs/otphp": "^10.0",
"stevenmaguire/oauth2-keycloak": "^5.1",
"stof/doctrine-extensions-bundle": "^1.12",
"sunra/php-simple-html-dom-parser": "~1.5",

@ -14,6 +14,7 @@ use Chamilo\CoreBundle\Repository\Node\UserRepository;
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\Core\User\UserInterface;
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 <gugli100@gmail.com>
@ -80,15 +85,36 @@ class AccountController extends BaseController
#[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');
@ -98,33 +124,86 @@ class AccountController extends BaseController
$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('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.
*/

@ -14,6 +14,7 @@ use Chamilo\CoreBundle\Settings\SettingsManager;
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 @@ class SecurityController extends AbstractController
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 @@ class SecurityController extends AbstractController
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 '';
}
}
}

@ -705,6 +705,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])]
protected bool $mfaEnabled = false;
#[ORM\Column(name: 'mfa_service', type: 'string', length: 255, nullable: true)]
protected ?string $mfaService = null;
#[ORM\Column(name: 'mfa_secret', type: 'string', length: 255, nullable: true)]
protected ?string $mfaSecret = null;
#[ORM\Column(name: 'mfa_backup_codes', type: 'text', nullable: true)]
protected ?string $mfaBackupCodes = null;
#[ORM\Column(name: 'mfa_last_used', type: 'datetime', nullable: true)]
protected ?\DateTimeInterface $mfaLastUsed = null;
public function __construct()
{
$this->skipResourceNode = false;
@ -2414,4 +2429,59 @@ class User implements UserInterface, EquatableInterface, ResourceInterface, Reso
{
return $session?->hasCoachInCourseList($user) || $course?->getSubscriptionByUser($user)?->isTutor();
}
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;
}
}

@ -7,6 +7,7 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -23,15 +24,19 @@ class ChangePasswordType extends AbstractType
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Current Password',
'required' => true,
'required' => false,
])
->add('newPassword', PasswordType::class, [
'label' => 'New Password',
'required' => true,
'required' => false,
])
->add('confirmPassword', PasswordType::class, [
'label' => 'Confirm New Password',
'required' => true,
'required' => false,
])
->add('enable2FA', CheckboxType::class, [
'label' => 'Enable Two-Factor Authentication (2FA)',
'required' => false,
])
;
}

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Schema\Schema;
final class Version20241002002830 extends AbstractMigrationChamilo
{
public function getDescription(): string
{
return 'Add MFA (2FA) fields to user table';
}
public function up(Schema $schema): void
{
$this->addSql("
ALTER TABLE user ADD mfa_enabled BOOLEAN NOT NULL DEFAULT false,
ADD mfa_service VARCHAR(255) DEFAULT NULL,
ADD mfa_secret VARCHAR(255) DEFAULT NULL,
ADD mfa_backup_codes TEXT DEFAULT NULL,
ADD mfa_last_used DATETIME DEFAULT NULL
");
}
public function down(Schema $schema): void
{
$this->addSql("
ALTER TABLE user DROP mfa_enabled,
DROP mfa_service,
DROP mfa_secret,
DROP mfa_backup_codes,
DROP mfa_last_used
");
}
}

@ -49,45 +49,65 @@
{{ form_errors(form.confirmPassword) }}
</div>
<div class="mb-4">
{{ form_label(form.enable2FA) }}
{{ form_widget(form.enable2FA, {'attr': {'class': 'form-checkbox'}}) }}
{{ form_errors(form.enable2FA) }}
</div>
<div class="flex items-center justify-center">
<input type="hidden" name="_token" value="{{ csrf_token('change_password') }}">
<button type="submit" class="btn btn--primary mt-4">{{ "Change Password"|trans }}</button>
<button type="submit" class="btn btn--primary mt-4">
{% if form.currentPassword.vars.value or form.newPassword.vars.value or form.confirmPassword.vars.value %}
{{ "Change Password"|trans }}
{% else %}
{{ "Update Settings"|trans }}
{% endif %}
</button>
</div>
{{ form_end(form) }}
{% if qrCode is defined and user.getMfaEnabled() %}
<div class="mt-6 text-center">
<h3 class="text-lg font-medium">{{ 'Scan the QR Code to enable 2FA'|trans }}</h3>
<img src="data:image/png;base64,{{ qrCode }}" alt="QR Code for 2FA">
</div>
{% endif %}
</div>
</section>
{{ password_checker_js('#change_password_newPassword') }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePasswordButtons = document.querySelectorAll('.toggle-password');
togglePasswordButtons.forEach(button => {
button.addEventListener('click', function() {
const input = document.querySelector(this.getAttribute('data-target'));
if (input) {
const type = input.getAttribute('type') === 'password' ? 'text' : 'password';
input.setAttribute('type', type);
this.querySelector('i').classList.toggle('mdi-eye-outline');
this.querySelector('i').classList.toggle('mdi-eye-off-outline');
}
});
});
document.addEventListener('DOMContentLoaded', function() {
const togglePasswordButtons = document.querySelectorAll('.toggle-password');
togglePasswordButtons.forEach(button => {
button.addEventListener('click', function() {
const input = document.querySelector(this.getAttribute('data-target'));
if (input) {
const type = input.getAttribute('type') === 'password' ? 'text' : 'password';
input.setAttribute('type', type);
this.querySelector('i').classList.toggle('mdi-eye-outline');
this.querySelector('i').classList.toggle('mdi-eye-off-outline');
}
});
});
const newPasswordInput = document.querySelector('#change_password_newPassword');
const newPasswordErrors = document.querySelector('#new-password-errors');
const serverErrors = document.querySelector('#server-errors');
const newPasswordInput = document.querySelector('#change_password_newPassword');
const newPasswordErrors = document.querySelector('#new-password-errors');
const serverErrors = document.querySelector('#server-errors');
newPasswordInput.addEventListener('input', function() {
if (serverErrors) {
serverErrors.style.display = 'none';
}
if (newPasswordErrors) {
newPasswordErrors.style.display = 'none';
}
newPasswordInput.addEventListener('input', function() {
if (serverErrors) {
serverErrors.style.display = 'none';
}
if (newPasswordErrors) {
newPasswordErrors.style.display = 'none';
}
});
});
});
</script>
{% endblock %}

Loading…
Cancel
Save