JSON Web Tokenを使ってSocket.IOで認証する


Socket.IOでJWTでの認証ってどうやったらできるのかなーと思って基本的な動作をするコードを書いてみました。

結論から言えば問題なく動作しました。
コードはGitHubに置いてあるので、もっといい書き方があればコメントかPRください。
[sample-angular-express-socketio-jwt](https://github.com/MATSUMOTO-Takashi/sample-angular-express-socketio-jwt "GitHubリポジトリ")



# 環境
以下の環境で実施

- Node 4.2.2
- Express 4.13.1
- Socket.IO 1.3.7
- jsonwebtoken 5.4.1
- socketio-jwt 4.3.3
- AngularJS 1.4.0
- ngstorage 0.3.10
- angular-socket-io 0.7.0

`server`以下にNode（Express+Socket.IO）プロジェクトが、`front`以下にAngularプロジェクトがあります。

## server
```
$ npm start
```

で動かします。3080番ポートでリッスンします。

## front
```
$ gulp serve
```

で動かします。3000番ポートでリッスンします。

gulpのレシピをいじれば3080番ポートにプロキシできますが、今回はあえて直接APIサーバーにアクセスさせてます。
その場合CORS（Cross-Origin Resource Sharing）が必要になりますがモジュールを導入して解決しています。

また練習も兼ねてCoffeeScriptで書いてみました。

# サーバーサイド
`express-generator`をベースに開発。

```shell-session
$ npm install -g express-generator
$ express --git server
```

以下、`server`ディレクトリで作業。

## 起動時のPORT指定
```json:package.json
{
  "scripts": {
    "start": "PORT=3080 node ./bin/www"
  },
```

## 必要なモジュールをインストール
```shell-session
$ npm install --save coffee-script cors jsonwebtoken socket.io socketio-jwt
```

## JWT用の共通鍵を定義
```coffeescript:config.coffee
module.exports = exports =
  secret: "your project secret key"
```

## 認証用APIを定義
今回は`username=madoka`、`password=magica`のみ認証を通してそれ以外は弾くようにしています。
実際にはDBに接続することになるはずです。
あと有効期限を2時間にしてるのは気分です。

```coffeescript:routes/api.coffee
jwt = require "jsonwebtoken"
express = require "express"

config = require "../config"

router = express.Router()

router.post "/authenticate", (req, res, next) ->
  user =
    username: req.body.username
    password: req.body.password

  if user.username == "madoka" and user.password == "magica"
    token = jwt.sign user, config.secret, expiresIn: "2h"
    user.token = token

    res.json(user)
  else
    res.sendStatus 401


module.exports = router
```

## Expressの設定を適宜追加
今回はフロントサーバーとAPI+Socketサーバーがクロスドメインとなるため、普通だとAjax通信ができません。
JSONPとかを使えば抜けられるもののGETしか対応できないので、今回はAPI+Socketサーバーの方でCORSを実装することでクロスドメインの問題を解決します。
自分でヘッダーを設定してもいいんですが、corsモジュール使えば一発OKらしいので楽をします。

```coffeescript:app.coffee
# load cors
cors = require "cors"

# load api
api = require "./routes/api"

# use cors
app.use cors()

# use api
app.use "/api", api
```

## Socket.IOの処理を定義
Socket.IOはコネクション確立にフックする仕組みを提供しています。
クッキーとかでやりとりする場合でも対応できます。
今回はJWTのトークンをチェックすることで認証を行いますが、これもモジュールがあるのでそれを使います。
トークンに問題がなければ`socket.decoded_token`経由でデータにアクセスが可能です。

```coffeescript:socketio/app.coffee
socketioJwt = require "socketio-jwt"
config = require "../config"

module.exports = exports = (server) ->
  io = require("socket.io") server
  io.use socketioJwt.authorize
    secret: config.secret
    handshake: true

  io.on "connection", (socket) ->
    console.log socket.decoded_token
    socket.on "ping", (data, fn) ->
      fn "pong: #{data}"
```

## Socket.IOとExpressを連携
引数の`server`はhttpオブジェクトです。

```javascript:bin/www
/**
 * Socket.IO
 */

require('../socketio/app')(server);
```

最後に、JavaScriptではなくCoffeeScriptで書いているので、それ用の設定を追加します（先頭の方に）

```javascript:bin/www
require('coffee-script/register');
```

# クライアントサイド
`gulp-angular`をベースに開発。

```shell-session
$ npm install -g yo gulp generator-gulp-angular bower
$ mkdir front && cd $_ && yo gulp-angular
```

以下、`front`ディレクトリで作業。

## 必要なライブラリをインストール
```shell-session
$ bower install --save ngstorage angular-socket.io socket.io.client
```

## ロードするように設定
```coffeescript:src/app/index.module.coffee
angular.module 'front',
  ['ngAnimate', 'ngCookies', 'ngTouch', 'ngSanitize', 'ngResource', 'ngStorage', 'ui.router', 'ui.bootstrap', 'btford.socket-io']
```

## Userモデル（サービス）を定義
認証して発行されたトークンはローカルストレージに保存しておく。

```coffeescript:src/app/service/user.service.coffee
class User
  constructor: (@$localStorage, @$http) ->
    @load()

  authentication: ->
    @$http.post "http://localhost:3080/api/authenticate",
        username: @username
        password: @password
      .success (data) =>
        @$localStorage.user = data
        @load()

  discard: ->
    delete @$localStorage.user.token
    @token = null

  load: ->
    angular.extend(@, @$localStorage.user)

angular.module "front"
  .service "User", User
```

## socketServiceを定義
サーバーと接続するSocketをサービスとして定義する。
Promiseを利用して、サービスを使う側で認証成功/失敗時の処理をかけるようにしておく。
また接続時にqueryで発行済みのトークンを渡すことによって、Socketサーバーで認証のチェックを行えるようにする。

また`"force new connection": true`を指定してるのは、SPAの場合コネクションを勝手に貼り直しはせず内部でプーリングしてそれを使いまわそうとするらしく、強制的に新規接続をさせている。
そうしないとログイン/ログアウトを繰り返した際に、認証のチェックがうまく働かない。

```coffeescript:src/app/service/socket.service.coffee
class SokcetService
  constructor: (@socketFactory, @$q) ->

  connectWithAuth: (token) ->
    deferred = @$q.defer()

    conn = io.connect ":3080",
      query: "token=#{token}"
      "force new connection": true

    conn.on "connect", ->
      deferred.resolve(socket)
    .on "error", (err) ->
      deferred.reject(err)

    socket = @socketFactory
      ioSocket: conn

    return deferred.promise

angular.module "front"
  .service "socketService", SokcetService
```

## ログイン画面
ログイン画面を作ります。

```coffeescript:src/app/login/login.controller.coffee
class LoginController
  constructor: (@User, @$state) ->

  auth: ->
    @User.authentication()
      .success => @$state.go "main"

angular.module "front"
  .controller "LoginController", LoginController
```

```jade:src/app/login/login.jade
div.container
  form
    input(ng-model="login.User.username")
    input(type="password", ng-model="login.User.password")
    button(ng-click="login.auth()") ログイン
```

## メイン画面
ログイン後の画面を作ります。
`$scope.$on "destroy"`で処理をしているのはやはりSPAの宿命なのか、画面遷移をしてコントローラーが再作成されたりするとイベントリスナーだけゾンビのように生きていて多重に処理が走るため、それの対応です。
またSocketのコネクションも残ってしまうので明示的に切断しています。

```coffeescript:src/app/main/main.controller.coffee
class MainController
  results = []
  count = 0

  constructor: (@User, @socket, @$state, $scope) ->
    $scope.$on "$destroy", =>
      @socket.disconnect()
      @socket.removeAllListeners()
    @results = results

  ping: ->
    @socket.emit "ping", "test!!", (data) ->
      results.push {count: ++count, data: data}

  logout: ->
    @User.discard()
    @$state.go "login"

angular.module "front"
  .controller "MainController", MainController
```

```jade:src/app/main/main.jade
div.container
  p main
  button(ng-click="main.ping()") ping
  button(ng-click="main.logout()") ログアウト

  ul: li(ng-repeat="data in main.results | orderBy:'':-1") {{data.count}}:{{data.data}}
```

## ルーティング
最後にルーティングの設定をします。
メイン画面はUI Routerのresolveを利用し、遷移前に接続を確立させています。

```coffeescript:src/app/index.route.coffee
angular.module "front"
  .config ($stateProvider, $urlRouterProvider) ->
    $stateProvider
      .state "login",
        url: "/"
        templateUrl: "app/login/login.html"
        controller: "LoginController"
        controllerAs: "login"
      .state "main",
        url: "/main"
        templateUrl: "app/main/main.html"
        controller: "MainController"
        controllerAs: "main"
        resolve:
          socket: (socketService, User, $state) ->
            socketService.connectWithAuth User.token
              .then (socket) ->
                console.log "success!"
                return socket
              , (err) ->
                if err.type == "UnauthorizedError" or err.code == "invalid_token"
                  console.log "failed!!"
                  $state.go "login"

    $urlRouterProvider.otherwise '/'
```

# まとめ
基本的にはモジュールを組み合わせれば大丈夫ですね。
今回はAngularJS + Express + JWTという組み合わせに、Socket.IOを組み込むことで、SPAでSocket.IOに認証機能を持たせることができました。

結構手探りな部分が多かったので、運用実績のある構成やここはこうした方がいいよみたいな指摘等あればぜひコメントなどでいただければと思います。

[sample-angular-express-socketio-jwt](https://github.com/MATSUMOTO-Takashi/sample-angular-express-socketio-jwt "GitHubリポジトリ")