Next.js に CASL React を組み込んで権限管理をしてみた

アプリケーションの権限設計について調べていたところ、以下の記事に出会いました。
アプリケーションにおける権限設計の課題 - kenfdev’s blog

権限設計で考えなければいけないことがまとまっていて、とても参考になりました。ありがとうございました。

 
上記の記事を読む中で

  • RBAC(Role-Based Access Control)
  • ABAC(Attribute-Based Access Control)

あたりを試してみたくなりました。

ただ、スクラッチで作るには時間がかかりそうだったため、記事で紹介されているライブラリを使ってみることにしました。

 
調べてみたところ、JavaScript製の CASL

JAXenter: When should you use CASL?

Sergii Stotskyi: Whenever you have a requirement to implement Access Control in the application. CASL, in its core, implements ABAC (i.e., Attribute Based Access Control), but it can be successfully used to implement RBAC (Role Based Access Control) and even Claim based access control.

"CASL is an isomorphic JavaScript permission management library"

という点で良さそうでした。

 
また、実装した例なども色々と見つかりました。

 
そこで、Next.js に CASL を組み込んで権限管理をしてみることにしました。

ただ、色々と悩んだことがあったため、メモを残します。

 
目次

 

環境

実装すること

  • Next.js に CASL React で組み込む
  • 認証(ログイン)は NextAuth.js を使う
  • 用意するページは2つ
    • /
      • ログインページ
      • ToDoが書かれているページへのリンクあり
    • /todo/<id>
      • ToDoが書かれているページ
      • ログインユーザーのロールに従い、画面の表示を変える

 

実装しないこと

  • TypeScriptで書く
  • NextAuth.js での認証はがんばらない
    • 今回の検証ではログインさえできれば良いので、OAuthとか使わず、ユーザーとパスワードにする
    • ユーザー名とパスワードが一致していれば、ログインOKにする
  • ロールはがんばらない
    • ログインしたユーザーの name をロールにする
  • エラーハンドリング
    • 記事の最後にありますが、今回は CASL を使うのが目的なため、エラーハンドリングはあきらめています。。。

 

構成

  • Next.js 11.1.2
  • React 17.0.2
  • @casl/react 2.3.0
  • @casl/ability 5.4.3
  • next-auth 3.29.0

 

NextAuth.js によるログイン機能を実装

今回はログインをがんばらないため、NextAuth.js を使ってログイン機能を実装することにしました。

また、手軽にログインを実現したかったため、NextAuth.js では OAuth2.0 などを使わず、ユーザーとパスワードだけでログインすることとしました。

そこで、以下の記事を参考に、ユーザーとパスワードだけでログインできるようにしてみます。
Next.js + NextAuthでメールアドレス/パスワード認証をする

なお、今回はCASLを使うのが目的であったため、「ユーザー名とパスワードが一致すれば、ログインOK」というセキュリティ的には明らかによろしくない実装となっています。

 
NextAuth.js の実装は上記の記事通りですので、細かな説明は省略します。

まずはインストールします。

# 現在のディレクトリ名で Next.js のアプリを作り、現在のディレクトリの中に各種ソースを入れる
% npx create-next-app .

# NextAuth を追加
$ yarn add next-auth

 
続いて、 NextAuth.js 用APIを作成します。

# NextAuth 用APIのファイルを用意
% mkdir pages/api/auth 
% touch "pages/api/auth/[...nextauth].js"

 
pages/api/auth/[...nextauth].js を実装します。

import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'

const authenticate = credentials =>
  // CASLの検証が目的なので、認証は適当...
  credentials.name === credentials.password ?
    { id: 1, name: credentials.name } :
    null;

const options = {
  providers: [
    Providers.Credentials({
      name: 'Name',
      credentials: {
        name: { label: 'Name', type: 'text', placeholder: 'admin' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async credentials => {
        const user = authenticate(credentials)
        return user ?
          Promise.resolve(user) :
          Promise.resolve(null)
      },
    }),
  ],
}

export default (req, res) => NextAuth(req, res, options)

 
pages/_app.js を変更します。

import {Provider} from 'next-auth/client'

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  )
}

 
pages/index.js にログイン機能とToDoページへのリンクを追加します。

import {signIn, signOut, useSession} from 'next-auth/client'
import Link from 'next/link'

export default function Home() {
  const [session, loading] = useSession();

  if (loading) {
    return <div>Loading...</div>
  }

  return (
    <div>
      {session && (
        <>
          Signed in as {session.user.name} <br/>
          <button onClick={signOut}>Sign out</button>
        </>
      )}
      {!session && (
        <>
          Not signed in <br/>
          <button onClick={signIn}>Sign in</button>
        </>
      )}

      <Link href="/todo/1">
        <a>Go Todo</a>
      </Link>
    </div>
  )
}

 
package.json で、Next.js の起動ポートを変更しておきます。

