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