まだプログラマーですが何か?

プログラマーネタ中心。たまに作成したウェブサービス関連の話も https://twitter.com/dotnsf

タグ:express

Node.js + Express のプログラミング環境で作ったアプリケーションで、「全てのリクエストに対する共通の前処理」を行う方法を紹介します。

Node.js + Express を使って HTTP リクエストを処理するアプリケーションの例として以下のようなコードを想定します(ルーティングを使っていませんが、使っている場合でも同様に可能です):
var express = require( 'express' );
var bodyParser = require( 'body-parser' );
var app = express();

app.use( bodyParser.urlencoded( { extended: true, limit: '10mb' } ) );
app.use( bodyParser.json() );

app.get( '/', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'get /' }, 2, null ) );
  res.end();
});

app.get( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'get /hello.' }, 2, null ) );
  res.end();
});

app.post( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'post /hello.' }, 2, null ) );
  res.end();
});


var port = process.env.port || 3000;
app.listen( port );
console.log( 'server started on ' + port );

上記例では GET /, GET /hello, POST /hello という3種類の HTTP リクエストに対するハンドラが定義されていて、それぞれに対応する異なる処理が個別に行われるようになっています。HTTP リクエストはもっと多くても少なくても構いません。

このような条件下において「全ての HTTP リクエストに対して、リクエストパラメータに id=XXX が含まれていたら、それを記録する」という処理を追加したい場合にどうするか? というのが今回のお題です。その実装例が以下赤字部分です(以下ではシンプルに id の値を記録ではなく表示しているだけです):
var express = require( 'express' );
var bodyParser = require( 'body-parser' );
var app = express();

app.use( bodyParser.urlencoded( { extended: true, limit: '10mb' } ) );
app.use( bodyParser.json() );

//. 全てのリクエストに対して前処理
app.use( '/*', function( req, res, next ){
  //. HTTP リクエスト URL に id=XXX というパラメータが含まれていたら表示する
  var id = req.query.id;
  if( id ){
    console.log( 'id: ' + id );  
  }
  next();  //. 個別処理へ
});

app.get( '/', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'get /' }, 2, null ) );
  res.end();
});

app.get( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'get /hello.' }, 2, null ) );
  res.end();
});

app.post( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  res.write( JSON.stringify( { status: true, message: 'post /hello.' }, 2, null ) );
  res.end();
});


var port = process.env.port || 3000;
app.listen( port );
console.log( 'server started on ' + port );


app.use( '/path', function( req, res, next ){ ... } ); で /path への全 HTTP リクエスト(GET, POST, PUT, DELETE, ..)のハンドラを用意します。この /path 部分を '/*' とすることで全てのパスへの全てのリクエストに対するハンドラが定義できます。

このハンドラの中で id=XXXX というパラメータが指定されているかを調べ、指定されていたらその値(req.query.id)を取り出して表示しています。実際にはここで取り出した id 値を表示するだけでなく、記録したり、別の処理に使ったりすることを想定しています。

この処理で最も大事なのは最後に next(); を実行していることです。シングルスレッド処理の Node.js ではこれでハンドリング処理を終了とするのではなく、「次の処理へ」と指示することで個別のハンドラへの処理も行わせています。これによって共通の事前処理をした上で、個別処理も行う、という実装が可能になります。



Node.js + Express で作るウェブアプリケーションを HTTPS 対応し、更に「HTTPS 以外の通信を認めない」ように設定してみます。なお SSL 対応用のドメイン取得や証明書の取得は完了しているものとし、ドメインは mydomain.com 、秘密鍵ファイルと証明書ファイルがそれぞれ、
 ・/etc/letsencrypt/live/mydomain.com/privkey.pem (秘密鍵)
 ・/etc/letsencrypt/live/mydomain.com/cert.pem (証明書)
に存在しているものと仮定して以下を説明します。


【Node.js + Express で HTTPS 通信】
Node.js + Express で HTTPS 通信を行うには https モジュールを利用します:
var express = require( 'express' );
var app = express();

var fs = require( 'fs' );
var https = require( 'https' );
var options = {
  key: fs.readFileSync( "/etc/letsencrypt/live/mydomain.com/privkey.pem" ),
  cert: fs.readFileSync( "/etc/letsencrypt/live/mydomain.com/cert.pem" )
};
var https_server = https.createServer( options, app );

