[PHP]JSON+cURLで時刻同期型ワンタイムパスワードによる認証

外部サーバーと通信する際、許可されたクライアントであるかを確認するためにワンタイムパスワードによる認証を必要とする場合があります。 ワンタイムパスワードにはいくつか種類がありますが、ここでは時刻同期型のワンタイムパスワードを使って認証を行ってみます。

この方式は、現在時刻を共通のパスフレーズで暗号化し、クライアントとサーバーで同じ値になるかを確認し、認証します。

注意:ここで提示しているサンプルは解説のために簡易化したものであり、十分な安全性は考慮されていません。

【クライアント側】
<?php
//cURLによるJSONデータ送信
function post_param($url, $params=array()){
	$conn	 = curl_init($url);
	$content = json_encode($params);
	$header	 = array(
		'Content-type: application/json; charset=UTF-8',
		'Content-Length: ' . strlen($content)
	);

	curl_setopt($conn, CURLOPT_CUSTOMREQUEST, "POST");
	curl_setopt($conn, CURLOPT_POSTFIELDS, $content);
	curl_setopt($conn, CURLOPT_CONNECTTIMEOUT, 30);
	curl_setopt($conn, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($conn, CURLOPT_HEADER, $header);

	// SSL の証明書を検証しない
	curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, false);
	curl_setopt($conn, CURLOPT_SSL_VERIFYHOST, false);

	for ( $retry=0; $retry < 3; $retry++ ) {
		$res	 = curl_exec($conn);
		$info	 = curl_getinfo($conn);

		if($res === false || $info === false){
			sleep(3);
		} else {
			break;
		}
	}
	curl_close($conn);
	
	$body = substr ($res, $info["header_size"]);
	return json_decode($body, true);
}

date_default_timezone_set('UTC');

// 暗号化用パスフレーズ(必ず変更すること)
$salt = 'bOZGM6IZ5Od05wRMUA3ASUEZsPQOnslf';

// 通信先URL(例)
$url = 'http://localhost/server.php';

// ユーザーID
$id = 1;

$time = strtotime( date("Y-m-d H:i:00") );

$token = hash('sha256', $salt . $id . $time);

$param = array('id' => $id, 'token' => $token);
$res = post_param($url, $param);

echo $res['status'];
・送信内容(例)
Array
(
    [id] => 1
    [token] => 084750a0b42140de67296799bbfee02f68bebec3a5a5f337e13441367dac8b53
)
【サーバー側】
<?php
// ユーザーによってパスフレーズを切り替える
function get_user_salt($id){

	// 本来はMySQLなどのデータベースから取得するべき
	switch($id){
		case(1): $salt = 'bOZGM6IZ5Od05wRMUA3ASUEZsPQOnslf'; break;
		case(2): $salt = '2WsxL8hMfArsYT5psSFdHhR3Gfr9pQfY'; break;
		case(3): $salt = 'Si68GYAbnppYUH3745PbzEQQDEYUbiTc'; break;
		case(4): $salt = 'tHMKD4F8MHxLEiRbAK8NV3sLbShiJXKs'; break;
		case(5): $salt = '9K6nSS9j3GMYk6KDRB3aiYf4k3f24nkd'; break;
		default: $salt = ""; break;
	}
	
	return $salt;
}

date_default_timezone_set('UTC');
$json	 = file_get_contents('php://input');
$content = json_decode($json, true);

$id = isset($content['id'])	 ? $content['id']	 : null;
$client_token = isset($content['token']) ? $content['token'] : null;

// データベースからユーザーIDに紐付けられたパスフレーズを取得
$salt = get_user_salt($id);

// 前後1分を調べる
$server_token = array();
for($i=-1;$i<=1;$i++){
	$time = mktime( date('H'), date('i') + $i, 0, date('m'), date('d'), date('Y'));
	$server_token[] = hash('sha256', $salt . $id . $time);
}

$result = array();

if(in_array($client_token, $server_token)){
	$result['status'] = 'SUCCEEDED';	// 認証成功
} else {
	$result['status'] = 'FAILED';		// 認証失敗
}

header('Content-type: application/json');
echo json_encode($result);
・結果
SUCCEEDED

クライアントの PHP を実行すると cURL でサーバーに ID と認証用トークンが送信され、正しく認証された場合、配列 status として「SUCCEEDED」が返り、失敗した場合「FAILED」が返ります。
パスフレーズを用いてハッシュを行うので、この $salt は推測されにくいランダムな値に変更して下さい。この値はクライアントとサーバーで同じものを使う必要があります。
今回トークンは「パスフレーズ(salt) + ユーザーID + タイムスタンプ」を SHA-256 によるハッシュで暗号化して作りました。

サンプルなのでユーザー別のパスフレーズを取得する際(get_user_salt)、switch() を使って直書きした値を返していますが、
実際にはソースコードに直接書かずに MySQL などのデータベースに登録した内容を返すようにして下さい。
ID が 1 の時の $salt の内容がクライアント側の $salt と同じ値になっています。

