diff --git a/.env.dist b/.env.dist index 3bffcf3..f28828a 100644 --- a/.env.dist +++ b/.env.dist @@ -20,3 +20,12 @@ LDAP_BASE_DN= LDAP_BIND_USER= LDAP_BIND_PASS= LDAP_DN_STRING= +LDAP_UID_KEY="uid" + +# LDAP group that identifies an Admin +# Users with this LDAP group will automatically +# get the admin role in this system. +LDAP_ADMIN_ROLE_DN="cn=admins,cn=groups,cn=accounts,dc=caldwell,dc=local" +LDAP_EMAIL_ATTRIBUTE=mail +LDAP_USERNAME_ATTRIBUTE=uid +LDAP_NAME_ATTRIBUTE=displayname diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 465eb22..c40c018 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -10,6 +10,9 @@ security: class: App\User\Framework\Entity\User property: email + custom_ldap_provider: + id: App\User\Framework\Security\LdapUserProvider + app_ldap_provider: ldap: service: Symfony\Component\Ldap\Ldap @@ -27,7 +30,7 @@ security: security: false main: lazy: true - provider: app_ldap_provider + provider: custom_ldap_provider # form_login: # login_path: app_login # check_path: app_login diff --git a/config/services.yaml b/config/services.yaml index 9bcee5e..ec7bdf0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -34,6 +34,20 @@ services: - '%env(DATABASE_URL)%' # LDAP + App\User\Framework\Security\LdapUserProvider: + arguments: + $userRepository: '@App\User\Framework\Repository\UserRepository' + $ldap: '@Symfony\Component\Ldap\LdapInterface' + $baseDn: '%env(LDAP_BASE_DN)%' + $searchDn: '%env(LDAP_BIND_USER)%' + $searchPassword: '%env(LDAP_BIND_PASS)%' + $defaultRoles: ['ROLE_USER'] + $uidKey: '%env(LDAP_UID_KEY)%' +# $passwordAttribute: '%env(LDAP_PASSWORD_ATTRIBUTE)%' + + + Symfony\Component\Ldap\LdapInterface: '@Symfony\Component\Ldap\Ldap' + Symfony\Component\Ldap\Ldap: arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ] tags: @@ -47,3 +61,4 @@ services: options: protocol_version: 3 referrals: false + diff --git a/migrations/Version20250510185814.php b/migrations/Version20250510185814.php new file mode 100644 index 0000000..d7d3723 --- /dev/null +++ b/migrations/Version20250510185814.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + ALTER TABLE user ADD username VARCHAR(255) DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE user DROP username + SQL); + } +} diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index 55e391e..c7326d4 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -18,7 +18,7 @@ final class IndexController extends AbstractController #[Route('/', name: 'app_index')] public function index(): Response { - dd($this->getUser()); +// dd($this->getUser()); return $this->render('index/index.html.twig', [ 'active_downloads' => $this->downloadRepository->getActivePaginated(), 'recent_downloads' => $this->downloadRepository->latest(5), diff --git a/src/User/Framework/Entity/User.php b/src/User/Framework/Entity/User.php index 8263231..0e2b842 100644 --- a/src/User/Framework/Entity/User.php +++ b/src/User/Framework/Entity/User.php @@ -22,6 +22,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: 'integer')] private int $id; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $username; + #[ORM\Column(type: 'string', length: 180, unique: true)] private ?string $email; @@ -88,7 +91,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ public function getUserIdentifier(): string { - return (string) $this->email; + return (string) $this->username ?? $this->email; } /** @@ -241,4 +244,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(?string $username): static + { + $this->username = $username; + + return $this; + } } diff --git a/src/User/Framework/Security/LdapAuthenticator.php b/src/User/Framework/Security/LdapAuthenticator.php new file mode 100644 index 0000000..f1eb511 --- /dev/null +++ b/src/User/Framework/Security/LdapAuthenticator.php @@ -0,0 +1,73 @@ +headers->has('X-AUTH-TOKEN'); + } + + public function authenticate(Request $request): Passport + { + // $apiToken = $request->headers->get('X-AUTH-TOKEN'); + // if (null === $apiToken) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + // throw new CustomUserMessageAuthenticationException('No API token provided'); + // } + + // implement your own logic to get the user identifier from `$apiToken` + // e.g. by looking up a user in the database using its API key + // $userIdentifier = /** ... */; + + // return new SelfValidatingPassport(new UserBadge($userIdentifier)); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // on success, let the request continue + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $data = [ + // you may want to customize or obfuscate the message first + 'message' => strtr($exception->getMessageKey(), $exception->getMessageData()), + + // or to translate this message + // $this->translator->trans($exception->getMessageKey(), $exception->getMessageData()) + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } + + // public function start(Request $request, ?AuthenticationException $authException = null): Response + // { + // /* + // * If you would like this class to control what happens when an anonymous user accesses a + // * protected page (e.g. redirect to /login), uncomment this method and make this class + // * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface. + // * + // * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point + // */ + // } +} diff --git a/src/User/Framework/Security/LdapUserProvider.php b/src/User/Framework/Security/LdapUserProvider.php new file mode 100644 index 0000000..a7937e5 --- /dev/null +++ b/src/User/Framework/Security/LdapUserProvider.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\User\Framework\Security; + +use App\User\Framework\Entity\User; +use App\User\Framework\Repository\UserRepository; +use Symfony\Component\Ldap\Entry; +use Symfony\Component\Ldap\Exception\ExceptionInterface; +use Symfony\Component\Ldap\Exception\InvalidCredentialsException; +use Symfony\Component\Ldap\Exception\InvalidSearchCredentialsException; +use Symfony\Component\Ldap\LdapInterface; +use Symfony\Component\Security\Core\Exception\InvalidArgumentException; +use Symfony\Component\Security\Core\Exception\UnsupportedUserException; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Ldap\Security\LdapUser; + +/** + * LdapUserProvider is a simple user provider on top of LDAP. + * + * @author Grégoire Pineau + * @author Charles Sarrazin + * @author Robin Chalas + * + * @template-implements UserProviderInterface + */ +class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterface +{ + private string $uidKey; + private string $defaultSearch; + private string $usernameAttribute; + private string $emailAttribute; + private string $displayNameAttribute; + private RoleFetcherInterface $roleFetcher; + + public function __construct( + private UserRepository $userRepository, + private LdapInterface $ldap, + private string $baseDn, + private ?string $searchDn = null, + #[\SensitiveParameter] private ?string $searchPassword = null, + private array $defaultRoles = [], + ?string $uidKey = null, + ?string $filter = null, + private ?string $passwordAttribute = null, + private array $extraFields = [], + string $usernameAttribute = 'uid', + string $emailAttribute = 'mail', + string $displayNameAttribute = 'displayName', + ) { + $uidKey ??= 'sAMAccountName'; + $filter ??= '({uid_key}={user_identifier})'; + + $this->uidKey = $uidKey; + $this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter); +// $this->roleFetcher = $defaultRoles; + $this->usernameAttribute = $usernameAttribute; + $this->emailAttribute = $emailAttribute; + $this->displayNameAttribute = $displayNameAttribute; + } + + public function loadUserByIdentifier(string $identifier): UserInterface + { + try { + $this->ldap->bind($this->searchDn, $this->searchPassword); + } catch (InvalidCredentialsException) { + throw new InvalidSearchCredentialsException(); + } + + $identifier = $this->ldap->escape($identifier, '', LdapInterface::ESCAPE_FILTER); + $query = str_replace('{user_identifier}', $identifier, $this->defaultSearch); + $search = $this->ldap->query($this->baseDn, $query, ['filter' => 0 == \count($this->extraFields) ? '*' : $this->extraFields]); + + $entries = $search->execute(); + $count = \count($entries); + + if (!$count) { + $e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier)); + $e->setUserIdentifier($identifier); + + throw $e; + } + + if ($count > 1) { + $e = new UserNotFoundException('More than one user found.'); + $e->setUserIdentifier($identifier); + + throw $e; + } + + $entry = $entries[0]; + + try { + $identifier = $this->getAttributeValue($entry, $this->uidKey); + } catch (InvalidArgumentException) { + // This is empty + } + + return $this->loadUser($identifier, $entry); + } + + public function refreshUser(UserInterface $user): UserInterface + { + if (!$user instanceof LdapUser) { + throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user))); + } + + return new LdapUser($user->getEntry(), $user->getUserIdentifier(), $user->getPassword(), $user->getRoles(), $user->getExtraFields()); + } + + /** + * @final + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof LdapUser) { + throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user))); + } + + if (null === $this->passwordAttribute) { + return; + } + + try { + $user->getEntry()->setAttribute($this->passwordAttribute, [$newHashedPassword]); + $this->ldap->getEntryManager()->update($user->getEntry()); + $user->setPassword($newHashedPassword); + } catch (ExceptionInterface) { + // ignore failed password upgrades + } + } + + public function supportsClass(string $class): bool + { + return User::class === $class; + } + + /** + * Loads a user from an LDAP entry. + */ + protected function loadUser(string $identifier, Entry $entry): UserInterface + { + $extraFields = []; + + foreach ($this->extraFields as $field) { + $extraFields[$field] = $this->getAttributeValue($entry, $field); + } + + if (null !== $this->passwordAttribute) { + $password = $this->getAttributeValue($entry, $this->passwordAttribute); + } + +// $roles = $this->roleFetcher->fetchRoles($entry); + + $dbUser = $this->getDbUser($identifier); + + if (null === $dbUser) { + $dbUser = new User(); + $dbUser->setPassword("test"); + } + + $dbUser + ->setName($entry->getAttribute($this->displayNameAttribute)[0] ?? null) + ->setEmail($entry->getAttribute($this->emailAttribute)[0] ?? null) + ->setUsername($entry->getAttribute($this->usernameAttribute)[0] ?? null); + + $this->userRepository->getEntityManager()->persist($dbUser); + $this->userRepository->getEntityManager()->flush(); + + return $dbUser; + } + +// protected function loadUser(string $identifier, Entry $entry): UserInterface +// { +// $password = null; +// $extraFields = []; +// +// if (null !== $this->passwordAttribute) { +// $password = $this->getAttributeValue($entry, $this->passwordAttribute); +// } +// +// foreach ($this->extraFields as $field) { +// $extraFields[$field] = $this->getAttributeValue($entry, $field); +// } +// +// return new LdapUser($entry, $identifier, $password, $this->defaultRoles, $extraFields); +// } + + private function getDbUser(string $identifier): ?UserInterface + { + if (in_array($this->uidKey, ['mail', 'email'])) { + return $this->userRepository->findOneBy(['email' => $identifier]); + } else { + return $this->userRepository->findOneBy(['username' => $identifier]); + } + } + + private function getAttributeValue(Entry $entry, string $attribute): mixed + { + if (!$entry->hasAttribute($attribute)) { + throw new InvalidArgumentException(\sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn())); + } + + $values = $entry->getAttribute($attribute); + if (!\in_array($attribute, [$this->uidKey, $this->passwordAttribute])) { + return $values; + } + + if (1 !== \count($values)) { + throw new InvalidArgumentException(\sprintf('Attribute "%s" has multiple values.', $attribute)); + } + + return $values[0]; + } +} \ No newline at end of file