Skip to content

Commit

Permalink
Merge pull request #595 from systemli/feat/keycloak_api
Browse files Browse the repository at this point in the history
feat: Add keycloak API endpoints
  • Loading branch information
doobry-systemli committed May 6, 2024
2 parents 4b9b737 + d0f1a09 commit 603a50b
Show file tree
Hide file tree
Showing 12 changed files with 312 additions and 26 deletions.
8 changes: 8 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,11 @@ MAILER_DELIVERY_ADDRESS="admin@example.org"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=14&charset=utf8"
DATABASE_URL="mysql://mail:password@127.0.0.1:3306/mail?charset=utf8mb4"
###< doctrine/doctrine-bundle ###

### Enable keycloak API ###
# Set to `true` to enable keycloak API
KEYCLOAK_API_ENABLED=false
# Access is restricted to these IPs (supports subnets like `10.0.0.1/24`)
KEYCLOAK_API_IP_ALLOWLIST="127.0.0.1, ::1"
# Warning: set a secure access token
KEYCLOAK_API_ACCESS_TOKEN="insecure"
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ DOVECOT_MAIL_GID="5000"
WEBMAIL_URL="https://webmail.example.org"
WKD_DIRECTORY="/tmp/.well-known/openpgpkey"
WKD_FORMAT="advanced"
KEYCLOAK_API_ENABLED=true
KEYCLOAK_API_IP_ALLOWLIST="127.0.0.1, ::1"
KEYCLOAK_API_ACCESS_TOKEN="insecure"
18 changes: 18 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@ security:
algorithm: sodium
legacy:
id: 'App\Security\Encoder\LegacyPasswordHasher'
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: plaintext

# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# Custom UserProvider to allow login via email and localpart (without domain)
user:
id: App\Security\UserProvider
keycloak:
memory:
users:
- identifier: keycloak
roles: ['ROLE_KEYCLOAK']

role_hierarchy:
# User
Expand Down Expand Up @@ -104,6 +110,12 @@ security:
dev:
pattern: ^/(_(profiler|error|wdt)|css|images|js)/
security: false
keycloak:
pattern: ^/api/keycloak
stateless: true
provider: keycloak
access_token:
token_handler: App\Security\KeycloakAccessTokenHandler
main:
pattern: ^/
provider: user
Expand Down Expand Up @@ -145,3 +157,9 @@ security:
- { path: "^/account", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')" }
- { path: "^/openpgp", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')" }
- { path: "^/admin", roles: ROLE_DOMAIN_ADMIN }
- {
path: "^/api/keycloak",
ips: "%env(KEYCLOAK_API_IP_ALLOWLIST)%",
allow_if: "'%env(KEYCLOAK_API_ENABLED)%' == 'true' and is_granted('ROLE_KEYCLOAK')",
}
- { path: "^/api/keycloak", roles: ROLE_NO_ACCESS }
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ services:
- '@Doctrine\ORM\EntityManagerInterface'
public: true

App\Security\KeycloakAccessTokenHandler:
arguments:
$keycloakApiAccessToken: "%env(KEYCLOAK_API_ACCESS_TOKEN)%"

App\Sender\WelcomeMessageSender:
public: true

Expand Down
90 changes: 90 additions & 0 deletions src/Controller/KeycloakController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Controller;

use App\Dto\KeycloakUserValidateDto;
use App\Entity\Domain;
use App\Entity\User;
use App\Handler\UserAuthenticationHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Annotation\Route;

class KeycloakController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $manager, private readonly UserAuthenticationHandler $handler)
{}

#[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,
): Response
{
$users = $this->manager->getRepository(User::class)->findUsersByString($domain, $search, $max, $first)->map(function (User $user) {
return [
'id' => explode('@', $user->getEmail())[0],
'email' => $user->getEmail(),
];
});
return $this->json($users);
}

#[Route(path: '/api/keycloak/{domainUrl}/count', name: 'api_keycloak_count', methods: ['GET'], stateless: true)]
public function getUsersCount(#[MapEntity(mapping: ['domainUrl' => 'name'])] Domain $domain): Response
{
return $this->json($this->manager->getRepository(User::class)->countDomainUsers($domain));
}

#[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,
): Response
{
if (!str_contains($email, '@')) {
$email .= '@' . $domain->getName();
}

if (null === $foundUser = $this->manager->getRepository(User::class)->findByDomainAndEmail($domain, $email)) {
return $this->json([
'message' => 'user not found',
], Response::HTTP_NOT_FOUND);
}

return $this->json([
'id' => explode('@', $foundUser->getEmail())[0],
'email' => $foundUser->getEmail(),
]);
}

