「チャレンジ&レスポンス」で「WEB API」を利用した「通信プログラム」を作る方法とは!?
今回は、普段なら「フレームワーク・ライブラリ」で「良しなに」してくれそうなところをご紹介していこうと思います。
「会員制WEBサイト」では、「会員しかアクセスできないページ」で「ユーザー認証」が必要になりますが、近年では「Ajax」などでの通信を行うことが多いため、サーバー側に「WEB API」を用意して「Javascript」から「WEB API」に必要な情報をリクエストすることも多いのではないでしょうか。
「WEB API」で「ユーザー認証」をする際に「チャレンジ&レスポンス」を利用してプログラムを作る方法をお話していきたいと思います。
「WEB」を利用した通信
「会員制WEBサイト」では、「ユーザー認証」が必要になりますが、通常の「画面遷移」が発生するシーンでは、「セッション」などの「サーバー側」で保存している情報の有無から「ログイン状態」などを確認することができます。
しかし、現在の「WEBページ」の作成では「SPA(シングルページアプリケーション)」などの手法が採用されることも多く、「WEB API」を利用した通信を利用する機会が多くなってきています。
「WEB API」を利用しない通信
始めに「WEB API」を利用しない場合について見ていきたいと思います。
まず、「クライアント」から「サーバー」に必要なファイルを「リクエスト」します。
「リクエスト」を受け取った「サーバー」は、ファイル内の「HTMLコード」を「クライアント」へ送信します。
このプロセスを繰り返すことで、ユーザーは「WEBページ」を閲覧することができます。
通信のイメージは下図のようになります。

このケースのそれぞれのページへのアクセスの「ユーザー認証」は、各ページにアクセスした際に「クライアントのクッキーに保存したID」と「サーバーのセッションに保存したID」を照合すると「認証されたユーザー」であるかや「どのユーザーがアクセスしているのか?」をサーバー側で識別することができます。

上図の例では、「クライアント」の「クッキーに保存されたID」が「u3edq8wj」で、「セッション」の内容と照合すると、「ユーザーB」が「u3edq8wj」のため、「ユーザーBのアクセスである」と認証され、「HTMLコード」が「クライアント」に送られます。
「認証に必要なID」は「ログイン処理」時にあらかじめ「クライアントのクッキー」と「サーバーのセッション」に保存しておきます。
もし「該当ユーザー」が見つからない場合は、「未認証ユーザー」がアクセスをしているため、「ログインページ」などにリダイレクトをします。
「WEB API」を利用した通信
「シングルページアプリケーション(SPA)」などは、「画面遷移」が発生しないため、「WEB API」を利用した通信を行います。
通信は「Javascript」などのプログラムで制御することになりますので、今回は、「JQUERY」の「Ajaxメソッド」を利用した方法をご紹介していきたいと思います。
他にも「XMLHttpRequest(XHR)」や「axios」などのライブラリを利用した方法もあります。
通信のイメージは、下図のようになります。

「Javascript」のプログラムから「サーバー」の「WEB API」に「リクエスト」を送信し、「レスポンス」として「JSON・XML」などのデータを「サーバー」から受信します。
このプロセスを繰り返すことで、「画面遷移」は行わず、「ページ内の情報」を更新していきます。
「表示に必要なデータ」のみを受信するため、「HTMLコード」を受信する場合と比較すると、「通信データ量」を削減することもできます。
「ユーザー認証の仕組み」は「WEB APIを利用しない通信」と手順はほとんど変わりません。
「クライアント」に保存した「識別用データ」を、「サーバー」へ送信し、「サーバー」のデータと照合して認証します。
今回は「チャレンジ&レスポンス」という仕組みを利用して、「ユーザー認証」を行う方法について、簡易的な「サンプルプログラム」を書きながらご説明していきたいと思います。
チャレンジ&レスポンス
「チャレンジ&レスポンス」は「パスワード認証」を行う仕組みですが、「サーバー」で「チャレンジ」と呼ばれる値を生成し、「クライアント」へ送信します。
「クライアント側」では、「受け取ったチャレンジ」と「パスワード」を元に「レスポンス」と呼ばれる値を生成し、「サーバー」へ送信します。
「サーバー」側では、「サーバーで保存している値(チャレンジとパスワード)」を元に「クライアント」と同様の手順で「レスポンス」を作成し、「クライアントから受け取ったレスポンス」と照合します。
「照合」の結果同じ値であれば「認証されたユーザー」だとわかります。
手順が少し複雑ですが、下図のようなイメージになります。

