これはNuxt.js Advent Calendar 2019 4日目の記事です。
VueがForm作るのに向いていることもあり、admin画面をVue/Nuxtで作るユースケースはそれなりにあると思います。どう実装するかですが、よくあるパターンとしてRole-based access controlがあります。
サンプル
https://auth0.com/blog/role-based-access-control-rbac-and-react-apps/
これはReactの実装例ですが、Nuxtなりにやってみるとどうなるかというところから書いてみます。
制御したいものを考える
権限のない人に見せたくないものは多くの場合
- 特定のページ
- 特定の要素(コンポーネント)
の2つになるかと思います。なので、ページアクセスの制御とレンダリング制御ができれば良さそうです。
具体例は後述しますが、今回はv-if
とslot
をラップした権限制御用のコンポーネントとNuxtのmiddleware
で行いたいと思います。双方が扱う権限制御のための関数は同じものにします。
Role-based access controlとは
簡単に言えば、単一、あるいは複数の権限(permission)の組み合わせをロール(役割)とし、ロールをユーザーごとに紐づけて権限制御をする手法です。
JavaScriptでは簡単なオブジェクトで表現できます。
export default {
read: {
displayName: '読み取りだけできる人',
permissions: ['contents_read']
},
update: {
displayName: '読み書きできる人',
permissions: [
'contents_read',
'contents_update'
]
},
admin: {
displayName: 'なんでもできる人',
permissions: [
'contents_read',
'contents_update',
'user_update'
]
}
}
permissionsがそのロールに与えられている権限になります。
簡単ですが、権限制御の元ネタは完成です。(動的でなければ)これをユーザーごとに紐づければいいわけですが、紐づけたデータを用意するのはフロントエンドだけでは無理ですね。サーバーからapiで返してもらいましょう。さらに言えば紐づける前のこのリストもサーバーから返してもらっても良いです。修正が入る時に色々楽になります。
このリストはaccessControlList
みたいな名前にしておきます。長いのでacl
です。
サーバーから来るユーザー情報はこんな感じで。
user: {
id: 1,
name: "権限剛",
roles: ["admin"]
}
ロールを細かく制御する場合、ユーザーに紐づけるロールも複数になりえるので配列でキー名を持たせます。これでロールと権限の準備は完了です。
ロールをVuexで管理する
ログインしている間、acl
とroles
は保持していて欲しいので、Vuexに入れておくのが無難です。アクセストークンを管理している場合一緒に入れておくと良いでしょう。
store/auth.js
export const state = () => ({
accessToken: null,
user: {},
acl: {}
})
雰囲気コードなので今回getterやaction,mutationは細かく書きません。
権限を見る関数を作る
権限制御関数の要件は以下のようなものです。
- 受け取ったpermissonを持つユーザーかどうか判定して真偽値を返す
-
acl
とroles
からそのユーザーに紐づくpermissionを全て取り出せる
コードにしてみた方が早いです。今回は複数のpermissionを配列として渡せるようにします。
export const permissionCheck = (roles, permissions, acl) => {
// 権限が配列で渡されなかった場合要素数1の配列として返す
const permissionsArray = Array.isArray(permissions)
? permissions
: [permissions]
// aclからユーザーの持つロールを1つずつ取り出して、そのロールが持つpermissionと比較して1つでもマッチすればOK
return roles.some((role) => {
return permissionsArray.some((p) => {
return acl[role] && acl[role].permissions.includes(p)
})
})
}
ちょっと処理が追いづらくなってますが要するに要求権限とユーザー権限のマッチングをしています。ただしこれだけでは使えないためコンポーネント側から色々渡してあげる必要があります。
コンポーネントが呼び出す関数は以下のようなものです。
export const check = (store, permissions) => {
const acl = store.getters['auth/acl']
const roles = store.getters['auth/user'] && store.getters['auth/user'].roles
if (!acl || !roles) {
return false
}
return permissionCheck(roles, permissions, acl)
}
コンポーネントからはストアと要求権限を渡します。ここまでで関数の定義は終わりです。
コンポーネントの制御をしてみる
権限が通った時に表示したいコンポーネントをslot
で渡すラッパーコンポーネントを作ります。ダメだった時のフォールバック用に、v-else
で表示できるようにしてみます。
<template>
<div>
<slot v-if="checkRule" name="yes" />
<slot v-else name="no" />
</div>
</template>
<script>
import { check } from '~/modules/accessControl'
export default {
name: 'CanUse',
props: {
permission: {
type: [String, Array],
required: true,
default: ''
},
},
computed: {
checkRule() {
return check(this.$store, this.permission)
},
},
}
</script>
これでCanUse
コンポーネントには要求権限をprops
で渡し、表示したい任意のコンポーネントをslot
で渡せます。
以下は例です。
<template>
<div>
<input
v-model="formComment"
type="textarea"
></input>
<can-use permission="contents_update">
<template #yes>
<button @click="commentUpdate">
変更する
</button>
</template>
<template #no>
<button disabled>
変更する
</button>
</template>
</can-use>
</div>
</template>
<script>
import CanUse from '~/components/CanUse'
export default {
components: {
CanUse
},
data() {
return {
formComment: ''
}
},
methods: {
commentUpdate() {
this.$emit('update', { comment: this.formComment })
}
}
}
これでcontents_update
を持つユーザー以外にはdisable
になったボタンが表示されます。
ページの権限制御をしてみる
コンポーネントはslot
を用いて出し分けが出来ましたが、ページ制御は結構面倒臭いです。Nuxtでは特に。
要件的にはこんな感じです。
- 権限を持たないログイン済みのユーザーがページにアクセスした場合403ページにリダイレクトする
- ページごとに必要な権限を定義したい
シンプルですが、各ページコンポーネントでやるのはかなりしんどいためこの処理はmiddleware
を用いて行います。以下のようなものです。
import { check } from '~/modules/accessControl'
export default ({ store, route, error }) => {
// アクセス済みかのチェックとか色々省略
// ページ遷移時にユーザー権限をチェック
const permissions = route.meta[0].requiredPermissions
if (permissions && permissions.length) {
if (!check(store, permissions)) {
// 権限がない場合403ページへリダイレクトさせる
return error({
statusCode: 403,
data: { message: '閲覧権限がありません' },
})
}
}
}
この関数はroute
に要求権限がある前提のコードになっているためこのままでは動きません。ページの権限制御を望み通りに動かすためにrouterを拡張します。
routerに権限情報を持たせる
Nuxtはnuxt.config.js
でrouterを拡張することができます。権限は以下のような感じで外部ファイルにルートごとの権限を定義しておくとメンテナンスが楽です。
const UserUpdate = 'user_update'
const ContentsRead = 'contents_read'
// ルート名:権限の配列
export const pagePermissions = {
home: [ContentsRead],
login: [],
logout: [],
admin: [UserUpdate]
}
routerオプションのextendRoutes
でルーターに対して権限を追加。
import { pagePermissions } from '~/utils/pagePermissions'
--省略--
router: {
extendRoutes(routes) {
// ページごとに必要権限をmetaとして追加
routes.forEach((route) => {
const meta = pagePermissions[route.name] || []
route.meta = {
requiredPermissions: meta,
}
})
},
これでルーターに権限情報を付与することができました。adminページはadmin
権限を持つユーザーのみアクセスすることができます。ページでmiddleware
を使うようにします。
export default {
middleware: ['authenticated'],
}
ページの権限制御ができました。
番外編:asyncDataで権限制御する
もしかしたらasyncData
やfetch
で叩くapiを権限で制御したいというニーズが出てくるかもしれません。そういう時でもcheck
関数をうまく使えば制御できます。
asyncData
やfetch
ではコンテキストからstore
を受け取れるので、それと要求権限を渡します。権限の不要なapiは事前に配列へプッシュしておき、権限が必要なapiはあとからcheck
を見てよければプッシュ、ダメなら空オブジェクトを入れておきます。その後Promise.all
でapiを全て叩いて値を取得します。
import { check } from '~/modules/accessControl'
import api from '~/api'
export default {
async asyncData({ error, params, store }) {
//権限不要なapiを先に詰める
const promises = [api.detail(params.id)]
// 権限判定
const hasAdmin = check(store,'admin')
//admin権限を持つユーザーはadminユーザー一覧を取得できる
if (hasAdmin) {
promises.push(api.admins(params.id))
} else {
promises.push({})
}
try {
const res = await Promise.all(promises)
const results = {
detail: res[0].data
admins: res[1].data
}
return {
...results,
}
} catch (e) {
return error(e.response)
}
},
// ...省略
try/catch
で書いていますが別にthen/catch
でも良いです。チームや好みに合わせると良いと思います。
おわり
今回は動作のデモなどは間に合わなくてお見せできませんでしたが、実際にプロダクトで動作しているコードの一部を紹介いたしました。
この設計の欠点として、CanUse
コンポーネントのレンダリングテストが書きにくかったり、TypeScriptへの対応がまだだったり完全とは言えません。
もしこの記事を読んで権限制御にチャレンジする方はこういった欠点を補うことのできる設計を目指してみると良いかと思います。
明日は@TsukasaGRさんです。