時刻を同期するため、サーバーとクライアントのタイムゾーンは共通のものを使用する必要があります。
上の例では UTC(協定世界時) を使っていますが「Asia/Tokyo」などでも問題有りません。
認証には時刻の分単位までを比較するので秒は省きます。
具体的には秒を常に「0 秒」としてタイムスタンプを生成します。
サーバー側では前後一分のずれを許容するために、現在時刻に「-1 分」「+0 分」「+1 分」した3つのタイムスタンプをもとにしたトークン作っておき、クライアントから送信された認証用トークンがその中に含まれているかを in_array() で調べます。

トークンの内容は 1 分ごとに変化するので、パスフレーズ(salt)の内容を知られない限り推測はかなり困難になります。
とはいえ中間者攻撃にはあまり強くないため、より安全な通信を行うのであれば SSL を利用して通信そのものを暗号化して下さい。

送信内容を JSON にしたのは Javascript との連携が取りやすいことと、文字型・数値型の区別があること、多次元の連想配列が使いやすいことが理由です。
PHP で使うことだけを前提としているのであれば serialize() を使うのも良いと思います。

[PHP]ページごとの訪問者数を調べるアクセスカウンター

ページごとに訪問者数をカウントするためには、概ね次のような流れを取ります。

・訪問者の IP アドレスを取得する
・同じ日に同一の IP アドレスが記録されていないか調べる
・その日に初めてアクセスしていた場合はカウントを増やす
・訪問者の IP アドレスを記録する

一日のログのうち、直近100件に同じ人がアクセスしていなければ1増えるという条件で、カウンターをクラス化してみました。
今回はデータベース代わりにテキストファイルを利用した簡素なものなので MySQL 等の RDBMS は不要です。

[view_counter.class.php]

<?php
class ViewCounter
{
	const SALT = 'hello_world.12345';
	private $log_dir;
	private $db_dir;
	
	function __construct($log_dir=null, $db_dir=null){
		//ログディレクトリ設定
		$this->log_dir	 = is_null($log_dir) ? dirname(__FILE__) . '/log/' : $log_dir;
		$this->db_dir	 = is_null($db_dir)	 ? dirname(__FILE__) . '/db/' : $db_dir;
	}

	//IPをチェックしてカウントを増やす
	function log($id){
		$ip = date("Ymd_") . md5($this::SALT . $_SERVER['REMOTE_ADDR']);

		$file = $this->log_dir . $id . '_' . md5($this::SALT . $id) . '.log';
		
		$data = array();
		$flag = true;
		
		$fp = fopen($file, 'a+b');
		flock($fp, LOCK_EX);
		
		//直近100件までを読み込む
		for($i=0;$i<100;$i++){
			if(feof($fp)) break;
			$line = fgets($fp);
			if($ip === rtrim($line)){
				$flag = false;
				break;
			} else {
				$data[] = $line;
			}
		}
	
		if($flag){
			array_unshift($data, $ip . "\n");
			ftruncate ($fp, 0);
			rewind($fp);
			foreach($data as $value){
				fwrite($fp, $value);
			}
		}
		
		fflush($fp);
		flock($fp, LOCK_UN);
		fclose($fp);
		
		if($flag){
			$count = $this->count_up($id);
		} else {
			$count = $this->get_count($id);
		}

		return $count;
	}
	
	//データベースのカウントを増やす
	function count_up($id, $num=1){
		$file = $this->db_dir . $id . '_' .md5($this::SALT . $id) . '.log';
		
		if(file_exists($file)){
			$count = (int)file_get_contents($file);
		} else {
			$count = 0;
		}
		
		if($num > 0){
			$count = $count + $num;
			file_put_contents( $file, $count, LOCK_EX );
		}
		return $count;
	}
	
	//データベースのカウントを得る
	function get_count($id){
		$count = $this->count_up($id, 0);
		return $count;
	}
}

【使い方】

<?php
include_once 'view_counter.class.php';
$counter = new ViewCounter();

// ページ固有のID
$id = 1234;
$count = $counter->log( $id );

echo $count;

使用する前に書き込み可能な2つのディレクトリを作成します。
ひとつは訪問者の IP を記録するディレクトリ($log_dir)、 もう一つはページごとの総アクセス数を記録するディレクトリ($db_dir)です。
必要に応じてディレクトリ内に「.htaccess」ファイルを設置し「deny from all」を記述して直接アクセスされないようにしたほうが良いでしょう。
指定方法は直接 __construct の初期設定を書き換えるか、new でインスタンス化する際に引数として与えます。

$log_dir = dirname(__FILE__) . '/log/';
$db_dir  = dirname(__FILE__) . '/db/';
$counter = new ViewCounter($log_dir, $db_dir);

「const SALT」の内容は適切な値に書き換えて下さい。
暗号化に使用するだけなので推測されにくい文字列であれば構いません。