app.get( '/hello', function( req, res ){
  res.contentType( "text/plain" );
  res.writeHead( 200 );
  res.end( "Hello World." );
});

var https_port = 8443;

https_server.listen( https_port );

console.log( "server starging on " + https_port + ' ...' );

https モジュールを使って 8443 番ポートで待ち受ける https サーバーを作ります。その際に秘密鍵や証明書を指定することで SSL 通信を行うことができるようにしています。

このファイルを Node.js で実行して、ウェブブラウザや curl コマンドで https://mydomain.com:8443/hello にアクセスすることで動作を確認することができます(curl コマンドで localhost など、異なるドメインにアクセスする場合は $ curl https://localhost:8443/hello --insecure といった具合に --insecure オプションを付けて実行することで確認できます)。

また、上記の方法だと https のみアクセスできます(http だと接続エラー)が、http / https 両方でのアクセスに対応するには以下のように改良します:
var express = require( 'express' );
var app = express();
var settings = require( './settings' );

var fs = require( 'fs' );
var http = require( 'http' );
var https = require( 'https' );
var options = {
  key: fs.readFileSync( "/etc/letsencrypt/live/mydomain.com/privkey.pem" ),
  cert: fs.readFileSync( "/etc/letsencrypt/live/mydomain.com/cert.pem" )
};
var https_server = https.createServer( options, app );
var http_server = http.createServer( app );

app.get( '/hello', function( req, res ){
  res.contentType( "text/plain" );
  res.writeHead( 200 );
  res.end( "Hello World." );
});

var http_port = 8080;
var https_port = 8443;

https_server.listen( https_port );
http_server.listen( http_port );

console.log( "server starging on " + http_port + ' / ' + https_port + ' ...' );

これを実行すると、http を 8080 番ポートで、https を 8443 番ポートで同時に待受けてレスポンスを返すことができるようになりました。ウェブブラウザや curl コマンドで http://mydomain.com:8080/hello や https://mydomain.com:8443/hello にアクセスして挙動を確認してください。


【HTTP 通信をしない(HTTPS へ転送)】
最後に、上記プログラムを更に改良して HTTP 通信をしない(強制的に HTTPS 通信に転送する)ための設定を加えます。それには Strict-Transport-Security ヘッダを付けてレスポンスを返すことで実現できます:
var express = require( 'express' );
var app = express();
var settings = require( './settings' );

var fs = require( 'fs' );
var http = require( 'http' );
var https = require( 'https' );
var options = {
  key: fs.readFileSync( "/etc/letsencrypt/live/mydomain.com/privkey.pem" ),
  cert: fs.readFileSync( "/etc/letsencrypt/live/mydomain.com/cert.pem" )
};
var https_server = https.createServer( options, app );
var http_server = http.createServer( app );

//add HSTS
app.use( function( req, res, next ){
  res.setHeader( 'Strict-Transport-Security', 'max-age=15552000' );
  next();
});

app.get( '/hello', function( req, res ){
  res.contentType( "text/plain" );
  res.writeHead( 200 );
  res.end( "Hello World." );
});

var http_port = 8080;
var https_port = 8443;

https_server.listen( https_port );
http_server.listen( http_port );

console.log( "server starging on " + http_port + ' / ' + https_port + ' ...' );

HSTS(HTTP Strict Transport Security) という仕組みを強制することで HTTP でリクエストを受け取っても HTTPS に強制的に変更(リダイレクト)されて通信を続ける、というものです。リダイレクトさせるには HTTP そのものを閉じるわけにはいかない(リダイレクト直前のリクエストを受け取らないといけない)ので、このようなレスポンスヘッダによって実現しています:

2019112500



【参考】
良い感じにHTTPS対応したexpress(Node.js)サーバを立てる
node.jsによるHTTPSサーバの作り方

某アプリを Slack 対応する経緯で Slack API の中の、特に認証/認可を司る OAuth API を使う機会があったので自己まとめです。

もともとやりたかったのはウェブアプリに Slack アカウントでログインして、そのログインした人の権限でチャネル一覧を取得し(※)、ウェブアプリから指定したチャネルにメッセージを書き込む、ということでした。この中の※部分までを Node.js + Express + EJS で実現したコードを Github に公開しています(後述)。


実際に試してみるにはまず Slack に対象アプリケーションを登録する必要があります。 https://api.slack.com/apps を開いてログインし、"Create New App" ボタンをクリックしてウェブアプリを登録します:
2019052401


