Node.js
JWT
Koa

koaでjsonwebtokenを使っていい感じに認証する

More than 3 years have passed since last update.

koaのプラグインであまりしっくりくるものがなかったので、自分で認証処理を作ってみます。素人なので、中身はともかく「koaいい感じだ」くらいに思ってもらえれば結構です。

主にSPAのバックエンドで使う用になると思います。https://github.com/endaaman/koa-jwt-example に全コード乗せてあります。


ゴール


認証方法

Authorization: Bearer <JSON WEB TOKEN>というヘッダで認証します。


トークンの取得(ログイン)

POST /api/session


リクエスト

{

"username": "hoge",
"password": "fuga"
}

に対して、


レスポンス

{

"token": "<JSON WEB TOKEN>",
"user": {
"_id": "<ID>",
"username": "hoge"
}
}

みたいに取得できれば良い。


認証を掛けたいエンドポイント


ユーザー情報を取得するだけ

auth = require '../lib/auth'

router.get '/', auth, (next)->
@body = @user
yield next


こんな感じ書いて、適切なトークンでなければ401を返して、認証されていれば復号化されたユーザー、つまり


レスポンス

{

"_id": "<ID>",
"username": "hoge"
}

こんなレスポンスが返れば良い。


実装


必要なモジュールと起動について


package.json

{

"private": true,
"scripts": {
"start": "node --harmony bootstrap.js ",
"pm2": "pm2 start bootstrap.js --watch --node-args \"--harmony\" --name example"
},
"dependencies": {
"bcrypt": "^0.8.5",
"coffee-script": "^1.9.3",
"jsonwebtoken": "^5.0.5",
"koa": "^1.0.0",
"koa-bodyparser": "^2.0.1",
"koa-json": "^1.1.1",
"koa-logger": "^1.3.0",
"koa-router": "^5.1.2",
"lodash": "^3.10.1",
"mongoose": "^4.1.4",
"q": "^1.4.1"
}
}

2015年9月1日時点のものです。素のJavaScriptを書くのが得意ではないのでCoffeeScriptを使っていきます。koaを使うには--harmonyオプションが必要になるので、起動用にbootstrap.jsを用意しています。

あとpm2--watchで使うとかなり捗ります。


./bootstrap.js

require('coffee-script/register');

require('./app');


./app.coffee

koa = require 'koa'

mongoose = require 'mongoose'

mongoose.model 'User', require './model/user'
mongoose.connect 'mongodb://localhost:27017/example'

api = do require 'koa-router'
api.use '/users', require './api/user'
api.use '/session', require './api/session'

app = koa()
app
.use do require 'koa-logger'
.use do require 'koa-bodyparser'
.use do require 'koa-json'
.use api.routes()
.use api.allowedMethods()
.listen 3000



使っているモジュールについて


lodash

ObjectとArrayに対するいい感じの操作が揃ってます。


bcrypt

パスワードをいい感じに暗号化・照合してくれます。


mongoose

MongoDBをnodeから使うときのおなじみのやつ。Promiseを返すこともできるので、koaの相性もいい感じ。


koa-router

koaの公式にkoa-routeというものもありますが、koa-routerの多機能でいい感じにRESTっぽくかけるのでこっちを使います。


koa-logger

  <-- GET /

--> GET / 404 18ms -

みたいにいい感じにログを吐いてくれます。


koa-bodyparser

リクエストをいい感じにパースthis.request.bodyに書いてくれます。


koa-json

レスポンスをいい感じにJSONにしてくれます。


認証処理を書く前の下準備


ユーザーのモデル


./model/user.coffee

mongoose = require 'mongoose'

Schema = mongoose.Schema
module.exports = new Schema
username:
type: String
required: true
index:
unique: true
hashed_password:
type: String
required: true


特に説明付け加える説明はないと思います。そのままだと実際には使いにくいのでapprovedとかadminとか生やして、メール承認や管理人の承認とかできるようにするといいでしょう。


暗号化・照合に必要な鍵。環境変数から引っ張ります。


./secret.coffee

module.exports = do ->

if not process.env.SECRET
console.warn 'secret was set dengerous key'
process.env.SECRET or 'THIS_IS_DENGEROUS_SECRET'

環境変数に存在していなくても動くようにはしておきます。


認証部分

本題です。JWTを使った認証を行うにあたって、必須となるややこしい処理は


  • パスワードの暗号化

    POST /usersのユーザー登録で行います。./api/user.coffeeに定義します。


  • パスワードの照合

    POST /sessionのログインで行います。./api/session.coffeeに定義します。


  • トークンの復号化

    ミドルウェア的に使います。./lib/auth.coffeeに定義します。


この3点です。


トークンの復号化

説明の都合上、トークンの復号化から説明します。


./lib/auth.coffee

_ = require 'lodash'

Q = require 'q'
jwt = require 'jsonwebtoken'

secret = require '../secret'

# Promiseでラップしてyieldableにする
jwtVerify = Q.nbind jwt.verify, jwt

module.exports = (next)->
# ヘッダースタイル
# Authorization: Bearer TOKEN_STRING

# `Authorization`ヘッダを確認します
if not @request.header.authorization?
@throw 401
return

# `Authorization`ヘッダの値のスタイルをチェックします
parts = @request.header.authorization.split ' '
validTokenStyle = parts.length is 2 and parts[0] is 'Bearer'

if not validTokenStyle
@throw 401
return

# トークンを取得します
token = parts[1]

# トークンをデコードします。
# 不正なトークンであればエラーを起こすのでtry catchで捕捉します
try
decoded = yield jwtVerify token, secret
catch
@throw 401
return

