diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index b1b290f7..ccf67125 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -6,6 +6,7 @@ framework: session: # With this config, PHP's native session handling is used handler_id: ~ + cookie_lifetime: 20 #expires in 14 days # When using the HTTP Cache, ESI allows to render page fragments separately # and with different cache configurations for each fragment # https://symfony.com/doc/current/book/http_cache.html#edge-side-includes diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 83ccbc1c..d8f41ddc 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -28,6 +28,7 @@ security: - Bolt\Security\LoginFormAuthenticator logout: + handler: Bolt\Security\LogoutListener path: bolt_logout target: bolt_login diff --git a/src/Entity/User.php b/src/Entity/User.php index 9552fdab..3117c8f2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -96,6 +96,11 @@ class User implements UserInterface, \Serializable */ private $disabled = false; + /** + * @ORM\OneToOne(targetEntity="Bolt\Entity\UserAuthToken", mappedBy="user", cascade={"persist", "remove"}) + */ + private $userAuthToken; + public function __construct() { } @@ -290,4 +295,22 @@ class User implements UserInterface, \Serializable return $this; } + + public function getUserAuthToken(): ?UserAuthToken + { + return $this->userAuthToken; + } + + public function setUserAuthToken(?UserAuthToken $userAuthToken): self + { + $this->userAuthToken = $userAuthToken; + + // set (or unset) the owning side of the relation if necessary + $newUser = null === $userAuthToken ? null : $this; + if ($userAuthToken->getUser() !== $newUser) { + $userAuthToken->setUser($newUser); + } + + return $this; + } } diff --git a/src/Entity/UserAuthToken.php b/src/Entity/UserAuthToken.php new file mode 100644 index 00000000..b691c9df --- /dev/null +++ b/src/Entity/UserAuthToken.php @@ -0,0 +1,85 @@ +id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): self + { + $this->user = $user; + + return $this; + } + + public function getUseragent(): ?string + { + return $this->useragent; + } + + public function setUseragent(string $useragent): self + { + $this->useragent = $useragent; + + return $this; + } + + public function getValidity(): ?\DateTimeInterface + { + return $this->validity; + } + + public function setValidity(\DateTimeInterface $validity): self + { + $this->validity = $validity; + + return $this; + } + + public static function factory(User $user, string $useragent, \DateTime $validity): self + { + $userAuthToken = new self(); + + $userAuthToken->setUser($user); + $userAuthToken->setUseragent($useragent); + $userAuthToken->setValidity($validity); + + return $userAuthToken; + } +} diff --git a/src/Repository/UserAuthTokenRepository.php b/src/Repository/UserAuthTokenRepository.php new file mode 100644 index 00000000..61b14281 --- /dev/null +++ b/src/Repository/UserAuthTokenRepository.php @@ -0,0 +1,50 @@ +createQueryBuilder('u') + ->andWhere('u.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('u.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?UserAuthToken + { + return $this->createQueryBuilder('u') + ->andWhere('u.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php index 10a84c59..13dca338 100644 --- a/src/Security/LoginFormAuthenticator.php +++ b/src/Security/LoginFormAuthenticator.php @@ -4,7 +4,11 @@ declare(strict_types=1); namespace Bolt\Security; +use Bolt\Entity\UserAuthToken; use Bolt\Repository\UserRepository; +use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\RouterInterface; @@ -32,12 +36,16 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator /** @var UserPasswordEncoderInterface */ private $passwordEncoder; - public function __construct(UserRepository $userRepository, RouterInterface $router, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder) + /** @var ObjectManager */ + private $em; + + public function __construct(UserRepository $userRepository, RouterInterface $router, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder, ObjectManager $em) { $this->userRepository = $userRepository; $this->router = $router; $this->csrfTokenManager = $csrfTokenManager; $this->passwordEncoder = $passwordEncoder; + $this->em = $em; } protected function getLoginUrl() @@ -84,6 +92,26 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) { + + $user = $token->getUser(); + + if($user->getUserAuthToken()) + { + $this->em->remove($user->getUserAuthToken()); + $this->em->flush(); + } + + $user->setLastseenAt(new \DateTime()); + $user->setLastIp($request->getClientIp()); + $useragent = $request->headers->get('User-Agent'); + $sessionLifetime = $request->getSession()->getMetadataBag()->getLifetime(); + $expirationTime = (new \DateTime())->modify("+".$sessionLifetime." second"); + $userAuthToken = UserAuthToken::factory($user, $useragent, $expirationTime); + $user->setUserAuthToken($userAuthToken); + + $this->em->persist($user); + $this->em->flush(); + return new RedirectResponse($request->getSession()->get( '_security.'.$providerKey.'.target_path', $this->router->generate('bolt_dashboard') diff --git a/src/Security/LogoutListener.php b/src/Security/LogoutListener.php new file mode 100644 index 00000000..774b3c96 --- /dev/null +++ b/src/Security/LogoutListener.php @@ -0,0 +1,28 @@ +em = $em; + } + + public function logout(Request $request, Response $response, TokenInterface $token) + { + $user = $token->getUser(); + $this->em->remove($user->getUserAuthToken()); + $this->em->flush(); + } + +} \ No newline at end of file diff --git a/templates/users/listing.html.twig b/templates/users/listing.html.twig index 8f71b615..0a69c1cc 100644 --- a/templates/users/listing.html.twig +++ b/templates/users/listing.html.twig @@ -33,7 +33,7 @@ {{ iteratedUser.username }} {{ iteratedUser.email }} {{ iteratedUser.roles|join(', ') }} - {{ iteratedUser.lastseenAt|default('-') }} + {{ iteratedUser.lastseenAt|date('Y-m-d H:i:s')|default('-') }} {{ iteratedUser.lastIp|default('12.34.56.78') }} {{ macro.buttonlink('action.edit', path('bolt_user_edit', {'id': iteratedUser.id}), 'edit', 'secondary') }} @@ -54,6 +54,35 @@ {{ macro.buttonlink('action.add_user', path('bolt_user_edit', {'id': 0}), 'user-plus', 'secondary') }}

+

Current sessions

+ + + + + + + + + + + + + {% for iteratedUser in users %} + {% if iteratedUser.getUserAuthToken %} + {% set authtoken = iteratedUser.getUserAuthToken %} + + + + + + + + + {% endif %} + {% endfor %} + +
#{{ 'listing.title_username'|trans }}{{ 'listing.title_last_seen'|trans }}{{ 'listing.title_session_expires'|trans }}{{ 'listing.title_ip_address'|trans }}{{ 'listing.title_browser'|trans }}
{{ iteratedUser.id }}{{ iteratedUser.username }}{{ iteratedUser.lastseenAt|date('Y-m-d H:i:s')|default('-') }}{{ authtoken.validity|date('Y-m-d H:i:s')|default('-') }}{{ iteratedUser.lastIp|default('12.34.56.78') }}{{ authtoken.useragent|default('-') }}
+ {% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 997747c9..403d921f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -1977,5 +1977,23 @@ User has been disabled successfully! + + + listing.title_session_expires + Session expires + + + + + listing.title_ip_address + IP address + + + + + listing.title_browser + Browser / platform + +