Help us understand the problem. What is going on with this article?

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-ifslotをラップした権限制御用のコンポーネントと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で管理する

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

store/auth.js

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

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

権限を見る関数を作る

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

  • 受け取ったpermissonを持つユーザーかどうか判定して真偽値を返す
  • aclrolesからそのユーザーに紐づく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で権限制御する

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

asyncDatafetchではコンテキストから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さんです。

ushironoko
たまに書く。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした