stateにパーミッションを設定する
皆さん、Angular.jsしてますか。本記事ではAngular.jsアプリケーションでのパーミッション制御について書きます。最終的にはモンキーパッチ的な方法で解決していますが、なんとか上手く動かすことができたので少しでも参考になれば幸いです。
こうやって書きたい
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
というサービスを作ります。
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で再びページ遷移を起こす 」ということします。
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
は少し使いどころと違うかなと思います。初歩的な見落としや、もっと良い方法があるよ!という方は気軽にコメントしてください。