Skip to content

Commit 2234a30

Browse files
authored
Merge pull request #313 from uzulla/issue292/mfa-email
メールによる多要素認証(MFA)の実装
2 parents 2dac5a6 + 3560cf1 commit 2234a30

36 files changed

+577
-107
lines changed

app/config.sample.php

+5
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@
2121
// Please edit the path when change `app` and `public` relative path condition.
2222
define('WWW_DIR', __DIR__ . '/../public/'); // this path need finish with slash.
2323

24+
// パスワード再発行機能などで送信されるメールのFROMとするメールアドレス
2425
define("ADMIN_MAIL_ADDRESS", "[email protected]");
26+
2527
// メールが送信出来ない環境で、パスワードリセットする場合にのみ1を設定してください。
2628
// パスワードリセットが成功した後は必ず "1" 以外の "0" 等の値に変更してください。
2729
define("EMERGENCY_PASSWORD_RESET_ENABLE", "0");
2830

31+
// ログイン時にメール認証を有効化するか
32+
define("MFA_EMAIL", "0");
33+
2934
// If you want get error log on display.
3035
// define('ERROR_ON_DISPLAY', "1");
3136
// ini_set('display_errors', '1');

app/config_read_from_env.php

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
}
5252
define("SENDMAIL_PATH", (string)getenv("FC2_SENDMAIL_PATH"));
5353
define("EMERGENCY_PASSWORD_RESET_ENABLE", (string)getenv("FC2_EMERGENCY_PASSWORD_RESET_ENABLE"));
54+
define("MFA_EMAIL", (string)getenv("FC2_MFA_EMAIL"));
5455

