Node.js + Express で作成する Web アプリケーションでセッション認証に対応したアプリケーションを作ってみます。今回はその実装のために Express Session パッケージと JWT(Json Web Token)パッケージを使った例を紹介します。
【設計】
最初に、以下で紹介するソースコード(の最新版)をこちらで公開しています。よろしければこちらを参照/クローンして使ってください:
https://github.com/dotnsf/jwtsample
まず大まかな仕組みは以下のようにします:
・Node.js と Express で必要な REST API を用意する。今回は POST /login(ログイン)、GET /logout(ログアウト)、GET /items(一覧取得)、そして POST /item(新規作成)の4つの REST API を用意します。
・POST /item は認証済みのユーザーからでないと実行できないようにします。他の3つは認証前でも実行できるものとします。
(注 本来ログアウトは GET 以外のメソッドで実装するべきですが、今回はサンプルをシンプルにしたので URL のみで実行できるよう GET で実装しています。実際のアプリを作る際には POST で実装してください)
その上で、次のような流れを実現できるようにします:
(1) ユーザー ID とパスワードで認証(ログイン)する
(2) 正しい ID とパスワードが指定された場合はそのユーザーオブジェクトで JWT を作成し、セッションに格納する
(3) 新規作成の API 実行時には新たに ID とパスワードを要求するのではなく、セッションに正しいオブジェクトが存在している場合のみ実行を許可する。またこの際、セッション内のオブジェクトからユーザー名をデコードし、タイムスタンプと合わせて作成データに含める(この API をいつどのログインユーザーが実行したのか、の情報を作成データに含める)
(4) ログアウトの API 実行時にセッションをリセットする。そのため再度ログインしないと新規作成の API は実行できなくなる
上記流れの中で3箇所セッションを操作しています。逆にセッションの操作だけでログインの確認や実行権限の有無を判断できるようにしています。
で、この流れを実装すべく、以下の(単なるページロードも含めて) 6つの API を用意することにします:
【実装】
上記設計を実装する上で2つの静的ページ index.html と login.html が必要です。前者は一覧を表示した上で新規作成も行うページで、後者はログインのページです。まずこれらを先に作っておきます。
まずは 1. index.html 。こちらは 5. で作成する GET /items の API を使って全アイテムの一覧を取得して表示する機能と、6. の POST /item を使って新規にアイテムを作成する機能を作ります。今回は1つの表の中で一覧表示と、その最下行から新規作成できるような表を jQuery を併用して作ってみました:
次に 2. login.html 、こちらは id と password を入力して /login に POST するだけのシンプルなページです:
そして、3. ~ 6. は Node.js で実装します:
なお、上記の 3. ログイン処理と 4. ログアウト処理は(今回アプリケーション側を複雑にしたくなかったので)セッションの値を処理した後に redirect メソッドで直接ページを切り替えるようにしています。本来は他の処理と同様に結果を JSON で返す形にするのがいいと思っています。
個人的に肝になると思われる箇所を赤字にしています。まず今回はトークンの仕組みとして Json Web Token を利用しており、またそのトークンをセッションで管理するため、これら2つのパッケージをロードしています:
4. ログイン処理(POST /login )時は本来ならば既存のユーザーディレクトリなどを参照して認証を行います。今回のサンプルではユーザーディレクトリを使わずに「入力した ID とパスワードが一致していれば正しい認証が行われた」とみなしています。この辺りは実装時に少し気をつけてください。
そして正しい認証が行われた後にその情報を使ってオブジェクトを作り、そのオブジェクトを JWT でトークン化して、セッションに記録するように処理しています:
一方、3. ログアウト処理ではセッションの内容をクリアするだけでログイン情報が消えることになります:
データは本来はデータベースなどに保存するべきものですが、今回は簡易的にメモリ内の items 変数で管理しています(そのためアプリケーションサーバーを再起動すると中身がリセットされます)。そして 5. データの一覧取得ではその items 変数をそのまま JSON 配列で返すようにしています。なお、このメソッドはトークンの有無に関係なく実行可能です。
そして 6. データの新規作成時にはまずセッション内のトークンの有無を確認します。トークンがあればログイン済み、なければログイン前と判断します:
トークンが存在していた場合、そのトークンをデコード(verify)することで、トークン化前のユーザーオブジェクトを取り出すことができます:
そして取り出したユーザーオブジェクトを使って、送信データ(req.body)に加えて、ユーザー情報とタイムスタンプを追加して1つのデータオブジェクトを生成しています。これを items 配列に加える形で一覧に追加しています:
これによって、5. のデータ一覧で取り出すデータにはデータを作成したユーザーの情報やそのタイムスタンプも合わせて含まれるように実現しています。
こうして作成した app.js と index.html、login.html を使ってアプリケーションを動かします。なお、実際に動かす場合は2つの HTML ファイル(index.html と login.html)を public/ フォルダ内に格納しておきます(app.js で静的コンテンツファイルは public/ フォルダ内にある、と定義しています)。
【動作確認】
Node.js で app.js を実行します:
今回の app.js では 3000 番ポートでアプリケーションが稼働します。環境によって 3000 番ポートが使えない場合は app.js 内の該当箇所を編集してください。
ウェブブラウザで該当サーバーの 3000 番ポートを指定(例: http://localhost:3000/)してアクセスします。挙動を確認しやすくするため、開発コンソールも開いておきます。最初のロード時には index.html が表示されます。この中で GET /items が実行されており、その取得結果が開発コンソールに表示されています(最初は中身が存在しないので、空配列になっているはずです):
まだログインしていないのでデータの作成はできないのですが、作成できないことを確認してみます。name と price に適当な商品名とその価格を入力し、Add ボタンをクリックします:
すると開発コンソールに "error:Unauthorized" というメッセージが表示されます。「まだログインしてない」というエラーです。この表示がでればとりあえず未認証ではデータを作成できない、ということが確認できます:
ではログインしてみます。ブラウザの URL パスを /login.html に指定して(例: http://localhost:3000/login.html)アクセスするとログイン画面が表示されます:
上述のように今回のサンプルでは id とパスワードに同じものが指定された場合はログイン成功とみなすことにしているので、最初はわざと異なる値(例えば id: user1、password: user)を入力して "Login" ボタンをクリックしてみます。正しく動くと(ログインできないので)元の画面に戻ってきてしまいます:
改めて id とパスワード欄両方に "user1" と入力して "Login" ボタンをクリックします。この場合は認証の条件を満たしているのでログインに成功し、元の一覧ページに移動します:
外見上の違いがなくわかりにくいのですが、今回はログイン済みの状態で一覧ページを見ています(ちゃんと作る場合はログイン有無で外見上の違いもあるといいと思います)。改めて name と price を入力して "Add" ボタンをクリックします:
さっきはログイン前だったので "Unauthorized" エラーになりましたが、今回はログイン済みなのでデータの新規作成に成功します。そして GET /items が実行された結果が開発コンソールに表示され、先程まで空配列だったところにデータが1つはいった配列が表示されていることがわかります。また画面にも入力したデータがテーブル表示されるようになり、そこには(入力していない)ユーザー名も表示されています。先程のログインの記録がセッションに残っており、その情報を使って API が実行されていることがわかります:
もう1つデータを追加すると画面では2行に表示され、また開発コンソールにも2つのデータを持った配列が表示されます:
ここでいったんログアウトしてみます。URL パスに /logout (例: http://localhost:3000/logout)を指定してアクセスしログアウト処理を実行します。画面はそのまま元の一覧ページに戻りますが、今度はログインされていない状態で表示されています。そのため3つ目のデータを作成しようと "Add" ボタンをクリックしても最初と同様に "error:Unauthorized" エラーが表示されてしまいます:
と、まあこんな感じです。JWT でログイン情報をトークン化してセッションで保持/消去を管理することで、このようなセッション認証対応アプリを作ることができるようになります。
見栄えとかリンクとか、本来はもう少し凝った UI を考えるべきなのでしょうが、今回の話の中では本質的でないので省略して手抜き簡易化しています。ごめんなさい。
【設計】
最初に、以下で紹介するソースコード(の最新版)をこちらで公開しています。よろしければこちらを参照/クローンして使ってください:
https://github.com/dotnsf/jwtsample
まず大まかな仕組みは以下のようにします:
・Node.js と Express で必要な REST API を用意する。今回は POST /login(ログイン)、GET /logout(ログアウト)、GET /items(一覧取得)、そして POST /item(新規作成)の4つの REST API を用意します。
・POST /item は認証済みのユーザーからでないと実行できないようにします。他の3つは認証前でも実行できるものとします。
(注 本来ログアウトは GET 以外のメソッドで実装するべきですが、今回はサンプルをシンプルにしたので URL のみで実行できるよう GET で実装しています。実際のアプリを作る際には POST で実装してください)
その上で、次のような流れを実現できるようにします:
(1) ユーザー ID とパスワードで認証(ログイン)する
(2) 正しい ID とパスワードが指定された場合はそのユーザーオブジェクトで JWT を作成し、セッションに格納する
(3) 新規作成の API 実行時には新たに ID とパスワードを要求するのではなく、セッションに正しいオブジェクトが存在している場合のみ実行を許可する。またこの際、セッション内のオブジェクトからユーザー名をデコードし、タイムスタンプと合わせて作成データに含める(この API をいつどのログインユーザーが実行したのか、の情報を作成データに含める)
(4) ログアウトの API 実行時にセッションをリセットする。そのため再度ログインしないと新規作成の API は実行できなくなる
上記流れの中で3箇所セッションを操作しています。逆にセッションの操作だけでログインの確認や実行権限の有無を判断できるようにしています。
で、この流れを実装すべく、以下の(単なるページロードも含めて) 6つの API を用意することにします:
# | メソッド | パス | 目的 |
---|---|---|---|
1 | GET | /(index.html) | インデックスページのロード |
2 | GET | /login.html | ログインページのロード |
3 | GET | /logout | ログアウトし、インデックページをロード |
4 | POST | /login | ID とパスワードでログイン |
5 | GET | /items | 全アイテムのリストを取得 |
6 | POST | /item | 新たにアイテムを作成し、リストに追加 |
【実装】
上記設計を実装する上で2つの静的ページ index.html と login.html が必要です。前者は一覧を表示した上で新規作成も行うページで、後者はログインのページです。まずこれらを先に作っておきます。
まずは 1. index.html 。こちらは 5. で作成する GET /items の API を使って全アイテムの一覧を取得して表示する機能と、6. の POST /item を使って新規にアイテムを作成する機能を作ります。今回は1つの表の中で一覧表示と、その最下行から新規作成できるような表を jQuery を併用して作ってみました:
<html> <head> <meta charset="utf8"/> <script type="text/javascript" src="//code.jquery.com/jquery-2.0.3.min.js"></script> <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet"/> <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script> <title>LIST</title> <script> $(function(){ getItems(); }); function getItems(){ $('#items_table_tbody').html( '' ); $.ajax({ type: 'GET', url: '/items', success: function( items ){ console.log( items ); items.forEach( function( item ){ var tr = '<tr><td>' + item.name + '</td><td>' + item.price + '</td><td>' + item.user.id + '</td></tr>'; $('#items_table_tbody').append( tr ); }); var tr = "<tr>" + "<td><input type='text' id='name' placeholder='name'/></td>" + "<td><input type='text' id='price' placeholder='price'/></td>" + "<td><input type='button' class='btn btn-primary' value='Add' onClick='addItem();'/></td>" + "</tr>" $('#items_table_tbody').append( tr ); }, error: function( err ){ console.log( err ); } }); } function addItem(){ var name = $('#name').val(); var price = parseInt( $('#price').val() ); $.ajax({ type: 'POST', url: '/item', data: { name: name, price: price }, success: function( data ){ console.log( data ); getItems(); }, error: function( jqXHR, textStatus, errorThrown ){ console.log( textStatus + ":" + errorThrown ); } }); } </script> </head> <body> <div class="navbar navbar-default"> <div class="container"> <div class="navbar-header"> <a href="/" class="navbar-brand">Items</a> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> </div> <div class="collapse navbar-collapse target"> <ul class="nav navbar-nav navbar-right" id="navbar"> </ul> </div> </div> </div> <div class="container" style="padding:20px 0; font-size:8px;"> <table class="table table-hover table-bordered" id="documents_table"> <thead class="table-inverse"> <tr> <th>name</th> <th>price</th> <th>user</th> </tr> </thead> <tbody id="items_table_tbody"> </tbody> </table> </div> </body> </html>
次に 2. login.html 、こちらは id と password を入力して /login に POST するだけのシンプルなページです:
<html> <head> <meta charset="utf8"/> <script type="text/javascript" src="//code.jquery.com/jquery-2.0.3.min.js"></script> <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet"/> <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script> <title>Login</title> </head> <body> <div class="row title"> <div class="container"> <div class="col-md-4 col-md-offset-4"> <form id="loginform" class="form-signin" method="POST" action="/login"> <h2>Login</h2> <input type="text" class="form-control clear" id="id" name="id" placeholder="user id" required="" autofocus=""/><br/> <input type="password" class="form-control clear" id="password" name="password" placeholder="password" required=""/><br/> <button class="btn btn-lg btn-primary btn-block" type="submit">Login</button> </form> </div> </div> </body> </html>
そして、3. ~ 6. は Node.js で実装します:
// app.js var express = require( 'express' ); var bodyParser = require( 'body-parser' ); var jwt = require( 'jsonwebtoken' ); var session = require( 'express-session' ); var app = express(); var superSecret = 'weloveibmcloud'; //. 適当に変更してください app.set( 'superSecret', superSecret ); app.use( express.static( __dirname + '/public' ) ); //. 静的コンテンツは /public 以下 app.use( bodyParser.urlencoded() ); app.use( bodyParser.json() ); //. Express-Session の設定 app.use( session({ secret: superSecret, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: false, //. https で使う場合は true maxage: 1000 * 60 * 60 //. 60min } }) ); //. ログイン処理(4.) app.post( '/login', function( req, res ){ res.contentType( 'application/json' ); var id = req.body.id; var password = req.body.password; /* * 本来はユーザーディレクトリやマスター DB などを参照して id & password が正しいかどうかを判断する。 * 今回は簡易的に id と password の中身が一致している場合はその id でログイン成功したものとする */ if( id && id == password ){ //. ログイン成功 var user = { id: id, password: password }; //. トークンの素になるオブジェクト var token = jwt.sign( user, superSecret, { expiresIn: '25h' } ); req.session.token = token; //. セッションに記錄 res.redirect( '/' ); }else{ //. ログイン失敗 res.redirect( '/login.html' ); } }); //. ログアウト(3.) app.get( '/logout', function( req, res ){ req.session.token = null; //. セッションをリセット res.redirect( '/' ); }); var items = []; //. データ一覧(本来はデータベースなどで管理するもの、今回はメモリ処理だけで実装) //. データ一覧取得(5.) app.get( '/items', function( req, res ){ res.contentType( 'application/json; charset=utf-8' ); res.write( JSON.stringify( items, 2, null ) ); res.end(); }); //. データ新規作成(6.) app.post( '/item', function( req, res ){ res.contentType( 'application/json; charset=utf-8' ); var token = ( req.session && req.session.token ) ? req.session.token : null; if( !token ){ res.status( 401 ); res.write( JSON.stringify( { status: false, result: 'No token provided.' }, 2, null ) ); res.end(); }else{ //. トークンをデコードして、ログイン時のユーザーオブジェクトを取りだす jwt.verify( token, app.get( 'superSecret' ), function( err, user ){ if( err ){ res.status( 401 ); res.write( JSON.stringify( { status: false, result: 'Invalid token.' }, 2, null ) ); res.end(); }else{ var item = req.body; //. ポストされたデータ item.user = user; //. セッションのユーザー情報をデータに含める item.timestamp = ( new Date() ).getTime(); //. タイムスタンプをデータに含める items.push( item ); res.write( JSON.stringify( { status: true, item: item }, 2, null ) ); res.end(); } }); } }); var port = 3000; app.listen( port ); console.log( 'server started on ' + port );
なお、上記の 3. ログイン処理と 4. ログアウト処理は(今回アプリケーション側を複雑にしたくなかったので)セッションの値を処理した後に redirect メソッドで直接ページを切り替えるようにしています。本来は他の処理と同様に結果を JSON で返す形にするのがいいと思っています。
個人的に肝になると思われる箇所を赤字にしています。まず今回はトークンの仕組みとして Json Web Token を利用しており、またそのトークンをセッションで管理するため、これら2つのパッケージをロードしています:
: var jwt = require( 'jsonwebtoken' ); var session = require( 'express-session' ); :
4. ログイン処理(POST /login )時は本来ならば既存のユーザーディレクトリなどを参照して認証を行います。今回のサンプルではユーザーディレクトリを使わずに「入力した ID とパスワードが一致していれば正しい認証が行われた」とみなしています。この辺りは実装時に少し気をつけてください。
そして正しい認証が行われた後にその情報を使ってオブジェクトを作り、そのオブジェクトを JWT でトークン化して、セッションに記録するように処理しています:
: //. ログイン成功 var user = { id: id, password: password }; //. トークンの素になるオブジェクト var token = jwt.sign( user, superSecret, { expiresIn: '25h' } ); req.session.token = token; //. セッションに記錄 :
一方、3. ログアウト処理ではセッションの内容をクリアするだけでログイン情報が消えることになります:
: req.session.token = null; //. セッションをリセット :
データは本来はデータベースなどに保存するべきものですが、今回は簡易的にメモリ内の items 変数で管理しています(そのためアプリケーションサーバーを再起動すると中身がリセットされます)。そして 5. データの一覧取得ではその items 変数をそのまま JSON 配列で返すようにしています。なお、このメソッドはトークンの有無に関係なく実行可能です。
そして 6. データの新規作成時にはまずセッション内のトークンの有無を確認します。トークンがあればログイン済み、なければログイン前と判断します:
: var token = ( req.session && req.session.token ) ? req.session.token : null; :
トークンが存在していた場合、そのトークンをデコード(verify)することで、トークン化前のユーザーオブジェクトを取り出すことができます:
: //. トークンをデコードして、ログイン時のユーザーオブジェクトを取りだす jwt.verify( token, app.get( 'superSecret' ), function( err, user ){ :
そして取り出したユーザーオブジェクトを使って、送信データ(req.body)に加えて、ユーザー情報とタイムスタンプを追加して1つのデータオブジェクトを生成しています。これを items 配列に加える形で一覧に追加しています:
: var item = req.body; //. ポストされたデータ item.user = user; //. セッションのユーザー情報をデータに含める item.timestamp = ( new Date() ).getTime(); //. タイムスタンプをデータに含める :
これによって、5. のデータ一覧で取り出すデータにはデータを作成したユーザーの情報やそのタイムスタンプも合わせて含まれるように実現しています。
こうして作成した app.js と index.html、login.html を使ってアプリケーションを動かします。なお、実際に動かす場合は2つの HTML ファイル(index.html と login.html)を public/ フォルダ内に格納しておきます(app.js で静的コンテンツファイルは public/ フォルダ内にある、と定義しています)。
【動作確認】
Node.js で app.js を実行します:
$ node app
今回の app.js では 3000 番ポートでアプリケーションが稼働します。環境によって 3000 番ポートが使えない場合は app.js 内の該当箇所を編集してください。
ウェブブラウザで該当サーバーの 3000 番ポートを指定(例: http://localhost:3000/)してアクセスします。挙動を確認しやすくするため、開発コンソールも開いておきます。最初のロード時には index.html が表示されます。この中で GET /items が実行されており、その取得結果が開発コンソールに表示されています(最初は中身が存在しないので、空配列になっているはずです):
まだログインしていないのでデータの作成はできないのですが、作成できないことを確認してみます。name と price に適当な商品名とその価格を入力し、Add ボタンをクリックします:
すると開発コンソールに "error:Unauthorized" というメッセージが表示されます。「まだログインしてない」というエラーです。この表示がでればとりあえず未認証ではデータを作成できない、ということが確認できます:
ではログインしてみます。ブラウザの URL パスを /login.html に指定して(例: http://localhost:3000/login.html)アクセスするとログイン画面が表示されます:
上述のように今回のサンプルでは id とパスワードに同じものが指定された場合はログイン成功とみなすことにしているので、最初はわざと異なる値(例えば id: user1、password: user)を入力して "Login" ボタンをクリックしてみます。正しく動くと(ログインできないので)元の画面に戻ってきてしまいます:
改めて id とパスワード欄両方に "user1" と入力して "Login" ボタンをクリックします。この場合は認証の条件を満たしているのでログインに成功し、元の一覧ページに移動します:
外見上の違いがなくわかりにくいのですが、今回はログイン済みの状態で一覧ページを見ています(ちゃんと作る場合はログイン有無で外見上の違いもあるといいと思います)。改めて name と price を入力して "Add" ボタンをクリックします:
さっきはログイン前だったので "Unauthorized" エラーになりましたが、今回はログイン済みなのでデータの新規作成に成功します。そして GET /items が実行された結果が開発コンソールに表示され、先程まで空配列だったところにデータが1つはいった配列が表示されていることがわかります。また画面にも入力したデータがテーブル表示されるようになり、そこには(入力していない)ユーザー名も表示されています。先程のログインの記録がセッションに残っており、その情報を使って API が実行されていることがわかります:
もう1つデータを追加すると画面では2行に表示され、また開発コンソールにも2つのデータを持った配列が表示されます:
ここでいったんログアウトしてみます。URL パスに /logout (例: http://localhost:3000/logout)を指定してアクセスしログアウト処理を実行します。画面はそのまま元の一覧ページに戻りますが、今度はログインされていない状態で表示されています。そのため3つ目のデータを作成しようと "Add" ボタンをクリックしても最初と同様に "error:Unauthorized" エラーが表示されてしまいます:
と、まあこんな感じです。JWT でログイン情報をトークン化してセッションで保持/消去を管理することで、このようなセッション認証対応アプリを作ることができるようになります。
見栄えとかリンクとか、本来はもう少し凝った UI を考えるべきなのでしょうが、今回の話の中では本質的でないので省略して