この手法のポイントは「クライアント」も「サーバー」も「パスワード」を送信していないことです。
「パスワード」を送信しなくても認証ができるため、「パスワード」を送信して認証する場合と比べて、安全に認証を行うことができます。
サンプルプログラムの作成
次に「チャレンジ&レスポンス」を利用したサンプルプログラムを作っていきたいと思います。
今回作成するファイルは下記の様になります。
- regist.php
- ユーザー登録用ページ
- login.html
- ログインページ
- login.php
- ログイン処理用ページ
- logout.php
- ログアウトページ
- item_list.html
- 商品一覧表示用ページ
- item_list_api.php
- 商品一覧表示用API
この6つのファイルの中で、「WEB API」で通信を行っているのは、
- item_list.html
- item_list_api.php
の2つのファイルです。
ユーザー登録
「ユーザー登録用ページ」の「regist.php」の内容は下記のようになります。
<?php
$errorFlg = false; //エラーフラグ
$existFlg = false; //ユーザーの存在フラグ
if( $_SERVER['REQUEST_METHOD'] === 'POST' ){
$name = $_POST['name']; //「ユーザー名」の取得
$password = $_POST['password']; //「パスワード」の取得
$pattern = '/^[0-9a-zA-Z:;!#&@%+$"<>]{8,20}$/'; //正規表現パターン
//「ユーザー名」と「パスワード」のチェック
if( preg_match($pattern, $name) !== 1 || preg_match($pattern, $password) !== 1){
//「ユーザー名」と「パスワード」にエラーがある場合
$errorFlg = true;
} else {
//「ユーザー名」と「パスワード」にエラーが無い場合
try {
$password_hash = password_hash($password, PASSWORD_DEFAULT); //「パスワードハッシュ」の生成
$dbh = new PDO('mysql:dbname=test;host=localhost;charset=utf8','root', 'root');
//既存ユーザー名のチェック
$sql = 'SELECT id, name, password FROM users WHERE name = ?';
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $name, PDO::PARAM_STR);
$statement->execute();
$user = $statement->fetchAll(PDO::FETCH_ASSOC);
if( count($user) !== 0){
//既存ユーザー名が存在する場合
$existFlg = true;
} else {
//「ユーザー名」と「パスワード」を「データベース」に登録
$statement = $dbh->prepare('INSERT INTO users (name,password) values (?, ?);');
$statement->bindValue(1, $name, PDO::PARAM_STR);
$statement->bindValue(2, $password_hash, PDO::PARAM_STR);
$statement->execute();
}
}catch (PDOException $e) {
print 'error';
}
}
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ユーザー登録</title>
<script src="jquery-3.5.1.min.js"></script>
<link rel="stylesheet" href="./css/common.css">
</head>
<body>
<form action="#" method="POST" id="regist_form">
<h1>ユーザー登録</h1>
<?php if( $_SERVER['REQUEST_METHOD'] === 'POST' ){
if($errorFlg){ ?>
<p id="notice_error">下記の注意事項をお読みの上、正しい名前とパスワードを入力してください。</p>
<?php } else if($existFlg){ ?>
<p id="notice_error">入力された「ユーザー名」は既に存在しています。</p>
<?php } else { ?>
<p>ユーザー登録が完了しました。ログインを行ってください。</p>
<?php } ?>
<?php } ?>
<p>名前:<input type="text" name="name" value="hogehoge"></p>
<p>パスワード:<input type="password" name="password" value="fugafuga"></p>
<div id="notice">
<p>※「名前」と「パスワード」は、8文字以上20文字以内で入力してください。</p>
<p>※大文字小文字のアルファベット・数字・記号(:;!#&@%+$)を半角で入力してください。</p>
</div>
<input type="submit" id="regist_btn" value="登録">
<a href="./login.html">ログインへ</a>
</form>
</body>
</html>
今回は、「MAMP」を利用してプログラムを作成しているため、「データベース」は「MySQL」を利用していますので、「データベースの接続情報」も「MAMP」の設定になっています。
ちなみに、「MAMP」は「ローカルWEB開発」が行えるソフトウェアです。
ソフトウェアをダウンロードして、インストールするだけで利用できるため、初心者の方でも開発に取り組みやすいのではないでしょうか。
今回ご紹介しているプログラムには、処理の内容をコメントで付記していますので、処理の内容については「コメントの内容」を参照してみてください。
ログイン
まず、「ログイン処理」に関連するファイルをご説明していきたいと思います。
「login.html」の内容は、
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
<script src="jquery-3.5.1.min.js"></script>
<link rel="stylesheet" href="./css/common.css">
<script>
$(function () {
//「ログインボタン」のイベントリスナーの設定
$('#login_btn').click(login);
});
/**
* ログイン処理(レスポンスの生成)
*/
function login() {
let response = ''; //レスポンス格納用
$.ajax({
type: 'POST', //送信方式を指定
url: './login.php', //送信先を指定
data: { name: $('#name').val(), password: $('#password').val() } //送信データ(ユーザー名・レスポンス)を指定
}).done(function (responseData, textStatus, jqXHR) {
if (responseData === 'unauthorized') {
//ユーザー認証失敗時の処理
$('#notice_error').html('「ユーザー名」または「パスワード」が間違っています。');
} else {
//ユーザー認証成功時の処理
//レスポンスの作成
let promise = new Promise(resolve => digestMessage(responseData))
async function digestMessage(message) {
const encoder = new TextEncoder(); //「テキストエンコーダー」の生成
const data = encoder.encode(message); //「Uint8Array」のデータ取得
//「SHA256ハッシュ」の生成
await crypto.subtle.digest('SHA-256', data).then(function (digest_binary) {
var digest_array = new Uint8Array(digest_binary, 0, 32); //「Uint8Array」のデータ再生成
//「SHA256ハッシュバイナリ」から「16進数のSHA256ハッシュ」を取得
digest_array.map(value => {
response += value.toString(16).padStart(2, '0');
});
//クッキーへ「ユーザー名」を保存
document.cookie = "name=" + $('#name').val() + "; samesite=lax;";
//クッキーへ「SHA256ハッシュデータ」を保存
document.cookie = "response_hash=" + response + "; samesite=lax;";
//サーバーへレスポンスを送信
$.ajax({
type: 'POST', //送信方式を指定
url: './login.php', //送信先を指定
data: { name: $('#name').val(), response_hash: response } //送信データ(ユーザー名・レスポンス)を指定
}).done(function (responseData, textStatus, jqXHR) {
//サーバー側のレスポンスのデータベースの保存から完了後「アイテム一覧ページ」へ遷移
window.location.href = './item_list.html';
}).fail(function (jqXHR, textStatus, errorThrown) {
//サーバー側のレスポンスのデータベースの保存失敗時の処理
$('#notice_error').html('ログイン処理が実行できません。');
});
})
}
}
}).fail(function (jqXHR, textStatus, errorThrown) {
$('#notice_error').html('ログイン処理が実行できません。');
});
}
</script>
</head>
<body>
<div id="login_form">
<h1>ログイン</h1>
<p id="notice_error"></p>
<p>名前:<input type="text" id="name" value="hogehoge"></p>
<p>パスワード:<input type="password" id="password" value="fugafuga"></p>
<button id="login_btn">ログイン</button>
<p><a href="./regist.php">ユーザー登録を行う</a></p>
</div>
</body>
</html>
「common.css」ファイルをインポートしていますが、ファイルの内容は下記のようになります。
h1 {
text-align: center;
border-bottom: solid 1px #cccccc;
}
a {
display: block;
}
#notice {
border: solid 1px #cccccc;
border-radius: 10px;
padding: 0 10px;
}
#notice_error {
color: #ff0000;
font-weight: bold;
}
#login_btn, #regist_btn {
width: 100%;
text-align: center;
padding: 10px;
border-radius: 10px;
margin: 5px auto;
}
#login_form, #regist_form {
width: 270px;
margin: 0 auto;
}
#login_form a,#regist_form a {
text-align: right;
}
#login_form input, #regist_form input {
float: right;
}
この「CSSファイル」は他のファイルからもインポートして利用している「共用CSS」です。
「JQUERY」ファイルも同じくインポートしています。
このファイルを表示すると下図のようになります。

