見出し画像

Express.jsで認可をして、利用者の操作履歴を保存する ~ 401, 403, http-error, next() ~

プログラム自学案内の 40 回目です。

前回の記事では、Express.jsアプリにまやかしのサインイン機能を追加しました。今回は、Express.jsアプリで認可の仕組みをどのように入れればいいか紹介します。また、いかにもSNSっぽい機能、利用者ごとの操作履歴や「いいね」を保存・照会できる機能を作ります。

これまでの記事はこちらからご覧ください。


認可処理の紹介

まずは 認可(authorization) の仕組みを作ってみましょう。前々回の記事のおさらいになりますが、認可とは 権限のある人にだけ、データの読み書きなどの操作を許可するしくみです。

認可というのは認証ほど難しくはありません。単に、req.userに設定されたユーザ情報に応じて、if文などでプログラムの振る舞いを変えればよいのですから。

ですが、認可処理の作り方にも、しきたり があります。この記事ではそれを紹介します。

方針その1:HTTPのしきたりにあわせてエラーを返す

Webサービスでは、認可処理で、権限のない人に対して操作を拒否する場合、ステータスコード 401403 などの クライアントエラーレスポンス を応答するのが行儀のよいやり方です。

次に示す画像みたいにです。

403 エラーレスポンス が 応答されたところ

方針その2:ルーティングの前にやる

認可処理は、ルーティングモデルの処理が行われる前にすませてしまうのが、プログラムの 堅牢性 を保つために得策です。 すなわち、次のコードに示す場所で認可のプログラミングをします。

app.js

// (前略)

app.use(auth.session_authenticate); // 認証して利用者を特定

// ここに特定された利用者にたいする、認可判定処理を入れる!

app.use('/', router); // ルーティング

// (後略)

サンプル:磯野家画面に認可処理を入れる

さっそくサンプルコードをお見せしましょう。 ここでは、これまでの記事で作ってきたおだてまくりプログラムの、磯野家画面(/family)に対してのみ、認可を入れます。

まず、401や403を作るために、http-errors というライブラリをインストールします。

npm install http-errors

そうしたら、このモジュールをapp.jsでインポートして、

app.js

const createError = require('http-errors'); //追加

次の通り、認可のミドルウェアを追加します。これで、再度アプリを立ち上げると、サインインしていない場合には、磯野家画面が表示できなくなるはずです。ぜひ、動作を確認してみてください。

app.js

// (前略)

app.use(auth.session_authenticate);

// ここで認可のミドルウェアを追加
app.use('/family', (req, res, next) => {
    if (req.user) {
        next();
    } else {
        next(createError(401, 'サインインしないと利用できません'));
    }
});

app.use('/', router);

// (後略)

上のコードで、理解しづらいのが next()next(createError(401, 'サインインしないと利用できません')) だと思います。

next() は「後続のミドルウェアに進みなさい」という意味です。上のコードでは、req.user がある(認証された利用者情報がある)ならば、後続のミドルウェア、すなわち routerに処理が進みます。

一方、next()の引数にエラーを入れて next(error) とすると、「後続のミドルウェアに進まず、後続のエラー処理のミドルウェアに進みなさい」という意味になります。上のコードでは、 req.user がない(認証された利用者情報がない)とき、routerには処理は進まず、Express.jsフレームワークのデフォルトのエラー処理に進みます。そして、createError(401, …) で作られたエラーにしたがい、401レスポンスが返却されます。

なお、Express.jsの公式ガイドでは以下のページでその説明がされています(ですが、このページを読み内容を理解するのはなかなか難しいかもしれません)。
https://expressjs.com/ja/guide/writing-middleware.html https://expressjs.com/ja/guide/using-middleware.html https://expressjs.com/ja/guide/error-handling.html

課題:磯野家のメンバーにのみ画面を認可する

つぎに、サンプルコードに対する課題をいくつか紹介しましょう。

1つ目です。認可とは 権限のある人にだけに、機能を許可することを言います。ところが、さきのサンプルではサインインした人 全員 に磯野家画面を見せてしまいました。これでは面白みがありません。

