まえがき
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'
とか入れておけば大丈夫です。
本番稼働時は、何かしらの秘密鍵を環境変数に入れておいてあげてください。
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
(ログアウト処理)
認証は、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
でパースします。
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
を渡します。
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インスタンスを渡します。
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
を下記のように実装します。
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
だけ載せておきます。
データの取得
ストアはこんな感じになってます。
ユーザー情報の取得をしてます。
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
を見てみましょう。
<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」使いましょう!(笑)