登録するアプリの名前と、対象ワークスペースを指定します(つまり同じアプリを複数のワークスペースで使いたい場合は、アプリを複数登録する必要があります)。以下では名前は "Slack OAuth Sample"、ワークスペースは "dotnsf" を指定しています(ワークスペースはログインしたユーザーが利用可能なワークスペース一覧から選択します)。最後に "Create App" ボタンをクリックして作成します:
2019052402


すると指定したアプリケーションの API 設定画面に切り替わります。画面左上に入力したアプリ名がデフォルトアイコンと一緒に表示されていて、"Basic Information" メニューが選択されていることを確認します:
2019052403


この画面を下スクロールすると App Credentials という項目があります。この中の Client IDClient Secret の値を後で使うので、どこかにコピーしておくか、いつでもこの画面を開ける状態にしておきましょう。なお Client ID の値は画面内に表示されていますが、Client Secret の値は初期状態では非表示になっています。"Show" ボタンをクリックして内容を表示し、その表示された値をあとで使うことに注意してください。またこれらの値は他の人には教えないように、自分で管理する必要があります:
2019052404


次に画面左のメニューから "OAuth & Permissions" を選び、少し下にスクロールすると Redirect URLs という項目があります。ここにウェブアプリケーションを動かす際のコールバック URL を登録しておく必要があります。"Add New Redirect URL" ボタンをクリックします:
2019052405


すると Redirect URL を追加する画面になるので、http(s)://サーバー名/slack/callback と入力します。この値は開発時には開発時用のサーバー名とポート番号、本番環境では本番環境用のサーバー名を指定する必要があります。下図では開発時向けに localhost の 6010 番ポートで動かす想定で http://localhost:6010/slack/callback と指定しています。ここの値は実際の環境に合わせて適宜変更してください。入力し終わったら "Add" ボタンをクリックして、その後 "Save URLs" ボタンをクリックします:
2019052406


画面上部に "Success!" というメッセージが表示されればリダイレクト URL の設定は完了です。正しい Redirect URLs が登録されたことを確認します:
2019052407


続けて、このアプリで利用する Slack 機能のスコープを指定します。実は OAuth 認証だけであればここの設定は不要なのですが、今回のデモアプリでは OAuth 認証後にログインユーザーが参照できるチャネルの一覧を取得して表示する、という機能が含まれています。また実際のアプリケーションではそのアプリケーションで実装する機能によって、ここでスコープを追加する必要があります:
2019052408


今回はログインユーザーが利用できるチャネル一覧を取得するため、"channels:read" スコープを追加します。また他に必要なスコープがあればここで追加します。最後に "Save Changes" ボタンをクリックして変更を反映します:
2019052409


これで Slack API 側の設定は完了しました。

では改めて Github からアプリケーションを取得します。Node.js がインストール済みの実行サーバー上で以下の URL を指定して git clone するか、ソースコードをダウンロード&展開してください:
 https://github.com/dotnsf/slack-oauth

2019052410


ソースコード内の settings.js をテキストエディタで開き、exports.slack_client_id の値と exports.slack_client_secret の値を上記で確認した client_id と client_secret の値に(コピー&ペーストなどで)変更して、保存してください。

なお、このサンプルアプリケーションでは以下のリクエスト API(?)が用意されていて、これらを明示的&内部的に使って動きます:
リクエスト API用途
GET /ユーザーがアクセスする唯一のページ。アクセス時に認証情報がセッションに含まれているとチャネル一覧が表示される。認証情報がセッションに含まれていない場合は認証前とみなして「ログイン」ボタンを表示する
GET /slack/loginユーザーページで「ログイン」ボタンをクリックした時に実行される。Slack の OAuth 認証ページにリダイレクトされる
GET /slack/callbackSlack の OAuth 認証ページで Authorize された時のリダイレクトページ。この URL が Slack OAuth 設定時に指定されている必要がある。アクセストークンを暗号化してセッションに保存し、GET / へリダイレクトされる
POST /slack/logoutログアウト(セッション情報を削除)する
GET /channelsチャネル一覧を取得する。認証後にユーザーページが表示されると内部的にこの REST API が AJAX 実行されて、画面にチャネル一覧が表示される。