そこで、磯野家のメンバーにのみ 磯野家画面へのアクセスを許可し、それ以外の利用者には 403 を返すにはどうすればいいでしょうか。考えてみてください。

磯野家のメンバーかどうかを判定するには、DBの磯野家テーブルを参照すればよいと言うことになります。ここで厄介なのは、DBの参照は非同期処理だという点にあります。ちょっと難しいので、ヒントをお見せします。

app.js

app.use(auth.session_authenticate);

app.use('/family', (req, res, next) => {
    if (req.user) {
        next();
    } else {
        next(createError(401, 'サインインしないと利用できません'));
    }
});

app.use('/family', async (req, res, next) => {
    try {
        const isMember = await family.isMember(req.user);
        if (isMember) {
            next();
        } else {
            next(createError(403,
                '家族のみが、利用できます。あなたは利用できません'));
        }
    } catch (error) {
        next(error);
    }
});

app.use('/', router);

// (後略)

app.js はこんな感じになります。あとは、await family.isMember(req.user); の部分で、ユーザが磯野家のメンバーかどうか、判定すればよいというわけです。

この判定処理は、models/family.js に書いて、app.js

const family = require('./models/family');

でインポートするのがよいでしょう。

別の課題:家族の追加・削除処理にも認可対象にする

課題2つ目です。これまでの記事で作ったExpress.jsアプリには、家族関連機能が3つあります。家族表示、家族追加、家族削除です。

controllers/router.js

router.get('/family', async function (req, res, next) {
    const data = await family.list();
    res.render('family.html', { "family": data });
});

router.post('/add-family', async function (req, res, next) {
    const data = await family.add(req.body.name, req.body.age);
    res.render('family_result.html', { "message": data });
});

router.post('/remove-family', async function (req, res, next) {
    const data = await family.remove(req.body.name, req.body.age);
    res.render('family_result.html', { "message": data });
});

これらのすべてを、認可された人のみに許可するにはどうすればいいでしょうか? ポイントは、app.useでPathが指定されたときは、そのPathで始まる(前方一致)全てのリクエストパスにミドルウェアが適用されるという点です。

// '/family'で始まる 全てのリクエストパスに対して 認可処理が呼び出される
app.use('/family',  (req, res, next) => {
    if (req.user) {
        next();
    } else {
        next(createError(401, 'サインインしないと利用できません'));
    }
}); 

// '/'で始まる 全てのリクエストパスに対してrouterが呼び出される
app.use('/', router);

なので、家族の追加・削除処理リクエストパスを、'/family'で始まるように変えてしまえば良いのですね。

controllers/router.js