$id の値はページ番号やファイル名など、ページ固有の値を指定します。
この ID ごとにアクセスが記録されます。
複数のページで同じ ID を用いればグループ単位でのアクセスカウンターにも出来ます。

「for($i=0;$i<100;$i++)」となっているのは、ログファイルから同一 IP の訪問者を検索する際、直近 100 行を調べるように制限しているからです。
これはログファイルの肥大化を防ぐためのものですが、厳密に一人一日1回までとしたい場合はこの記述を削除するか、大きな数字にしておくのがいいと思います。

一日一回ということを判断するために、記録する IP アドレスの先頭に年月日をつけています。
IP アドレスはプライバシー保護の都合上暗号化していますが、不正なアクセスをするユーザーを調べたいのであれば md5() を使わずに生の IP で記録するのも手ですが、他人の目に触れないように配慮が必要です。

実際にアクセスをカウントするには「log( $id )」を使います。
実行すると IP アドレスが記録され、現在のアクセス数が返ります。
カウントを増やさずにアクセス数だけ取得したい場合は「get_count($id)」で取得できます。

[PHP]ディレクトリ内の一定時間が経過した古いファイルだけを削除する

フォルダ内の古くなったファイルだけを消すには、
filemtime() で最終更新日を取得し、unlink() で削除します。

最終更新日は Unix 時間で取得されるので削除期限の指定には strtotime() を使いました。
「24 hours ago」であれば 24 時間前より古いファイルが削除されます。
単純に 「time() – (60 * 60 * 24)」としたり、mktime() を使ったりするのも良いでしょう。

【注意】
このスクリプトを実行するとファイルが削除されます。
実行前に必ず echo などで削除対象のファイルが適切に指定されているか確認して下さい。

<?php
date_default_timezone_set('Asia/Tokyo');

//削除期限
$expire = strtotime("24 hours ago");

//ディレクトリ
$dir = dirname(__FILE__) . '/dir/';

$list = scandir($dir);
foreach($list as $value){
	$file = $dir . $value;
	if(!is_file($file)) continue;
	$mod = filemtime( $file );
	if($mod < $expire){
		//chmod($file, 0666);
		unlink($file);
	}
}

削除にパーミッションの変更が必要な場合は chmod() で適切な権限に変更して下さい。
それでも削除できない場合はディレクトリのパーミッションや所有者を確認して下さい。

ディレクトリが多階層になっていて、指定したディレクトリ以下の全てのファイルを再帰的に探索して削除するには次のようにします。

<?php
date_default_timezone_set('Asia/Tokyo');

//削除期限
$expire = strtotime("24 hours ago");

//ディレクトリ
$dir = dirname(__FILE__) . '/dir/';

remove_old_files($dir, $expire);
 
function remove_old_files($dir, $timestamp){
	$iterator = new RecursiveIteratorIterator(
		new RecursiveDirectoryIterator(
			$dir,
			 FilesystemIterator::CURRENT_AS_FILEINFO
			|FilesystemIterator::SKIP_DOTS
			|FilesystemIterator::KEY_AS_PATHNAME
		), RecursiveIteratorIterator::LEAVES_ONLY
	);

	foreach($iterator as $pathname => $info){
		if($info->getMTime() < $timestamp){
			//chmod($pathname, 0666);
			unlink($pathname);
		}
	}
}

RecursiveCallbackFilterIterator を利用すると除外項目をより詳細に設定できます。
下の例では 指定した時間より古く、指定した項目名でないもの を条件に処理をしています。

<?php
date_default_timezone_set('Asia/Tokyo');

//削除期限
$expire = strtotime("24 hours ago");

//ディレクトリ
$dir = dirname(__FILE__) . '/dir/';

// 除外項目
$ignores = [
    $dir . 'example'
];

remove_old_files($dir, $expire, $ignores);

function remove_old_files($dir, $timestamp, $ignores = [])
{
	// フィルター処理
    $filter = function ($file, $key, $iterator) use ($timestamp, $ignores) {
        if ($iterator->hasChildren()) {
            return true;
        }

		// 除外対象を正規表現で選別
        foreach ($ignores as $ignore) {
            $pattern = '/^' . preg_quote($ignore, '/') . '/';
            if (preg_match($pattern, $file)) {
                return false;
            }
        };

		// 古くない項目は除外
        if ($file->getMTime() >= $timestamp)  return false;

        return true;
    };

    $dirIterator = new RecursiveDirectoryIterator(
        $dir,
         FilesystemIterator::CURRENT_AS_FILEINFO
        |FilesystemIterator::SKIP_DOTS
        |FilesystemIterator::KEY_AS_PATHNAME
    );

    $iterator = new RecursiveIteratorIterator(
       new RecursiveCallbackFilterIterator($dirIterator, $filter),
        RecursiveIteratorIterator::LEAVES_ONLY
    );

    foreach ($iterator as $pathname => $info) {
        //chmod($pathname, 0666);
        unlink($pathname);
    }
}