Socket.IOでJWTでの認証ってどうやったらできるのかなーと思って基本的な動作をするコードを書いてみました。
結論から言えば問題なく動作しました。
コードはGitHubに置いてあるので、もっといい書き方があればコメントかPRください。
sample-angular-express-socketio-jwt
環境
以下の環境で実施
- 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
をベースに開発。
$ npm install -g express-generator
$ express --git server
以下、server
ディレクトリで作業。
起動時のPORT指定
{
"scripts": {
"start": "PORT=3080 node ./bin/www"
},
必要なモジュールをインストール
$ npm install --save coffee-script cors jsonwebtoken socket.io socketio-jwt
JWT用の共通鍵を定義
module.exports = exports =
secret: "your project secret key"
認証用APIを定義
今回はusername=madoka
、password=magica
のみ認証を通してそれ以外は弾くようにしています。
実際にはDBに接続することになるはずです。
あと有効期限を2時間にしてるのは気分です。
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らしいので楽をします。
# 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
経由でデータにアクセスが可能です。
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オブジェクトです。
/**
* Socket.IO
*/
require('../socketio/app')(server);
最後に、JavaScriptではなくCoffeeScriptで書いているので、それ用の設定を追加します(先頭の方に)
require('coffee-script/register');
クライアントサイド
gulp-angular
をベースに開発。
$ npm install -g yo gulp generator-gulp-angular bower
$ mkdir front && cd $_ && yo gulp-angular
以下、front
ディレクトリで作業。
必要なライブラリをインストール
$ bower install --save ngstorage angular-socket.io socket.io.client
ロードするように設定
angular.module 'front',
['ngAnimate', 'ngCookies', 'ngTouch', 'ngSanitize', 'ngResource', 'ngStorage', 'ui.router', 'ui.bootstrap', 'btford.socket-io']
Userモデル(サービス)を定義
認証して発行されたトークンはローカルストレージに保存しておく。
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の場合コネクションを勝手に貼り直しはせず内部でプーリングしてそれを使いまわそうとするらしく、強制的に新規接続をさせている。
そうしないとログイン/ログアウトを繰り返した際に、認証のチェックがうまく働かない。
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
ログイン画面
ログイン画面を作ります。
class LoginController
constructor: (@User, @$state) ->
auth: ->
@User.authentication()
.success => @$state.go "main"
angular.module "front"
.controller "LoginController", LoginController
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のコネクションも残ってしまうので明示的に切断しています。
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
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を利用し、遷移前に接続を確立させています。
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に認証機能を持たせることができました。
結構手探りな部分が多かったので、運用実績のある構成やここはこうした方がいいよみたいな指摘等あればぜひコメントなどでいただければと思います。