router.get('/family', async function (req, res, next) {

// (中略)

router.post('/family/add', async function (req, res, next) {

// (中略)

router.post('/family/remove', async function (req, res, next) {

上記のようにリクエストパスを定義しなおせば、家族の追加・削除も、許可されたひと以外はできなくなります。

views/family.htmlも修正し、追加・削除処理も認可されたユーザ以外はエラートなることを確かめてください。

利用者ごとの情報保管

つぎに、せっかくサインインの仕組みが整ったので、SNSっぽい機能を作ってみましょう。

課題:おだてた/おだてられた履歴を表示してみる

これまで、おだてた履歴はセッションに保管されていました(すなわち、一度ログアウトすると消えてしまいました)。

サインインの仕組みができたので、おだてた履歴をセッションの代わりに利用者ごとに DBに保管することで、ログアウトしてももう一度ログインすると確認できるようにします。そして、逆に、利用者がおだてられた場合には、「誰からおだてられたか」も確認できるようにしましょう。

「いいね」をしたりされたりの関係

ヒントをいくつか出しますので、ぜひチャレンジしてみてください。

ヒント1:DB設計

履歴を格納するのに、こんな感じでテーブルを作るといいと思います。

CREATE TABLE flatter_history (
    flatterer text, /*おだてた人*/
    flatteree text, /*おだてられた人*/
    flatter_time timestamp with time zone,
    PRIMARY KEY (flatterer, flatteree, flatter_time)
);

このテーブルにデータが書き込まれるのは、誰かが誰かをおだてたときです。一方で、このテーブルが読み込まれるのは、利用者がおだてた人、利用者をおだてた人のリストが必要になったときです。読み書きそれぞれのSQLはこんな感じになるでしょう。

--誰かが誰かをおだてたときに実行されるSQLの例
INSERT INTO flatter_history VALUES ('紫式部', '藤原彰子', now());
INSERT INTO flatter_history VALUES ('紫式部', '藤原道長', now());
INSERT INTO flatter_history VALUES ('紫式部', '藤原道長', now());
INSERT INTO flatter_history VALUES ('藤原道長', '紫式部', now());
INSERT INTO flatter_history VALUES ('乙丸', '紫式部', now());
INSERT INTO flatter_history VALUES ('百舌彦', '藤原道長', now());
INSERT INTO flatter_history VALUES ('清少納言', '藤原定子', now());

--紫式部をおだてた人のリストを取得するSQLの例
SELECT DISTINCT flatterer 
FROM flatter_history
WHERE flatteree = '紫式部';

--紫式部がおだてた人のリストを取得するSQLの例
SELECT DISTINCT flatteree 
FROM flatter_history
WHERE flatterer = '紫式部';

ヒント2:おだてるときの処理

いままではおだてる機能では、ただおだて文句を作成しておわりでした。これからは、おだてるついでに「だれがおだてたか」履歴に追加する必要が生じますので、models/flatter.js のおだて機能に、おだてる自分の名前も渡す必要が生じてきます。また、おだてるときに履歴テーブルにその履歴を書き込みますので、リクエストに対応付けられる関数は、async関数になります。

コントローラの部分のコードはこんな感じになります。

controllers/router.js

router.get('/flatter/:name', async function (req, res, next) {
    const data = await flatter.flatter(req.params.name, req.user);
    res.render('flatter_view.html', data);
});

router.get('/flatter-by-query', async function (req, res, next) {
    const data = await flatter.flatter(req.query.name, req.user);
    res.render('flatter_view.html', data);
});

router.post('/flatter-by-post', async function (req, res, next) {
    const data = await flatter.flatter(req.body.name, req.user);
    res.render('flatter_view.html', data);
});

対応する models/flatter.js のflatter関数をどんなふうに改造すればいいか、ぜひ考えてみてください。

ヒント3:メイン画面を表示するときの処理

メイン画面のHTMLファイルはこんな感じになります。

views/list_views.html


    <!-- 前略 -->

    <hr>
    <br>
    あなたが いままでに おだてた人たち
    <ul>
        {{#history.flatterees}}
        <li>{{.}}さん</li>
        {{/history.flatterees}}
    </ul>
    <hr>
    <br>
    あなたを いままでに おだてた人たち
    <ul>
        {{#history.flatterers}}
        <li>{{.}}さん</li>
        {{/history.flatterers}}
    </ul>
    <hr>

    <!-- 後略 -->

さて、この画面を表示するときに、history.flatterees, history.flatterersを取得する必要がありますね。このためには、やはり、DBにアクセスしますので、リクエストに対応づけられうのは async関数になります。

ルータのコードはこんな感じになります。

controllers/router.js

router.get('/', async function (req, res, next) {
    const user = req.user;
    const flatterees = await flatter.getFlatterees(user);
    const flatterers = await flatter.getFlatterers(user);
    const data = {
        username: user,
        namelist: namelist().namelist,
        history: {
            flatterees: flatterees,
            flatterers: flatterers
        },
    };
    res.render('list_view.html', data);
});

あとは、models/flatter.js に、getFlatterees、getFlatterersという、履歴をDBから取得する機能を作ればよいというわけ。是非、作りこんでみてください。

まとめと次回予告

今回の記事では、サインインの仕組みができているのを前提に、「認可」「利用者の行動履歴の保管」という、Web サービスでは欠かせない仕組みを作ってみました。今回の記事の課題は難易度が高いですが、この課題がクリアできれば、もうほどんどなんでも作れる気分になれると思うのですね。

次回予告です。いよいよ最後の難関、利用者にとっても開発者にとってもまったく面倒くさい ちゃんとした認証 のしくみを Express.js アプリに作りこんでみます。

#コラム #プログラミング #認可 #passport .js

この記事が気に入ったらサポートをしてみませんか?