- Published on
招待機能を作ってみた
- Authors
- Name
- Naoya 'noine' Sato
- @noineniya
サインアップの招待機能を作った
チャットアプリを作っていまして、最初は招待制で動かす事になりました。
どうやって作ろうかなと手を動かしながらやってみたことをメモしておきます。
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
にトークンを生成するファイルを作成します。
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 で叩くようにしました。
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 を実行するようにコンポーネントを作る。
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 からトークン付きでアクセスできるようになりました。
次にサインアップページの制御です。
サインアップページへのアクセスには、
- 正しいトークン付きでアクセス
- 間違ったトークン付きでアクセス
- トークンがない状態でアクセス
のパターンが考えられます。
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 は次のようにしています。
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 で行っているので、クライアントサイドからは隠せていると思います。
備忘録として載せました。
もっと良い方法がありましたらぜひ教えてください!!
それではまた。