Edited at

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

More than 3 years have passed since last update.

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

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


こうやって書きたい


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というサービスを作ります。


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で再びページ遷移を起こす 」ということします。


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は少し使いどころと違うかなと思います。初歩的な見落としや、もっと良い方法があるよ!という方は気軽にコメントしてください。