<?php
namespace Plugin\JsysAsi42;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\HttpFoundation\RequestStack;
use Doctrine\ORM\EntityManagerInterface;
use Eccube\Entity\Member;
use Eccube\Event\EccubeEvents;
use Eccube\Event\EventArgs;
use Eccube\Event\TemplateEvent;
use Eccube\Repository\MemberRepository;
use Plugin\JsysAsi42\Entity\Config;
use Plugin\JsysAsi42\Entity\JsysAsiLoginHistoryLockStatus;
use Plugin\JsysAsi42\Entity\JsysAsiLoginHistoryStatus;
use Plugin\JsysAsi42\Entity\JsysAsiLoginHistoryTfaStatus;
use Plugin\JsysAsi42\Repository\ConfigRepository;
use Plugin\JsysAsi42\Repository\JsysAsiTfaUserRepository;
use Plugin\JsysAsi42\Repository\JsysAsiTfaOtpHistoryRepository;
use Plugin\JsysAsi42\Repository\JsysAsiLoginHistoryRepository;
use Plugin\JsysAsi42\Service\JsysAsiTfaService;
use Plugin\JsysAsi42\Service\JsysAsiNotificationService;
use Plugin\JsysAsi42\Service\JsysAsiLockService;
use Doctrine\ORM\Query\Expr\Join;
use Eccube\Request\Context;
use Plugin\JsysAsi42\Security\Http\Authenticator\Passport\Badge\JsysAsiAuthBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Twig\Environment;
class Event implements EventSubscriberInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
/**
* @var Environment
*/
private $twig;
/**
* @var RequestStack
*/
private $requestStack;
/**
* @var Context
*/
private $requestContext;
/**
* @var JsysAsiTfaService
*/
private $tfaService;
/**
* @var JsysAsiLockService
*/
private $lockService;
/**
* @var JsysAsiNotificationService
*/
private $notifyService;
/**
* @var MemberRepository
*/
private $memberRepo;
/**
* @var JsysAsiTfaUserRepository
*/
private $tfaUserRepo;
/**
* @var JsysAsiTfaOtpHistoryRepository
*/
private $otpHistoryRepo;
/**
* @var JsysAsiLoginHistoryRepository
*/
private $loginHistoryRepo;
/**
* @var Config
*/
private $Config;
/**
* JsysAsi Event constructor.
* @param EntityManagerInterface $entityManager
* @param Environment $twig
* @param RequestStack $requestStack
* @param Context $requestContext
* @param JsysAsiTfaService $tfaService
* @param JsysAsiLockService $lockService
* @param JsysAsiNotificationService $notifyService
* @param ConfigRepository $configRepo
* @param MemberRepository $memberRepo
* @param JsysAsiTfaUserRepository $tfaUserRepo
* @param JsysAsiTfaOtpHistoryRepository $otpHistoryRepo
* @param JsysAsiLoginHistoryRepository $loginHistoryRepo
*/
public function __construct(
EntityManagerInterface $entityManager,
Environment $twig,
RequestStack $requestStack,
Context $requestContext,
JsysAsiTfaService $tfaService,
JsysAsiLockService $lockService,
JsysAsiNotificationService $notifyService,
ConfigRepository $configRepo,
MemberRepository $memberRepo,
JsysAsiTfaUserRepository $tfaUserRepo,
JsysAsiTfaOtpHistoryRepository $otpHistoryRepo,
JsysAsiLoginHistoryRepository $loginHistoryRepo
) {
$this->entityManager = $entityManager;
$this->twig = $twig;
$this->requestStack = $requestStack;
$this->requestContext = $requestContext;
$this->tfaService = $tfaService;
$this->lockService = $lockService;
$this->notifyService = $notifyService;
$this->memberRepo = $memberRepo;
$this->tfaUserRepo = $tfaUserRepo;
$this->otpHistoryRepo = $otpHistoryRepo;
$this->loginHistoryRepo = $loginHistoryRepo;
$this->Config = $configRepo->get();
}
/**
* @return array
*/
public static function getSubscribedEvents()
{
return [
CheckPassportEvent::class => ['onCheckPassport', -10],
LoginSuccessEvent::class => 'onLoginSuccess',
LoginFailureEvent::class => 'onLoginFailure',
EccubeEvents::ADMIN_SETTING_SYSTEM_MEMBER_DELETE_COMPLETE => 'onAdminMemberDeleteComplete',
'@admin/login.twig' => 'onAdminRenderLogin',
];
}
/**
* パスポートチェック
* 優先順位の指定によりログイン成功判定後に実行、ログイン失敗時は実行されない
*
* @param CheckPassportEvent $event
* @return void
*/
public function onCheckPassport(CheckPassportEvent $event)
{
// 管理画面のみ実行
if (!$this->requestContext->isAdmin()) {
return;
}
// ログインユーザーを取得
$passport = $event->getPassport();
$user = null;
if ($passport->hasBadge(UserBadge::class)) {
/** @var UserBadge $badge */
$badge = $passport->getBadge(UserBadge::class);
$user = $badge->getUser();
}
if (!$user instanceof Member) {
return;
}
// リクエストからパラメータ取得
$request = $this->requestStack->getMainRequest();
$ip = $request->getClientIp();
$code = $request->request->get('jsys_asi_otp');
// 2要素認証コード・IPアドレスロックをチェックし結果ステータスを取得
$tfa_status = $this->getTfaStatusCode($user, $code);
$lock_status = $this->getLockStatusCode($ip);
// チェックに失敗した場合はステータスに拒否を代入
$status = JsysAsiLoginHistoryStatus::SUCCESS;
if (
JsysAsiLoginHistoryTfaStatus::USED == $tfa_status
|| JsysAsiLoginHistoryTfaStatus::INVALID == $tfa_status
|| JsysAsiLoginHistoryLockStatus::LOCKED == $lock_status
) {
$status = JsysAsiLoginHistoryStatus::DENY;
}
// バッジを作成、ステータスが成功の場合は解決済にする
$badge = new JsysAsiAuthBadge($status, $tfa_status, $lock_status);
if (JsysAsiLoginHistoryStatus::SUCCESS == $status) {
$badge->markResolved();
}
// パスポートへ設定
$passport->addBadge($badge);
// ステータスが成功以外の場合は認証失敗例外を発生
if (JsysAsiLoginHistoryStatus::SUCCESS != $status) {
throw new CustomUserMessageAuthenticationException('Invalid credentials.');
}
}
/**
* ログイン成功
*
* @param LoginSuccessEvent $event
* @return void
*/
public function onLoginSuccess(LoginSuccessEvent $event)
{
// 管理画面のみ実行
if (!$this->requestContext->isAdmin()) {
return;
}
// ログインユーザーを取得
$passport = $event->getPassport();
$user = null;
if ($passport->hasBadge(UserBadge::class)) {
/** @var UserBadge $badge */
$badge = $passport->getBadge(UserBadge::class);
$user = $badge->getUser();
}
if (!$user instanceof Member) {
return;
}
// 認証バッジが無ければ終了
if (!$passport->hasBadge(JsysAsiAuthBadge::class)) {
return;
}
/** @var JsysAsiAuthBadge $auth_badge */
$auth_badge = $passport->getBadge(JsysAsiAuthBadge::class);
// ログイン成功をログイン履歴へ保存
$this->loginHistoryRepo->saveHistory(
$user->getLoginId(),
$user->getLoginDate(),
$auth_badge->getStatus(),
$auth_badge->getTfaStatus(),
$auth_badge->getLockStatus()
);
// ログイン成功通知が有効ならログイン成功メールを送信
if ($this->Config->getOptionLoginSuccessMail()) {
$this->notifyService->sendLoginSuccessMail($user);
}
}
/**
* ログイン失敗
*
* @param LoginFailureEvent $event
* @return void
*/
public function onLoginFailure(LoginFailureEvent $event)
{
// 管理画面のみ実行
if (!$this->requestContext->isAdmin()) {
return;
}
// リクエストからパラメータ取得
$request = $this->requestStack->getMainRequest();
$ip = $request->getClientIp();
$code = $request->request->get('jsys_asi_otp');
// ログインIDとログインIDと一致するユーザーを取得
$passport = $event->getPassport();
$login_id = '';
$user = null;
if ($passport->hasBadge(UserBadge::class)) {
/** @var UserBadge $badge */
$badge = $passport->getBadge(UserBadge::class);
$login_id = $badge->getUserIdentifier();
/** @var Member $user */
$user = $this->memberRepo->findOneBy(['login_id' => $login_id]);
}
// 認証バッジがあればバッジから結果を取得、無ければチェックして結果を取得
$status = JsysAsiLoginHistoryStatus::FAILURE;
$tfa_status = JsysAsiLoginHistoryTfaStatus::DISABLED;
$lock_status = JsysAsiLoginHistoryLockStatus::DISABLED;
if ($passport->hasBadge(JsysAsiAuthBadge::class)) {
/** @var JsysAsiAuthBadge $auth_badge */
$auth_badge = $passport->getBadge(JsysAsiAuthBadge::class);
$status = $auth_badge->getStatus();
$tfa_status = $auth_badge->getTfaStatus();
$lock_status = $auth_badge->getLockStatus();
} else {
// 2要素認証コードとIPアドレスロックをチェック
if ($user) {
$tfa_status = $this->getTfaStatusCode($user, $code);
}
$lock_status = $this->getLockStatusCode($ip);
}
// 2要素とロックチェックに問題があればlog_infoで保存
$msg = 'JsysAsi 管理 ログイン' . JsysAsiLoginHistoryStatus::DENY ? '拒否' : '失敗';
if (
JsysAsiLoginHistoryTfaStatus::USED == $tfa_status
|| JsysAsiLoginHistoryTfaStatus::INVALID == $tfa_status
) {
log_info($msg . ' 不正認証コード', [
'member_id' => $user->getId(),
'code' => $code,
]);
}
if (JsysAsiLoginHistoryLockStatus::LOCKED == $lock_status) {
log_info($msg . ' ロック済みIPアドレス', [
'login_id' => $login_id,
'ip_address' => $ip,
]);
}
// ログイン失敗・拒否をログイン履歴へ保存
$this->loginHistoryRepo->saveHistory(
$login_id,
new \DateTime(),
$status,
$tfa_status,
$lock_status
);
// ログインに連続で失敗している場合はIPアドレスをロック
if($this->lockService->shouldLockIpAddress($ip)) {
$this->lockService->lockIpAddress($ip);
}
// ログイン失敗通知が有効ならログイン失敗メールを送信
if ($this->Config->getOptionLoginFailureMail()) {
$this->notifyService->sendLoginFailureMail($login_id);
}
}
/**
* 管理 > 設定 > システム設定 > メンバー管理 > 削除
* - 2要素認証ユーザーとOTP履歴を削除
* @param EventArgs $event
*/
public function onAdminMemberDeleteComplete(EventArgs $event)
{
// メンバーが存在しない2要素認証ユーザーを全て取得
$tfaUsers = $this->tfaUserRepo->createQueryBuilder('u')
->select()
->leftJoin(Member::class, 'm', Join::WITH, 'm.id = u.member_id')
->where('m.id IS NULL')
->getQuery()
->getResult();
// 削除されたメンバーのOTP履歴と2要素認証ユーザーを削除
foreach ($tfaUsers as $tfaUser) {
$this->otpHistoryRepo->deleteByTfaUser($tfaUser);
$this->entityManager->remove($tfaUser);
$this->entityManager->flush($tfaUser);
log_info('JsysAsi 2要素認証ユーザー 削除', [ 'JsysAsiTfaUser' => $tfaUser]);
}
}
/**
* 管理 > ログイン画面
* - 2要素認証有効時にOTPを追加
* @param TemplateEvent $event
*/
public function onAdminRenderLogin(TemplateEvent $event)
{
// 2要素認証が有効なら認証コードを画面に追加
if ($this->Config->getOptionTfa()) {
$this->addOtpTwigToAdminLogin($event);
}
}
/**
* 管理ログイン画面に認証コードを追加します。
* @param TemplateEvent $event
*/
private function addOtpTwigToAdminLogin(TemplateEvent $event)
{
/*
* admin/login.twigにsnippet.twigのincludeがないため、ソースへ直接追加
*/
$tpl = '@JsysAsi42/admin/login_ext_otp.twig';
$otp = $this->twig->getLoader()->getSourceContext($tpl)->getCode();
$search = '{% if error %}';
$event->setSource(str_replace($search, $otp . $search, $event->getSource()));
}
/**
* 2要素認証コードをチェックし、結果コードを取得します。
*
* @param Member $user
* @param string $code
* @return int
*/
private function getTfaStatusCode($user, $code): int
{
// 2要素認証機能無効
if (!$this->Config->getOptionTfa()) {
return JsysAsiLoginHistoryTfaStatus::DISABLED;
}
// 有効なコード
if ($this->tfaService->isValidCode($user, $code)) {
return JsysAsiLoginHistoryTfaStatus::VALID;
}
// 使用済みコード または 無効なコード
$result = $this->tfaService->getTfaResult();
if (JsysAsiTfaService::RESULT_TFA_USED == $result) {
return JsysAsiLoginHistoryTfaStatus::USED;
}
return JsysAsiLoginHistoryTfaStatus::INVALID;
}
/**
* IPアドレスロックをチェックし、結果コードを取得します。
*
* @param string $ip
* @return int
*/
private function getLockStatusCode($ip): int
{
// IPアドレスロック機能無効
if (!$this->Config->getOptionIpAddressLock()) {
return JsysAsiLoginHistoryLockStatus::DISABLED;
}
// ロック済み
if ($this->lockService->isLockedIpAddress($ip)) {
return JsysAsiLoginHistoryLockStatus::LOCKED;
}
// 未ロック
return JsysAsiLoginHistoryLockStatus::UNLOCKED;
}
}