Allow 2FA for SAML and LDAP users (#4000)
* inline totp image as data uri to prevent caching issues * allow 2fa for ldap and saml users * allow 2FA access for super admin to all profiles
This commit is contained in:
@@ -3,12 +3,14 @@ scheb_two_factor:
|
||||
security_tokens:
|
||||
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
|
||||
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
|
||||
- App\Saml\SamlToken
|
||||
|
||||
totp:
|
||||
enabled: true
|
||||
template: security/2fa.html.twig # Overwritten template
|
||||
window: 1 # How many codes before/after the current one would be accepted as valid
|
||||
# server_name: Server Name # Server name used in QR code
|
||||
issuer: Kimai # Issuer name used in QR code
|
||||
# parameters: # Additional parameters added in the QR code
|
||||
# image: 'https://my-service/img/logo.png'
|
||||
|
||||
two_factor_condition: App\Security\TwoFactorCondition
|
||||
|
||||
# FIXME add backup codes - https://symfony.com/bundles/SchebTwoFactorBundle/current/backup_codes.html
|
||||
|
||||
@@ -367,23 +367,34 @@ final class ProfileController extends AbstractController
|
||||
return $this->redirectToRoute('user_profile_2fa', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
$qrCodeContent = $totpAuthenticator->getQRContent($profile);
|
||||
|
||||
$result = Builder::create()
|
||||
->writer(new PngWriter())
|
||||
->writerOptions([])
|
||||
->data($qrCodeContent)
|
||||
->encoding(new Encoding('UTF-8'))
|
||||
->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
|
||||
->size(200)
|
||||
->margin(0)
|
||||
->roundBlockSizeMode(new RoundBlockSizeModeMargin())
|
||||
->build();
|
||||
|
||||
return $this->render('user/2fa.html.twig', [
|
||||
'tab' => '2fa',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
'deactivate' => $this->getTwoFactorDeactivationForm($profile)->createView(),
|
||||
'qr_code' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getTwoFactorDeactivationForm(User $user): FormInterface
|
||||
{
|
||||
return $this->createFormBuilder(
|
||||
[],
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_2fa_deactivate', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
]
|
||||
)->getForm();
|
||||
return $this->createFormBuilder([], [
|
||||
'action' => $this->generateUrl('user_profile_2fa_deactivate', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
])->getForm();
|
||||
}
|
||||
|
||||
#[Route(path: '/{username}/2fa_deactivate', name: 'user_profile_2fa_deactivate', methods: ['POST'])]
|
||||
@@ -405,28 +416,4 @@ final class ProfileController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('user_profile_2fa', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{username}/totp-qr-code', name: 'user_profile_2fa_image', methods: ['GET'])]
|
||||
#[IsGranted('2fa', 'profile')]
|
||||
public function displayTotpQrCode(User $profile, TotpAuthenticatorInterface $totpAuthenticator): Response
|
||||
{
|
||||
if (!$profile->hasTotpSecret()) {
|
||||
throw $this->createNotFoundException('User has no TOTP secret.');
|
||||
}
|
||||
|
||||
$qrCodeContent = $totpAuthenticator->getQRContent($profile);
|
||||
|
||||
$result = Builder::create()
|
||||
->writer(new PngWriter())
|
||||
->writerOptions([])
|
||||
->data($qrCodeContent)
|
||||
->encoding(new Encoding('UTF-8'))
|
||||
->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
|
||||
->size(200)
|
||||
->margin(0)
|
||||
->roundBlockSizeMode(new RoundBlockSizeModeMargin())
|
||||
->build();
|
||||
|
||||
return new Response($result->getString(), 200, ['Content-Type' => 'image/png']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,6 @@ final class TwoFactorCondition implements TwoFactorConditionInterface
|
||||
/** @var User $user */
|
||||
$user = $context->getUser();
|
||||
|
||||
// only internal users support 2FA currently
|
||||
if (!$user->isInternalUser()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// never require 2FA on API calls
|
||||
if (str_starts_with($context->getRequest()->getRequestUri(), '/api/')) {
|
||||
return false;
|
||||
|
||||
@@ -83,9 +83,8 @@ final class UserVoter extends Voter
|
||||
}
|
||||
|
||||
if ($attribute === '2fa') {
|
||||
// two factor only works for internal users and
|
||||
// can only be activated by the logged-in user for himself
|
||||
return $subject->isInternalUser() && $subject->getId() === $user->getId();
|
||||
// can only be activated by the logged-in user for himself or by a super-admin
|
||||
return $subject->getId() === $user->getId() || $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
$permission = $attribute;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{{ 'profile.2fa_intro'|trans }}
|
||||
</p>
|
||||
<p>
|
||||
<img src="{{ path('user_profile_2fa_image', {'username': user.username}) }}" alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" />
|
||||
<img alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" src="{{ qr_code.dataUri }}" />
|
||||
</p>
|
||||
{% if user.totpAuthenticationEnabled %}
|
||||
<div class="row mb-3">
|
||||
|
||||
@@ -474,17 +474,36 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
self::assertFalse($user->hasTotpSecret());
|
||||
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/2fa');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$user = $this->getUserByName(UserFixtures::USERNAME_USER);
|
||||
self::assertTrue($user->hasTotpSecret());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertNotFalse($content);
|
||||
|
||||
$imgUrl = $this->createUrl('/profile/' . UserFixtures::USERNAME_USER . '/totp-qr-code');
|
||||
$this->assertStringContainsString('<img src="' . $imgUrl . '" alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" />', $content);
|
||||
|
||||
$formUrl = $this->createUrl('/profile/' . UserFixtures::USERNAME_USER . '/2fa');
|
||||
$content = $client->getResponse()->getContent();
|
||||
$this->assertNotFalse($content);
|
||||
|
||||
$this->assertStringContainsString('<img alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" src="', $content);
|
||||
$this->assertStringContainsString('<form name="user_two_factor" method="post" action="' . $formUrl . '" id="user_two_factor_form">', $content);
|
||||
}
|
||||
|
||||
public function testTwoFactorAsAdmin(): void
|
||||
{
|
||||
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/profile/' . UserFixtures::USERNAME_USER . '/2fa');
|
||||
}
|
||||
|
||||
public function testTwoFactorAsSuperAdmin(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/2fa');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$content = $client->getResponse()->getContent();
|
||||
$this->assertNotFalse($content);
|
||||
$formUrl = $this->createUrl('/profile/' . UserFixtures::USERNAME_USER . '/2fa');
|
||||
|
||||
$this->assertStringContainsString('<img alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" src="', $content);
|
||||
$this->assertStringContainsString('<form name="user_two_factor" method="post" action="' . $formUrl . '" id="user_two_factor_form">', $content);
|
||||
}
|
||||
|
||||
@@ -522,32 +541,4 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
{
|
||||
$this->assertUrlIsSecured('/profile/' . UserFixtures::USERNAME_USER . '/2fa_deactivate', 'POST');
|
||||
}
|
||||
|
||||
public function testIsTwoFactorImageSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/profile/' . UserFixtures::USERNAME_USER . '/totp-qr-code');
|
||||
}
|
||||
|
||||
public function testTwoFactorImageFailsOnMissingSecret(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/totp-qr-code');
|
||||
$this->assertRouteNotFound($client);
|
||||
}
|
||||
|
||||
public function testTwoFactorImage(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$user = $this->getUserByName(UserFixtures::USERNAME_USER);
|
||||
self::assertFalse($user->hasTotpSecret());
|
||||
|
||||
// this is required, so the totp secret is stored in the user entity
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/2fa');
|
||||
|
||||
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/totp-qr-code');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
self::assertEquals('image/png', $client->getResponse()->headers->get('Content-Type'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user