アプリケーションの権限設計について調べていたところ、以下の記事に出会いました。
アプリケーションにおける権限設計の課題 - kenfdev’s blog
権限設計で考えなければいけないことがまとまっていて、とても参考になりました。ありがとうございました。
上記の記事を読む中で
あたりを試してみたくなりました。
ただ、スクラッチで作るには時間がかかりそうだったため、記事で紹介されているライブラリを使ってみることにしました。
調べてみたところ、JavaScript製の CASL
が
CASL React
を使うことで、 React (Next.js) に対応できそう- TypeScriptで書かれている
- リポジトリの README に
Heavily inspired by cancan.
と書かれていたので、CanCanCan
知ってれば何とかなりそう CanCanCan
と組み合わせた事例があった- 以下の作者インタビューより、ABACやRBACに対応してそう
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"
という点で良さそうでした。
また、実装した例なども色々と見つかりました。
- 公式ドキュメントのCookbook
- 作者のサンプルリポジトリ
- Nest.jsのドキュメント
- Next.jsアプリの権限管理にCASLを使う
- マイクロサービスなアーキテクチャで権限制御を考えている - sisisinのブログ
- React, Manage Dynamic Permissions Using CASL & Redux. - DEV Community
- Scaleable Authorization Management Made Easy With CASL.JS 😀
- Role-Based User Authorization in JavaScript with CASL
- Managing User Permissions in a VueJS App - Vue.js Developers
そこで、Next.js に CASL
を組み込んで権限管理をしてみることにしました。
ただ、色々と悩んだことがあったため、メモを残します。
目次
環境
実装すること
- Next.js に CASL React で組み込む
- 認証(ログイン)は NextAuth.js を使う
- 用意するページは2つ
/
- ログインページ
- ToDoが書かれているページへのリンクあり
/todo/<id>
- ToDoが書かれているページ
- ログインユーザーのロールに従い、画面の表示を変える
実装しないこと
- TypeScriptで書く
- まずはJavaScriptから...
- NextAuth.js での認証はがんばらない
- 今回の検証ではログインさえできれば良いので、OAuthとか使わず、ユーザーとパスワードにする
- ユーザー名とパスワードが一致していれば、ログインOKにする
- ロールはがんばらない
- ログインしたユーザーの name をロールにする
- エラーハンドリング
- 記事の最後にありますが、今回は
CASL
を使うのが目的なため、エラーハンドリングはあきらめています。。。
- 記事の最後にありますが、今回は
構成
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.tsx
で buildAbilityFor
を使って 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
フックを用意- useSession() | Client API | NextAuth.js
session
に変更があったら ability をuseState
フックで変更する
- 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.js
に AbilityProvider
を追加します。
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ページの getServerSideProps
で session
がなければ、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