現時点では自分用の備忘録です。
ちょっと定時過ぎてるしさっさと帰りたいんでソースコード丸々掲載します。
現時点では、親クラスのメソッドに依存している記述が多く、
他の方が見てもなんやこれ状態だと思いますので、
そのうち、ちゃんとまとめたいと思ってます(思ってるだけ)
要件
- メアドとパスワードで管理画面にログインする(ログイン機能の記述は割愛します)
- パスワードを忘れた場合、専用フォームにメアドを入力することで、再設定リンクつきのメールを送信
- 再設定リンクの有効期限はメール送信から1時間以内
- 再設定ページで、新しいパスワードを入力することで、パスワードを更新、管理画面にログインする
DB
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`label` varchar(255) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE (email)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `password_resets` (
`email` varchar(255) NOT NULL,
`token` varchar(255) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
KEY (`email`, `token`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
Config
application/configs/auth.php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
// @see https://qiita.com/Go-Noji/items/6a8e09e66b3f4857266e
$config['salt'] = 'h6F4htejW69moG6gndjplC12'; // なんでもいい
$config['auth_time'] = 3600; // 3600秒=1時間
Controller
application/controllers/Auth.php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* @see application/classes/Admin_Controller.php
*/
class Auth extends Admin_Controller
{
private $columnNameUserName = 'email';
private $columnNameUserPassword = 'password';
public function __construct()
{
parent::__construct();
$this->loadModel('users');
$this->loadModel('password_resets');
$this->config->load('auth');
}
/**
* パスワードを忘れた方はこちらページ
*/
public function password_send()
{
$this->view_data = $this->viewVariablesBase();
if($this->isVerified()){
$this->setInfoMessage("既にログイン済みです");
$this->redirectToDashBoard();
}
if($this->isGetMethod()){
return $this->displayView($this->view_data);
}
else if($this->isPostMethod()){
return $this->passwordSendAction();
}
}
/**
* パスワード再設定メールを送りましたページ
*/
public function password_sent()
{
$this->view_data = $this->viewVariablesBase();
if($this->isVerified()){
$this->setInfoMessage("既にログイン済みです");
$this->redirectToDashBoard();
}
return $this->displayView($this->view_data);
}
/**
* パスワード再設定ページ
*/
public function password_reset()
{
$this->view_data = $this->viewVariablesBase();
if($this->isVerified()){
$this->setInfoMessage("既にログイン済みです");
$this->redirectToDashBoard();
}
if($this->isGetMethod()){
$currentToken = $this->input->get('token');
if($this->isValidEmailToken($currentToken) === FALSE){
$this->setDangerMessage("アクセストークンが正しくないか失効しています。\nお手数ですが、操作をやり直してください");
return $this->redirectToLoginPage();
}
$this->view_data['token'] = $this->token;
return $this->displayView($this->view_data);
}
else if($this->isPostMethod()){
$currentToken = $this->input->post('token');
if($this->isValidEmailToken($currentToken) === FALSE){
$this->setDangerMessage("アクセストークンが正しくないか失効しています。\nお手数ですが、操作をやり直してください");
return $this->redirectToLoginPage();
}
// 有効期限切れのアクセストークンをついでに削除
$this->deleteExpiredAccessTokens();
return $this->passwordResetAction();
}
}
private function passwordSendAction()
{
// 基本的な入力バリデーションのチェック(DB接続なし) @see application/rules/admin/auth.php
if(! $this->form_validation->run('password_send')){
return $this->redirectBackWithInput();
}
$email = $this->input->post('email');
$user = $this->getUserInfoByEmail($email);
// メールアドレスが存在する場合
if($user){
$token = $this->createAccessToken($user->email, $user->password, time());
$this->storeAccessToken($user->email, $token);
$this->sendMailPasswordReset($user, $token);
}
return $this->redirectToPasswordSentPage();
}
private function getUserData($input)
{
$userName = $this->columnNameUserName;
$item = $this->users->findBy($userName, $input[$userName]);
return $item;
}
private function setUserInfo($item)
{
$this->session->is_verified = TRUE;
$userInfo = array(
'id' => $item->id,
'label' => $item->label,
);
$this->session->set_userdata(parent::SESSION_NAME_USERINFO, $userInfo);
return $this->session->sess_regenerate(TRUE);
}
/**
* アクセストークンとメアドをパスワードリセット用テーブルに保存
*
* @params string $email
* @params string $token
* @return void
*/
private function storeAccessToken($email, $token)
{
$now = date('Y-m-d H:i:s');
try {
$this->password_resets->email = $email;
$this->password_resets->token = $token;
$this->password_resets->created_at = $now;
$this->db->trans_begin();
$this->password_resets->insert();
$this->db->trans_commit();
}
catch(Exception $e){
$this->db->trans_rollback();
echo $e->getMessage();
return FALSE;
}
}
private function getUserInfoByEmail($email)
{
return $this->users->findBy('email', $email);
}
private function sendMailPasswordReset($user, $token)
{
$serviceName = env('APP_NAME') . " CMS";
$data = [
'name' => $user->label,
//'email' => $user->email,
'reset_link' => $this->getPasswordResetLink($token),
'service_name' => $serviceName,
'company_name' => env('COMPANY_NAME'),
'company_name_en' => env('COMPANY_NAME_EN'),
];
$viewPath = 'admin/layouts/mails/password_reset';
$message = $this->twig->render($viewPath, $data);
$this->load->library('email'); // @see config/email.php
$this->email->from(env('MAIL_FROM_ADDR'), env('MAIL_FROM_NAME'));
$this->email->to($user->email);
$this->email->subject("[" . $serviceName . "] パスワード再設定のご案内");
$this->email->message($message);
$ret = $this->email->send(); // boolean
//var_dump($this->email->print_debugger()); die;
return $ret;
}
/**
* メールアドレス, パスワード, タイムスタンプ, ソルトを
* 組み合わせてハッシュ文字列を作成する
* [TODO] Users_modelに書くべき内容では?
*
* @params string $email
* @params string $password
* @params integer $time
* @return string e.g. "f66407d441be589f5e0df4a6da2b46d0c1c051b29c271f125a93fd23325e269e"
*/
private function createAccessToken($email, $password, $time)
{
$salt = $this->config->item('salt');
$hashStr = $email . '|' . $password . '|' . $time . $salt;
$token = hash('sha256', $hashStr);
return $token;
}
private function getPasswordResetLink($token)
{
$protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$domain = $_SERVER['HTTP_HOST'];
$path = base_url('admin/auth/password_reset/?token=' . $token);
$url = $protocol . $domain . $path;
return $url; // e.g. "https://www.example.com/cms/public/admin/auth/password_reset/?token=f66407d441be589f5e0df4a6da2b46d0c1c051b29c271f125a93fd23325e269e"
}
private function isValidEmailToken($currentToken)
{
// $currentToken GETの場合、クエリパラメータ POSTの場合、hidden値
$item = $this->password_resets->findBy('token', $currentToken);
//トークンが不正な場合
if(empty($item) || $currentToken !== $item->token){
return FALSE;
}
$dt = new DateTime($item->created_at);
$time = (int) $dt->format('U'); // トークン発行日時 UNIX秒
$now = time(); // ページアクセス日時 UNIX秒
$authTime = (int) $this->config->item('auth_time'); // トークンの有効期限 UNIX秒
$expired = $time + $authTime; // トークンの失効日時 UNIX秒
// ページアクセス日時がトークン有効日時であるかをチェック
// 条件の前半はページアクセス日時がトークンの発行日時以降であることを確認
// 条件の後半はページアクセス日時がトークンの失効日時以内であることを確認
if($time <= $now && $now < $expired){ // トークンが妥当な場合
$this->token = $item->token; // TODO ここで設定するべきなのか?
return TRUE;
}
else { // トークンが有効日時外である場合
return FALSE;
}
}
private function passwordResetAction()
{
// 基本的な入力バリデーションのチェック(DB接続なし) @see application/rules/admin/auth.php
if(! $this->form_validation->run('password_reset')){
return $this->redirectBackWithInput();
}
// より厳密な入力バリデーションのチェック(DB接続なし)
$this->form_validation->set_rules('new_password', "新しいパスワード", 'callback_isValidPasswordPattern'); // @see isValidPasswordPattern();
if(! $this->form_validation->run()){ // callback_isValidPasswordPattern
return $this->redirectBackWithInput();
}
// アクセストークンからユーザー情報を取得
$input = $this->input->post();
$token = $this->token;
$item = $this->password_resets->findBy('token', $token);
$user = $this->getUserInfoByEmail($item->email);
// パスワード再設定&使用済みのアクセストークン削除処理を実行
try {
$this->db->trans_begin();
// パスワード再設定
$this->users->password = $input['new_password'];
$this->users->update($user->id);
// 使用済みのアクセストークンを削除
$this->password_resets->delete_record(array('token' => $token));
$this->db->trans_commit();
}
catch(Exception $e){
$this->db->trans_rollback();
echo $e->getMessage();
return FALSE;
}
// パスワード再設定が完了した時
// 有効期限切れのアクセストークンをついでに削除
$this->deleteExpiredAccessTokens();
// ダッシュボードに遷移させる
$this->setUserInfo($user);
$this->setSuccessMessage("パスワード再設定が完了しました。");
return $this->redirectToDashBoard();
}
/**
* パスワード再設定メールを送ったが、パスワード再設定を完了しないで放置した場合
* アクセストークンのレコードがDBに残り続ける
* セキュリティ上よくない気がするので有効期限外のレコードは全削除することにした
*/
private function deleteExpiredAccessTokens()
{
$now = time(); // 現在日時 UNIX秒
$authTime = (int) $this->config->item('auth_time'); // トークンの有効期限 UNIX秒
$expired = $now - $authTime; // 失効済のトークン日時 UNIX秒
$dt = new DateTime();
$dt->setTimestamp($expired);
$expiredAt = $dt->format('Y-m-d H:i:s');
try {
$this->db->trans_begin();
$this->db->where('created_at <', $expiredAt)->delete('password_resets');
$this->db->trans_commit();
}
catch(Exception $e){
$this->db->trans_rollback();
echo $e->getMessage();
return FALSE;
}
}
/**
* CodeIgniter Strong Password Validation
* [TODO] Users.phpと同じ内容 できれば一つにまとめたい
* @return bool
*/
public function isValidPasswordPattern($password)
{
$msgLabel = "isValidPasswordPattern";
$password = trim($password);
$regex_lowercase = '/[a-z]/';
$regex_uppercase = '/[A-Z]/';
$regex_number = '/[0-9]/';
$regex_special = '/[!@#$%^&*()\-_=+{};:,<.>§~]/';
//$password_length_min = 8;
//$password_length_max = 32;
if (empty($password))
{
$this->form_validation->set_message($msgLabel, '{field}欄は入力必須項目です');
return FALSE;
}
if (preg_match_all($regex_lowercase, $password) < 1)
{
$this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、アルファベット小文字を含む必要があります');
return FALSE;
}
if (preg_match_all($regex_uppercase, $password) < 1)
{
$this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、アルファベット大文字を含む必要があります');
return FALSE;
}
if (preg_match_all($regex_number, $password) < 1)
{
$this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、数字を含む必要があります');
return FALSE;
}
if (preg_match_all($regex_special, $password) < 1)
{
$this->form_validation->set_message($msgLabel, '{field}欄は少なくとも1文字以上、次の記号のいずれかを含む必要があります' . ' ' . '!@#$%^&*()\-_=+{};:,<.>§~');
return FALSE;
}
//if (strlen($password) < $password_length_min)
//{
// $this->form_validation->set_message($msgLabel, 'The {field} field must be at least ' . $password_length_min . ' characters in length.');
// return FALSE;
//}
//if (strlen($password) > $password_length_max)
//{
// $this->form_validation->set_message($msgLabel, 'The {field} field cannot exceed ' . $password_length_max . ' characters in length.');
// return FALSE;
//}
return TRUE;
}
}