Vue+SSRのユニバーサルコードに認証処理を実装する

# まえがき

Nuxt使っていれば、[Auth Module](https://auth.nuxtjs.org/)を使えば良いと思います。

僕のような「Nuxt使わずにやってみたい」と考え「[Vue SSRガイド](https://ssr.vuejs.org/ja/)」とかを読んだ方のみ、多少は役に立つかもしれません。

# 構成

サーバーサイドとクライアントサイドから、共通してアクセスできるAPI Serverがあります。

また認証方式は、ユーザーのID・PWを管理し、JWTを発行する形式にします。

- API サーバー（アプリのデータを提供）
- Web サーバー（Webページを提供。SSRしているところ。）

の２つのサーバーが登場します。

コードベースは、[Vue SSRガイド](https://ssr.vuejs.org/ja/)やった時のコードです。

# どこを守ればいいのか

APIサーバーのエンドポイントを、守るのは当たり前として、Webサーバーのエンドポイントを守るというのはどうなるだろうかと考えていました。

例えば、

ランディングページ（`/`）や、ログイン用のページ（`/login`）は、ログインしていなくても使えるのだけれど、

アプリのページ（`/user`）とかは、きっちり保護したい。

というような。

もちろん、URL直でアクセスされたら、サーバーサイドのミドルウェアでブロックすることは可能なんですが、フロントエンドルーティングを採用している為、画面遷移をサーバーサイドでブロックできません。

なので、[Vue Routerのナビゲーションガード](https://router.vuejs.org/ja/guide/advanced/navigation-guards.html#グローバルビフォーガード)である程度は守ってあげることにしました。

# 実装

ホスト名は、

開発時：

- API サーバー`localhost:3001`
- Web サーバー`localhost:3000`

本番稼働時：

- API サーバー`api.example.com`
- Web サーバー`www.example.com`

の前提で書いています。

## API Server

ユーザーの管理について、今回は横着してメモリに入れてるだけなので、適宜DBに入れて取ってきたりしてあげてください。

また`bcrypt`とか使ってないので、パスワード検証とかも適宜直してください。

また、`process.env.PRIVATE_KEY`は、開発時とかは適当に`'test'`とか入れておけば大丈夫です。

本番稼働時は、何かしらの秘密鍵を環境変数に入れておいてあげてください。

```js:index.js
const isDevelopment = process.env.NODE_ENV === "development";
const serverDomain = isDevelopment ? "localhost" : "example.com";
const apiServerDomain = isDevelopment ? "localhost:3001" : "api.example.com";
const webServerDomain = isDevelopment ? "localhost:3000" : "www.example.com";
const webServerOrigin = isDevelopment
  ? "http://localhost:3000"
  : "https://www.example.com";

const express = require("express");
const helmet = require("helmet");
const bodyParser = require("body-parser");
const cors = require("cors");
const session = require("cookie-session");
const cookieParser = require("cookie-parser");

// express configuration
const app = express();

// request body
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

// secure app
app.use(helmet.hidePoweredBy());
app.use(helmet.noSniff());

// allow cors from web server
app.use(
  cors({
    credentials: true,
    origin: webServerOrigin
  })
);

// cookie based session for storing jwt securely
app.use(
  session({
    name: "session",
    keys: [process.env.PRIVATE_KEY],
    cookie: {
      secure: !isDevelopment,
      httpOnly: true,
      domain: "." + serverDomain
    }
  })
);
app.use(cookieParser());

// authentication
const passport = require("passport");
const jwt = require("jsonwebtoken");
const JwtStrategy = require("passport-jwt").Strategy,
  ExtractJwt = require("passport-jwt").ExtractJwt;

const users = [];

function findUserById(id) {
  const user = users.find(x => x.id === id);

  if (user) {
    return { ...user };
  } else {
    return user;
  }
}

function findUserByEmail(email) {
  const user = users.find(x => x.email === email);

  if (user) {
    return { ...user };
  } else {
    return user;
  }
}

const uuidv4 = require("uuid/v4");

function addUser(param, cb) {
  let user = { ...param };

  // 衝突は考慮しない
  user.id = uuidv4();

  // bcryptとかは省略
  users.push({ ...user });

  cb(null, user);
}

function validatePassword(password, hash) {
  return password === hash;
}

// cookieに入れてある場合と、Bearerトークンとして渡される場合の2パターンどちらもOKにしてます。
const jwtFromRequest = ExtractJwt.fromExtractors([
  // extract jwt from cookie
  function(req) {
    let token = null;
    if (req && req.cookies && req.cookies["accessToken"]) {
      token = req.cookies["accessToken"];
    }
    return token;
  },
  ExtractJwt.fromAuthHeaderAsBearerToken()
]);
const secretOrKey = process.env.PRIVATE_KEY;
const issuer = apiServerDomain;
const audience = webServerDomain;

passport.use(
  new JwtStrategy({ jwtFromRequest, secretOrKey, issuer, audience }, function(
    jwt_payload,
    done
  ) {
    const user = findUserById(jwt_payload.sub);

    if (user) {
      delete user.password;
      return done(null, user);
    } else {
      return done(null, false);
    }
  })
);

function issueToken(user, cb) {
  delete user.password;

  jwt.sign(
    user,
    secretOrKey,
    {
      issuer,
      audience,
      subject: user.id,
      expiresIn: 60 * 60 // 1h later
    },
    cb
  );
}

// error handling
app.use(function(err, req, res, next) {
  console.error(err);
  res.status(500).send("Internal Server Error");
});

// ユーザー登録
app.post("/user", function(req, res) {
  const { password, email, displayName } = req.body;

  if ([password, email, displayName].some(x => !x)) {
    // リクエスト内容チェックエラー（かなり雑）
    res.status(400).send("Bad Request");
  } else {
    // 実際は、メールアドレスチェックなどに入るのかもしれませんが、即登録します。
    addUser(
      {
        password,
        email,
        displayName
      },
      function(err, user) {
        if (err) next(err);

        issueToken(user, function(err, token) {
          if (err) next(err);

          res
            .status(201)
            .cookie("accessToken", token)
            .send();
        });
      }
    );
  }
});

app.get("/user", passport.authenticate("jwt", { session: false }), function(
  req,
  res
) {
  res.status(200).json(req.user);
});

app.get(
  "/token-verification",
  passport.authenticate("jwt", { session: false }),
  function(req, res) {
    res.status(200).send();
  }
);

app.post("/login", function(req, res) {
  const { email, password } = req.body;

  if ([email, password].some(x => !x)) {
    res.status(400).send("Bad Request");
  } else {
    const user = findUserByEmail(email);

    if (user) {
      if (validatePassword(user.password, password)) {
        issueToken(user, function(err, token) {
          if (err) next(err);

          res
            .status(201)
            .cookie("accessToken", token)
            .send();
        });
      } else {
        res.status(400).send("Bad Request");
      }
    } else {
      res.status(400).send("Bad Request");
    }
  }
});

app.delete("/logout", function(req, res) {
  res
    .status(204)
    .clearCookie("accessToken")
    .send();
});

app.listen(3001);
```

ちょっと余計なコードが多いですが解説すると、

下記の５つのエンドポイントを用意していて、cookieベースのセッションを使ってJWTのやり取りをしています。

- `/user` `POST` （ユーザー登録）
- `/user` `GET` （ユーザー情報取得）⇒ 別にいらないのだけれど、Protected Endpointの例として。
- `/token-verification`（JWT検証）
- `/login` `POST` （ログイン処理）
- `/logout` `DELETE` （ログアウト処理）

認証は、`passport`と`passport-jwt`で実装しています。

SSRのときのAPI Serverへのリクエストは、Bearerトークンとして渡すので、

JWTの抽出は`ExtractJwt.fromExtractors`を使って「cookie」と「Bearerトークン」どっちでもOKにしています。

ユーザー登録とログイン処理でJWTを発行して、守りたいエンドポイントは、JWTを検証してます。

【補足】ところどころ、userオブジェクトをスプレッド構文（`{ ...user }`）でクローンしているのは、メモリに入れてるが故に、`delete password`で、ストアしてあるメモリからも`password`プロパティが消えちゃうからです。（泣）

## Web Server

Web Server側では、cookieからJWTを取り出し、BearerトークンとしてAPI Serverにアクセスします。

### JWTの取り出し

##### `cookie-parser`の設定

`cookie-parser`でパースします。

```js
const helmet = require("helmet");
const cookieParser = require("cookie-parser");

module.exports.configure = function(app) {
  app.use(cookieParser());
};
```

##### contextに渡す

下記のような感じで、リクエストから`context`に渡せば、SSR中もトークンを利用できます。

```js
const html = await renderer.renderToString({
  url: req.url,
  accessToken: req.cookies["accessToken"]
});
```

### ユニバーサルなAPIクラスの実装

cookieとBearerトークン両方に対応するユニバーサルなAPIクラスを実装しました。

##### APIクラスを用意

`axios`を使ってます。

`apiServerUrl`は、APIサーバーに対するURLです。

このクラスは、リクエスト毎にトークンが異なる為、コンストラクタでトークンを受け取りインスタンス化されます。

クライアントサイドでは、cookieを使ってトークンを渡すため、クライアントサイドでは`Authorization`ヘッダーは空になります。

```js
import Axios from "axios";

import { apiServerUrl } from "./constants";

const axios = Axios.create({
  baseURL: `${apiServerUrl}/`,
  withCredentials: true,
  validateStatus(status) {
    return true;
  }
});

export default class Api {
  constructor(accessToken) {
    this.authorization = accessToken ? `Bearer ${accessToken}` : "";
  }

  async createUser(user) {
    const response = await axios.post("/user", user, {
      headers: {
        "Content-Type": "application/json"
      }
    });

    if (response.status === 201) {
      console.log("Success");
    } else {
      console.error(response.statusText);
      throw new Error(response.statusText);
    }
  }

  async getUser() {
    const response = await axios.get("/user", {
      headers: {
        Authorization: this.authorization
      }
    });

    if (response.status === 200) {
      const user = response.data;

      return user;
    } else if (response.status === 401) {
      console.error("Unauthorized");

      return {
        code: 401,
        message: "Unauthorized"
      };
    } else {
      console.log("some error has occured..." + response.statusText);

      throw new Error(response.statusText);
    }
  }

  async login(emailAndPassword) {
    const response = await axios.post("/login", emailAndPassword, {
      headers: {
        "Content-Type": "application/json"
      }
    });

    if (response.status === 201) {
      console.log("Success");
    } else if (response.status === 400) {
      return {
        code: 400,
        message: "email or password is invalid."
      };
    } else {
      console.error(response.statusText);
      throw new Error(response.statusText);
    }
  }

  async logout() {
    const response = await axios.delete("/logout");

    if (response.status === 204) {
      console.log("Success");
    } else {
      console.error(response.statusText);
      throw new Error(response.statusText);
    }
  }

  async verify() {
    const response = await axios.get("/token-verification");

    if (response.status === 200) {
      return true;
    } else if (response.status === 401) {
      return false;
    } else {
      console.error(response.statusText);
      throw new Error(response.statusText);
    }
  }
}
```

##### APIクラスをインスタンス化して使う

[Vue SSRガイド](https://ssr.vuejs.org/ja/)読んだ前提で書いています。

下記のように、`createApp`に先ほど`context`に入れた`accessToken`を渡します。

```js:server-entry.js
import { createApp } from "./app";

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp(context.accessToken);

    router.push(context.url);
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      context.rendered = () => {
        context.state = store.state;
      };

      resolve(app);
    }, reject);
  });
};
```

`createApp`側では、受け取ったトークンでAPIクラスのインスタンスを生成し、

どこでも使えるように`Vue.prototype.$api`に設定しています。

これで、`this.$api.fetchUser`みたいな感じで使えるようになります。

`store`と`router`にもAPIインスタンスを渡します。

```js:app.js
import { createRouter } from "./router";
import { createStore } from "./store";
import Api from "./api";

export function createApp(accessToken) {
  const api = new Api(accessToken);
  Vue.prototype.$api = api;

  const router = createRouter(api);
  const store = createStore(api);

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });

  return { app, router, store };
}
```

### ナビゲーションガード

トークンの検証が成功しない場合は、ナビゲーションを取り消し、ログイン画面に遷移させるようにします。

ルーターの`beforeEach`を下記のように実装します。

```js:router.js
import Vue from "vue";
import VueRouter from "vue-router";

Vue.use(VueRouter);

export function createRouter(api) {
  const router = new VueRouter({
    mode: "history",
    routes: [
      {
        path: "/",
        component: () => import("./pages/Login.vue"),
        meta: {
          public: true
        }
      },
      {
        path: "/user",
        component: () => import("./pages/User.vue")
      }
    ]
  });

  router.beforeEach(async (to, from, next) => {
    if (to.matched.some(x => !x.meta.public)) {
      const verified = await api.verify();

      if (verified) {
        next();
      } else {
        next({
          path: "/"
        });
      }
    } else {
      next();
    }
  });

  return router;
}
```

`meta: { public: true }`を付けたルートに対しては、認証を行わないようにしています。

`api.verify`を用いて、トークンの検証を行い、検証に失敗した場合は`next`に`{ path: "/" }`を設定し、ログイン画面に飛ばしています。

この部分が、クライアントサイドでできる一応の認証処理になります。

最後に、`store.js`だけ載せておきます。

### データの取得

ストアはこんな感じになってます。

ユーザー情報の取得をしてます。

```js:store.js
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export function createStore(api) {
  return new Vuex.Store({
    state: () => ({
      user: null
    }),

    actions: {
      fetchUser({ commit }) {
        return api.getUser().then(res => {
          if (res.code !== 401) {
            commit("setUser", res);
          }
        });
      }
    },

    mutations: {
      setUser(state, user) {
        this.state.user = user;
      }
    }
  });
}
```

このストアを使っているコンポーネント`User.vue`を見てみましょう。

```vue:User.vue
<template>
  <div>{{user}}</div>
</template>

<script>
export default {
  computed: {
    user() {
      return this.$store.state.user;
    }
  },
  methods: {
    fetchUser() {
      return this.$store.dispatch("fetchUser");
    }
  },
  serverPrefetch() {
    return this.fetchUser();
  },
  mounted() {
    if (this.user === null) {
      this.fetchUser();
    }
  }
};
</script>
```

横着して、ユーザー情報をJSONで表示するだけになってますが、ここはSSRガイドの通りにやっているだけです。

特に珍しいことはしてないです。

# あとがき

ポイントとしては、localStorageにトークン入れてBearerトークンとして渡さずに、cookieで渡している部分でしょうか。

こうすることで、SSR中もJWTが使えます。

そして、BearerトークンとcookieどちらもOKにすることで、ユニバーサルなコードが動くようにしています。

一応最低限のXSSの対策は入れたつもりですが、ちゃんとしたプロダクトレベルだともっといろいろやることあるかもしれません。

また、cookieベースのセッション（スケールする）とかあまり知らなかったので、ちょっと勉強になりました。

そして結論、

「Nuxt」使いましょう！（笑）