ReactのアプリケーションにRole Based Access Controlを実装する

# はじめに

この記事はReactのサンプルアプリケーションにRBAC(Role Based Access Control)を実装する手順で、[こちらの原文](https://auth0.com/blog/role-based-access-control-rbac-and-react-apps/)を元に作成しています。ソースコードに"もしロールがxxだったらxxする"と実装しがちですが、この記事では要件やロールが変わっても柔軟に対応できてステップ数も少なくメンテナンス性の高い方法で実装しています。nodeとnpmのインストール、Auth0の無料アカウントの取得とテナントの作成が完了していることが前提となっています。まだの方は[こちら](https://qiita.com/furuth/items/8826b66a3ec2b2b14222)の記事を参照の上ご準備をお願いします。
完成版のソースコードは[ここ](https://github.com/auth0-blog/react-rbac)で公開しています。

# 環境
- **OS** : `macOS Mojave 10.14.6`
- **node** : `10.15.3`
- **npm** : `6.10.3`


# 手順
## アプリケーションの作成
任意のディレクトリでnpxを実行してnpmのバイナリを実行します。

```console
$ npx create-react-app rbac-tutotial-app
```

rbac-tutorial-app配下に必要なディレクトリ・ファイルを作成します。

```console
$ cd rbac-tutorial-app/src
$ rm App.js App.test.js App.css index.css logo.svg serviceWorker.js
$ mkdir components pages
$ touch pages/dashboard.js pages/home.js
```

React Routerをインストールします。

```console
$ pwd
rbac-tutotial-app
$ npm install react-router react-router-dom
```

src/inded.jsを編集してRouting Pageを作成します。

```jsx:index.js
import React from 'react';
import ReactDOM from 'react-dom';
import {BrowserRouter as Router, Route, Switch} from 'react-router-dom';

import HomePage from './pages/home';
import DashboardPage from './pages/dashboard';

function App() {
  return (
    <div className="App container">
      <div className="jumbotron">
        <Router>
          <Switch>
            <Route exact path="/" component={HomePage}/>
            <Route path="/dashboard" component={DashboardPage}/>
          </Switch>
        </Router>
      </div>
    </div>
  );
}

const rootElement = document.getElementById('root');
ReactDOM.render(<App/>, rootElement);
```

src/pages/home.jsを編集してHomeページを作成します。

```src/pages/home.js
import React from 'react';

const HomePage = () => (
  <h2>Home</h2>
);

export default HomePage;
```

src/pages/dashboard.jsを編集してDashboardページを作成します。

```src/pages/dashboard.js
import React from 'react';

const DashboardPage = () => (
  <h2>Dashboard</h2>
);

export default DashboardPage;
```

index.htmlの<head>セクションにBootstrapのリンクを追加します。

```html:index.html
 <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
```

アプリケーションを起動してChromeで`http://localhost:3000`, `http://localhost:3000/dashboard`に各々アクセスして動作を確認します。

```console
$ pwd
rbac-tutotial-app
$ npm start
```
<img src="https://i.imgur.com/a0jjOAI.png" width=70%>
<br>


## Auth0との連携
Auth0にログインしてテナントを作成します。**この記事では詳細な手順は割愛しています。[こちら](https://qiita.com/furuth/items/8826b66a3ec2b2b14222)をご参照お願いします。**
作成したアプリケーションをAuth0に登録します。左ペインの"Applications"をクリック、右上の"CREATE APPLICATION"を押します。
<img src="https://i.imgur.com/Wr9irHa.png" width=70%>

”Name"に任意の名前を入力、"Single Page Web Applications"を選択して"CREATE"ボタンを押します。
<img src="https://i.imgur.com/OYeNmZA.png" width=70%>

"Settings"タブをクリックして”Allowed Callback URLs“に”`http://localhost:3000/callback`”を入力します。
<img src="https://i.imgur.com/TSpKLQy.png" width=70%>

ユーザにロールを割り当てるルールを作成します。左ペインの"Rules"をクリック、右上の"CREATE RULE"を押します。
<img src="https://i.imgur.com/dX5R9s2.png" width=70%>

"empty rule"をクリックします。
<img src="https://i.imgur.com/EWR5dx0.png" width=70%>

javascriptを作成して"SAVE"ボタンを押します。**"your-email-id@gmail.com"にはご自身のメールアドレスを指定します。**

```jsx
function (user, context, callback) {
  user.app_metadata = user.app_metadata || {};

  if (user.email === 'your-email-id@gmail.com') {
    user.app_metadata.role = 'admin';
  } else {
    user.app_metadata.role = 'writer';
  }

  auth0.users.updateAppMetadata(user.user_id, user.app_metadata)
    .then(() => {
      context.idToken['https://rbac-tutorial-app/role'] = user.app_metadata.role;
      callback(null, user, context);
    })
    .catch((err) => {
      callback(err);
    });
}
```

Auth0-jsライブラリをインストールします。

```console
$ pwd
rbac-tutotial-app
$ npm install auth0-js
```

src/auth0-variables.jsを作成して環境変数を設定します。”<YOUR_AUTH0_DOMAIN>”、“<YOUR_AUTH0_CLIENT_ID>”にはAuth0のダッシュボードで登録したアプリケーションのdomain, clientIdを指定します。

```jsx:src/auth0-variables.js
export const AUTH_CONFIG = {
  domain: "<YOUR_AUTH0_DOMAIN>",
  roleUrl: "https://rbac-tutorial-app/role",
  clientId: "<YOUR_AUTH0_CLIENT_ID>",
  callbackUrl: "http://localhost:3000/callback"
};
```

<img src="https://i.imgur.com/DMV1JTS.png" width=70%>

src/authContext.jsを作成して複数のReactコンポーネントとAuth0の連携を可能にします。

```jsx:src/authContext.js
import { createContext } from "react";

const authContext = createContext({
  authenticated: false, // to check if authenticated or not
  user: {}, // store all the user details
  accessToken: "", // accessToken of user for Auth0
  initiateLogin: () => {}, // to start the login process
  handleAuthentication: () => {}, // handle Auth0 login process
  logout: () => {} // logout the user
});

export const AuthProvider = authContext.Provider;
export const AuthConsumer = authContext.Consumer;
};
```

src/Auth.js(authCotextの親コンポーネント)を作成します。

```jsx:src/Auth.js
import React, { Component } from "react";
import auth0 from "auth0-js";

import { AUTH_CONFIG } from "../constants/auth0-variables";
import { AuthProvider } from "../authContext";

const auth = new auth0.WebAuth({
  domain: AUTH_CONFIG.domain,
  clientID: AUTH_CONFIG.clientId,
  redirectUri: AUTH_CONFIG.callbackUrl,
  audience: `https://${AUTH_CONFIG.domain}/userinfo`,
  responseType: "token id_token"
});

class Auth extends Component {
  state = {
    authenticated: false,
    user: {
      role: "visitor"
    },
    accessToken: ""
  };

  initiateLogin = () => {
    auth.authorize();
  };

  logout = () => {
    this.setState({
      authenticated: false,
      user: {
        role: "visitor"
      },
      accessToken: ""
    });
  };

  handleAuthentication = () => {
    auth.parseHash((error, authResult) => {
      if (error) {
        console.log(error);
        console.log(`Error ${error.error} Occured`);
        return;
      }

      this.setSession(authResult.idTokenPayload);
    });
  };

  setSession(data) {
    const user = {
      id: data.sub,
      email: data.email,
      role: data[AUTH_CONFIG.roleUrl]
    };
    this.setState({
      authenticated: true,
      accessToken: data.accessToken,
      user
    });
  }

  render() {
    const authProviderValue = {
      ...this.state,
      initiateLogin: this.initiateLogin,
      handleAuthentication: this.handleAuthentication,
      logout: this.logout
    };
    return (
      <AuthProvider value={authProviderValue}>
        {this.props.children}
      </AuthProvider>
    );
  }
}

export default Auth;
```

src/index.jsを修正してcallbackルートを追加します。

```jsx:src/index.js
import React from "react";
import ReactDOM from "react-dom";
import {BrowserRouter as Router, Route, Switch} from "react-router-dom";

import HomePage from "./pages/home";
import DashboardPage from "./pages/dashboard";
import CallbackPage from "./pages/callback";
import Auth from "./components/Auth";

function App() {
  return (
    <div className="App container">
      <Auth>
        <div className="jumbotron">
          <Router>
            <Switch>
              <Route exact path="/" component={HomePage}/>
              <Route path="/dashboard" component={DashboardPage}/>
              <Route path="/callback" component={CallbackPage}/>
            </Switch>
          </Router>
        </div>
      </Auth>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App/>, rootElement);
```

src/pages/callback.js(callbackページ)を作成します。

```jsx:src/pages/callback.js
import React from "react";
import {Redirect} from "react-router-dom";

import {AuthConsumer} from "../authContext";

const Callback = props => (
  <AuthConsumer>
    {({handleAuthentication}) => {
      if (/access_token|id_token|error/.test(props.location.hash)) {
        handleAuthentication();
      }
      return <Redirect to="/"/>;
    }}
  </AuthConsumer>
);

export default Callback;
```

src/components/Login.js(Loginコンポーネント)を作成します。

```jsx:src/components/Login.js
import React from "react";

import { AuthConsumer } from "../authContext";

const Login = () => (
  <AuthConsumer>
    {({ initiateLogin }) => (
      <button className="btn btn-sm btn-primary" onClick={initiateLogin}>
        Login
      </button>
    )}
  </AuthConsumer>
);

export default Login;
```

src/components/Logout.js(Logoutコンポーネント)を作成します。

```jsx:src/components/Logout.js
import React from "react";

import { AuthConsumer } from "../authContext";

const Logout = () => (
  <AuthConsumer>
    {({ logout }) => (
      <button className="btn btn-sm btn-default" onClick={logout}>
        Logout
      </button>
    )}
  </AuthConsumer>
);

export default Logout;
```

src/components/Home.js(Homeコンポーネント)を作成します。

```jsx:src/components/Home.js
import React from "react";
import { Redirect } from "react-router-dom";

import { AuthConsumer } from "../authContext";
import Login from "../components/Login";
import PostsList from "../components/PostsList";

const HomePage = () => (
  <AuthConsumer>
    {({ authenticated }) =>
      authenticated ? (
        <Redirect to="/dashboard" />
      ) : (
        <div>
          <h2>Welcome to React RBAC Tutorial.</h2>
          <Login />
          <PostsList />
        </div>
      )
    }
  </AuthConsumer>
);

export default HomePage;
```

<br>


## 認可の実装
src/rbac-rules.jsを作成してRole-Permissionの紐付けを定義します。

```jsx:src/rbac-rules.js
const rules = {
  visitor: {
    static: ["posts:list", "home-page:visit"]
  },
  writer: {
    static: [
      "posts:list",
      "posts:create",
      "users:getSelf",
      "home-page:visit",
      "dashboard-page:visit"
    ],
    dynamic: {
      "posts:edit": ({userId, postOwnerId}) => {
        if (!userId || !postOwnerId) return false;
        return userId === postOwnerId;
      }
    }
  },
  admin: {
    static: [
      "posts:list",
      "posts:create",
      "posts:edit",
      "posts:delete",
      "users:get",
      "users:getSelf",
      "home-page:visit",
      "dashboard-page:visit"
    ]
  }
};

export default rules;
```

src/components/Can.js(Canコンポーネント)を作成します。

```jsx:src/components/Can.js
import rules from "../rbac-rules";

const check = (rules, role, action, data) => {
  const permissions = rules[role];
  if (!permissions) {
    // role is not present in the rules
    return false;
  }

  const staticPermissions = permissions.static;

  if (staticPermissions && staticPermissions.includes(action)) {
    // static rule not provided for action
    return true;
  }

  const dynamicPermissions = permissions.dynamic;

  if (dynamicPermissions) {
    const permissionCondition = dynamicPermissions[action];
    if (!permissionCondition) {
      // dynamic rule not provided for action
      return false;
    }

    return permissionCondition(data);
  }
  return false;
};

const Can = props =>
  check(rules, props.role, props.perform, props.data)
    ? props.yes()
    : props.no();

Can.defaultProps = {
  yes: () => null,
  no: () => null
};

export default Can;
```

src/pages/dashboard.jsを修正してDashboardページでCanコンポーネントを利用します。

```jsx:src/pages/dashboard.js
import React from "react";
import { Redirect } from "react-router-dom";

import { AuthConsumer } from "../authContext";
import Can from "../components/Can";
import Logout from "../components/Logout";
import Profile from "../components/Profile";
import PostsList from "../components/PostsList";

const DashboardPage = () => (
  <AuthConsumer>
    {({ user }) => (
      <Can
        role={user.role}
        perform="dashboard-page:visit"
        yes={() => (
          <div>
            <h1>Dashboard</h1>
            <Logout />
            <Profile />
            <PostsList />
          </div>
        )}
        no={() => <Redirect to="/" />}
      />
    )}
  </AuthConsumer>
);

export default DashboardPage;
```

src/components/Profile.js(Profileコンポーネント)を作成します。

```jsx:src/components/Profile.js
import React from "react";

import {AuthConsumer} from "../authContext";

const Profile = () => (
  <AuthConsumer>
    {({user}) => (
      <div>
        <h2>User Profile</h2>
        <ul>
          <li>ID: {user.id}</li>
          <li>Email: {user.email}</li>
          <li>Role: {user.role}</li>
        </ul>
      </div>
    )}
  </AuthConsumer>
);

export default Profile;
```

src/components/PostsList.js(PostsListコンポーネント)を作成します。

```jsx:src/components/PostsList.js
import React from "react";

import posts from "../posts";
import {AuthConsumer} from "../authContext";
import Can from "./Can";

const PostsList = () => (
  <AuthConsumer>
    {({user}) => (
      <div>
        <h2>Posts List</h2>
        <table className="table">
          <thead>
          <tr>
            <th scope="col">#</th>
            <th scope="col">Title</th>
            <th scope="col">Actions</th>
          </tr>
          </thead>
          <tbody>
          {posts.map((post, index) => (
            <tr key={post.id}>
              <th scope="row">{index + 1}</th>
              <td>{post.title}</td>
              <td>
                <Can
                  role={user.role}
                  perform="posts:edit"
                  data={{
                    userId: user.id,
                    postOwnerId: post.ownerId
                  }}
                  yes={() => (
                    <button className="btn btn-sm btn-default">
                      Edit Post
                    </button>
                  )}
                />
                <Can
                  role={user.role}
                  perform="posts:delete"
                  yes={() => (
                    <button className="btn btn-sm btn-danger">
                      Delete Post
                    </button>
                  )}
                />
              </td>
            </tr>
          ))}
          </tbody>
        </table>
      </div>
    )}
  </AuthConsumer>
);

export default PostsList;
```

src/posts.jsを作成してProfileコンポーネントとPostsListコンポーネントをラップします。

```jsx:src/posts.js
const posts = [
  {
    id: "a",
    title: "Post a",
    ownerId: ""
  },
  {
    id: "b",
    title: "Post b",
    ownerId: ""
  },
  {
    id: "c",
    title: "Post c",
    ownerId: ""
  },
  {
    id: "d",
    title: "Post d",
    ownerId: ""
  }
];

export default posts;
```

アプリケーションを起動してChromeで`http://localhost:3000`にアクセスして動作を確認します。Writer, Admin各々のロールでログインして画面を確認します。

```console
$ pwd
rbac-tutotial-app
$ npm start
```
<img src="https://i.imgur.com/WXgT6ct.png" width=70%>
<img src="https://i.imgur.com/wAq20ej.png" width=70%>
<br>

# おわりに
従来型の認可の実装方式はロールやパーミッションをハードコードするケースが多く、ステップ数も増えてしまいソフトウェアの品質や管理性を低下させてしまう要因になっていました。"あるものは使い倒す"の発想で認証・認可はAuth0の機能を使い、認証・認可は最小のコードで実装したいですね。