{
  "name": "nextjs_casl-sample",
  "scripts": {
    "dev": "next dev -p 4800",
//...

 
なお、NEXTAUTH_URL のポートはNext.jsと合わせます。

NEXTAUTH_URL=http://localhost:4800

 
実装が終わったため、 yarn run dev して、ログインできるようになればOKです。

 

CASLによる権限管理を実装

以下の公式ドキュメントやサンプルを参考にしながら、CASL を Next.js に組み込んでいきます。

 

権限管理 (Ability) の作成

今回はログインしたユーザーごとに権限が異なるため、サンプルのように user を引数に取る

  • buildAbilityFor
  • defineRulesFor

を作成していきます。

今回、公式サンプルと異なり、 subject type detection を行いません。

また、 ユーザー名 = ロール名 として、以下の権限で設定します。

ユーザー名 Staff部分の表示 Manager部分の表示 Admin部分の表示
admin o o o
manager o o x
staff o x x
その他 x x x

 
まずは、公式サンプル通り、 AbilityBuilder を使って権限 (ability ) を config/abilities.js ファイルに定義します。
AbilityBuilder class| Define Rules | CASL

import {Ability, AbilityBuilder} from '@casl/ability';

export default function defineRulesFor(user) {
  const {can, rules} = new AbilityBuilder(Ability);

  switch(user?.name) {
    case 'admin':
      can('manage', 'all');
      break;
    case 'manager':
      can('show', 'Manager');
      can('show', 'Staff');
      break;
    case 'staff':
      can('show', 'Staff');
      break;
    default:
  }

  return rules;
}

export function buildAbilityFor(user) {
  // 公式サンプルと異なり、今回は subject type detection を行わないので、オプションなしでインスタンス生成
  return new Ability(defineRulesFor(user));
}

 

AbilityProvider コンポーネントの作成

公式サンプルでは、 App.tsxbuildAbilityFor を使って ability をインスタンス化しています。

// https://github.com/stalniy/casl-examples/blob/19c2dbd1c3dc9fb855ed24108b4481a4eafca5ba/packages/react-todo/src/App.tsx#L7

const ability = buildAbilityFor('member');

 
しかし、今回は / でログインしたユーザーを元に ability をインスタンス化する必要があるため、 _app.js では ability を決め打ちできません。

そこで、

  • ability を useState フックで保存
  • AuthNext.js の useSession フックを使って取得できる session の状態を監視する useEffect フックを用意
  • ability をグローバルに扱えるよう、 useContext フックを使用

というような処理を行うコンポーネント (components/AbilityProvider.jsx) を用意します。

import {createContext, useEffect, useState} from 'react';
import {useSession} from 'next-auth/client';
import {buildAbilityFor} from '../config/abilities';

// グローバルな state を管理する Context を用意
export const AbilityContext = createContext({});

const Component = props => {
  const [session] = useSession();
  const [ability, setAbility] = useState({});

  // session の値が変わったら、session の内容により、abilityの値を変える
  useEffect(() => {
    session ? setAbility(buildAbilityFor(session.user)) : setAbility({});
  }, [session]);

  const {children} = props;

  // Providerコンポーネントを用意し、 ability をグローバルで扱えるようにする
  return (
    <AbilityContext.Provider value={{ability}}>
      {children}
    </AbilityContext.Provider>
  )
}
export default Component

 

_app.js に AbilityProvider を追加

アプリのグローバルで扱えるよう、_app.jsAbilityProvider を追加します。

sessionのProviderの下で、 Component を囲むように定義します。

import {Provider} from 'next-auth/client'
import AbilityProvider from '../components/AbilityProvider';

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider session={pageProps.session}>
      <AbilityProvider>
        <Component {...pageProps} />
      </AbilityProvider>
    </Provider>
  )
}

 

権限ごとに表示が異なるページを用意

pages/todo/[todoId].js に実装します。

このページでは

  • 権限ごとに表示を変えるため、Context から ability を取り出す
    • const {ability} = useContext(AbilityContext);
  • ability.can() により、表示可否を決める
    • 例: {ability.can('show', 'Admin') && <li>Admin</li>}
  • ログインユーザー名を表示するため、 session を取り出す
    • const [session] = useSession();

を行います。

import {useRouter} from 'next/router'
import {useContext} from 'react'
import {AbilityContext} from '../../components/AbilityProvider';
import {useSession} from 'next-auth/client';

const Page = () => {
  const router = useRouter();
  const {todoId} = router.query;
  const {ability} = useContext(AbilityContext);
  const [session] = useSession();

  return (
    <div>
      <h1>ToDo</h1>
      <p>User: {session.user.name}</p>
      <p>Todo ID: {todoId}</p>
      <ul>
        {ability.can('show', 'Admin') && <li>Admin</li>}
        {ability.can('show', 'Manager') && <li>Manager and above</li>}
        {ability.can('show', 'Staff') && <li>Staff and above</li>}
        <li>Everyone</li>
      </ul>
    </div>
  );
}

export default Page

 

ToDoページに直接アクセスした時には404を表示する

ここまでで、クライアントサイドでのルーティングでは想定通りの動きとなりそうです。

ただ、直接 http://localhost:4800/todo/1 にアクセスしたり、ログイン前に ToDo ページに移動するとエラーになってしまいます。

 
そこで、Next.js で404を表示できるようにします。
Advanced Features: Custom Error Page | Next.js

まずは、独自の 404 ページ (pages/404.js) を用意します。

export default function Custom404() {
  return <h1>404 - Page Not Found</h1>
}

 
次に、ToDoページの getServerSidePropssession がなければ、404ページを表示させます。

なお、NextAuth.jsで session をサーバーサイドで取得するには、 getSession を使います。 getSession() | Client API | NextAuth.js

import {getSession, useSession} from 'next-auth/client';

export async function getServerSideProps(context) {
  const session = await getSession(context);
  if (!session) {
    return {
      notFound: true
    };
  }

  return {
    props: {}
  }
}

 
以上で実装が終わりました。

 

動作確認

一通りできたため、ログインユーザーごとの表示を確認します。

 

admin

 

manager

 

staff

 

その他

 

できていないこと

ログイン後、ToDoページに直接アクセスした場合はエラーになります。

とはいえ、今回は CASL を使うのがメインなので、あきらめます。。。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/nextjs_casl-sample