482 lines
16 KiB
PHP
482 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Service;
|
||
|
||
|
||
use App\Entity\Organizations;
|
||
use App\Entity\Roles;
|
||
use App\Entity\User;
|
||
use App\Entity\UserOrganizatonApp;
|
||
use App\Entity\UsersOrganizations;
|
||
use App\Service\AwsService;
|
||
use DateTimeImmutable;
|
||
use DateTimeZone;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Doctrine\ORM\EntityNotFoundException;
|
||
use Exception;
|
||
use League\Bundle\OAuth2ServerBundle\Model\AccessToken;
|
||
use Random\RandomException;
|
||
use SebastianBergmann\CodeCoverage\Util\DirectoryCouldNotBeCreatedException;
|
||
use Symfony\Bundle\SecurityBundle\Security;
|
||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||
|
||
class UserService
|
||
{
|
||
|
||
public const NOT_FOUND = 'Entity not found';
|
||
|
||
public function __construct(private readonly EntityManagerInterface $entityManager,
|
||
private readonly Security $security,
|
||
private readonly AwsService $awsService
|
||
)
|
||
{
|
||
|
||
}
|
||
|
||
/**
|
||
* Generate a random password for a new user until they set their own.
|
||
* @throws RandomException
|
||
*/
|
||
public function generateRandomPassword(): string
|
||
{
|
||
$length = 50; // Length of the password
|
||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+';
|
||
$charactersLength = strlen($characters);
|
||
$randomPassword = '';
|
||
|
||
for ($i = 0; $i < $length; $i++) {
|
||
$randomPassword .= $characters[random_int(0, $charactersLength - 1)];
|
||
}
|
||
|
||
return $randomPassword;
|
||
}
|
||
|
||
|
||
/**
|
||
* Check if the user is currently connected.
|
||
* This method check if the user is currently connected to one of the applications.
|
||
*
|
||
* @param String $userIdentifier
|
||
* @return bool
|
||
*/
|
||
public function isUserConnected(string $userIdentifier): bool
|
||
{
|
||
$now = new DateTimeImmutable();
|
||
$tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([
|
||
'userIdentifier' => $userIdentifier,
|
||
'revoked' => false
|
||
]);
|
||
|
||
foreach ($tokens as $token) {
|
||
// Assuming $token->getExpiry() returns a DateTimeInterface
|
||
if ($token->getExpiry() > $now) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Check if the user have the rights to access the page
|
||
* Self check can be skipped when checking access for the current user
|
||
*
|
||
* @param User $user
|
||
* @param bool $skipSelfCheck
|
||
* @return bool
|
||
* @throws Exception
|
||
*/
|
||
public function hasAccessTo(User $user, bool $skipSelfCheck = false): bool
|
||
{
|
||
if (!$skipSelfCheck && $user->getUserIdentifier() === $this->security->getUser()->getUserIdentifier()) {
|
||
return true;
|
||
}
|
||
$userOrganizations = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]);
|
||
if ($userOrganizations) {
|
||
foreach ($userOrganizations as $uo) {
|
||
if ($this->isAdminOfOrganization($uo->getOrganization())) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
if ($this->security->isGranted('ROLE_SUPER_ADMIN')) {
|
||
return true;
|
||
}
|
||
return false;
|
||
|
||
}
|
||
|
||
/**
|
||
* Check if the user is an admin of the organization
|
||
* A user is considered an admin of an organization if they have the 'ROLE_ADMIN' AND have the link to the
|
||
* entity role 'ROLE_ADMIN' in the UsersOrganizationsApp entity
|
||
* (if he is admin for any application of the organization).
|
||
*
|
||
* @param UsersOrganizations $usersOrganizations
|
||
* @return bool
|
||
* @throws Exception
|
||
*/
|
||
public function isAdminOfOrganization(Organizations $organizations): bool
|
||
{
|
||
$actingUser = $this->getUserByIdentifier($this->security->getUser()->getUserIdentifier());
|
||
$uo = $this->entityManager->getRepository(UsersOrganizations::class)->findOneBy(['users' => $actingUser, 'organization' => $organizations]);
|
||
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
|
||
if ($uo) {
|
||
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)->findOneBy(['userOrganization' => $uo,
|
||
'role' => $roleAdmin,
|
||
'isActive' => true]);
|
||
if ($uoa && $this->security->isGranted('ROLE_ADMIN')) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
|
||
/**
|
||
* Get the user by their identifier.
|
||
*
|
||
* @param string $userIdentifier
|
||
* @return User|null
|
||
* @throws Exception
|
||
*/
|
||
public function getUserByIdentifier(string $userIdentifier): ?User
|
||
{
|
||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $userIdentifier]);
|
||
if (!$user) {
|
||
throw new EntityNotFoundException(self::NOT_FOUND);
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
|
||
/**
|
||
* Format users without organization for admin view.
|
||
*
|
||
* @param array $users
|
||
* @return array
|
||
*/
|
||
public function formatNoOrgUsersAsAssoc(array $noOrgUsers): array
|
||
{
|
||
$group = [
|
||
'id' => null,
|
||
'name' => 'Utilisateurs',
|
||
'users' => [],
|
||
];
|
||
|
||
foreach ($noOrgUsers as $user) {
|
||
$group['users'][] = [
|
||
'entity' => $user,
|
||
'connected' => $this->isUserConnected($user->getUserIdentifier()),
|
||
];
|
||
}
|
||
|
||
// Use a fixed key (e.g., 0 or 'none') to avoid collisions with real org IDs
|
||
return ['none' => $group];
|
||
}
|
||
|
||
//TODO: reset function
|
||
public function handleProfilePicture(User $user, $picture): void
|
||
{
|
||
// Get file extension
|
||
$extension = $picture->guessExtension();
|
||
|
||
// Create custom filename: userNameUserSurname_ddmmyyhhmmss
|
||
$customFilename = $user->getName() . $user->getSurname() . '_' . date('dmyHis') . '.' . $extension;
|
||
// $customFilename = $user->getName() . $user->getSurname() . "." .$extension;
|
||
try {
|
||
$this->awsService->PutDocObj($_ENV['S3_PORTAL_BUCKET'], $picture, $customFilename, $extension, 'profile/');
|
||
|
||
$user->setPictureUrl('profile/' . $customFilename);
|
||
} catch (FileException $e) {
|
||
// Handle upload error
|
||
throw new FileException('File upload failed: ' . $e->getMessage());
|
||
}
|
||
|
||
//
|
||
//
|
||
}
|
||
|
||
/**
|
||
* Format users of a specific organization.
|
||
*
|
||
* @param UsersOrganizations[] $userOrganizations
|
||
* @return array
|
||
*/
|
||
public function formatOrgUsers(array $userOrganizations): array
|
||
{
|
||
$users = [];
|
||
|
||
foreach ($userOrganizations as $uo) {
|
||
$user = $uo->getUsers();
|
||
|
||
$users[] = [
|
||
'entity' => $user,
|
||
'connected' => $this->isUserConnected($user->getUserIdentifier()),
|
||
'isActive' => (bool)$uo->isActive(),
|
||
];
|
||
}
|
||
|
||
return $users;
|
||
}
|
||
|
||
/**
|
||
* Handle user's role synchronization
|
||
*
|
||
* @param User $user
|
||
* @param string $role
|
||
* @param boolean $add
|
||
* @return void
|
||
*/
|
||
public function syncUserRoles(User $user, string $role, bool $add): void
|
||
{
|
||
$roleFormatted = $this->formatRoleString($role);
|
||
|
||
if ($add) {
|
||
// Add the main role if not already present
|
||
if (!in_array($roleFormatted, $user->getRoles(), true)) {
|
||
$user->setRoles(array_merge($user->getRoles(), [$roleFormatted]));
|
||
}
|
||
|
||
// If SUPER ADMIN is given → ensure ADMIN is also present
|
||
if ($roleFormatted === 'ROLE_SUPER_ADMIN' && !in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||
$user->setRoles(array_merge($user->getRoles(), ['ROLE_ADMIN']));
|
||
}
|
||
} else {
|
||
// Remove the role if present and not used elsewhere
|
||
if (in_array($roleFormatted, $user->getRoles(), true)) {
|
||
$uos = $this->entityManager->getRepository(UsersOrganizations::class)
|
||
->findBy(['users' => $user, 'isActive' => true]);
|
||
|
||
$hasRole = false;
|
||
foreach ($uos as $uo) {
|
||
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
|
||
->findBy([
|
||
'userOrganization' => $uo,
|
||
'isActive' => true,
|
||
'role' => $this->entityManager->getRepository(Roles::class)
|
||
->findOneBy(['name' => $role]),
|
||
]);
|
||
|
||
if ($uoa) {
|
||
$hasRole = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Only remove globally if no other app gives this role
|
||
if (!$hasRole) {
|
||
$roles = $user->getRoles();
|
||
$roles = array_filter($roles, fn($r) => $r !== $roleFormatted);
|
||
$user->setRoles($roles);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Format role string to match the ROLE_ convention
|
||
*/
|
||
public function formatRoleString(string $role): string
|
||
{
|
||
$role = str_replace(' ', '_', trim($role));
|
||
$role = strtoupper($role);
|
||
if (str_starts_with($role, 'ROLE_')) {
|
||
return $role;
|
||
}
|
||
return 'ROLE_' . $role;
|
||
}
|
||
|
||
public function revokeUserTokens(string $userIdentifier)
|
||
{
|
||
$tokens = $this->entityManager->getRepository(AccessToken::class)->findBy([
|
||
'userIdentifier' => $userIdentifier,
|
||
'revoked' => false
|
||
]);
|
||
|
||
foreach ($tokens as $token) {
|
||
$token->revoke();
|
||
}
|
||
}
|
||
|
||
public function formatStatutForOrganizations(array $rows): array
|
||
{
|
||
$formatted = array_map(function (UsersOrganizations $uo) {
|
||
$user = $uo->getUsers();
|
||
$picture = $this->awsService->getPublicUrl($_ENV['S3_PORTAL_BUCKET']) . $user->getPictureUrl();
|
||
if ($uo->getStatut() === "INVITED") {
|
||
$statut = "INVITED";
|
||
// if user invited but not accepted in 1 hour, set statut to EXPIRED
|
||
$now = new DateTimeImmutable();
|
||
$invitationTime = $uo->getModifiedAt();
|
||
$expiryTime = $invitationTime->modify('+1 hour');
|
||
if ($now > $expiryTime) {
|
||
$statut = "EXPIRED";
|
||
}
|
||
} else {
|
||
$statut = $uo->isActive() ? "ACTIVE" : "INACTIVE";
|
||
}
|
||
return [
|
||
'pictureUrl' => $picture,
|
||
'name' => $user->getSurname(),
|
||
'prenom' => $user->getName(),
|
||
'email' => $user->getEmail(),
|
||
'isConnected' => $this->isUserConnected($user->getUserIdentifier()),
|
||
'statut' => $statut,
|
||
'showUrl' => '/user/view/' . $user->getId() . '?organizationId=' . $uo->getOrganization()->getId(),
|
||
'id' => $user->getId(),
|
||
];
|
||
}, $rows);
|
||
return $formatted;
|
||
}
|
||
|
||
public function generatePasswordToken(User $user, int $orgId): string
|
||
{
|
||
$orgString = "o" . $orgId . "@";
|
||
$token = $orgString . bin2hex(random_bytes(32));
|
||
$user->setPasswordToken($token);
|
||
$user->setTokenExpiry(new DateTimeImmutable('+1 hour', new \DateTimeZone('Europe/Paris')));
|
||
$this->entityManager->persist($user);
|
||
$this->entityManager->flush();
|
||
return $token;
|
||
}
|
||
|
||
public function isPasswordTokenValid(User $user, string $token): bool
|
||
{
|
||
if ($user->getPasswordToken() !== $token || $user->getTokenExpiry() < new DateTimeImmutable()) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public function isPasswordStrong(string $newPassword): bool
|
||
{
|
||
$pewpew = 0;
|
||
if (preg_match('/\w/', $newPassword)) { //Find any alphabetical letter (a to Z) and digit (0 to 9)
|
||
$pewpew++;
|
||
}
|
||
if (preg_match('/\W/', $newPassword)) { //Find any non-alphabetical and non-digit character
|
||
$pewpew++;
|
||
}
|
||
if (strlen($newPassword) > 8) {
|
||
$pewpew++;
|
||
}
|
||
return $pewpew >= 3;
|
||
}
|
||
|
||
public function updateUserPassword(User $user, string $newPassword): void
|
||
{
|
||
$user->setPassword(password_hash($newPassword, PASSWORD_BCRYPT));
|
||
$user->setModifiedAt(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')));
|
||
$user->setPasswordToken(null);
|
||
$user->setTokenExpiry(null);
|
||
$this->entityManager->persist($user);
|
||
$this->entityManager->flush();
|
||
}
|
||
|
||
public function getOrgFromToken(string $token): ?int
|
||
{
|
||
if (str_starts_with($token, 'o')) {
|
||
$parts = explode('@', $token);
|
||
if (count($parts) === 2) {
|
||
$orgPart = substr($parts[0], 1); // Remove the leading 'o'
|
||
if (is_numeric($orgPart)) {
|
||
return (int)$orgPart;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Get roles array for a user, optionally including super admin roles.
|
||
* ViewSAdminRoles flag determines if super admin roles should be included.
|
||
*
|
||
* @param User $actingUser
|
||
* @param bool $viewSAdminRoles
|
||
* @return array
|
||
*/
|
||
public function getRolesArrayForUser(User $actingUser, bool $viewSAdminRoles = false): array
|
||
{
|
||
$roles = $this->entityManager->getRepository(Roles::class)->findAll();
|
||
$rolesArray = [];
|
||
foreach ($roles as $role) {
|
||
if (!$viewSAdminRoles && $role->getName() === 'SUPER ADMIN') {
|
||
continue;
|
||
}
|
||
$rolesArray[] = [
|
||
'id' => $role->getId(),
|
||
'name' => $role->getName(),
|
||
];
|
||
}
|
||
|
||
return $rolesArray;
|
||
}
|
||
|
||
public function canEditRolesCheck(User $actingUser, User $user, $org, bool $isAdmin): bool
|
||
{
|
||
$userRoles = $user->getRoles();
|
||
$actingUserRoles = $actingUser->getRoles();
|
||
// if acting user is admin, he can´t edit super admin roles
|
||
|
||
if (in_array('ROLE_SUPER_ADMIN', $userRoles, true) && !in_array('ROLE_SUPER_ADMIN', $actingUserRoles, true)) {
|
||
return false;
|
||
}
|
||
return $isAdmin && !empty($org);
|
||
|
||
}
|
||
|
||
/**
|
||
* Handle already existing user when creating a user.
|
||
* If the user exists but is inactive, reactivate them.
|
||
*
|
||
* @param User $user
|
||
* @param Organizations $organization
|
||
* @return void
|
||
*/
|
||
public function handleExistingUser(User $user, Organizations $organization): void
|
||
{
|
||
if (!$user->isActive()) {
|
||
$user->setIsActive(true);
|
||
$this->entityManager->persist($user);
|
||
}
|
||
$uo = new UsersOrganizations();
|
||
$uo->setUsers($user);
|
||
$uo->setOrganization($organization);
|
||
$uo->setStatut("INVITED");
|
||
$uo->setIsActive(false);
|
||
$uo->setModifiedAt(new \DateTimeImmutable('now'));
|
||
$this->entityManager->persist($uo);
|
||
$this->entityManager->flush();
|
||
}
|
||
|
||
/**
|
||
* Format new user data.
|
||
* Capitalize name and surname.
|
||
* Trim strings.
|
||
* Set random password
|
||
* Handle picture if provided
|
||
*
|
||
* @param User $user
|
||
* @return void
|
||
*/
|
||
public function formatNewUserData(User $user, $picture): void
|
||
{
|
||
// capitalize name and surname
|
||
$user->setName(ucfirst(strtolower($user->getName())));
|
||
$user->setSurname(ucfirst(strtolower($user->getSurname())));
|
||
|
||
// trim strings
|
||
$user->setName(trim($user->getName()));
|
||
$user->setSurname(trim($user->getSurname()));
|
||
$user->setEmail(trim($user->getEmail()));
|
||
|
||
//FOR SETTING A DEFAULT RANDOM PASSWORD OF 50 CHARACTERS until user set his own password
|
||
$user->setPassword($this->generateRandomPassword());
|
||
|
||
if($picture) {
|
||
$this->handleProfilePicture($user, $picture);
|
||
}
|
||
}
|
||
}
|