#[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,
): Response
{
if (null === $user = $this->manager->getRepository(User::class)->findByDomainAndEmail($domain, $email)) {
return $this->json([
'message' => 'authentication failed',
], Response::HTTP_FORBIDDEN);
}

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

return $this->json([
'message' => 'success',
]);
}
}
18 changes: 18 additions & 0 deletions src/Dto/KeycloakUserValidateDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

class KeycloakUserValidateDto {
#[Assert\NotBlank]
private string $password = '';

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

public function setPassword(string $password): void {
$this->password = $password;
}
}
2 changes: 2 additions & 0 deletions src/Enum/Roles.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class Roles
public const USER = 'ROLE_USER';
public const DOMAIN_ADMIN = 'ROLE_DOMAIN_ADMIN';
public const ADMIN = 'ROLE_ADMIN';
public const KEYCLOAK = 'ROLE_KEYCLOAK';

public static function getAll(): array
{
Expand All @@ -22,6 +23,7 @@ public static function getAll(): array
self::USER => self::USER,
self::DOMAIN_ADMIN => self::DOMAIN_ADMIN,
self::ADMIN => self::ADMIN,
self::KEYCLOAK => self::KEYCLOAK,
];
}
}
3 changes: 3 additions & 0 deletions src/EventListener/LocaleListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public function __construct(
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if ($request->attributes->getBoolean('_stateless')) {
return;
}
$session = $request->getSession();
$sessionLocale = $session->get('_locale');

Expand Down
55 changes: 29 additions & 26 deletions src/Repository/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Repository;

use App\Entity\Domain;
use Doctrine\Common\Collections\AbstractLazyCollection;
use Doctrine\Common\Collections\Collection;
use DateTime;
Expand All @@ -17,18 +18,31 @@

class UserRepository extends EntityRepository implements PasswordUpgraderInterface
{
/**
* @param $email
*
* @return User|null
*/
public function findByEmail($email): ?User
public function findById(int $id): ?User
{
return $this->findOneBy(['id' => $id]);
}

public function findByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
}

public function findByDomainAndEmail(Domain $domain, string $email): ?User
{
return $this->findOneBy(['domain' => $domain, 'email' => $email]);
}

public function findUsersByString(Domain $domain, string $string, int $max, int $first): AbstractLazyCollection|LazyCriteriaCollection
{
$criteria = Criteria::create()->where(Criteria::expr()->eq('domain', $domain));
$criteria->andWhere(Criteria::expr()->contains('email', $string));
$criteria->setMaxResults($max);
$criteria->setFirstResult($first);
return $this->matching($criteria);
}

/**
* @param DateTime $dateTime
* @return AbstractLazyCollection|(AbstractLazyCollection&Selectable)|LazyCriteriaCollection
*/
public function findUsersSince(DateTime $dateTime)
Expand All @@ -37,7 +51,6 @@ public function findUsersSince(DateTime $dateTime)
}

/**
* @param int $days
* @return AbstractLazyCollection|(AbstractLazyCollection&Selectable)|LazyCriteriaCollection
* @throws Exception
*/
Expand Down Expand Up @@ -65,35 +78,31 @@ public function findInactiveUsers(int $days)
return $this->matching(new Criteria($expression));
}

/**
* @return User[]|array
*/
public function findDeletedUsers(): array
{
return $this->findBy(['deleted' => true]);
}

/**
* @return int
*/
public function countUsers(): int
{
return $this->matching(Criteria::create()
->where(Criteria::expr()->eq('deleted', false)))->count();
}

/**
* @return int
*/
public function countDomainUsers(Domain $domain): int
{
return $this->matching(Criteria::create()
->where(Criteria::expr()->eq('domain', $domain))
->andWhere(Criteria::expr()->eq('deleted', false)))
->count();
}

public function countDeletedUsers(): int
{
return $this->matching(Criteria::create()
->where(Criteria::expr()->eq('deleted', true)))->count();
}

/**
* @return int
*/
public function countUsersWithRecoveryToken(): int
{
return $this->matching(Criteria::create()
Expand All @@ -102,9 +111,6 @@ public function countUsersWithRecoveryToken(): int
)->count();
}

/**
* @return int
*/
public function countUsersWithMailCrypt(): int
{
return $this->matching(Criteria::create()
Expand All @@ -113,9 +119,6 @@ public function countUsersWithMailCrypt(): int
)->count();
}

/**
* @return int
*/
public function countUsersWithTwofactor(): int
{
return $this->matching(Criteria::create()
Expand Down
22 changes: 22 additions & 0 deletions src/Security/KeycloakAccessTokenHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Security;

use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

class KeycloakAccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private string $keycloakApiAccessToken)
{
}

public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge {
if ($accessToken !== $this->keycloakApiAccessToken) {
throw new BadCredentialsException('Invalid access token');
}

return new UserBadge('keycloak');
}
}
Loading

0 comments on commit 603a50b

Please sign in to comment.