Open11

LuciaとUpstashを使ってセッション管理

melodyclue_routermelodyclue_router

パスワードリセットのメール送信は /password-resetで、
パスワードをリセットするのは、`/password-reset/[token]でやる。

実際にパスワードをリセットする画面 (signupの使い回し)
password-reset

melodyclue_routermelodyclue_router

トークンを生成する

やってることは

  1. ユーザーが持ってるパスワードリセットトークンを全て取得
  2. 有効期限が十分残ってる (ドキュメントでは1時間以上)トークンがあれば取得して再利用
  3. なければ生成して利用
melodyclue_routermelodyclue_router

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
}
melodyclue_routermelodyclue_router

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
}
melodyclue_routermelodyclue_router

パスワードリセットのメール送信は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}`,
      }),
    })
melodyclue_routermelodyclue_router

トークンの検証は

  1. トークンの検証
  2. ユーザーの全てのセッションを無効化
  3. メールアドレス検証が終わってなければ、有効化
  4. セッション作って渡す

もし間違ったメールアドレスを打ち込んだら、アカウント乗っ取られるんじゃ?って思った

melodyclue_routermelodyclue_router

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
melodyclue_routermelodyclue_router

メールアドレス検証トークンもパスワードリセットと同じ

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
}