5556
if (strlen((string)getenv("FC2_GITHUB_REPO")) > 0) {
5657
define("GITHUB_REPO", (string)getenv("FC2_GITHUB_REPO"));

app/db/0_initialize.sql

+22
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,28 @@ CREATE TABLE `comments`
184184
DEFAULT CHARSET = utf8mb4;
185185
/*!40101 SET character_set_client = @saved_cs_client */;
186186

187+
--
188+
-- Table structure for table `email_login_token`
189+
--
190+
191+
DROP TABLE IF EXISTS `email_login_token`;
192+
/*!40101 SET @saved_cs_client = @@character_set_client */;
193+
/*!40101 SET character_set_client = utf8mb4 */;
194+
CREATE TABLE `email_login_token`
195+
(
196+
`id` bigint(20) NOT NULL AUTO_INCREMENT,
197+
`user_id` int(11) NOT NULL,
198+
`token` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
199+
`expire_at` datetime NOT NULL,
200+
`updated_at` datetime NOT NULL,
201+
`created_at` datetime NOT NULL,
202+
PRIMARY KEY (`id`),
203+
UNIQUE KEY `email_login_token_token_uindex` (`token`)
204+
) ENGINE = InnoDB
205+
DEFAULT CHARSET = utf8mb4
206+
COLLATE = utf8mb4_unicode_ci;
207+
/*!40101 SET character_set_client = @saved_cs_client */;
208+
187209
--
188210
-- Table structure for table `entries`
189211
--
885 Bytes
Binary file not shown.

app/locale/en_US.UTF-8/LC_MESSAGES/messages.po

+20
Original file line numberDiff line numberDiff line change
@@ -2591,3 +2591,23 @@ msgstr ""
25912591
msgid "You can change your login password at the following URL."
25922592
msgstr ""
25932593

2594+
msgid "Sent login mail."
2595+
msgstr "ログインURLをメールしました"
2596+
2597+
msgid "Sent one time login url to your mail address. Please check your mailbox."
2598+
msgstr "一度だけ利用可能なログインURLが記載されたメールを送信しました、メールを確認してください。"
2599+
2600+
msgid "[Fc2blog OSS] Your login url"
2601+
msgstr "[Fc2blog OSS] ログインURLのおしらせ"
2602+
2603+
msgid "You can login by the following URL."
2604+
msgstr "以下のURLにアクセスし、ログインしてください。"
2605+
2606+
msgid "You requested site login."
2607+
msgstr "ログイン要求を受け付けました。"
2608+
2609+
msgid "Login request expired."
2610+
msgstr "ログインURLは無効です"
2611+
2612+
msgid "Login request was expired. Please try again login form."
2613+
msgstr "このログインURLは無効です、再度ログインフォームから要求してください。"
877 Bytes
Binary file not shown.

app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.po

+20
Original file line numberDiff line numberDiff line change
@@ -2638,3 +2638,23 @@ msgstr "※ もしこのメールに心当たりがない場合には、無視
26382638
msgid "You can change your login password at the following URL."
26392639
msgstr "以下のURLにアクセスして、パスワードを再設定してください。"
26402640

2641+
msgid "Sent login mail."
2642+
msgstr "ログインURLをメールしました"
2643+
2644+
msgid "Sent one time login url to your mail address. Please check your mailbox."
2645+
msgstr "一度だけ利用可能なログインURLが記載されたメールを送信しました、メールを確認してください。"
2646+
2647+
msgid "[Fc2blog OSS] Your login url"
2648+
msgstr "[Fc2blog OSS] ログインURLのおしらせ"
2649+
2650+
msgid "You can login by the following URL."
2651+
msgstr "以下のURLにアクセスし、ログインしてください。"
2652+
2653+
msgid "You requested site login."
2654+
msgstr "ログイン要求を受け付けました。"
2655+
2656+
msgid "Login request expired."
2657+
msgstr "ログインURLは無効です"
2658+
2659+
msgid "Login request was expired. Please try again login form."
2660+
msgstr "このログインURLは無効です、再度ログインフォームから要求してください。"

app/src/Model/EmailLoginToken.php

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Fc2blog\Model;
5+
6+
use Fc2blog\App;
7+
use Fc2blog\Web\Html;
8+
use Fc2blog\Web\Request;
9+
10+
class EmailLoginToken
11+
{
12+
public $id;
13+
public $user_id;
14+
public $token;
15+
public $expire_at;
16+
public $updated_at;
17+
public $created_at;
18+
19+
public static function factoryWithUser(User $user): self
20+
{
21+
$self = new static();
22+
$self->user_id = $user->id;
23+
$self->token = App::genRandomString();
24+
$self->expire_at = date("Y-m-d H:i:s", time() + 60 * 60 * 24);
25+
$self->updated_at = date("Y-m-d H:i:s");
26+
$self->created_at = date("Y-m-d H:i:s");
27+
return $self;
28+
}
29+
30+
public static function factoryFromArray(array $list): self
31+
{
32+
$self = new static();
33+
$self->id = $list['id'];
34+
$self->user_id = $list['user_id'];
35+
$self->token = $list['token'];
36+
$self->expire_at = $list['expire_at'];
37+
$self->updated_at = $list['updated_at'];
38+
$self->created_at = $list['created_at'];
39+
return $self;
40+
}
41+
42+
public function isExpired(): bool
43+
{
44+
return strtotime($this->expire_at) < time();
45+
}
46+
47+
public function getLoginUrl(Request $request): string
48+
{
49+
return Html::adminUrl(
50+
$request,
51+
'Session',
52+
'mailLogin',
53+
['token' => $this->token],
54+
true
55+
);
56+
}
57+
}
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Fc2blog\Model;
5+
6+
class EmailLoginTokenModel extends Model
7+
{
8+
public function getTableName(): string
9+
{
10+
return 'email_login_token';
11+
}
12+
13+
/**
14+
* バリデート処理
15+
* @param array $data
16+
* @param array|null $valid_data
17+
* @param array $white_list
18+
* @return array
19+
*/
20+
public function validate(array $data, ?array &$valid_data = [], array $white_list = []): array
21+
{
22+
// バリデートを定義
23+
$this->validates = array(
24+
'login_id' => array(
25+
'required' => true,
26+
'maxlength' => array('max' => 512),
27+
),
28+
);
29+
30+
return parent::validate($data, $valid_data, $white_list);
31+
}
32+
33+
/**
34+
* @param array $values
35+
* @param array $options
36+
* @return int|null last insert id or null
37+
*/
38+
public function insert(array $values, array $options = []): ?int
39+
{
40+
unset($values['id']); // insertのため、pkを削除
41+
$values['updated_at'] = $values['created_at'] = date('Y-m-d H:i:s');
42+
$last_insert_id = parent::insert($values, $options);
43+
return $last_insert_id === false ? null : (int)$last_insert_id;
44+
}
45+
}
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Fc2blog\Model;
5+
6+
use Fc2blog\Config;
7+
use Fc2blog\Service\MailService;
8+
use Fc2blog\Service\TwigService;
9+
use Fc2blog\Web\Request;
10+
use RuntimeException;
11+
12+
class EmailLoginTokenService
13+
{
14+
public static function create(EmailLoginToken $token): ?int
15+
{
16+
$pr_model = new EmailLoginTokenModel();
17+
return $pr_model->insert((array)$token);
18+
}
19+
20+
public static function getByToken(string $token): ?EmailLoginToken
21+
{
22+
$pr_model = new EmailLoginTokenModel();
23+
$result = $pr_model->find('row', [
24+
'where' => 'token = :token',
25+
'params' => ['token' => $token]
26+
]);
27+
28+
if ($result === false) return null;
29+
30+
return EmailLoginToken::factoryFromArray($result);
31+
}
32+
33+
public static function revokeToken(EmailLoginToken $token): void
34+
{
35+
$pr_model = new EmailLoginTokenModel();
36+
$pr_model->deleteById($token->id);
37+
}
38+
39+
public static function createAndSendToken(Request $request, User $user): bool
40+
{
41+
// create token
42+
$token = EmailLoginToken::factoryWithUser($user);
43+
if (is_null(static::create($token))) {
44+
throw new RuntimeException("create password reset token failed.");
45+
}
46+
47+
// mail sending.
48+
$login_url = $token->getLoginUrl($request);
49+
50+
$email = new Email();
51+
$email->setFrom(
52+
Config::get("ADMIN_MAIL_ADDRESS"),
53+
"Fc2blog OSS"
54+
);
55+
$email->setTo($user->login_id);
56+
$email->setSubjectAndBodyByTwig(
57+
TwigService::getTwigInstance(),
58+
'mail/email_login.twig',
59+
['url' => $login_url]
60+
);
61+
return MailService::send($email);
62+
}
63+
64+
// TODO clean up token.
65+
}

app/src/Model/Fc2TemplatesModel.php

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public function getListAndPaging(array $condition)
3838
$url .= '&device=' . $condition['device'];
3939
}
4040

