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 22, 2024
1 parent d05ceb2 commit 86063ac
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 18 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
74 changes: 61 additions & 13 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,16 @@

class KeycloakController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $manager, private readonly UserAuthenticationHandler $handler)
{}
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 +47,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,8 +69,8 @@ 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)) {
Expand All @@ -77,14 +79,60 @@ public function postUserValidate(
], Response::HTTP_FORBIDDEN);
}

if ($this->handler->authenticate($user, $requestData->getPassword()) === null) {
if ($requestData->getCredentialType() === 'password') {
if ($this->handler->authenticate($user, $requestData->getPassword()) === null) {
return $this->json([
'message' => 'authentication failed',
], Response::HTTP_FORBIDDEN);
}

return $this->json([
'message' => 'authentication failed',
], Response::HTTP_FORBIDDEN);
'message' => 'success',
]);
}

if ($requestData->getCredentialType() === 'totp') {
if (!$user->isTotpAuthenticationEnabled()) {
return $this->json([
'message' => 'user not configured for TOTP',
], Response::HTTP_FORBIDDEN);
}

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

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

return $this->json([
'message' => 'success',
]);
'message' => 'credential type 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' => 'user not found',
], Response::HTTP_NOT_FOUND);
}

if ($credentialType === 'password') {
return $this->json(['message' => 'success']);
}

if ($credentialType === 'totp' && $user->isTotpAuthenticationEnabled()) {
return $this->json(['message' => 'success']);
}

return $this->json([
'message' => 'credential type not supported',
], Response::HTTP_NOT_FOUND);
}
}
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;
}
}
32 changes: 27 additions & 5 deletions tests/Controller/KeycloakControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,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 Down Expand Up @@ -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,12 +81,12 @@ 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();

Expand All @@ -108,4 +108,26 @@ public function testPostUserValidateWrongPassword(): void
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertEquals($expected, $data);
}

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

self::assertResponseStatusCodeSame(403);
}

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

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

0 comments on commit 86063ac

Please sign in to comment.