Nuxt.jsで権限管理

これは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では簡単なオブジェクトで表現できます。

```js

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`です。

サーバーから来るユーザー情報はこんな感じで。

```javascript
user: {
  id: 1,
  name: "権限剛",
  roles: ["admin"]
}
```

ロールを細かく制御する場合、ユーザーに紐づけるロールも複数になりえるので配列でキー名を持たせます。これでロールと権限の準備は完了です。

## ロールをVuexで管理する

ログインしている間、`acl`と`roles`は保持していて欲しいので、Vuexに入れておくのが無難です。アクセストークンを管理している場合一緒に入れておくと良いでしょう。

`store/auth.js`

```js
export const state = () => ({
  accessToken: null,
  user: {},
  acl: {}
})
```

雰囲気コードなので今回getterやaction,mutationは細かく書きません。

## 権限を見る関数を作る

権限制御関数の要件は以下のようなものです。

- 受け取ったpermissonを持つユーザーかどうか判定して真偽値を返す
- `acl`と`roles`からそのユーザーに紐づくpermissionを全て取り出せる

コードにしてみた方が早いです。今回は複数のpermissionを配列として渡せるようにします。

```javascript

export const permissionCheck = (roles, permissions, acl) => {
  // 権限が配列で渡されなかった場合要素数1の配列として返す
  const permissionsArray = Array.isArray(permissions)
    ? permissions
    : [permissions]
  // aclからユーザーの持つロールを１つずつ取り出して、そのロールが持つpermissionと比較して１つでもマッチすればOK
  return roles.some((role) => {
    return permissionsArray.some((p) => {
      return acl[role] && acl[role].permissions.includes(p)
    })
  })
}
```

ちょっと処理が追いづらくなってますが要するに要求権限とユーザー権限のマッチングをしています。ただしこれだけでは使えないためコンポーネント側から色々渡してあげる必要があります。

コンポーネントが呼び出す関数は以下のようなものです。

```javascript
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`で表示できるようにしてみます。

```vue
<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`で渡せます。
以下は例です。

```vue
<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`を用いて行います。以下のようなものです。

```javascript

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を拡張することができます。権限は以下のような感じで外部ファイルにルートごとの権限を定義しておくとメンテナンスが楽です。

```ts

const UserUpdate = 'user_update'
const ContentsRead = 'contents_read'

// ルート名：権限の配列
export const pagePermissions = {
  home: [ContentsRead],
  login: [],
  logout: [],
  admin: [UserUpdate]
}

```

routerオプションの`extendRoutes`でルーターに対して権限を追加。

```js
import { pagePermissions } from '~/utils/pagePermissions'
--省略--
router: {
  extendRoutes(routes) {
    // ページごとに必要権限をmetaとして追加
    routes.forEach((route) => {
      const meta = pagePermissions[route.name] || []
      route.meta = {
        requiredPermissions: meta,
      }
    })
  },
```

これでルーターに権限情報を付与することができました。adminページは`admin`権限を持つユーザーのみアクセスすることができます。ページで`middleware`を使うようにします。

```javascript
export default {
  middleware: ['authenticated'],
}
```

ページの権限制御ができました。

## 番外編：asyncDataで権限制御する

もしかしたら`asyncData`や`fetch`で叩くapiを権限で制御したいというニーズが出てくるかもしれません。そういう時でも`check`関数をうまく使えば制御できます。

`asyncData`や`fetch`ではコンテキストから`store`を受け取れるので、それと要求権限を渡します。権限の不要なapiは事前に配列へプッシュしておき、権限が必要なapiはあとから`check`を見てよければプッシュ、ダメなら空オブジェクトを入れておきます。その後`Promise.all`でapiを全て叩いて値を取得します。

```javascript
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さんです。