「ログイン画面」に入力された「ユーザー名」と「パスワード」は「login.php」に送信されます。
そして、「login.php」で「名前(ユーザー名)」と「パスワード」が存在するかを「データベース」のデータと照合します。

「ユーザー」が存在していたら、前出の「チャレンジ&レスポンス」に関する処理を実行していきます。
「login.php」の内容は下記になります。
<?php
try {
$dbh = new PDO('mysql:dbname=test;host=localhost;charset=utf8','root', 'root');
if( $_SERVER['REQUEST_METHOD'] === 'POST' ){
$name = $_POST['name']; //「ユーザー名」の取得
if( isset($_POST['response_hash']) === true ){
$response = $_POST['response_hash']; //「レスポンス(SHA256ハッシュ値)」の取得
//「レスポンス(SHA256ハッシュ値)」の「データベースの値」を更新
$sql = 'UPDATE users SET response = ? WHERE name = ?';
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $response, PDO::PARAM_STR);
$statement->bindValue(2, $name, PDO::PARAM_STR);
$statement->execute();
} else {
$password = $_POST['password']; //「パスワード」の取得
//「ID・ユーザー名・パスワードハッシュ」を取得
$sql = 'SELECT id, name, password FROM users WHERE name = ?';
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $name, PDO::PARAM_STR);
$statement->execute();
$user = $statement->fetchAll(PDO::FETCH_ASSOC);
if( count($user) !== 0){
//「ユーザー」が存在している場合「パスワード」を照合
if(password_verify($password,$user[0]['password'])){
//ユーザー認証成功時に「チャレンジ」を生成し「クライアント」へ送信
$hash_word = 'hogefuga' . time() . $user[0]['password'];
$challenge = hash('sha256', $hash_word);
print $challenge;
} else {
print 'unauthorized';
}
} else {
//ユーザーが存在しない場合
print 'unauthorized';
}
}
}
}catch (PDOException $e) {
print 'error';
}
ログアウト
「ログアウト」を行っている「logout.php」の内容は下記のようになります。
<?php
//「ユーザー名」のクッキーが存在するかどうか?
if (isset($_COOKIE['name']) === TRUE) {
$name = $_COOKIE['name']; //「名前」
try {
//「データベース」に保存している「レスポンス」を削除
$dbh = new PDO('mysql:dbname=test;host=localhost;charset=utf8','root', 'root');
$sql = "UPDATE users SET response = '' WHERE name = ?";
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $name, PDO::PARAM_STR);
$statement->execute();
//「クライアント」のクッキー(ユーザー名&レスポンス)を削除
setcookie('name', '', time() - 3600);
setcookie('response_hash', '', time() - 3600);
}catch (PDOException $e) {
print 'error';
}
// ログアウトの処理が完了したら「ログインページ」へ遷移
header('Location: ./login.html');
exit;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ログアウト</title>
</head>
<body>
<p>あなたは現在ログインしていません。</p>
<a href="./login.html">ログインへ</a>
</body>
</html>
「ログアウト処理」では、「クライアント」と「サーバー」の「レスポンス」を削除しています。
アイテム一覧表示
ログイン後に「アイテム一覧ページ」に遷移しますが、「サーバー」からの「アイテムデータ取得」は「WEB API」を利用して行っています。

