Published on

招待機能を作ってみた

Authors

サインアップの招待機能を作った

チャットアプリを作っていまして、最初は招待制で動かす事になりました。
どうやって作ろうかなと手を動かしながらやってみたことをメモしておきます。
Next.js と Amplify を使って実装していまして、Amplify add authを使って Cognito でユーザー管理をしています。

流れ

まずはサインアップまでの流れをざっくり考えます。
サインイン済のユーザーが招待できる
→
招待ボタンを押すと URL が発行される
→
発行された URL からサインアップしてユーザー登録できる
→
※招待 URL は使いまわしさせない、一回だけ使用可能

かなりざっくりこんな感じです。

招待 URL とは…

サインアップページの URL を発行しただけだと、招待したのかどうか分かりません。
それこそ URL が分かれば誰でもサインアップできてしまいます。
招待 URL を発行するときに、URL にクエリとしてトークンをつけて、そのトークンが認証できればサインアップできるようにしようと思いました。
/signup?token=${token}
こんな感じですね。

トークンを生成

いろんな作り方がありそうですが、今回は比較的簡単な方法になります。
改善方法ありましたら教えてください。

Next.js を使っているのですが、普通に作るとクライアントサイドでの処理になってしまうので、API Route で処理させます。
Amplify にデプロイしているので、app ディレクトリは使わず pages ディレクトリで実装しています。

/pages/apiにトークンを生成するファイルを作成します。

generateToken.ts
import { NextApiRequest, NextApiResponse } from "next";
import { withSSRContext, Amplify, graphqlOperation } from "aws-amplify";
import jwt from "jsonwebtoken";
import config from "../../src/aws-exports.js";

import crypto from "crypto";
import { CreateInviteTokenInput } from "@/src/API";
import { createInviteToken } from "@/src/graphql/mutations";

// API RouteでAmplify認証情報を取得する設定
Amplify.configure({ ...config, ssr: true });

//トークンをエンコードしたりデコードするためのシークレットキー
//envに書き込んだりデプロイ先の環境変数に入れます
const JWT_SECRET: string = process.env.JWT_SECRET || "";

export default async function generateToken(req: NextApiRequest, res: NextApiResponse) {
  const { Auth } = withSSRContext({ req });
  const { API } = withSSRContext({ req });

  //URLとして発行したいのでドメイン取得
  const domain = process.env.DOMAIN || req.headers.host;

  let user;
  let inviteUrl;
  let token;
  let encodeToken;
  try {
    // ログインしているユーザー情報を取得
    user = await Auth.currentAuthenticatedUser();

    //ランダムな32ビット整数を生成
    const random = crypto.randomBytes(4).readUInt32BE(0);

    //トークン生成 誰が発行したかわかるようにユーザー名を入れておく
    token = user.username + random;

    //トークンをエンコードしてからURLに入れる
    encodeToken = jwt.sign(token, JWT_SECRET);

    //招待URL生成
    inviteUrl = `${domain}/signup?token=${encodeURIComponent(encodeToken)}`;
  } catch (err) {
    console.log("error: no authenticated user");
  }

  //エンコード前のトークンをDBに保存
  if (token !== undefined) {
    try {
      const tokenVariables: CreateInviteTokenInput = {
        token: token,
        clicked: false,
      };
    //Appsyncに保存するクエリ
      await API.graphql(graphqlOperation(createInviteToken, { input: tokenVariables }));
    } catch (err) {
      console.log("error: createInviteToken", err);
    }
  }

  //招待URLを返す
  res.statusCode = 200;
  res.json({
    inviteUrl: inviteUrl ? inviteUrl : null,
  });
}

生成のプロセスを隠して、検証ができれば、トークンの作り方は何でもいいかなって感じです。
複雑にしようと思えばいくらでもできるかと思います。

そしてこの API を叩くのですが、Hooks で叩くようにしました。

useTokenGenerator.ts
import { useState } from "react";

export default function useTokenGenerator() {
  const [inviteToken, setInviteToken] = useState("");

  const generateToken = async () => {
    try {
      const response = await fetch("/api/generateToken");
      const data = await response.json();
      setInviteToken(data.inviteUrl);
    } catch (error) {
      console.log("error", error);
    }
  };

  return {
    inviteToken,
    generateToken,
  };
}

Hooks なので、トップレベルでしか使えません。
なので関数と返り値を return してあげて、Hooks の中の関数を実行するタイミングを調整できるようにしました。
…確かそんなんで躓いたからこうしたはず。。
少し時間経っているので定かではないですが。

んでボタンを押したら Hooks を実行するようにコンポーネントを作る。

招待URLを発行するコンポーネント抜粋
  const [inviteURL, setInviteURL] = useState("");
  const { inviteToken, generateToken } = useTokenGenerator();

  const handleButtonClick = async () => {
    await generateToken();
    //generateToken()するとinviteTokenに値が入る
    const url = inviteToken ? inviteToken : "";
    setInviteURL(url);
  };
  //inviteTokenが更新されたらステートも更新
  useEffect(() => {
    setInviteURL(inviteToken ? inviteToken : "");
  }, [inviteToken]);

  return (
    <div>
      <button onClick={handleButtonClick}>招待コード生成</button>
      <p>URL:</p>
      {inviteURL !== "" && (
        <div>{inviteURL}</div>
      )}
    </div>
  );