41+
// TODO offline mode
4142
$json = file_get_contents($url);
4243
$json = json_decode($json, true);
4344

@@ -74,6 +75,7 @@ public function findByIdAndDevice(string $id, string $device)
7475
{
7576
$url = 'https://admin.blog.fc2.com/oss_api.php?action=template_view&id=' . $id . '&device=' . $device;
7677

78+
// TODO Offline mode
7779
$json = file_get_contents($url);
7880
$json = json_decode($json, true);
7981
$json['id'] = $id;

app/src/Model/User.php

+47-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@
33

44
namespace Fc2blog\Model;
55

6-
class User
6+
use ArrayAccess;
7+
use ArrayIterator;
8+
use Countable;
9+
use Iterator;
10+
use IteratorAggregate;
11+
use ReflectionClass;
12+
use ReflectionException;
13+
14+
class User implements ArrayAccess, IteratorAggregate, Countable
715
{
816
public $id;
917
public $login_id;
@@ -44,4 +52,42 @@ public function verifyPassword(string $input_password): bool
4452
return password_verify($input_password, $this->password);
4553
}
4654

55+
// ==== for array access ====
56+
public function offsetExists($offset): bool
57+
{
58+
return isset($this->{$offset});
59+
}
60+
61+
public function offsetGet($offset)
62+
{
63+
return $this->offsetExists($offset) ? $this->{$offset} : null;
64+
}
65+
66+
public function offsetSet($offset, $value)
67+
{
68+
$r = new ReflectionClass(static::class);
69+
try {
70+
$prop = $r->getProperty($offset);
71+
if ($prop->isPublic()) {
72+
$this->{$prop->getName()} = $value;
73+
}
74+
} catch (ReflectionException $e) {
75+
}
76+
}
77+
78+
public function offsetUnset($offset)
79+
{
80+
// don't un-settable
81+
}
82+
83+
public function getIterator(): Iterator
84+
{
85+
return new ArrayIterator((array)$this);
86+
}
87+
88+
public function count(): int
89+
{
90+
$r = new ReflectionClass(static::class);
91+
return count($r->getProperties());
92+
}
4793
}

app/src/Service/UserService.php

+7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ public static function getByLoginId(string $login_id): ?User
1414
return User::factory($repo->findByLoginId($login_id));
1515
}
1616

17+
public static function getByLoginIdAndPassword(string $login_id, string $password): ?User
18+
{
19+
$repo = new UsersModel();
20+
$user = User::factory($repo->findByLoginId($login_id));
21+
return !is_null($user) && $user->verifyPassword($password) ? $user : null;
22+
}
23+
1724
public static function updatePassword(User $user, string $password): bool
1825
{
1926
$user->setPassword($password);

0 commit comments

Comments
 (0)