通信ごとに「レスポンス」を異なるものに変更していくことで、より安全に通信を行うことができます。
「item_list.html」の内容は、下記の様になります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>商品一覧</title>
<script src="jquery-3.5.1.min.js"></script>
<link rel="stylesheet" href="./css/common.css">
<link rel="stylesheet" href="./css/item_list.css">
<script>
//クッキーからデータを取得
let name = getCookieByName('name'); //ユーザー名を取得
let response_hash = getCookieByName('response_hash'); //レスポンスを取得
$(function () {
let response = ''; //レスポンスハッシュを格納
$.ajax({
type: 'POST', //送信方式を指定
url: './item_list_api.php', //送信先を指定
data: { name: name, response_hash: response_hash } //送信データ(ユーザー名・レスポンス)を指定
}).done(function (responseData, textStatus, jqXHR) {
//通信成功時の処理
if (responseData !== 'error' && responseData !== 'unauthorized') {
let receive_response = JSON.parse(responseData); //「JSONデータ」をパース
let challenge = receive_response[0]; //チャレンジを取得
//アイテムデータを表示
displayItems(receive_response[1]);
let response = ''; //レスポンスを格納
//レスポンスの作成
let promise = new Promise(resolve => digestMessage(responseData))
async function digestMessage(message) {
const encoder = new TextEncoder(); //「テキストエンコーダー」の生成
const data = encoder.encode(message); //「Uint8Array」のデータ取得
//「SHA256ハッシュ」の生成
await crypto.subtle.digest('SHA-256', data).then(function (digest_binary) {
let digest_array = new Uint8Array(digest_binary, 0, 32); //「Uint8Array」のデータ再生成
//「SHA256ハッシュバイナリ」から「16進数のSHA256ハッシュ」を取得
digest_array.map(value => {
response += value.toString(16).padStart(2, '0');
});
//クッキーへ「「SHA256ハッシュデータ」を保存
document.cookie = "response_hash=" + response + "; samesite=lax;";
//サーバーへ「レスポンス」を送信
$.ajax({
type: 'POST', //送信方式を指定
url: './item_list_api.php', //送信先を指定
data: { name: name, response_hash: response, send_response: true } //送信データ(ユーザー名・レスポンス)を指定
}).fail(function (jqXHR, textStatus, errorThrown) {
//通信エラー時の処理
$('#notice_error').html('エラーが発生しました。');
});
})
}
}
}).fail(function (jqXHR, textStatus, errorThrown) {
//通信エラー時の処理
$('#notice_error').html('エラーが発生しました。');
});
});
/**
* アイテムを表示
* @param items 表示データ
*/
function displayItems(items) {
items.map(item => {
$('#item_list_tb').append('<tr></tr>');
$('tr:last-child').append('<td>' + item['name'] + '</td>');
$('tr:last-child').append('<td>' + item['price'] + '</td>');
});
}
/**
* クッキーの取得
* @param name 取得するクッキー名
*/
function getCookieByName(name) {
let cookie_value = '';
let cookies_array = document.cookie.split(';');
cookies_array.map(cookie => {
let data = cookie.split('=');
if (data[0].trim() === name) {
cookie_value = data[1];
}
});
return cookie_value;
}
</script>
</head>
<body>
<h1>商品一覧</h1>
<p id="notice_error"></p>
<table id="item_list_tb">
<tr>
<th>商品名</th>
<th>価格</th>
</tr>
</table>
<a href="./logout.php">ログアウト</a>
</body>
</html>
「アイテム一覧表示」に使用している「item_list.css」の内容は下記のようになります。
table{
width: 300px;
border-collapse: collapse;
}
table , tr, th, td{
border: solid 1px #5f5f5f;
}
th, td {
padding: 5px 10px;
}
そして、「item_list_api.php」の内容は下記の様になります。
<?php
//「POST送信」の場合
if( $_SERVER['REQUEST_METHOD'] === 'POST' ){
//「ユーザー名」の取得
$name = $_POST['name'];
//「レスポンス(SHA256ハッシュ値)」の取得
$response_hash = $_POST['response_hash'];
try {
$dbh = new PDO('mysql:dbname=test;host=localhost;charset=utf8','root', 'root');
if( isset($_POST['send_response']) === true ){
//2回目の通信時の処理(「クライアント」から「レスポンス(SHA256ハッシュ値)」を受信した場合)
//「レスポンス(SHA256ハッシュ値)」の「データベースの値」を更新
try {
$sql = 'UPDATE users SET response = ? WHERE name = ?';
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $response_hash, PDO::PARAM_STR);
$statement->bindValue(2, $name, PDO::PARAM_STR);
$statement->execute();
}catch (PDOException $e) {
print 'error';
}
} else {
//初回アクセス時の処理
$response = []; //レスポンス格納用
//「ユーザー名」と「SHA256ハッシュ値(レスポンス)」の確認
$sql = 'SELECT id FROM users WHERE name = ? and response = ?';
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $name, PDO::PARAM_STR);
$statement->bindValue(2, $response_hash, PDO::PARAM_STR);
$statement->execute();
$user = $statement->fetchAll(PDO::FETCH_ASSOC);
if( count($user) !== 0){
//「ユーザー名」と「SHA256ハッシュ値(レスポンス)」が合っている場合(認証成功)
//チャレンジを生成
$hash_word = 'hogefuga' . time() . response_hash; //「ハッシュ文字列」を生成
$challenge = hash('sha256', $hash_word); //「SHA256ハッシュ値」の生成
$response[] = $challenge;
//「アイテムデータ」を「データベース」から取得
$sql = 'SELECT * FROM items;';
$statement = $dbh->prepare($sql);
$statement->bindValue(1, $name, PDO::PARAM_STR);
$statement->bindValue(2, $response_hash, PDO::PARAM_STR);
$statement->execute();
$items = $statement->fetchAll(PDO::FETCH_ASSOC);
$response[] = $items;
$json = json_encode($response); //「アイテムデータ配列」を「JSON」へ変換
print $json;
} else {
//認証失敗
print 'unauthorized';
}
}
}catch (PDOException $e) {
print 'error';
}
}
全体の通信の流れは、下図のようになります。

この通信では、「クライアント」から「サーバー」へ2回の「データ送信」を行っています。
「クライアント」と「サーバー」の処理の量が増えることと、通信回数も増えますが、「レスポンス」を何回も再利用する場合と比べて、より安全に通信を行うことができます。
今回作成したプログラムは「技術の説明用」のプログラムであるため、「実用できるレベル」の実装ではありませんが、実際の開発では「フレームラーク・ライブラリ」を利用して安全に通信を行う方が良いでしょう。
「安全に通信を行う方法」を知っておくことはセキュリティの観点からも重要なため、「チャレンジ&レスポンス」の仕組みについてご説明をしてきましたが、「安全なプログラムを作れるプログラマ」になるためにも、さまざまな「セキュリティ技術」について学んでみてください。