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"
}
こんなレスポンスが返れば良い。
実装
必要なモジュールと起動について
{
"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
で使うとかなり捗ります。
require('coffee-script/register');
require('./app');
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にしてくれます。
認証処理を書く前の下準備
ユーザーのモデル
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
とか生やして、メール承認や管理人の承認とかできるようにするといいでしょう。
鍵
暗号化・照合に必要な鍵。環境変数から引っ張ります。
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点です。
トークンの復号化
説明の都合上、トークンの復号化から説明します。
_ = 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 401
をyeild next
にするといい感じです。
パスワードの暗号化
ユーザー登録部分です。ユーザー名と暗号化したパスワードをDBに保存します。
_ = 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
保存は不要です。
これでユーザー登録できるようになりました。
パスワードの照合
_ = 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 -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 -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 -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なりで教えてください。少しでも役に立ったと思ってもらえる方がいれば、幸いです。