これならトークンをどう作っているのか、クライアント再度からは隠せていると思う。

招待 URL からアクセス

これで招待 URL からトークン付きでアクセスできるようになりました。
次にサインアップページの制御です。
サインアップページへのアクセスには、

  1. 正しいトークン付きでアクセス
  2. 間違ったトークン付きでアクセス
  3. トークンがない状態でアクセス

のパターンが考えられます。

signup.tsxから抜粋
export default function SignUp() {
  const [message, setMessage] = useState<string>("");
  const [display, setDisplay] = useState<boolean>();

    // URLのトークンを取得
  const router = useRouter();
  const { token } = router.query;

  useEffect(() => {
    if (router.isReady) {
      const verifyToken = async (token: string) => {
        try {
          //URLのトークンを検証用のAPIへ送る
          const res = await fetch(`/api/signup/${token}`);
          //レスポンスが正常な場合
          if (res.ok) {
            const data = await res.json();
            if (data.message === "認証成功") {

              //1の正しいトークンの場合
              setMessage(data.message);
            } else {

              //2の間違ったトークンの場合
              setMessage(data.message);
            }
          } else {
            console.log("res 401 NG", res.json());
          }
        } catch (err) {
          console.log("fetch error", err);
          router.push("/");
        }
      };

      if (typeof token === "string") {
        verifyToken(token);
        // 画面を表示するフラグ
        setDisplay(true);
      } else {

        // 3のトークンがない場合はトップページにリダイレクト
        router.push("/");
      }
    }
  }, [router, token]);

  return(
    {display && (
      // 省略
    )}
  )

この例では message でコンポーネント内の条件を更に分けられるようにしていますが、検証用の API 次第で色々できますね。
ちなみにトークン用のスキーマはこうなっています。

type InviteToken
  @model
  @auth(rules: [{ allow: owner }, { allow: public, operations: [read, update] }]) {
  id: ID!
  token: String!
  clicked: Boolean!
}

検証時にはログインしていない状態なので、allow: publicとしています。
検証用の API は次のようにしています。

/pages/api/signup/[token].ts
import { NextApiRequest, NextApiResponse } from "next";
import config from "../../../src/aws-exports.js";
import { Amplify, API } from "aws-amplify";
import { listInviteTokens } from "@/src/graphql/queries";
import jwt from "jsonwebtoken";
import { ListInviteTokensQueryVariables } from "@/src/API";
import { updateInviteToken } from "@/src/graphql/mutations";

Amplify.configure(config);

const JWT_SECRET: string = process.env.JWT_SECRET || "";

const SignupTokenHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  //await fetch(`/api/signup/${token}`);で受け取ったトークン
  const { token } = req.query as { token: string };

  //トークンをデコード
  const verifyToken = (token: string, JWT_SECRET: string) => {
    try {
      const decoded = jwt.verify(token, JWT_SECRET);
      if (typeof decoded === "string") {
        return decoded;
      } else {
        return null;
      }
    } catch (err: any) {
      return null;
    }
  };
  const decoded = verifyToken(token, JWT_SECRET);

  // デコードが失敗した場合
  if (typeof decoded !== "string") {
    return res.status(200).json({ message: "不正なトークンです" });
  } else {
    //デコードしたトークンを引数にする
    const variables: ListInviteTokensQueryVariables = {
      filter: {
        token: {
          eq: decoded,
        },
      },
    };

    try {
      //DBにトークンがあるか確認
      //この時点ではログインしていないためauthModeを"API_KEY"と明示
      //今回はAppsyncをCognito認証としているので、ここだけ例外的に設定している
      const tokenData: any = await API.graphql({
        query: listInviteTokens,
        variables: variables,
        authMode: "API_KEY",
      });
      const tokenItem = tokenData.data.listInviteTokens.items[0];

      //トークンの検証
      if (tokenItem) {
        if (tokenItem.clicked) {
          // clickedがtrueならポップアップを表示するためのレスポンスを返す
          return res.status(200).json({ message: "使用済みの招待コードです" });
        } else {
          // clickedがfalseならclickedをtrueにする
          await API.graphql({
            query: updateInviteToken,
            variables: {
              input: {
                id: tokenItem.id,
                clicked: true,
              },
            },
            authMode: "API_KEY",
          });

          res.status(200).json({ message: "認証成功" });
        }
      } else {
        // トークンがデータベースにない場合
        return res.status(200).json({ message: "token not found" });
      }
    } catch (err) {
      console.log("error: other", err);
    }
  }
};

export default SignupTokenHandler;

この検証も API Route で行っているので、クライアントサイドからは隠せていると思います。

備忘録として載せました。
もっと良い方法がありましたらぜひ教えてください!!
それではまた。