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

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

まえがき

Nuxt使っていれば、Auth Moduleを使えば良いと思います。

僕のような「Nuxt使わずにやってみたい」と考え「Vue SSRガイド」とかを読んだ方のみ、多少は役に立つかもしれません。

構成

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

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

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

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

コードベースは、Vue SSRガイドやった時のコードです。

どこを守ればいいのか

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

例えば、

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

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

というような。

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

なので、Vue Routerのナビゲーションガードである程度は守ってあげることにしました。

実装

ホスト名は、

開発時:

  • API サーバーlocalhost:3001
  • Web サーバーlocalhost:3000

本番稼働時:

  • API サーバーapi.example.com
  • Web サーバーwww.example.com

の前提で書いています。

API Server

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

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

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

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

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);

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

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

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

認証は、passportpassport-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でパースします。

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

module.exports.configure = function(app) {
  app.use(cookieParser());
};
contextに渡す

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

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

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

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

APIクラスを用意

axiosを使ってます。

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

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

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

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ガイド読んだ前提で書いています。

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

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みたいな感じで使えるようになります。

storerouterにもAPIインスタンスを渡します。

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を下記のように実装します。

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だけ載せておきます。

データの取得

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

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

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を見てみましょう。

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」使いましょう!(笑)

Why do not you register as a user and use Qiita more conveniently?
  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
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