5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Fullstack React GraphQL TypeScript Tutorial をやってみた #4 ~redisにsession情報を保存し、cookieを使ってログイン状態を保持するまで~

Last updated at Posted at 2020-11-13

はじめに

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はインメモリデータベースなので応答が速いというメリットがあります。
下図に今回の全体像を描いてみました。
スクリーンショット 2020-11-12 18.55.06.png
スクリーンショット 2020-11-12 18.55.14.png

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はセッションの消滅時間。単位はミリ秒。
src/index.ts
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 });
...

上のコードを全体図に当てはめるとこんな感じでしょうか。
スクリーンショット 2020-11-12 20.42.21.png

contextプロパティにreq/resを追加してresolver間で共有する

動画のこの部分です

ApolloServerミドルウェアのcontextプロパティは関数をしていすることができ、その返り値のオブジェクトはresolver間で共有できます。さらに、関数の引数はクライアントからのリクエストとレスポンスをとることができます。

src/index.ts
...
 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 });
...

そして取り込んだreqresにも型を規定します。

src/types.ts
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.sessionExpress.Sessionと同じ型になります。

src/types.ts
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をミドルウェアとして使えば、この一連の作業は驚くほど簡単に実装でき、中で何が起きているかを意識する必要がありません。
スクリーンショット 2020-11-12 22.02.24.png

src/resolvers/users.ts
...

  @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っておもしろいですね。

index.d.ts
...
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ミドルウェアがやってくれるみたいです。ありがたいことです。

スクリーンショット 2020-11-12 21.49.17.png

src/resolvers/user.ts
...
@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時間ちょっと進みました。
先は長い。。。

次回=>

5
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?