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:
Kevin Papst
2023-05-01 08:28:35 +02:00
committed by GitHub
parent 01e26dca9e
commit 7112e932a2
6 changed files with 51 additions and 77 deletions

View File

@@ -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

View File

@@ -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']);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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'));
}
}