<?php
namespace Platform\SecurityBundle\Service\Firewalls;
use Cms\CoreBundle\Util\Doctrine\EntityManager;
use Cms\FrontendBundle\Service\FrontendBuilder;
use Cms\TenantBundle\Entity\Tenant;
use Cms\TenantBundle\Model\ProductsBitwise;
use Platform\SecurityBundle\Entity\Identity\Account;
use Platform\SecurityBundle\Entity\Login\Attempt;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\Event\LogoutEvent;
/**
* TODO: have this registered to the dashboard firewalls dispatcher...
* Handles various security event handling relating to the "dashboard" firewall specifically.
*/
final class DashboardSecuritySubscriber implements EventSubscriberInterface
{
/**
* @var ParameterBagInterface
*/
private ParameterBagInterface $params;
/**
* @var EntityManager
*/
private EntityManager $em;
/**
* @var RouterInterface
*/
private RouterInterface $router;
/**
* @param ParameterBagInterface $params
* @param EntityManager $em
* @param RouterInterface $router
*/
public function __construct(
ParameterBagInterface $params,
EntityManager $em,
RouterInterface $router
)
{
$this->params = $params;
$this->em = $em;
$this->router = $router;
}
/**
* {@inheritDoc}
*/
public static function getSubscribedEvents(): array
{
return [
AuthenticationSuccessEvent::class => [
['onAuthenticationSuccessCheckSystemStatus', 0],
['onAuthenticationSuccessCheckTenantStatus', 0],
['onAuthenticationSuccessCheckAttempts', 0],
['onAuthenticationSuccessCheckUserStatus', 0],
],
LoginFailureEvent::class => [
['onLoginFailureRedirectToLogin', 0],
],
LoginSuccessEvent::class => [
['onLoginSuccessLastLoggedInUpdate', 0],
['onLoginSuccessRedirection', 0],
],
LogoutEvent::class => [
['onLogout', 0],
],
];
}
/**
* Handles checking whether logins for the entire system are enabled/disabled.
* This is reserved for future use; there may be times when we need to ensure people cannot log in to the system.
*
* @param AuthenticationSuccessEvent $event
* @return void
*/
public function onAuthenticationSuccessCheckSystemStatus(AuthenticationSuccessEvent $event): void
{
// get the user
$user = $event->getAuthenticationToken()->getUser();
if ( ! $user instanceof Account) {
return;
}
// allow csadmin to continue to log in while in this state
if ($user->getEmail() === $this->params->get('app.security.csadmin.username')) {
return;
}
// NOOP: reserved for future use...
if (false) {
throw new AuthenticationException(
'Logins to the system are currently disabled.',
);
}
}
/**
* Checks that the tenant attached to the user is in an operable status.
*
* @param AuthenticationSuccessEvent $event
* @return void
*/
public function onAuthenticationSuccessCheckTenantStatus(AuthenticationSuccessEvent $event): void
{
// get the user
$user = $event->getAuthenticationToken()->getUser();
if ( ! $user instanceof Account) {
return;
}
// allow csadmin to continue to log in while in this state
if ($user->getEmail() === $this->params->get('app.security.csadmin.username')) {
return;
}
// tenant must be marked as "ok" in order for logins to continue to function
if ($user->getTenant()->getStatus() !== Tenant::STATUS__OK) {
throw new AuthenticationException(
'Logins to this instance are currently disabled.',
);
}
}
/**
* TODO: should probably add a tenant setting/property to control this check, in the events when customers goof and need to get in...
* Checks whether too many failed logins have been attempted for the given instance.
*
* @param AuthenticationSuccessEvent $event
* @return void
*/
public function onAuthenticationSuccessCheckAttempts(AuthenticationSuccessEvent $event): void
{
// get the user
$user = $event->getAuthenticationToken()->getUser();
if ( ! $user instanceof Account) {
return;
}
// allow csadmin to continue to log in while in this state
if ($user->getEmail() === $this->params->get('app.security.csadmin.username')) {
return;
}
// count how many failed logins for this customer in the last bit of time
$count = $this->em->getRepository(Attempt::class)->countLastHourFailed(
$user->getTenant(),
);
// check if the account exceeds our threshold
if ($count > 250) {
throw new AuthenticationException(
'Logins for this instance are currently disabled.',
);
}
}
/**
* Checks that the user is a proper, active status when logging in.
*
* @param AuthenticationSuccessEvent $event
* @return void
*/
public function onAuthenticationSuccessCheckUserStatus(AuthenticationSuccessEvent $event): void
{
// get the user
$user = $event->getAuthenticationToken()->getUser();
if ( ! $user instanceof Account) {
return;
}
// if the account is not active, we need to reject the login
if ( ! $user->isActive()) {
throw new AuthenticationException(
'Logins to this account are currently disabled.',
);
}
}
/**
* @param LoginFailureEvent $event
* @return void
*/
public function onLoginFailureRedirectToLogin(LoginFailureEvent $event): void
{
// ensure proper firewall
if ($event->getFirewallName() !== 'dashboard') {
return;
}
// redirect to the login selection page
// TODO: is this necessary; might be a setting in the yaml that can take care of this...
$event->setResponse(
new RedirectResponse(
$this->router->generate('platform.security.login.default.select'),
),
);
}
/**
* @param LoginSuccessEvent $event
* @return void
*/
public function onLoginSuccessLastLoggedInUpdate(LoginSuccessEvent $event): void
{
// ensure proper firewall
if ($event->getFirewallName() !== 'dashboard') {
return;
}
// get the user
$user = $event->getUser();
if ( ! $user instanceof Account) {
throw new \RuntimeException();
}
// mark the user with the latest timestamp
$this->em->getRepository(Account::class)->updateLastLoginTimestamp(
$user,
);
// refresh from the DB the current $user object
$this->em->refresh($user);
}
/**
* @param LoginSuccessEvent $event
* @return void
*/
public function onLoginSuccessRedirection(LoginSuccessEvent $event): void
{
// checks we need to run
// NOTE: order matters!
static $checks = [
ProductsBitwise::SITES__BASE => null,
ProductsBitwise::NOTIFICATIONS__V2 => 'app.notifications.dashboard.default.main',
ProductsBitwise::ACCESSIBILITY__CONTENT => 'products.ada.dashboard.default.main',
ProductsBitwise::ACCESSIBILITY__PDFS => 'products.ada.dashboard.default.main',
ProductsBitwise::SMM__BASE => 'products.smm.dashboard.default.main',
ProductsBitwise::APP__CAMPUSSUITE => 'products.app.dashboard.default.main',
ProductsBitwise::APP__SIA => 'products.app.dashboard.default.main',
];
// ensure proper firewall
if ($event->getFirewallName() !== 'dashboard') {
return;
}
// get the user
$user = $event->getUser();
if ( ! $user instanceof Account) {
throw new \RuntimeException();
}
// first see if we have a redirect stored in the passport (in case it was set via oauth or something)
$passport = $event->getPassport();
$redirect = null;
if ($passport instanceof Passport) {
$redirect = $passport->getAttribute('oauth_redirect');
}
// see if we have a redirect given in the authentication flow, if not already given
$redirect = $redirect ?: $event->getRequest()->query->get('redirect');
// determine where to go to, either redirect or dashboard
if ($redirect === null || $redirect === '' || $redirect === '/') {
// single out customers that have single-product instances to jump to those specific products
foreach ($checks as $product => $route) {
if ($user->getTenant()->getProducts()->checkAnyFlag($product)) {
$redirect = $route
? $this->router->generate($route)
: null;
break;
}
}
// if we still don't have a redirect, determine the best place to put them
if (empty($redirect)) {
switch (true) {
case $user->getTenant()->getProducts()->hasFlag(ProductsBitwise::SCHOOLNOW__MIGRATED):
$redirect = $this->router->generate('app.schoolnow.dashboard.default.main');
break;
default:
$redirect = $this->router->generate('cms.container.dashboard.dashboard.index');
}
}
}
// force https, should always be redirecting to a dashboard url anyway
$pos = strpos($redirect, 'http://');
if ($pos === 0) {
$redirect = substr_replace($redirect, 'https://', $pos, strlen('http://'));
}
// generate redirect to the dashboard
$response = new RedirectResponse($redirect);
// set user uid cookie
// TODO: is this necessary anymore?
$response->headers->setCookie(new Cookie(
FrontendBuilder::ACCOUNT_UID_KEY,
$user->getUid()->toString(),
0, '/', null, false, false
));
// set the response on the event
$event->setResponse($response);
}
/**
* @param LogoutEvent $event
* @return void
*/
public function onLogout(LogoutEvent $event): void
{
if ($event->getRequest() && $event->getRequest()->getSession()) {
$event->getRequest()->getSession()->invalidate();
}
}
}