プログラミングファン

「チャレンジ&レスポンス」で「WEB API」を利用した「通信プログラム」を作る方法とは!?

今回は、普段なら「フレームワーク・ライブラリ」で「良しなに」してくれそうなところをご紹介していこうと思います。

「会員制WEBサイト」では、「会員しかアクセスできないページ」で「ユーザー認証」が必要になりますが、近年では「Ajax」などでの通信を行うことが多いため、サーバー側に「WEB API」を用意して「Javascript」から「WEB API」に必要な情報をリクエストすることも多いのではないでしょうか。

「WEB API」で「ユーザー認証」をする際に「チャレンジ&レスポンス」を利用してプログラムを作る方法をお話していきたいと思います。

「WEB」を利用した通信

「会員制WEBサイト」では、「ユーザー認証」が必要になりますが、通常の「画面遷移」が発生するシーンでは、「セッション」などの「サーバー側」で保存している情報の有無から「ログイン状態」などを確認することができます。

しかし、現在の「WEBページ」の作成では「SPA(シングルページアプリケーション)」などの手法が採用されることも多く、「WEB API」を利用した通信を利用する機会が多くなってきています。

「WEB API」を利用しない通信

始めに「WEB API」を利用しない場合について見ていきたいと思います。

まず、「クライアント」から「サーバー」に必要なファイルを「リクエスト」します。

「リクエスト」を受け取った「サーバー」は、ファイル内の「HTMLコード」を「クライアント」へ送信します。

このプロセスを繰り返すことで、ユーザーは「WEBページ」を閲覧することができます。

通信のイメージは下図のようになります。

WEB APIを利用しない通信

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

WEB APIを利用しない通信(ユーザー認証)

上図の例では、「クライアント」の「クッキーに保存されたID」が「u3edq8wj」で、「セッション」の内容と照合すると、「ユーザーB」が「u3edq8wj」のため、「ユーザーBのアクセスである」と認証され、「HTMLコード」が「クライアント」に送られます。

「認証に必要なID」は「ログイン処理」時にあらかじめ「クライアントのクッキー」と「サーバーのセッション」に保存しておきます。

もし「該当ユーザー」が見つからない場合は、「未認証ユーザー」がアクセスをしているため、「ログインページ」などにリダイレクトをします。

「WEB API」を利用した通信

「シングルページアプリケーション(SPA)」などは、「画面遷移」が発生しないため、「WEB API」を利用した通信を行います。

通信は「Javascript」などのプログラムで制御することになりますので、今回は、「JQUERY」の「Ajaxメソッド」を利用した方法をご紹介していきたいと思います。

他にも「XMLHttpRequest(XHR)」や「axios」などのライブラリを利用した方法もあります。

通信のイメージは、下図のようになります。

WEB APIを利用した通信

「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開発」が行えるソフトウェアです。

→「MAMP」

ソフトウェアをダウンロードして、インストールするだけで利用できるため、初心者の方でも開発に取り組みやすいのではないでしょうか。

今回ご紹介しているプログラムには、処理の内容をコメントで付記していますので、処理の内容については「コメントの内容」を参照してみてください。

ログイン

まず、「ログイン処理」に関連するファイルをご説明していきたいと思います。

「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」を利用して行っています。

アイテム一覧取得(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';
    }
}

全体の通信の流れは、下図のようになります。

アイテム一覧取得(WEB API)~全体の処理の流れ~

この通信では、「クライアント」から「サーバー」へ2回の「データ送信」を行っています。

「クライアント」と「サーバー」の処理の量が増えることと、通信回数も増えますが、「レスポンス」を何回も再利用する場合と比べて、より安全に通信を行うことができます。

今回作成したプログラムは「技術の説明用」のプログラムであるため、「実用できるレベル」の実装ではありませんが、実際の開発では「フレームラーク・ライブラリ」を利用して安全に通信を行う方が良いでしょう。

「安全に通信を行う方法」を知っておくことはセキュリティの観点からも重要なため、「チャレンジ&レスポンス」の仕組みについてご説明をしてきましたが、「安全なプログラムを作れるプログラマ」になるためにも、さまざまな「セキュリティ技術」について学んでみてください。

HOMEへ