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: security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
- App\Saml\SamlToken
totp: totp:
enabled: true enabled: true
template: security/2fa.html.twig # Overwritten template template: security/2fa.html.twig # Overwritten template
window: 1 # How many codes before/after the current one would be accepted as valid 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 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 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()]); 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', [ return $this->render('user/2fa.html.twig', [
'tab' => '2fa', 'tab' => '2fa',
'user' => $profile, 'user' => $profile,
'form' => $form->createView(), 'form' => $form->createView(),
'deactivate' => $this->getTwoFactorDeactivationForm($profile)->createView(), 'deactivate' => $this->getTwoFactorDeactivationForm($profile)->createView(),
'qr_code' => $result,
]); ]);
} }
private function getTwoFactorDeactivationForm(User $user): FormInterface private function getTwoFactorDeactivationForm(User $user): FormInterface
{ {
return $this->createFormBuilder( return $this->createFormBuilder([], [
[], 'action' => $this->generateUrl('user_profile_2fa_deactivate', ['username' => $user->getUserIdentifier()]),
[ 'method' => 'POST'
'action' => $this->generateUrl('user_profile_2fa_deactivate', ['username' => $user->getUserIdentifier()]), ])->getForm();
'method' => 'POST'
]
)->getForm();
} }
#[Route(path: '/{username}/2fa_deactivate', name: 'user_profile_2fa_deactivate', methods: ['POST'])] #[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()]); 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 */ /** @var User $user */
$user = $context->getUser(); $user = $context->getUser();
// only internal users support 2FA currently
if (!$user->isInternalUser()) {
return false;
}
// never require 2FA on API calls // never require 2FA on API calls
if (str_starts_with($context->getRequest()->getRequestUri(), '/api/')) { if (str_starts_with($context->getRequest()->getRequestUri(), '/api/')) {
return false; return false;

View File

@@ -83,9 +83,8 @@ final class UserVoter extends Voter
} }
if ($attribute === '2fa') { if ($attribute === '2fa') {
// two factor only works for internal users and // can only be activated by the logged-in user for himself or by a super-admin
// can only be activated by the logged-in user for himself return $subject->getId() === $user->getId() || $user->isSuperAdmin();
return $subject->isInternalUser() && $subject->getId() === $user->getId();
} }
$permission = $attribute; $permission = $attribute;

View File

@@ -8,7 +8,7 @@
{{ 'profile.2fa_intro'|trans }} {{ 'profile.2fa_intro'|trans }}
</p> </p>
<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> </p>
{% if user.totpAuthenticationEnabled %} {% if user.totpAuthenticationEnabled %}
<div class="row mb-3"> <div class="row mb-3">

View File

@@ -474,17 +474,36 @@ class ProfileControllerTest extends ControllerBaseTest
self::assertFalse($user->hasTotpSecret()); self::assertFalse($user->hasTotpSecret());
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/2fa'); $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/2fa');
$this->assertTrue($client->getResponse()->isSuccessful());
$user = $this->getUserByName(UserFixtures::USERNAME_USER); $user = $this->getUserByName(UserFixtures::USERNAME_USER);
self::assertTrue($user->hasTotpSecret()); 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'); $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); $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'); $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'));
}
} }