では実際に起動してみます。起動サーバーにログインし、ソースコードのあるフォルダに移動した状態で、以下を実行します:
$ npm install
$ node app

そしてウェブブラウザで起動中のアプリケーションにアクセスします。以下の例では localhost:6010 でアプリケーションが起動されている想定になっているので、http://localhost:6010/ にアクセスします。上記の GET / が実行され、ログイン前のシンプルなページが表示されます。ここで "Login" を選択します:
2019052411


GET /slack/login が実行され、ブラウザは Slack API の OAuth 認証ページにリダイレクトされます。アプリケーションが利用する scope が表示され、このまま認証処理を許可するかどうかを聞かれます。許可する場合は "Authorize" を選択します:
2019052412


Authorize を選択すると認証と認可が完了し、そのアクセストークンが GET /slack/callback へ渡されます。そこでアクセストークンを暗号化してセッションに含めます。この状態であらためてトップページ(GET /)が表示され、ログイン処理が住んでいるのでログイン後の画面が表示されます。AJAX で GET /channels が実行され、ログインユーザーが参照することのできるチャネルの一覧が表示されれば成功です:
2019052413


以上、Slack API の OAuth を使ってウェブアプリケーションから Slack の認証を行い、認証ユーザーの権限で Slack API を外部から実行する、というアプリケーションのサンプルを作って実行するまでの手順紹介でした。


 

Node.js を使っていて 404 エラーや 500 エラーなどが発生した場合に表示されるエラーページをカスタマイズできないか、と思って挑戦してみました。

今回挑戦した環境では Node.js にフレームワークの Express と、テンプレートエンジンに EJS を使いました。エラーページを EJS で作ることを想定しています。

まず、JavaScript のコードはこんな感じです:

app.js
// app.js
var express = require( 'express' );
var app = express();

//. EJS テンプレートエンジン
app.set( 'views', __dirname + '/templates' );
app.set( 'view engine', 'ejs' );

//. / へのアクセスは正常にできる
app.get( '/', function( req, res ){
  res.render( 'index', {} );
});

//. /err へのアクセスは 500 エラーとする
app.get( '/err', function( req, res ){
  res.render( 'index', { value: novalue } ); //. novalue 変数が未定義なので 500 エラーが発生する
});

//. 有効なルーティングを上記に記述
//. /, /err 以外のパスは 404 エラー

//. 404 エラーが発生した場合、
app.use( function( req, res, next ){
  res.status( 404 ); //. 404 エラー
  res.render( 'err404', { path: req.path } ); //. 404 エラーが発生したパスをパラメータとして渡す
});

//. 500 エラーが発生した場合、
app.use( function( err, req, res, next ){
  res.status( 500 ); //. 500 エラー
  res.render( 'err500', { error: err } ); //. 500 エラーの内容をパラメータとして渡す
});

var port = 3000;
app.listen( port );
console.log( 'server started on ' + port );

上記を補足すると、Express でルーティングを2つ定義しています。1つめがドキュメントルート(/)へのアクセスで、この場合は正常な処理として(後述の)index.ejs を表示します。

2つめは /err へのアクセスで、こちらの場合はわざと 500 エラーを発生させています(index.ejs でページを表示させるような記述にしていますが、実際にはこの中で使われている novalue という変数が未定義なので、そこで 500 エラーが発生するようにしています)。

ルーティングとしてはこの2つ(/ と /err)だけを定義しているので、これら以外のパス(例えば /home)にアクセスがあった場合は 404 エラーが発生します。404 エラーが発生した場合の処理としては err404.ejs というテンプレートを使って、アクセスしたパス(/home)が存在していない、という旨のエラーページを表示します。

最後に 500 エラーが発生した場合の処理を定義しています。ここでは err500.ejs というテンプレートを使って、エラーの内容をあわせて表示します。上記で /err ページにアクセスすると 500 エラーが発生するので、この err500.ejs のページが表示されることになります。


次に上記で使うことにした3種類のテンプレート(index.ejs, err404.ejs, err500.ejs)を用意します。それぞれ以下のような内容にしました:

index.ejs
<html>
<head>
<title>index</title>
</head>
<body>
<h1>index</h1>
</body>
</html>
↑単に index と表示されるだけのシンプルなページ


err404.ejs
<html>
<head>
<title>err404</title>
</head>
<body>
<h1>err404</h1>
<%= path %> is not defined nor found.
</body>
</html>
↑ "(エラーがあったパス)is not defined nor found." というエラーメッセージが表示されるページ


