Skip to content

Commit

Permalink
✨ Add Support for TOTP in KeyCloak API
Browse files Browse the repository at this point in the history
  • Loading branch information
0x46616c6b committed May 30, 2024
1 parent d05ceb2 commit 710f6ef
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 32 deletions.
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
DATABASE_DRIVER=pdo_sqlite
DATABASE_URL=sqlite:///%kernel.project_dir%/var/db_test.sqlite
APP_DOMAIN=example.org
APP_ENV=test
APP_SECRET=165e25e3846534bb4665d7078a851c0b
APP_NAME="Userli"
APP_URL="https://users.example.org"
PROJECT_NAME="example.org"
PROJECT_LOGO_URL="https://www.example.org/logo.png"
PROJECT_URL="https://www.example.org"
SENDER_ADDRESS="admin@example.org"
NOTIFICATION_ADDRESS="monitoring@example.org"
Expand Down
80 changes: 69 additions & 11 deletions src/Controller/KeycloakController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Entity\User;
use App\Handler\UserAuthenticationHandler;
use Doctrine\ORM\EntityManagerInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticator;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -16,15 +17,21 @@

class KeycloakController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $manager, private readonly UserAuthenticationHandler $handler)
{}
const MESSAGE_SUCCESS = 'success';
const MESSAGE_AUTHENTICATION_FAILED = 'authentication failed';
const MESSAGE_USER_NOT_FOUND = 'user not found';
const MESSAGE_NOT_SUPPORTED = 'not supported';

public function __construct(private readonly EntityManagerInterface $manager, private readonly UserAuthenticationHandler $handler, private readonly TotpAuthenticator $totpAuthenticator)
{
}