# `jwt.verify`の戻り値には、トークンをサインした時刻の`iat`というフィールドが一緒に取れますが
# 今回は不要なので、必要なフィールドだけpickします
@user = _.pick decoded, ['_id', 'username']

yield next


 JWTはトークン自体にデータを保持することができるので、復号化するとトークンに埋め込まれたデータを取り出すことができます。なのでトークンの照合と認証だけを行うのであればDBアクセスが発生しません。

 これはJWTの最大のメリットはこの点にあると同時に、扱いに悩む部分でもあります。トークンにユーザー情報をガッツリ書き込んでしまうと、ユーザーの削除などが行われた場合に、すでに存在しないユーザーの認証を通してしまうことになるからです。

 ただ、トークン用にテーブル/コレクションを作成しないで済む点では依然有用で、実際に運用するにはidだけsignしておいて、ユーザーモデル自体はDBから改めて取得することになると思います。

 エンドポイントレベルでは追い出さないけどユーザー情報が欲しい場合は、./lib/auth.coffee@throw 401yeild nextにするといい感じです。

 

 


パスワードの暗号化

ユーザー登録部分です。ユーザー名と暗号化したパスワードをDBに保存します。


./api/user.coffee

_ = require 'lodash'

Q = require 'q'
bcrypt = require 'bcrypt'
jwt = require 'jsonwebtoken'
mongoose = require 'mongoose'

secret = require '../secret'
auth = require '../lib/auth'
User = mongoose.model 'User'

router = do require 'koa-router'

# Promiseでラップしてyieldableにする
bcryptGenSalt = Q.nbind bcrypt.genSalt, bcrypt
bcryptHash = Q.nbind bcrypt.hash, bcrypt

# ユーザー登録
router.post '/', (next)->
# 簡易的なバリデーション
valid = @request.body.username and @request.body.password
if not valid
@throw 400

# saltを作ります
salt = yield bcryptGenSalt 10
# 塩を振ってパスワードをhash化します
hashed_password = yield bcryptHash @request.body.password, salt

# saltはpasswordに含まれるのでsaltは保存する必要はありません
user = new User
username: @request.body.username
hashed_password: hashed_password

# indexでunique制約をかけているので`username`が重複していると失敗します
try
yield user.save()
catch
@throw 400

@status = 201
yield next

# ユーザー一覧
router.get '/', auth, (next)->
q = User.find {}
docs = yield q.select '_id username'
@body = docs
yield next

module.exports = router.routes()


GET /usersが若干フライングしてますが、やっていることは分かると思います。

ユーザー登録の失敗には基本的に400を返します。bcrypt.hashは、出力されたものをみれば分かるんですが、saltがそれ自体に含まれるのでsalt保存は不要です。

これでユーザー登録できるようになりました。


パスワードの照合


./api/session.coffee

_ = require 'lodash'

Q = require 'q'
bcrypt = require 'bcrypt'
jwt = require 'jsonwebtoken'
mongoose = require 'mongoose'

auth = require '../lib/auth'
User = mongoose.model 'User'
secret = require '../secret'
router = do require 'koa-router'

# Promiseでラップしてyieldableにする
bcryptCompare = Q.nbind bcrypt.compare, bcrypt
jwtVerify = Q.nbind jwt.verify, jwt

# ログイン部分
router.post '/', (next)->
# `username`で該当するユーザーを引っ張ります
q = User.findOne username: @request.body.username
doc = yield q.exec()
if not doc
@throw 401
return

# パスワードを照合します
ok = yield bcryptCompare @request.body.password, doc.hashed_password
if not ok
@throw 401
return

# `hashed_password`は取り除いておきます
user = _.pick doc, ['_id', 'username']

# シリアライズしたユーザーをトークンに書き込みます
# 第一引数のオブジェクトは書き換えられてしまうので、
# クローンしたものを渡してあげます
token = jwt.sign _.clone(user), secret

# トークンとユーザーが揃ったのでレスポンスに書き込みます
@body =
token: token
user: user
@status = 201
yield next

# ユーザー情報の取得
router.get '/', auth, (next)->
@body = @user
yield next

module.exports = router.routes()


必要な説明はコメントに書いたのであまり付け加えることはありませんね。

こんな感じで認証に必要なものは全て揃いました。


動かしてみる

https://github.com/endaaman/koa-jwt-example をcloneしてnpm installしてnpm startで起動します。

ポートやMongoDBの設定は./app.coffeeを適当にいじってください。


ユーザー登録


curl

curl -v \

-X POST \
-H 'Content-Type: application/json; charset=UTF-8' \
-H 'X-Accept: application/json' \
-d '{"username": "hoge", "password": "fuga"}' \
http://localhost:3000/users

201が返ればOK。


ログイン


curl

curl -v \

-X POST \
-H 'Content-Type: application/json; charset=UTF-8' \
-H 'X-Accept: application/json' \
-d '{"username": "hoge", "password": "fuga"}' \
http://localhost:3000/session

トークンと登録に使った情報がとれればOK

{

"token": "<JSON WEB TOKEN>",
"user": {
"_id": "<USER ID>",
"username": "hoge"
}
}


認証状態の確認

ログインでとれたトークンを飛ばして


curl

curl -v \

-H 'Content-Type: application/json; charset=UTF-8' \
-H 'X-Accept: application/json' \
-H 'Authorization: Bearer <JSON WEB TOKEN>' \
http://localhost:3000/session

ユーザー情報がとれます。

{

"_id": "<USER ID>",
"username": "hoge"
}


最後に

koaは非同期処理にあれこれに悩まされることがほどんど無いので、非常に脳にやさしいです。

何か誤った情報や誤解があればコメントなりTwitterなりで教えてください。少しでも役に立ったと思ってもらえる方がいれば、幸いです。