Angularログインフォーム用PHPスクリプト
前回AngularSPA(Single Page Application)用のログインページを作成しました。今回はそのバックエンドとなるPHPサーバーのスクリプトを考えたいと思います。
ログインAPIを作成する中で難しいのがセキュリティの部分です。下手に作ればセキュリティホールになるだけでなく他のサイトまで被害が及ぶなんてこともあります。 しかも、現行で提示されている最善策を完璧に実装できたとしても、現行の仕様の抜け穴が見つけられたりします。
古いので現在のPHPの仕様とは違う部分もあるかもしれませんが、それでもよりどころなく作るよりはいいのでgihyo.jp「なぜPHPアプリにセキュリティホールが多いのか?」を参考にさせていただき、ログインAPIを作成してみました。
ここで紹介するものはひとつの案であり、完全なものではありません。また、間違い等がありましたら指摘していただければ幸いです。
ログインAPI構築時のセキュリティの要点
まず、セキュリティに関して気を付けないといけない点をまとめました。
- クライアントからの入力値を利用する際に注意する(サニタイズ)
クライアントからのデータはそのままコードに利用したり、HTMLに出力しないように注意します。
たとえば次のように、クライアントから受け取ったままのファイル名をrequireするコードは厳禁です。
require_once("/root/api/".$POST_['filename'].".php");../の使用でディレクトリトラバーサルされるのに加え、古いバージョンのPHPではfilenameの値にnullを入れればそこで文字列の判別が終わるため拡張子がphpでなくとも読み込みができてしまいます。
このような場合は、open_basedirでPHPからアクセスできるフォルダを制限したり、basename関数で受け取った値からディレクトリ情報を除去したり、ホワイトリストチェックでfilenameをチェックいします。
同様にHTMLにクライアントからのデータを表示する場合はすべてにおいてhtmlspecialcharsやhtmlentities関数を通します。
- 文字コードに注意する
想定外の文字コードを受け入れると、文字化けにとどまらずセキュリティ機能であるhtmlspecialcharsやhtmlentitiesが思ったように機能しないことがあります。
そのため文字コードの指定が重要です。htmlspecialcharsやhtmlentities関数において明示的に文字コードを指定するだけでなく、コード毎にini_set('default_charset', '文字コード');を明示的に記述します。
同時に、クライアントから引数を受け取った時に文字コードの異変に気づいたら、エラーとしてそこで処理を止める必要があります。
文字コードチェックはmb_check_encoding関数で行えます。
ちなみにページの文字コードは簡単に変えられないケースも多いと思いますが、SJISやEUCよりはUTF-8の方がいいそうです。
- バリデーション(値のチェック)
変数のとりうる値を設定し、それに沿わないデータはエラーとします。これを行うことで、SQLインジェクションやOSインジェクションのリスクを減らすことができます。
- ログをとる
ログをとることもセキュリティ対策の一つです。ログイン者のIDやグローバルIPアドレスや時刻を記録します。
- SQLではプレースホルダを使う
昔は情報セキュリティSP試験でよく出題されました。SQLインジェクション対策のためプレースホルダをつかいます。
DBではなく、XMLで管理していてXPathを使う際も同様の脆弱性があります。こちらの場合は利用する文字のサニタイズを徹底します。
- パスワードの管理
万が一の漏洩時にダメージを少なくするために、テキストファイルやデータベース内にパスワードをそのまま保存しておかないようにします。
ハッシュ化する際はそのアルゴリズムにも注意し、単にハッシュするだけでなくユーザー毎に違うsalt(ランダムな値)を付与して保存します。
たとえば「hash関数(平分のパスワード+salt(ランダムな文字列))」とし、照合時も同じルールを用います。
データベースに保存するのはユーザーID、salt、ハッシュ値となります。
またhttpプロトコルでのBASIC認証は使わないようにします。
- セッション管理
セッションIDはログイン後にsession_regenerate_id関数で変更します。先の参考サイトではセッションアダプション対策として「正規のcookieの他に存在しうるcookieを全て削除します」とあります。
どういうことかというと、たとえば some.domain.com/apiでは、some.domain.comの/(ルート)とsome.domain.comの/api、domain.comの/とdomain.com/apiの4パターンでcookieが保存できてしまいます。
これらのうちどれが採用されるかはブラウザ実装に依存するのに加え、どのパターンのcookieかもサーバー側からではわからない為、攻撃者がユーザーに不正なsession_regenerate_id関数を実行したあとも、SESSIONIDを使わせ続けることができるというのがその理由です。
これはsession_set_cookie_params関数を使ってクッキーを保存するドメインやパスを指定しても同じ話です。(送られてくるcookieにはpath情報は含まれまれないためどうしたって制限できません)
- WAFを使う
Web Application Firewallを使うと、SQLインジェクションやクロスサイトスクリプティングを検出したりできます。これは脆弱性が発露した際の保険として利用されるもので、根本的な対策がある場合はこれに頼るべきではありません。
サーバーにインストールするオープンソースのWAFにModSecurityがあります。これはCRS(Core Rule Set:ウイルス対策ソフトにおけるパターンファイルみたいなもの)と一緒に利用します。(LinuxやMacOS用で、Windows版はありません)
- チャレンジレスポンス
パスワード認証時、ログイン時サーバーからチャレンジと呼ばれるランダムな文字列を受け取り、クライアント側はそれとパスワードを連結させhash化させたものを送信するようにします。サーバー側は自分が保持している値を使ってクライアント側と同じようにhashを算出しその一致をもってパスワードが一致したとみなします。これはSSL通信が破られた際の保険になります。
- apache2とPHPの設定
apacheサーバーや、PHPのバージョンを知られると攻撃のヒントを与えてしまします。そのため表示を消す設定をします。
- apache2(Windows)
httpd.confの「ServerRoot」のエントリーの後ぐらいに「ServerTokens Prod」と「ServerSigature Off」を記述します。
- apache2(Debian10)
apach2.confでの記述も可能ですがconf-availableディレクトリないのsecurity.confに該当のエントリーがあるので上書きされてしまします。なので、security.confを修正します。値はWindowsと同じように「ServerTokens Prod」と「ServerSigature Off」です。
- php.ini(Windows、Debian10)
php.iniでは「expose_php=Off」を設定します。おそらくデフォルトでOffになっていると思います
またApacheではルートディレクトリに対して「Options -Indexes」を設定して、一覧出力を止めます。
他、apacheのHeadear等の設定はQuiita:「Apacheセキュリティ設定」にスマートにまとめられています。
- apache2(Windows)
- 考える癖をつける
精神論的なことはあまり言いたくないのですが、多くの情報機器で情報セキュリティの脆弱性が発露します。大丈夫だと思っていても思わぬ穴があるものです。コードを書く時、このコード本当に大丈夫なのかと疑うことも大切だと思います。
PHPのセッション管理について
セッション管理は先にも紹介したPHP公式ページにあったコードを利用します。
このコードのではmy_session_regenerate_idではsession_regenerate_idの代わりの処理を実行します。新しいセッションIDを作成し、放棄する現在のセッションに保存します。また現在にセッションのdestoryed変数に現在のタイムスタンプをセットします。destoryed変数存在により破棄済みのセッションかどうかを判別します。
session_commit()で現在のセッションを終了させた後、新しいセッションIDをsession_id関数に渡して、session_start関数を呼ぶと指定したセッションIDでセッションを始めれるのですが、この時session.use_strict_modeが0になっている必要があります。
session.use_strict_modeは初期化していないセッションIDを受け入れるか否かの設定です。先にも少し出たセッションアダプション対策で通常は1(受け入れない)にしておく必要があるので、新しいセッションを作成したらすぐに値を1に戻します。公式ページにはセッションを開始した後戻していましたが、セッションが始まっていると値を変更できないようなので、一旦閉じて設定変更をして再度開始するように変更しました。
不安定なネットワーク上では、クライアント側のセッションIDが変わらないことがあります。そのためmy_session_start関数を設けています。これにより前のセッションを閉じてからすぐの場合は、古いセッションから新しいセッションIDを取得して、すでに存在しているはずの新しいセッションに移動します。時間がたっている場合は、ログイン情報を破棄してエラーにします。
このコードは不安定なネットワークの対応のためにセッションハイジャックに対して間口を開けているような気もします。ユーザーがログインしてセッションを変更した後でも、ハイジャック犯が指定した時間内にセッションを開始すれば新しいセッションを取得できてしまいます。
古いセッションを使えないようにするにsession_regenerate_idにtrueの引数を与えて、session即座に破棄すればいいような気もしますが、PHPのsession_regenerate_idには「セッションデータをすぐに破棄してしまうと、 セッションハイジャックの検出だけでなく、 防止もできなくしてしまいます。」との記述があります。
他のサイトではsession_regenerate_id(true)を利用しているところが散見されますが、公式ページの記法にしたがうようにして様子をみます。
ログインの手順
ログインのフローを紹介します。CLはクライアント(SPA)、SVはサーバー(PHP)を意味します。また「チャレンジ」は使い捨てのランダムな文字列、「ソルト」はパスワードをハッシュ化する際に利用しているユーザー毎に別なランダムな文字列を意味します。
- (CL)チャレンジを要求します
- (SV)チャレンジを作成して返します
この時グローバルアドレスがブラックリストに載っている場合は、なにもせずにエラーを返します。これはチャレンジ要求だけでなくすべての要求に対してエラーを返します。
- (CL)ソルトを要求します。この時チャレンジとユーザーIDを送信します
- (SV)サーバー側チャレンジが前回送信したものと一致したら、ソルトを返します。
この時チャレンジは新たに作り直して再度送信します。
ユーザーIDの有無をチェックされないように、ここではユーザーIDが存在しない場合でもソルトを返すようにします。正当なリクエスト時と不正なリクエスト時のレスポンス時間に差がでないようにも注意します。
- (CL)受け取った新しいチャレンジ+ソルトを使ってパスワードをハッシュ化し再度サーバーへ送信します
- (SV)サーバー側で受けっとった値を照合します。
サーバー側で保存しているパスワードを取得後、クライアントと同じ処理をして、受け取った値と一致するかチェックします。
認証に成功したら、セッション変数にログイン状態を設定して、APIアクセス用のキーを作成し成功通知と共に返します。
失敗した場合はチャレンジを再度更新して、再送信を要求します。
一定回数以上間違えた場合は一定時間ロックします。
- (CL)ログインの成功を通知されたらキーを保存します
キーはSessionStoreageもしくはLocalStorageに保存します。Cookieに保存してしまうと一致するサイトで無条件で送信してしまうので使いません。
SessionStoreageやLocalStorageはオリジン(≒ドメイン+ポート)毎にデータを管理するので、別サイトからはSessionStorageやLocalStorageからキーを取り出すことはできません。
データベース
データーベースのユーザーテーブルの構造は次のようにしました。
ユーザーIDは2文字以上の文字列を設定します。
権限レベルには1以上の整数を指定します。ログイン後の表示可否に利用する想定です。
ソルトは16進数の文字列16桁で取得ことにしました。ユーザー毎に別々の値にしユーザー名に依存しない(ユーザー名から算出しない)値にします。文字列でもいいのですが、16進数値にするのはダミーソルト生成時にユーザー名からハッシュ値算出をすることで固定値を取得できるからです。
ハッシュ化パスワードはパスワードの後にソルトの文字列を連結したものをsha256で計算します。
最終ログイン日とログイン失敗日時は日付型(ここではMySQLのDateTime型)をセットします。最終ログイン日はメンテンナンス用の項目でここでは利用していません。ログイン失敗日時はロックの時間をはかるのに使います。
ログイン失敗回数はロックのためのログイン失敗数を記憶します。
PHP
PHPスクリプトは次のようなコードになりました。データベース系の処理はMySQL(MariaDB)用の独自のクラスを使っているのですが、長くなりますのでコードの紹介は割愛させていただきます。
login.php
<?php
//mb_check_encordingが使えるかチェック
//DB操作用の自作クラスをインポートしています。
require_once('./mysql.php');
//使用方法 各PHPファイルでこのファイルをrequireして、次のように記述します。
$debug = new LoginApi();
$debug->init();//sessionはinitを呼ぶと開始されます。
$debug->makeResult($debug->auth());//ログインAPIではこのように使います
//$debug->levelCheck(要求レベル);//ログインAPIの他ではこのようにして表示可能かチェックします。
exit;
Class LoginApi {
//HASHアルゴリズム
const HASH='sha256';
//-9:致命的なエラー -8:プリフライトリクエストレスポンス -7:saltレスポンス -6:challengeレスポンス -2:ログアウト -1:認証失敗 0:認証済 1:認証成功)
const RESPONSE_FATAL_ERROR= -9;
const RESPONSE_PREFLIGHT=-8;
const RESPONSE_SALT= -7;
const RESPONSE_CHALLENGE= -6;
const RESPONSE_LOGOUT= -2;
const RESPONSE_AUTH_FAIL= -1;
const RESPONSE_AUTH_SUCCEED= 1;
//チャレンジ用のの文字列
const STRINGS = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
//以下の定数は環境に合わせて書き換えてください
//ログパス(ログファイルは外からアクセスできないように設定してください)
const LOGPATH = './logpath.log';
//SESSIONクッキー用設定
const DOMAIN = 'localhost';
const PATH='/';
const HTTPSONLY = false;//通常はtrueにしてください
const SESSION_LIMIT = 60 * 60;
//ソルトの文字数
const SALT_LENGTH = 16;
//ログイン失敗許可数
const LOGIN_LIMIT = 5;
//ロック時間(秒)
const LOCK_TIMEOUT = 60 * 60 * 3;
//拒否したいグローバルIPアドレスがあれば
//addressCheckメソッドに記載してください。
//パスワード管理用DB設定
const DBUSER="admin";
const DBPASS="admin";
const DBIP="localhost";
const DBNAME="test";
//Angularでのデバッグを考慮して、DEBUG=trueの時CORSを無条件に許可するヘッダを作成します。
const DEBUG=true;
function init() {
//文字コード設定(明示)
ini_set('default_charset', 'UTF-8');
//タイムゾーン設定
date_default_timezone_set('Asia/Tokyo');
//session用の設定、保持期間や、cookieの保存先設定
//有効期限,保存パス,ドメイン,secure属性,httponly(Javascript経由禁止)
session_set_cookie_params(time()+LoginApi::SESSION_LIMIT, LoginApi::PATH, LoginApi::DOMAIN, LoginApi::HTTPSONLY ? true : false, true);
//セッション開始
$this->my_session_start();
}
function auth() {
//HTTPSチェック 好みでリダイレクト等に変えてください
if (LoginApi::HTTPSONLY) {
if (empty($_SERVER['HTTPS'])) {
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_FATAL_ERROR;
}
}
//アドレスブラックリストチェック
if ($this->addressCheck()===false) {
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_FATAL_ERROR;
}
//メソッド別の処理
switch(strtolower($_SERVER['REQUEST_METHOD'])) {
case 'post':
//login処理は基本的にpostしか扱わない
//パラメーター取得
$str = file_get_contents('php://input');
//パラメーターが空の時はエラー
if(!isset($str) || $str==="") {
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください。";
return LoginApi::RESPONSE_FATAL_ERROR;
}
//文字コードチェック
if (mb_check_encoding($str,"UTF-8")===false) {
//文字コードエラーの時はログに内容を記載するのは危険なので内容は出力しない
$this->writeLog("文字コードチェックエラー","");
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_AUTH_FAIL;
}
//変換
$json = json_decode($str,TRUE);
//モードチェック
if ($json === null || !isset($json['type'])) {
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください。";
return LoginApi::RESPONSE_FATAL_ERROR;
}
switch($json['type']) {
case 'logout':
//ログアウト
$this->remove_all_authentication_flag_from_active_sessions();
$_SESSION['message']="ログアウトしました";
return LoginApi::RESPONSE_LOGOUT;
case 'challenge':
//challenge発行要求
$_SESSION['user']="";
$_SESSION['salt']="";
$_SESSION['challenge']=$this->getRandomStr();
$_SESSION['message']="challengeを取得しました";
return LoginApi::RESPONSE_CHALLENGE;
case 'salt':
//ユーザー名からsaltを取得します。
//salt取得時はクライアントはパスワードの代わりにchallengeの値をそのまま送信します。
//salt取得時のエラーはユーザー既存チェックに利用されないように、ユーザー未存在でもダミーのsaltを返します。
//ダミーのsaltもユーザー毎に固定にするためユーザー名+"dummy"をハッシュします。ただし文字長1字以下のユーザー名はエラーとします。
if (isset($json['user']) && isset($json['challenge']) && 1 < mb_strlen($json['user'])) {
$rawSalt = $this->getSalt($json['user'],$json['challenge']);
if($rawSalt !== null) {
$_SESSION['user']=$json['user'];
$_SESSION['salt']=$rawSalt;
$_SESSION['challenge']=$this->getRandomStr();//チャレンジ再生成
$_SESSION['message']="saltを取得しました";
$this->writeLog("salt取得",$json['user']);
return LoginApi::RESPONSE_SALT;
} else {
//salt取得失敗
$_SESSION['user']=$json['user'];
$_SESSION['salt']=substr(hash(LoginApi::HASH,$json['user']."dummy"),0,LoginApi::SALT_LENGTH);
$_SESSION['challenge']=$this->getRandomStr();//チャレンジ再生成
$_SESSION['message']="saltを取得しました";
return LoginApi::RESPONSE_SALT;
}
} else {
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_FATAL_ERROR;
}
case 'auth':
if (isset($json['user']) && isset($json['password'])) {
$intLevel = $this->loginCheck($json['user'],$json['password']);
if(0 < $intLevel) {
//ログイン成功
//セッションID変更
$this->my_session_regenerate_id();
$_SESSION['user']=$json['user'];
$_SESSION['login']=$intLevel;
$_SESSION['key']=$this->getRandomStr();
$_SESSION['message']="認証に成功しました";
return LoginApi::RESPONSE_AUTH_SUCCEED;
} else {
//ログイン失敗
$_SESSION['message']="認証に失敗しました";
$_SESSION['challenge']=$this->getRandomStr();//チャレンジ再生成
return LoginApi::RESPONSE_AUTH_FAIL;
}
} else {
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_FATAL_ERROR;
}
default:
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_FATAL_ERROR;
}
case 'options':
//プリフライトリクエストが発生するようならダミーデータを返す
$_SESSION['message']="プリフライトリクエストに対するレスポンスです";
return LoginApi::RESPONSE_PREFLIGHT;
case 'put':
case 'delete':
case 'get':
default:
//想定外
$_SESSION['message']="認証でエラーが発生しました。最初からやり直してください";
return LoginApi::RESPONSE_FATAL_ERROR;
}
}
function makeResult($intResult) {
//認証結果のJSONを作成+セッションに記憶(失敗時はユーザー名を返さない)
//(これらのヘッダはAngularデバッグ用なので本番では適正な値にしてください)
if (LoginApi::DEBUG) {
//header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Origin: http://localhost:4200');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');
header('Access-Control-Allow-Methods: GET, HEAD, POST, DELETE, PUT,OPTIONS');
}
//----------------------
$array=[];
switch($intResult) {
case LoginApi::RESPONSE_FATAL_ERROR:
$array['user']= "";
$array['salt']= "";
$array['result']=$intResult;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']="";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
break;
case LoginApi::RESPONSE_PREFLIGHT:
$array['user']= "";
$array['salt']= "";
$array['result']=0;
$array['challenge']="";
$array['key']="";
$array['message']="";
break;
case LoginApi::RESPONSE_CHALLENGE:
$array['user']= "";
$array['salt']= "";
$array['result']=$intResult;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']="";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
break;
case LoginApi::RESPONSE_SALT:
$array['user']= isset($_SESSION['user']) ? $_SESSION['user'] : "";
$array['salt']= isset($_SESSION['salt']) ? $_SESSION['salt'] : "";
$array['result']=$intResult;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']="";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
break;
case LoginApi::RESPONSE_AUTH_SUCCEED:
$array['user']= isset($_SESSION['user']) ? $_SESSION['user'] : "";
$array['salt']= isset($_SESSION['salt']) ? $_SESSION['salt'] : "";
$array['result']=$intResult;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']=isset($_SESSION['key']) ? $_SESSION['key'] :"";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
break;
case LoginApi::RESPONSE_LOGOUT:
$array['user']= "";
$array['salt']= "";
$array['result']=$intResult;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']="";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
break;
case LoginApi::RESPONSE_AUTH_FAIL:
$array['user']= "";
$array['salt']= isset($_SESSION['salt']) ? $_SESSION['salt'] : "";
$array['result']=$intResult;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']="";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
break;
default:
$array['user']= "";
$array['salt']= "";
$array['result']=LoginApi::RESPONSE_FATAL_ERROR;
$array['challenge']=isset($_SESSION['challenge']) ? $_SESSION['challenge'] :"";
$array['key']="";
$array['message']=isset($_SESSION['message']) ? $_SESSION['message'] : "";
}
//file_put_contents("./recv.txt",$array['challenge'],FILE_APPEND);
echo json_encode($array);
}
function my_session_start() {
//session開始、公式ページにあるサンプルそのままのコードです。
session_start();
if (isset($_SESSION['destroyed'])) {
if ($_SESSION['destroyed'] < time()-300) {
// 通常は起こるべきではない。攻撃や不安定なネットワークによる可能性がある
// このユーザーのセッションから、全ての認証ステータスを削除
$this->remove_all_authentication_flag_from_active_sessions();
throw(new DestroyedSessionAccessException);
}
if (isset($_SESSION['new_session_id'])) {
// 完全に expire してはいない。
// Cookie が不安定なネットワークによって失われた可能性がある。
// 適切なセッションIDのクッキーを設定するためにリトライする。
// 注意: 認証フラグを削除したい場合は、セッションIDを再度設定しようとしてはいけない。
session_commit();
session_id($_SESSION['new_session_id']);
// 新しいセッションIDが存在しているはず
session_start();
return;
}
}
}
function remove_all_authentication_flag_from_active_sessions() {
//ログイン情報の除去
unset($_SESSION['user']);
unset($_SESSION['challenge']);
unset($_SESSION['salt']);
unset($_SESSION['result']);
unset($_SESSION['key']);
unset($_SESSION['message']);
unset($_SESSION['login']);
}
function my_session_regenerate_id() {
//セッションID再作成 公式ページそのままのコードです。
// 不安定なネットワークのために、セッションID が設定されなかったときは、
// 新しいセッションID が、適切なセッションIDに設定されることが必須。
$new_session_id = session_create_id();
$_SESSION['new_session_id'] = $new_session_id;
// 破棄された時のタイムスタンプを設定
$_SESSION['destroyed'] = time();
// 現在のセッションを書き込んで閉じる
session_commit();
// 新しいセッションを新しいセッションIDで開始
session_id($new_session_id);
ini_set('session.use_strict_mode', 0);
//サンプルコードのままだとWaring Session ini settings cannot be changed when a session is activeが出る
//session_start();
//ini_set('session.use_strict_mode', 1);
session_start();//開始
session_commit();//一旦終了
ini_set('session.use_strict_mode', 1);//設定変更
session_start();//再開(すでに一度作成されたのでエラーにはならないはず)
// 新しいセッションには、以下の情報は不要
unset($_SESSION['destroyed']);
unset($_SESSION['new_session_id']);
}
function getRandomStr($intRepeat = 12) {
//ランダムな文字列を生成
$intStrLen = strlen(LoginApi::STRINGS)-1;
$ret="";
for($i=0; $i < $intRepeat; $i++) {
$ret .= substr(LoginApi::STRINGS,random_int(0,$intStrLen),1);
}
return $ret;
}
function writeLog($strMsg,$strUser) {
//ログ記述
$fp=fopen(LoginApi::LOGPATH,"a");
//ログファイルオープン失敗(この時認証失敗とより安全)
if ($fp===false) return false;
//ロックを取得するまで待つ
$intRetry = 10;
while (!flock($fp, LOCK_EX | LOCK_NB)) {
sleep(1);
if(--$intRetry < 0) {
//ロック失敗(この時認証失敗とするとより安全)
return false;
}
}
//ユーザー名に<?php...などと入っている場合そのまま出力するとログファイルの危険度が上がるので$struserをhtmlspecialcharsで処理しています。
//また長いプログラムコードを挿入されないように長さもカットしています。
fwrite($fp,date('Y/m/d H:i:s').",".$strMsg.",".htmlspecialchars(mb_substr($strUser,0,20,"UTF-8")).",".$_SERVER['REMOTE_ADDR'].",".$_SERVER['REQUEST_URI']."\r\n");
fclose($fp);
}
//------------------ ここ以下は各自の環境に合わせて実装してください。
function getSalt($strUser,$strChallenge) {
$db = new Mysql2();
if (isset($_SESSION['challenge'])=== false || $_SESSION['challenge']==='' || $_SESSION['challenge'] !== $strChallenge) {
//チャレンジが存在しないときはエラー
return null;
}
//DBオープン
if($db->open(LoginApi::DBIP,LoginApi::DBNAME,LoginApi::DBUSER,LoginApi::DBPASS)===false) {
return null;
}
//saltの取得
$dbData = $this->getDbData($db,$strUser,"salt");
if ($dbData === null) {
//ユーザーが存在しない他
//エラー(エラー状況の更新はしない)
//$this->updateLoginError($db,$strUser, $dbData['failcount']+1);
$db->close();
return null;
} else {
//ロック中か確認
if ($this->lockCheck($db, $strUser, $dbData['faildate'],$dbData['failcount']) === false) {
//エラー(エラー状況の更新はしない)
//$this->updateLoginError($db,$strUser, $dbData['failcount']+1);
$db->close();
return null;
}
$db->close();
return $dbData['salt'];
}
}
function addressCheck() {
//接続元のアドレスを確認してブラックリストで制限します。
//falseを返すと制限対象です。
//不正アクセスがひどい場合にログ等から手動でリストを作成します。
$blacklist=[];
for($i = 0; $i < count($blacklist); $i++) {
if($blacklist[$i]===$_SERVER['REMOTE_ADDR']) {
return false;
}
}
return true;
}
function loginCheck($strUser,$strInputPass) {
//認証して権限レベルを返します
//成功時は1以上の権限レベルを返すエラー時は内容に応じて負の数を返す
$db = new Mysql2();
if (isset($_SESSION['challenge'])=== false || $_SESSION['challenge']==='') {
//チャレンジが存在しないときはエラー
return -1;
}
//DBオープン
if($db->open(LoginApi::DBIP,LoginApi::DBNAME,LoginApi::DBUSER,LoginApi::DBPASS)===false) {
return -2;
}
//$strInputPass hash(hash(平文PASS+salt)+challenge)
$dbData = $this->getDbData($db,$strUser,"password");//=dbに保存してあるhash値= hash(平文PASS+salt)
if ($dbData === null) {
$db->close();
return -3; //ユーザーが存在しない
}
//ロック中
if ($this->lockCheck($db, $strUser, $dbData['faildate'],$dbData['failcount']) == false) {
//エラー+1
$this->updateLoginError($db,$strUser, $dbData['failcount']+1);
$db->close();
return -4;
}
if (hash(LoginApi::HASH, $dbData['password'].$_SESSION['challenge']) === $strInputPass) {
//ログイン処理
//ログイン状態記憶+エラーリセット
$this->updateLoginState($db,$strUser);
$db->close();
return $dbData['level'];
} else {
//パスワード相違
//エラー+1
$this->updateLoginError($db,$strUser, $dbData['failcount']+1);
$db->close();
return -5;
}
}
function lockCheck(&$db, $strUser, $strastFailTime, $intFailCnt) {
if (LoginApi::LOGIN_LIMIT < $intFailCnt) {
if (strtotime($strastFailTime) < time()+LoginApi::LOCK_TIMEOUT) {
//エラーリセット
$this->updateLoginError($db,$strUser, 0, 0);
return true;
} else {
return false;
}
} else {
return true;
}
}
function levelCheck($intRequireLevel = 0) {
if (isset($_SESSION['login'])) {
if ($intRequireLevel < $_SESSION['login']) {
return true;
}
}
return false;
}
function updateLoginState(&$db, $strUser) {
$ps;
$ret;
$strSql="";
$ps=$db->getPreparedStatement();
$strSql = "UPDATE users set `logindate`=?, `failedate`=? , `failcount`=? WHERE user=?";
try {
$ps->setSql($strSql);
$ps->setObjectAuto(0,date('Y-m-d H:i:s'));
$ps->setObjectAuto(1,0);
$ps->setObjectAuto(2,0);
$ps->setObjectAuto(3,$strUser);
$ps->execute();
$ret = true;
} catch(Exception $e) {
$ret=false;
} finally {
$ps->close();
}
return $ret;
}
function updateLoginError(&$db, $strUser,$failcount) {
$ps;
$ret;
$strSql="";
$ps=$db->getPreparedStatement();
$strSql = "UPDATE users set `failedate`=? , `failcount`=? WHERE user=?";
if(9999 < $failcount) {
//DB上限値
$failcount=9999;
}
try {
$ps->setSql($strSql);
$ps->setObjectAuto(0,date('Y-m-d H:i:s'));
$ps->setObjectAuto(1,$failcount);
$ps->setObjectAuto(2,$strUser);
$ps->execute();
$ret = true;
} catch(Exception $e) {
$ret=false;
} finally {
$ps->close();
}
return $ret;
}
function getDbData(&$db, $strUser, $strMode) {
$ps;
$ret;
$strSql="";
$ps=$db->getPreparedStatement();
switch($strMode) {
case 'password':
$strSql = "SELECT `password`, `level`, `faildate`, `failcount` FROM users WHERE user=?";
break;
case 'salt':
$strSql = "SELECT `salt`, `faildate`, `failcount` FROM users WHERE user=?";
break;
default:
return null;
}
try {
$ps->setSql($strSql);
$ps->setObjectAuto(0,$strUser);
$ps->execute();
if($ps->next()) {
switch($strMode) {
case 'password':
$ret['password'] = $ps->getString("password");
$ret['level'] = $ps->getString("level");
$ret['faildate'] = $ps->getString("faildate");
$ret['failcount'] = $ps->getInt("failcount");
break;
case 'salt':
$ret['salt'] = $ps->getString("salt");
$ret['faildate'] = $ps->getString("faildate");
$ret['failcount'] = $ps->getInt("failcount");
break;
default:
$ret = null;
}
} else {
$ret=null;
}
} catch(Exception $e) {
$ret=null;
} finally {
$ps->close();
}
return $ret;
}
}
Angularでの設定
前回のAngularログインフォームの作成から読んでいただいている方に向けて、Angularスクリプトの変更箇所を紹介します。
まず、インターフェースのAuthParamとLoginUserは次のように修正します。
auth-param.ts
export interface AuthParam {
user: string;
password: string;
key: string;
challenge: string;
type: string;
}
login-user.ts
export interface LoginUser {
user: string;
challenge: string;
salt: string;
key: string;
result: number;
message: string;
}
loginサービスは次のように修正します。前回はコンポーネント側でlocalStorageに保存しましたが、サービスで処理するほうが正しいと思うので変更しています。また、Observableを連続で処理する際にはpipeでmergeMapを利用しています。
login.service.ts
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { mergeMap, tap } from 'rxjs/operators';
import { LoginUser } from './interfaces/login-user';
import { AuthParam } from './interfaces/auth-param';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import * as shajs from 'sha.js';
@Injectable({
providedIn: 'root'
})
export class LoginService {
private loginUrl = 'http://localhost/login.php';
httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
withCredentials: true
};
login(strUser: string, strPassword: string):Observable<LoginUser> {
const authParam: AuthParam = {
user: strUser,
password: "",
key: "",
challenge: "",
type: "challenge"
}
//チャレンジ取得
return this.http.post<LoginUser>(this.loginUrl,authParam,this.httpOptions).pipe(
mergeMap((l: LoginUser) => {
console.log('--- challenge ---');
console.log(l);
authParam.type="salt";
authParam.challenge=l.challenge;
//salt取得
return this.http.post<LoginUser>(this.loginUrl,authParam,this.httpOptions);
}),
mergeMap((l: LoginUser) => {
console.log('--- salt ---');
console.log(l);
let strPassWk = shajs('sha256').update(strPassword+l.salt).digest('hex');//dbに入っている値
authParam.type="auth";
authParam.password=shajs('sha256').update(strPassWk+l.challenge).digest('hex');
authParam.challenge="";//不要なので削除
//認証
return this.http.post<LoginUser>(this.loginUrl,authParam,this.httpOptions);
}),
mergeMap((l: LoginUser) => {
console.log('--- auth ---');
console.log(l);
if (0 < l.result) {
//権限をローカルストレージにセット
localStorage.setItem('token', String(l.result));
localStorage.setItem('key', l.key);
} else {
//認証失敗時はローカルストレージのデータをクリア
localStorage.removeItem('token');
localStorage.setItem('key');
}
return of(l);
})
);
}
logout():Observable<LoginUser> {
const authParam: AuthParam = {
user: "",
password: "",
key: "",
challenge: "",
type: "logout"
}
return this.http.post<LoginUser>(this.loginUrl,authParam,this.httpOptions).pipe(
tap((l: LoginUser) => {
//通信エラー以外はログイン状態を除去
localStorage.removeItem('token');
localStorage.removeItem('key');
console.log("logout");
})
);
}
isLogin(): boolean {
let level = parseInt(localStorage.getItem('token'));
if (!isNaN(level) && 0 < level) {
return true;
} else {
return false;
}
}
constructor(private http: HttpClient) { }
}
ログインコンポーネントのHTMLです。ログアウト部も設けました。
login.component.html
<mat-card class="login-card">
<mat-card-header>
<mat-card-title class="login-title">{{isLogin() ? "ログアウトしますよろしいですか?" : "ユーザー名とパスワードを入力してボタンを押してください" }}</mat-card-title>
</mat-card-header>
<mat-card-content *ngIf="!isLogin()">
<form class="login-form" [formGroup]="loginForm" (keydown.enter)="$event.preventDefault()" autocomplete="off">
<mat-form-field>
<mat-label>ユーザー名</mat-label>
<input matInput formControlName="user" required />
</mat-form-field>
<mat-form-field>
<mat-label>パスワード</mat-label>
<input matInput [type]="blnPassVisible ? 'password' : 'text'" formControlName="password" required>
<mat-icon class="pointer" matSuffix (click)="blnPassVisible = !blnPassVisible">{{blnPassVisible ? 'visibility' : 'visibility_off'}}</mat-icon>
</mat-form-field>
<button type="button" mat-raised-button color="primary" [disabled]="loginForm.invalid" (click)="login()">ログイン</button>
</form>
</mat-card-content>
<mat-card-content *ngIf="isLogin()">
<button type="button" mat-raised-button color="primary" (click)="logout()">ログアウト</button>
</mat-card-content>
<mat-card-footer>
<mat-error *ngIf="0 < errors.length">
<ul>
<li *ngFor="let error of errors">{{error}}</li>
</ul>
</mat-error>
</mat-card-footer>
</mat-card>
<!-- 処理中の画面 -->
<div *ngIf="blnLoading" class="loading">
<div class="loading-inner">
<mat-spinner></mat-spinner>
{{strLoadingMsg}}
</div>
</div>
スクリプトはLocalStorageの部分をサービス側に持って行ったので既存のコードから該当箇所を削除し、ログアウトを追加しています。
login.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { LoginUser } from '../interfaces/login-user';
import { AuthParam } from '../interfaces/auth-param';
import { LoginService } from '../login.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
title="ログインページ";
blnPassVisible: boolean = true;
blnLoading: boolean = false;
strNextPage: string;
strLoadingMsg: string = "ログイン処理中です";
errors: string[]=[];
loginForm = this.fb.group({
user:[''],
password:[''],
});
constructor(private router : Router, private route: ActivatedRoute, private loginService: LoginService, private fb: FormBuilder) { }
ngOnInit(): void {
//ActivatedRouteを使ってログイン成功時の移動先を取得します。
if (this.route.snapshot.queryParams.hasOwnProperty('url')) {
this.strNextPage = this.route.snapshot.queryParams.url;
} else {
this.strNextPage ="/";
}
}
isLogin(): boolean {
return this.loginService.isLogin();
}
logout(): void {
this.loginService.logout().subscribe(v=>{});
this.router.navigateByUrl("/login-page");
}
login(): void {
//エラー配列クリア
this.errors = [];
if (this.loginForm.value.user==='') this.errors.push("ユーザー名を空白にはできません");
if (this.loginForm.value.password==='') this.errors.push("パスワードを空白にはできません");
//この段階でエラーなら戻る
if (0 < this.errors.length) {
return;
}
//サービスからObservableを取得
const user:Observable<LoginUser> = this.loginService.login(this.loginForm.value.user,this.loginForm.value.password);
//Wait画面の表示
this.blnLoading = true;
//処理実行
user.subscribe(
(loginUser: LoginUser) => {
//Wait画面を消す
this.blnLoading = false;
if(0 < loginUser.result) {
//ログインページを呼び出した元へ戻る
this.router.navigateByUrl(this.strNextPage);
} else {
this.errors.push("ユーザーIDまたはパスワードが不正です");
}
},
(error) => {
//Wait画面を消す
this.blnLoading = false;
console.log(error);
this.errors.push("通信エラーが発生しました");
}
);
}
}
CSS部は前回と変わっていません。
参考にさせていただいたサイトの皆様、ありがとうございました。