#[Route(path: '/api/keycloak/{domainUrl}', name: 'api_keycloak_index', methods: ['GET'], stateless: true)]
public function getUsersSearch(
#[MapEntity(mapping: ['domainUrl' => 'name'])] Domain $domain,
#[MapQueryParameter] string $search = '',
#[MapQueryParameter] int $max = 10,
#[MapQueryParameter] int $first = 0,
#[MapQueryParameter] string $search = '',
#[MapQueryParameter] int $max = 10,
#[MapQueryParameter] int $first = 0,
): Response
{
$users = $this->manager->getRepository(User::class)->findUsersByString($domain, $search, $max, $first)->map(function (User $user) {
Expand All @@ -45,7 +52,7 @@ public function getUsersCount(#[MapEntity(mapping: ['domainUrl' => 'name'])] Dom
#[Route(path: '/api/keycloak/{domainUrl}/user/{email}', name: 'api_keycloak_user', methods: ['GET'], stateless: true)]
public function getOneUser(
#[MapEntity(mapping: ['domainUrl' => 'name'])] Domain $domain,
string $email,
string $email,
): Response
{
if (!str_contains($email, '@')) {
Expand All @@ -67,24 +74,75 @@ public function getOneUser(
#[Route(path: '/api/keycloak/{domainUrl}/validate/{email}', name: 'api_keycloak_user_validate', methods: ['POST'], stateless: true)]
public function postUserValidate(
#[MapEntity(mapping: ['domainUrl' => 'name'])] Domain $domain,
#[MapRequestPayload] KeycloakUserValidateDto $requestData,
string $email,
#[MapRequestPayload] KeycloakUserValidateDto $requestData,
string $email,
): Response
{
if (null === $user = $this->manager->getRepository(User::class)->findByDomainAndEmail($domain, $email)) {
return $this->json([
'message' => 'authentication failed',
'message' => self::MESSAGE_AUTHENTICATION_FAILED,
], Response::HTTP_FORBIDDEN);
}

return match ($requestData->getCredentialType()) {
'password' => $this->handlePasswordValidate($user, $requestData),
'otp' => $this->handleTotpValidate($user, $requestData),
default => $this->json([
'message' => self::MESSAGE_NOT_SUPPORTED,
], Response::HTTP_BAD_REQUEST),
};
}

#[Route(path: '/api/keycloak/{domainUrl}/configured/{credentialType}/{email}', name: 'api_keycloak_user_configured', methods: ['GET'], stateless: true)]
public function getIsConfiguredFor(#[MapEntity(mapping: ['domainUrl' => 'name'])] Domain $domain, string $credentialType, string $email): Response
{
if (null === $user = $this->manager->getRepository(User::class)->findByDomainAndEmail($domain, $email)) {
return $this->json([
'message' => self::MESSAGE_USER_NOT_FOUND,
], Response::HTTP_NOT_FOUND);
}

return match ($credentialType) {
'password' => $this->json(['message' => self::MESSAGE_SUCCESS]),
'otp' => $this->handleTotpConfigured($user),
default => $this->json(['message' => self::MESSAGE_NOT_SUPPORTED], Response::HTTP_NOT_FOUND),
};
}

private function handlePasswordValidate(User $user, KeycloakUserValidateDto $requestData): Response
{
if ($this->handler->authenticate($user, $requestData->getPassword()) === null) {
return $this->json([
'message' => 'authentication failed',
'message' => self::MESSAGE_AUTHENTICATION_FAILED,
], Response::HTTP_FORBIDDEN);
}

return $this->json([
'message' => self::MESSAGE_SUCCESS,
]);
}

private function handleTotpValidate(User $user, KeycloakUserValidateDto $requestData): Response
{
if (!$user->isTotpAuthenticationEnabled()) {
return $this->json([
'message' => self::MESSAGE_NOT_SUPPORTED,
], Response::HTTP_FORBIDDEN);
}

if (!$this->totpAuthenticator->checkCode($user, $requestData->getPassword())) {
return $this->json([
'message' => self::MESSAGE_AUTHENTICATION_FAILED,
], Response::HTTP_FORBIDDEN);
}

return $this->json([
'message' => 'success',
'message' => self::MESSAGE_SUCCESS,
]);
}

private function handleTotpConfigured(User $user): Response
{
return $user->isTotpAuthenticationEnabled() ? $this->json(['message' => self::MESSAGE_SUCCESS]) : $this->json(['message' => self::MESSAGE_USER_NOT_FOUND], Response::HTTP_NOT_FOUND);
}
}
3 changes: 2 additions & 1 deletion src/DataFixtures/AbstractUserData.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
use App\Entity\User;
use App\Helper\PasswordUpdater;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticator;

abstract class AbstractUserData extends Fixture
{
private const PASSWORD = 'password';

protected string $passwordHash;

public function __construct(readonly PasswordUpdater $passwordUpdater)
public function __construct(readonly PasswordUpdater $passwordUpdater, readonly TotpAuthenticator $totpAuthenticator)
{
$user = new User();
$passwordUpdater->updatePassword($user, self::PASSWORD);
Expand Down
18 changes: 12 additions & 6 deletions src/DataFixtures/LoadUserData.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
class LoadUserData extends AbstractUserData implements DependentFixtureInterface, FixtureGroupInterface
{
private array $users = [
['email' => 'admin@example.org', 'roles' => [Roles::ADMIN]],
['email' => 'user@example.org', 'roles' => [Roles::USER]],
['email' => 'spam@example.org', 'roles' => [Roles::SPAM]],
['email' => 'support@example.org', 'roles' => [Roles::MULTIPLIER]],
['email' => 'suspicious@example.org', 'roles' => [Roles::SUSPICIOUS]],
['email' => 'domain@example.com', 'roles' => [Roles::DOMAIN_ADMIN]],
['email' => 'admin@example.org', 'roles' => [Roles::ADMIN], 'totp' => false],
['email' => 'user@example.org', 'roles' => [Roles::USER], 'totp' => false],
['email' => 'totp@example.org', 'roles' => [Roles::USER], 'totp' => true],
['email' => 'spam@example.org', 'roles' => [Roles::SPAM], 'totp' => false],
['email' => 'support@example.org', 'roles' => [Roles::MULTIPLIER], 'totp' => false],
['email' => 'suspicious@example.org', 'roles' => [Roles::SUSPICIOUS], 'totp' => false],
['email' => 'domain@example.com', 'roles' => [Roles::DOMAIN_ADMIN], 'totp' => false],
];

/**
Expand All @@ -33,7 +34,12 @@ public function load(ObjectManager $manager): void
$roles = $user['roles'];
$domain = $manager->getRepository(Domain::class)->findOneBy(['name' => $splitted[1]]);

$totpEnabled = $user['totp'];
$user = $this->buildUser($domain, $email, $roles);
if ($totpEnabled) {
$user->setTotpSecret($this->totpAuthenticator->generateSecret());
$user->setTotpConfirmed(true);
}

$manager->persist($user);
}
Expand Down
13 changes: 13 additions & 0 deletions src/Dto/KeycloakUserValidateDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,24 @@ class KeycloakUserValidateDto {
#[Assert\NotBlank]
private string $password = '';

#[Assert\NotBlank]
private string $credentialType = 'password';

public function getPassword(): string {
return $this->password;
}

public function setPassword(string $password): void {
$this->password = $password;
}

public function getCredentialType(): string
{
return $this->credentialType;
}

public function setCredentialType(string $credentialType): void
{
$this->credentialType = $credentialType;
}
}
65 changes: 51 additions & 14 deletions tests/Controller/KeycloakControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Tests\Controller;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class KeycloakControllerTest extends WebTestCase
Expand All @@ -26,8 +27,8 @@ public function testGetUsersSearch(): void
self::assertResponseIsSuccessful();

$expected = [
[ 'id' => 'admin', 'email' => 'admin@example.org' ],
[ 'id' => 'user', 'email' => 'user@example.org']
['id' => 'admin', 'email' => 'admin@example.org'],
['id' => 'user', 'email' => 'user@example.org']
];
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($expected, $data);
Expand All @@ -52,9 +53,8 @@ public function testGetUsersCount(): void

self::assertResponseIsSuccessful();

$expected = 5;
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($expected, $data);
self::assertEquals(6, $data);
}

public function testGetOneUser(): void
Expand All @@ -66,7 +66,7 @@ public function testGetOneUser(): void

self::assertResponseIsSuccessful();

$expected = [ 'id' => 'user', 'email' => 'user@example.org' ];
$expected = ['id' => 'user', 'email' => 'user@example.org'];
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($expected, $data);
}
Expand All @@ -81,31 +81,68 @@ public function testGetOneNonexistentUser(): void
self::assertResponseStatusCodeSame(404);
}

public function testPostUserValidatae(): void
public function testPostUserValidate(): void
{
$client = static::createClient([], [
'HTTP_Authorization' => 'Bearer insecure',
]);
$client->request('POST', '/api/keycloak/example.org/validate/support@example.org', ['password' => 'password']);
$client->request('POST', '/api/keycloak/example.org/validate/support@example.org', ['credentialType' => 'password', 'password' => 'password']);

self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals(['message' => 'success'], $data);

$client->request('POST', '/api/keycloak/example.org/validate/support@example.org', ['password' => 'wrong']);

$expected = ['message' => 'success'];
self::assertResponseStatusCodeSame(403);
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($expected, $data);
self::assertEquals(['message' => 'authentication failed'], $data);

$client->request('POST', '/api/keycloak/example.org/validate/support@example.org', ['credentialType' => 'wrong', 'password' => 'password']);

self::assertResponseStatusCodeSame(400);
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals(['message' => 'not supported'], $data);

$client->request('POST', '/api/keycloak/example.org/validate/404@example.org', ['credentialType' => 'password', 'password' => 'password']);

self::assertResponseStatusCodeSame(403);
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals(['message' => 'authentication failed'], $data);
}

public function testPostUserValidateWrongPassword(): void
public function testPostUserValidateOTP(): void
{
$client = static::createClient([], [
'HTTP_Authorization' => 'Bearer insecure',
]);
$client->request('POST', '/api/keycloak/example.org/validate/support@example.org', ['password' => 'wrong']);
$client->request('POST', '/api/keycloak/example.org/validate/support@example.org', ['credentialType' => 'otp', 'password' => '123456']);
self::assertResponseStatusCodeSame(403);

$client->request('POST', '/api/keycloak/example.org/validate/totp@example.org', ['credentialType' => 'otp', 'password' => '123456']);
self::assertResponseStatusCodeSame(403);

$expected = ['message' => 'authentication failed'];
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($expected, $data);
$user = $client->getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->findOneBy(['email' => 'totp@example.org']);
$totp = $client->getContainer()->get('scheb_two_factor.security.totp_factory')->createTotpForUser($user);

$client->request('POST', '/api/keycloak/example.org/validate/totp@example.org', ['credentialType' => 'otp', 'password' => $totp->now()]);
self::assertResponseIsSuccessful();
}
public function testGetIsConfiguredFor(): void
{
$client = static::createClient([], [
'HTTP_Authorization' => 'Bearer insecure',
]);
$client->request('GET', '/api/keycloak/example.org/configured/otp/support@example.org');
self::assertResponseStatusCodeSame(404);

$client->request('GET', '/api/keycloak/example.org/configured/otp/totp@example.org');
self::assertResponseIsSuccessful();

$client->request('GET', '/api/keycloak/example.org/configured/password/support@example.org');
self::assertResponseIsSuccessful();

$client->request('GET', '/api/keycloak/example.org/configured/password/404@example.org');
self::assertResponseStatusCodeSame(404);
}
}

0 comments on commit 710f6ef

Please sign in to comment.