err500.ejs
<html>
<head>
<title>err500</title>
</head>
<body>
<h1>err500</h1>
Error: <%= error %>
</body>
</html>
↑ "Error: (エラー内容)" というエラーメッセージが表示されるページ


app.js ではこれらのテンプレートファイルが templates/ フォルダに存在していることを前提に参照するようにしています。したがって templates/ フォルダを作って、その中にこれら3つの .ejs ファイルを格納しておきます。これで準備OK。


実際に動かして試してみました。まずは正常系、/ へのアクセスは普通に問題なくできます:
2018070401


次に /home にアクセスして、わざと 404 エラーを発生させてみた時の画面です。err404.ejs で定義されたテンプレートを使って期待通りにエラーページが表示されています:
2018070402


最後に /err にアクセスして、わざと 500 エラーを発生させてみた時の画面です。こちらも err500.ejs で定義されたテンプレートを使って期待通りにエラー内容が表示されています:
2018070403


上記コードのサンプルをこちらに用意しました:
https://github.com/dotnsf/nodejserror


これで Node.js でもカスタムエラーページを作れることがわかりました。

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 を用意することにします:
#メソッドパス目的
1GET/(index.html)インデックスページのロード
2GET/login.htmlログインページのロード
3GET/logoutログアウトし、インデックページをロード
4POST/loginID とパスワードでログイン
5GET/items全アイテムのリストを取得
6POST/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 が実行されており、その取得結果が開発コンソールに表示されています(最初は中身が存在しないので、空配列になっているはずです):
2018062801


まだログインしていないのでデータの作成はできないのですが、作成できないことを確認してみます。name と price に適当な商品名とその価格を入力し、Add ボタンをクリックします:
2018062802


すると開発コンソールに "error:Unauthorized" というメッセージが表示されます。「まだログインしてない」というエラーです。この表示がでればとりあえず未認証ではデータを作成できない、ということが確認できます:
2018062803


ではログインしてみます。ブラウザの URL パスを /login.html に指定して(例: http://localhost:3000/login.html)アクセスするとログイン画面が表示されます:
2018062804


上述のように今回のサンプルでは id とパスワードに同じものが指定された場合はログイン成功とみなすことにしているので、最初はわざと異なる値(例えば id: user1、password: user)を入力して "Login" ボタンをクリックしてみます。正しく動くと(ログインできないので)元の画面に戻ってきてしまいます:
2018062805


改めて id とパスワード欄両方に "user1" と入力して "Login" ボタンをクリックします。この場合は認証の条件を満たしているのでログインに成功し、元の一覧ページに移動します:
2018062806


外見上の違いがなくわかりにくいのですが、今回はログイン済みの状態で一覧ページを見ています(ちゃんと作る場合はログイン有無で外見上の違いもあるといいと思います)。改めて name と price を入力して "Add" ボタンをクリックします:
2018062807


さっきはログイン前だったので "Unauthorized" エラーになりましたが、今回はログイン済みなのでデータの新規作成に成功します。そして GET /items が実行された結果が開発コンソールに表示され、先程まで空配列だったところにデータが1つはいった配列が表示されていることがわかります。また画面にも入力したデータがテーブル表示されるようになり、そこには(入力していない)ユーザー名も表示されています。先程のログインの記録がセッションに残っており、その情報を使って API が実行されていることがわかります:
2018062808


もう1つデータを追加すると画面では2行に表示され、また開発コンソールにも2つのデータを持った配列が表示されます:
2018062809


ここでいったんログアウトしてみます。URL パスに /logout (例: http://localhost:3000/logout)を指定してアクセスしログアウト処理を実行します。画面はそのまま元の一覧ページに戻りますが、今度はログインされていない状態で表示されています。そのため3つ目のデータを作成しようと "Add" ボタンをクリックしても最初と同様に "error:Unauthorized" エラーが表示されてしまいます:
2018062810



と、まあこんな感じです。JWT でログイン情報をトークン化してセッションで保持/消去を管理することで、このようなセッション認証対応アプリを作ることができるようになります。

見栄えとかリンクとか、本来はもう少し凝った UI を考えるべきなのでしょうが、今回の話の中では本質的でないので省略して手抜き簡易化しています。ごめんなさい。



このページのトップヘ