はじめに
30代未経験からエンジニアを目指して勉強中のYNと申します。
この記事はBen Awad
さんのFullstack React GraphQL TypeScript Tutorialを初学者が進めていく、という内容です。
Ben
さんの動画は本当に質が高く、とても学びが多いのですが、自分のような初学者は躓きが多く、なかなか前に進まなかったので、振り返りのメモとして書きます。
今回の対象
動画の下記内容までです。
1:41:11 Session Authentication
2:03:06 Sessions Explained
前回 => https://qiita.com/theFirstPenguin/items/6dde647c01dad4e96e22
#始める前に
ブランチをコピー
動画の内容ごとに細かくブランチを切ってくれています。ありがたや。
まずはブランチをローカルにコピーして、そのブランチに移ります。
git pull origin 6_sessions:6_sessions
git checkout 6_sessions
まずは全体像を把握する。
今回はsession情報をredisに保存し、ブラウザにcookieを保存してもらうことで、一定時間ログイン状態を保持する実装を行います。
今回はsession情報の保存にredisを用いていますが、key-valueストアであれば、mongoDBまどでも構いません。redisはインメモリデータベースなので応答が速いというメリットがあります。
下図に今回の全体像を描いてみました。
Session Authenticationを実装する
上の全体図の通り、session情報をredisに保存して、そのkeyをcookieとしてクライアントに渡すことで、Authenticationを実装できます。
redisとexpress-sessionをインストール
まずはredisとexpress-sessionをインストールします。
brew install redis
yarn add redis connect-redis express-session
yarn add -D @types/redis @types/express-session @types/connect-redis
redis-server #redisを起動
express/sessionとconnect-redisをexpressのミドルウェアに設定
express-sessionの使い方はこちらの記事が分かりやすかったです。
- secret属性は指定した文字列を使ってクッキーIDを暗号化しクッキーIDが書き換えらているかを判断する。
- resaveはセッションにアクセスすると上書きされるオプションらしい。今回はfalse.
- saveUninitializedは未初期化状態のセッションも保存するようなオプション。今回はfalse.
- httpOnlyはクライアント側でクッキー値を見れない、書きかえれないようにするオプション
- secureオプションはhttpsで使用する場合はtrueにする。今回はhttp通信なのでfalse
- maxageはセッションの消滅時間。単位はミリ秒。
import redis from "redis";
import session from "express-session";
import connectRedis from "connect-redis";
...
const app = express();
const RedisStore = connectRedis(session);
const redisClient = redis.createClient();
app.use(
session({
name: "qid",
store: new RedisStore({
client: redisClient,
disableTouch: true,
}),
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 365 * 10, // 10 years
httpOnly: true,
sameSite: "lax", // csrf
secure: __prod__, // cookie only works in https
},
saveUninitialized: false,
secret: "qowiueojwojfalksdjoqiwueo",
resave: false,
})
);
...
apolloServer.applyMiddleware({ app });
...
contextプロパティにreq/resを追加してresolver間で共有する
ApolloServerミドルウェアのcontextプロパティは関数をしていすることができ、その返り値のオブジェクトはresolver間で共有できます。さらに、関数の引数はクライアントからのリクエストとレスポンスをとることができます。
...
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [HelloResolver, PostResolver, UserResolver],
validate: false,
}),
context: ({ req, res }): MyContext => ({ em: orm.em, req, res }), // contextプロパティからリクエスト情報を取り込む
});
apolloServer.applyMiddleware({ app });
...
そして取り込んだreq
とres
にも型を規定します。
import { EntityManager, IDatabaseDriver, Connection } from "@mikro-orm/core";
import { Request, Response } from "express";
export type MyContext = {
em: EntityManager<any> & EntityManager<IDatabaseDriver<Connection>>;
req: Request;
res: Response;
};
resolverでsession情報を保存する
動画のこの部分です
resolverで、user登録後にログイン情報を保持するための実装をします。
ここでいきなりreq.session
が出てきて戸惑いましたが、req: Request & { session: Express.Session }
によってRequest
型にsession
プロパティを追加しています。つまりreq.session
はExpress.Session
と同じ型になります。
import { EntityManager, IDatabaseDriver, Connection } from "@mikro-orm/core";
import { Request, Response } from "express";
export type MyContext = {
em: EntityManager<any> & EntityManager<IDatabaseDriver<Connection>>;
req: Request & { session: Express.Session }; // ここでsessionの型を規定
res: Response;
};
そしてユーザー登録時にredisにsession情報を格納し、その情報を
cookieとしてクライアントに返信する処理を実装します。
express-sessionをミドルウェアとして使えば、この一連の作業は驚くほど簡単に実装でき、中で何が起きているかを意識する必要がありません。
...
@Mutation(() => UserResponse)
async register(
@Arg("options") options: UsernamePasswordInput,
@Ctx() { em, req }: MyContext // contextプロパティからリクエスト情報をとってくる
): Promise<UserResponse> {
...
const hashedPassword = await argon2.hash(options.password);
const user = em.create(User, {
username: options.username,
password: hashedPassword,
});
try {
await em.persistAndFlush(user);
} catch (err) {
...
}
// store user id session
// this will set a cookie on the user
// keep them logged in
req.session.userId = user.id; // このたった1行で上図の④と⑤をやってくれる
return { user };
}
...
ところで、req.session.userId
の型は指定しなくていいの?と思いましたが、これを右クリックしてGo to Type Definition
を押してみると、node_modulesの中のindex.d.ts
にジャンプします。これを見ると、req.session
はSession型(interface)であり、Session型はSessionData型を継承しており、SessionData型は[key: string]: any
という、プロパティを自由に受け付ける構造になっていることが分かります。TypeScriptっておもしろいですね。
...
declare global {
namespace Express {
...
interface SessionData {
[key: string]: any; // ここでuserIdのような型を自由に追加できる構造になっている
cookie: SessionCookieData;
}
...
interface Session extends SessionData {
id: string;
regenerate(callback: (err: any) => void): void;
destroy(callback: (err: any) => void): void;
reload(callback: (err: any) => void): void;
save(callback: (err: any) => void): void;
touch(): void;
cookie: SessionCookie;
}
...
ブラウザからcookie情報を受け取りログイン保持する
動画のこの部分です
一度ログインしたあと、cookieの有効期限(maxAge
)が過ぎるまでブラウザにcookieが保存されるため、passwordなしでユーザを認証することができます。
このとき、cookieの復号など面倒なことは裏でexpress-sessionミドルウェアがやってくれるみたいです。ありがたいことです。
...
@Resolver()
export class UserResolver {
@Query(() => User, { nullable: true })
async me(@Ctx() { req, em }: MyContext) { // contextオブジェクトからリクエストに含まれる情報を取り込む
// you are not logged in
if (!req.session.userId) {
return null;
}
const user = await em.findOne(User, { id: req.session.userId }); // リクエストに含まれるcookieを秘密鍵で復号し、redisからuserIdを読み込む
return user;
}
...
最後に
今回はSessions Explained
まで書きました。
今さらsessionやcookieの有用性を知り、学びが多かったです。(今までLocalStorageでログイン情報保持してました。。。)
チュートリアル全14時間のうち、2時間ちょっと進みました。
先は長い。。。
次回=>