stateにパーミッションを設定する

stateにパーミッションを設定する

　皆さん、Angular.jsしてますか。本記事ではAngular.jsアプリケーションでのパーミッション制御について書きます。最終的にはモンキーパッチ的な方法で解決していますが、なんとか上手く動かすことができたので少しでも参考になれば幸いです。


## こうやって書きたい

```coffeescript:admin.coffee
angular.module 'app'
.config ($stateProvider) ->
    $stateProvider
    .state 'admin',
        url: '/admin',
            templateUrl: '/app/admin/admin.html'
            controller: 'AdminCtrl'
        data:
            restrict:
                role: 'admin'
                error: '/admin 以降へのアクセスは管理者権限が必要です。'
                next: 'login'				
```
　非adminユーザーが`/admin`にアクセスしようとした場合、`'/admin 以降へのアクセスは管理者権限が必要です。'`というメッセージをToastみたいなもので通知して、`'login'`というstateに飛ばす、という感じです。これをコントローラーの処理に入る前に`state.data`をチェックしてアクセスの制限を行います。
　いろいろ調べていると「resolveでやれ」というものが多いですが、今回はあえてresolveを使わない方法の一例を提示したいと思います。


## バックエンド
　認証にはcookie/sessionではなく、保持にはlocalStorageを使うことを前提として、tokenを使います。tokenが`"THISISACCESSTOKEN"`であるとしたら、認証が必要なAPIには`Access-Token: THISISACCESSTOKEN`という感じのHTTPヘッダを付けてアクセスするものとします。
　よくあるトークン認証です。

## 認証の実装
`Auth`というサービスを作ります。

```coffeescript:auth.coffee
angular.module serviceName
.factory 'Auth', ($http, $q, Token) ->
    _current =
        active: false
        user : {}

    _levels =
        admin: 2
        user: 1
        guest: 0

    level: ->
        if _current.active and _current.user?.admin?
            if _current.user.admin
                return _levels.admin
            else
                return _levels.user
        else
            return _levels.guest

    can : (role_or_level)->
        if role_or_level?
            if typeof role_or_level isnt 'number'
                needs_level = _levels[role_or_level] or 0
            else
                needs_level = role_or_level
        else
            needs_level = _levels.user

        needs_level <= @level()

    active: ->
        _current.active

    user: ->
        _current.user

    login: (credentials)->
        deferred = $q.defer()
        $http.post '/api/session',
            email: credentials.email
            password: credentials.password
        .success (data)=>
            Token.set data.token
            _current.active = true
            _current.user = data.user
            deferred.resolve _current
        .error (err) ->
            @logout()
            deferred.reject err

        deferred.promise

    logout: ()->
        Token.clear()
        _current.active = false
        _current.user = {}

    check: ()->
        deferred = $q.defer()
        t = Token.get()
        if t? and t isnt ''
            $http.get '/api/users/profile', {}
            .success (data)=>
                _current.active = true
                _current.user = data
                deferred.resolve _current
            .error (err)=>
                @logout()
                deferred.reject err
        else
            @logout()
            deferred.reject _current

        deferred.promise

```
　擬似コードです。長くてすいません。何をするかはなんとなく察してください。とりあえずここで抑えて欲しいポイントを以下に示します。
### Tokenサービスについて
これについてはソースコードは省略させてください。やることは単純で

* `Token`サービスはアクセストークンを保持する。
* `Token.set()`でlocalStorageにトークンを保存しながら、`$http.defaults.headers.common['Access-Token'] = token`で常にHTTPリクエストのヘッダにトークンが乗るようにします。
* `Token`は初期化の際にlocalStorageからストアして$httpのヘッダを設定します。

以上の仕様を満たすものとします。

### Authサービスについて
* `Auth.login()`は`POST /api/session`で、tokenとユーザー情報を取得します。
* `Auth.check()`は`GET /api/users/profile`で、tokenに対応するユーザー情報を取得します。これを認証状態の確認に使います。
* `Auth.logout()`でトークンとユーザー情報を破棄します。
* `Auth.can()`で引数に求められるユーザーのパーミッションの有無を取得します。roleは`admin > user > guest`という3レベルです。

Angularの初期化の際に`Auth.check()`を叩き、成功するとユーザー情報が返ります。これを元にパーミッションを設定します。

## stateのパーミッションをチェックする
　`$stateChangeStart`でやります。ここで引っ掛けられれば制限されたページへの遷移が発生する前にユーザーを追い出すことが可能です。パーミッションのチェックをするためにはユーザー情報が必要なので、`Auth.check()`がresolveされている必要があります。
　このpromiseの待機のために、「 ***初回のページ遷移で遷移イベントを中断し、APIを叩き、API Callがresolveされたときに改めてtoStateとtoParamsで再びページ遷移を起こす*** 」ということします。


```coffeescript:app.coffee
angular.module 'app'
.factory 'PageRestriction',
    (Auth)->
        (state)->
            _default =
                error: '権限がありません。'
                next: 'main'

            _result =
                can: true

            if state.data?.restrict?
                restrict = state.data.restrict
                _result.can = Auth.can state.data.restrict.role

                if not _result.can
                    _result.error = restrict.error or _default.error
                    _result.next = restrict.next or _default.next

            _result

#* Notifyは単純な通知を行うサービス
.run ($rootScope, $state, Auth, Notify)->
    hooking_first_change = true
    unregister = $rootScope.$on '$stateChangeStart', (ev, toState, toParams, fromState, fromParams)->
        go = ->
            hooking_first_change = false
            $state.transitionTo toState, toParams

        ev.preventDefault()
        Auth.check().then go, go
        unregister()

    $rootScope.$on '$stateChangeStart', (ev, toState, toParams, fromState, fromParams)->
        if hooking_first_change
            return
        result = PageRestriction(toState)
        if not result.can
            ev.preventDefault()
            Notify result.error
            $state.go result.next

```
　下の方の`$stateChangeStart`では確実にはユーザー情報が取得できているので、そこで`PageRestriction`というサービスで実際にはじめに書いたような`state.data.restrict`を読み取って、Authサービスからパーミッションの確認をして、結果から適切な通知とリダイレクトを行います。


## いや、resolve使えよ
　なんとかresolveで書きたかったんですが、Angular力が足りないのか、いまいちいい方法が思いつきませんでした。残念。ただ、$stateChangeStartが酷い方法で対処しているのに対して、state側はかなりスッキリと書けたので、個人的には及第点かな？という感じです。むしろよくよく見れば「stateのresolveを手元で解いた実装」にも見えなくもないです(強引)。
　一応最後に一点だけ触れておくと`angular.bootstrap`は少し使いどころと違うかなと思います。初歩的な見落としや、もっと良い方法があるよ！という方は気軽にコメントしてください。