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


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

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


## ゴール

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


### トークンの取得（ログイン）

`POST /api/session`で

```json:リクエスト
{
    "username": "hoge",
    "password": "fuga"
}
```
に対して、

```json:レスポンス
{
  "token": "<JSON WEB TOKEN>",
  "user": {
    "_id": "<ID>",
    "username": "hoge"
  }
}
```
みたいに取得できれば良い。

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

```coffee:ユーザー情報を取得するだけ
auth = require '../lib/auth'

router.get '/', auth, (next)->
	@body = @user
    yield next
```
こんな感じ書いて、適切なトークンでなければ`401`を返して、認証されていれば復号化されたユーザー、つまり

```json:レスポンス
{
  "_id": "<ID>",
  "username": "hoge"
}
```
こんなレスポンスが返れば良い。

## 実装

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

```json: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`で使うとかなり捗ります。

```js:./bootstrap.js
require('coffee-script/register');
require('./app');
```

```coffee:./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にしてくれます。


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

### ユーザーのモデル

```js:./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`とか生やして、メール承認や管理人の承認とかできるようにするといいでしょう。

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

```coffee:./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`に定義します。

この３点です。


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


```coffee:./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 401`を`yeild next`にするといい感じです。
　
　
### パスワードの暗号化

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

```coffee:./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`保存は不要です。

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

### パスワードの照合

```coffee:./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`を適当にいじってください。

### ユーザー登録

```bash: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。

### ログイン

```bash: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

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

### 認証状態の確認

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

```bash: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
```

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

```json
{
  "_id": "<USER ID>",
  "username": "hoge"
}
```

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

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