Open11
LuciaとUpstashを使ってセッション管理
パスワードリセットのメール送信は /password-reset
で、
パスワードをリセットするのは、`/password-reset/[token]でやる。
実際にパスワードをリセットする画面 (signupの使い回し)
トークンを生成する
やってることは
- ユーザーが持ってるパスワードリセットトークンを全て取得
- 有効期限が十分残ってる (ドキュメントでは1時間以上)トークンがあれば取得して再利用
- なければ生成して利用
prismaを使ってる
有効期限は2時間
import { generateRandomString, isWithinExpiration } from 'lucia/utils'
import { prisma } from '../db'
const EXPIRES_IN = 1000 * 60 * 60 * 2 // 2 hours
export const generatePasswordResetToken = async (userId: string) => {
const storedUserTokens = await prisma.passwordResetToken.findMany({
where: {
user_id: userId,
},
})
if (storedUserTokens.length > 0) {
const reusableStoredToken = storedUserTokens.find((token) => {
// check if expiration is within 1 hour
// and reuse the token if true
return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2)
})
if (reusableStoredToken) return reusableStoredToken.id
}
const token = generateRandomString(63)
await prisma.passwordResetToken.create({
data: {
id: token,
expires: new Date().getTime() + EXPIRES_IN,
user_id: userId,
},
})
return token
}
トークンのチェック
isWithinExpiration
といutilがluciaから提供されてるので使う
トークンがあるかチェックして、なければエラー、あればそのトークンを削除する
これはトランザクションで行う
それで、isWithinExpiration
で有効期限をチェックする
prismaでこんな感じ
// validate password token
export const validatePasswordResetToken = async (token: string) => {
const storedToken = await prisma.$transaction(async (prisma) => {
const storedToken = await prisma.passwordResetToken.findUnique({
where: {
id: token,
},
})
if (!storedToken) throw new Error('Invalid token')
await prisma.passwordResetToken.delete({
where: {
id: token,
},
})
return storedToken
})
const tokenExpires = Number(storedToken.expires) // bigint => number conversion
if (!isWithinExpiration(tokenExpires)) {
throw new Error('Expired token')
}
return storedToken.user_id
}
パスワードリセットのメール送信はresend
使う
クライアントを作って
export const resend = new Resend(env.RESEND_API_KEY)
JSXでメールテンプレート作って
interface EmailTemplateProps {
link: string
}
export const ResetPasswordEmail = ({ link }: EmailTemplateProps) => (
<div>
<h2>{`It's okay! This happens to the best of us.`}</h2>
<a href={link}>Reset password</a>
</div>
)
メール送る
await resend.emails.send({
from: 'Viewaly <xxxx@xxxxxx>',
to: [email],
subject: 'Reset your password',
react: ResetPasswordEmail({
link: `http://localhost:3000/password-reset/${token}`,
}),
})
トークンの検証は
- トークンの検証
- ユーザーの全てのセッションを無効化
- メールアドレス検証が終わってなければ、有効化
- セッション作って渡す
もし間違ったメールアドレスを打ち込んだら、アカウント乗っ取られるんじゃ?って思った
画面はこんな感じ
luciaのセットアップは以下の通り
upstashはセッション、セッションとユーザーの関係を管理して、ユーザーはprisma (今回はplanetscale使った)で管理する。
import { prisma as prismaAddapter } from '@lucia-auth/adapter-prisma'
import { upstash } from '@lucia-auth/adapter-session-redis'
import { Redis } from '@upstash/redis'
import { lucia } from 'lucia'
import { nextjs } from 'lucia/middleware'
import { env } from '@/env.mjs'
import { prisma } from '../db'
const upstashClient = new Redis({
url: 'xxxxxxxxx.upstash.io',
token: (your-upstash-token),
})
export const auth = lucia({
adapter: {
user: prismaAddapter(prisma),
session: upstash(upstashClient),
},
env: env.NODE_ENV === 'production' ? 'PROD' : 'DEV',
middleware: nextjs(),
getUserAttributes: (data) => {
return {
email: data.email,
emailVerified: data.emailVerified,
role: data.role,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
deletedAt: data.deletedAt,
}
},
sessionExpiresIn: {
activePeriod: 1000 * 60 * 60 * 24 * 30, // 1 month
idlePeriod: 0, // disable session renewal
},
sessionCookie: {
expires: false,
},
experimental: {
debugMode: env.NODE_ENV === 'development' ? true : false,
},
})
export type Auth = typeof auth
メールアドレス検証トークンもパスワードリセットと同じ
import { generateRandomString, isWithinExpiration } from 'lucia/utils'
import { prisma } from '../db'
const EXPIRES_IN = 1000 * 60 * 60 * 2 // 2 hours
export const generateEmailVerificationToken = async (userId: string) => {
const storedUserTokens = await prisma.emailVerificationToken.findMany({
where: {
user_id: userId,
},
})
if (storedUserTokens.length > 0) {
const reusableStoredToken = storedUserTokens.find((token) => {
// check if expiration is within 1 hour
// and reuse the token if true
return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2)
})
if (reusableStoredToken) return reusableStoredToken.id
}
const token = generateRandomString(63)
await prisma.emailVerificationToken.create({
data: {
id: token,
expires: new Date().getTime() + EXPIRES_IN,
user_id: userId,
},
})
return token
}
export const validateEmailVerificationToken = async (token: string) => {
const storedToken = await prisma.$transaction(async (_prisma) => {
const storedToken = await _prisma.emailVerificationToken.findUnique({
where: {
id: token,
},
})
if (!storedToken) throw new Error('Invalid token')
await _prisma.emailVerificationToken.deleteMany({
where: {
user_id: storedToken.user_id,
},
})
return storedToken
})
const tokenExpires = Number(storedToken.expires) // bigint => number conversion
if (!isWithinExpiration(tokenExpires)) {
throw new Error('Expired token')
}
return storedToken.user_id
}