如何给网站应用 2FA (双因素认证)

Web2fa双因素认证网站
浏览数 - 244发布于 - 2025-05-29 - 12:43
鲲

5889

目标

本文的目的是给网站对接 2FA,具体的来说是要给 TouchGal 对接 2FA 功能,现在我们来聊一下具体怎么实现。

首先,我们需要了解 HOTP 和 TOTP 的基础知识,这一点可以查看前一篇文章 HOTP 与 TOTP 详解以及 TypeScript 实战分析 ,这篇文章极为深入、清晰、透彻的论述了 HOTP 和 TOTP 相关的基础知识,需要仔细阅读。

上一篇文章中提到了 time2fa 这个 npm package,下面我们的项目也会安装这个包,有现成的就不用自己造轮子了。

我们当前的仓库在 https://github.com/KUN1007/kun-touchgal-next

技术栈选取

TouchGal 当前是 Next.js + Prisma + psql 实现的,我们先思考一下需要的包

我浏览了一下现在常用的 TOTP 的实现,选用了 time2fa 这个包,看了一下其它的实现,我发现选用 speakeasy 这个包的项目比较多

但是我又看了一下 speakeasy 的 GitHub 仓库,发现十年前,哎,果断放弃

剩下的生成二维码的库就用 time2fa 里面提到的 qrcode 这个包吧

设计思路

我们要给网站对接 2FA,需要实现的效果是

  1. 用户可以在设置页面设置是否启用 2FA
  2. 点击启用 2FA 则提示用户扫描二维码,以将网站的身份验证信息添加至 Google Authenticator 或 Microsoft Authenticator
  3. 将备用验证码展示给用户提示用户保存
  4. 下一次登陆时,如果用户启用了 2FA,则需要进入 /login/2fa 页面进行 2FA

实现一个较为简单的 2FA 逻辑,目的主要是登录验证(一般的网站也是这样的),更改邮箱密码使用 2FA 原理也差不多

TouchGal 是 Next.js 实现的,实际上主要实现原理都是一样的

项目实现

添加必要的字段

首先安装需要的包,然后在 schema.prisma 的 user model 中增加下面三个字段

  enable_2fa        Boolean  @default(false) // 用户是否启用 2FA
  two_factor_secret String   @default("")    // 2FA 的备份密钥
  two_factor_backup String[] @default([])    // 2FA 的备用验证码

编写相关的 API

保存 2FA 的密钥

/user/setting/2fa/save-secret

用户在点击启用 2FA 时,首先需要保存生成的密钥在数据库中,以便之后检查用户输入的 passcode

  const generateSecret = async () => {
    if (!user.uid) {
      toast.error('请登陆后再启用 2FA')
      return
    }

    startTransition(async () => {
      const key = Totp.generateKey({
        issuer: kunMoyuMoe.titleShort,
        user: user.name || user.uid.toString()
      })

      const res = await kunFetchPost<KunResponse<{}>>(
        '/user/setting/2fa/save-secret',
        { secret: key.secret }
      )

      kunErrorHandler(res, () => {
        setAuthStatus({
          ...authStatus,
          secret: key.secret,
          authUrl: key.url,
          hasSecret: true
        })
        onOpen()
        toast.success('密钥已生成,请使用身份验证器应用扫描二维码')
      })
    })
  }

启用 2FA

/user/setting/2fa/enable

这里先拿到用户提供的 passcode 和 secret,验证通过后将其存入数据库中保存

注意,这里我们返回了一个 backupCode,它是一个数组,在我们的实现中,它由 10 个 6 位的数字组成

当用户丢失了 Authenticator 或者因为其它情况无法输入 6 位 passcode 时,用户可以使用 backupCode 登录,然后重新进行 2FA 流程

这个设计增加了用户的容错性,不会因为手机丢了等等特殊情况造成账户无法登录

每一个 backupCode 只能使用一次,一旦被使用就会被从数据库中删除,剩下的都是未使用过的 backupCode,这增加了这一过程的安全性,即使 backupCode 泄露也不会产生任何风险,这也和 OTP 的设计思想符合

这里我们的 backupCode 有 10 个,所以用户有 10 次免输 passcode 而使用 backupCode 进行 2FA 的机会,当 backupCode 较少时应该提醒用户重新进行 2FA 以重置 backupCode

import { prisma } from '~/prisma/index'
import { NextRequest, NextResponse } from 'next/server'
import { verifyHeaderCookie } from '~/middleware/_verifyHeaderCookie'
import { kunParsePostBody } from '~/app/api/utils/parseQuery'
import { Totp, generateBackupCodes } from 'time2fa'
import { enableUser2FASchema } from '~/validations/user'

const verifyAndEnable2FA = async (uid: number, token: string) => {
  const user = await prisma.user.findUnique({
    where: { id: uid }
  })

  if (!user || !user.two_factor_secret) {
    return '未找到 2FA 密钥, 请先生成 2FA 密钥'
  }

  const verified = Totp.validate({
    passcode: token,
    secret: user.two_factor_secret
  })

  if (!verified) {
    return '2FA 验证码无效'
  }

  const codes = generateBackupCodes()

  await prisma.user.update({
    where: { id: uid },
    data: {
      enable_2fa: true,
      two_factor_backup: codes
    }
  })

  return { backupCode: codes }
}

export const POST = async (req: NextRequest) => {
  const input = await kunParsePostBody(req, enableUser2FASchema)
  if (typeof input === 'string') {
    return NextResponse.json(input)
  }
  const payload = await verifyHeaderCookie(req)
  if (!payload) {
    return NextResponse.json('用户未登录')
  }

  const result = await verifyAndEnable2FA(payload.uid, input.token)
  return NextResponse.json(result)
}

获取 2FA 状态

/user/setting/2fa/status

获取用户当前的 2FA 状态,判断用户是启用还是未启用 2FA

上面提到了 这里我们的 backupCode 有 10 个,所以用户有 10 次免输 passcode 而使用 backupCode 进行 2FA 的机会 ,所以这个 API 还会同时返回用户剩余的 backupCode 数量,从而使用户得知自己的 backupCode 状况

import { prisma } from '~/prisma/index'
import { NextRequest, NextResponse } from 'next/server'
import { verifyHeaderCookie } from '~/middleware/_verifyHeaderCookie'

const get2FAStatus = async (uid?: number) => {
  if (!uid) {
    return { enabled: false, hasSecret: false }
  }

  const user = await prisma.user.findUnique({
    where: { id: uid },
    select: {
      enable_2fa: true,
      two_factor_secret: true,
      two_factor_backup: true
    }
  })

  return {
    enabled: user?.enable_2fa || false,
    hasSecret: !!user?.two_factor_secret,
    backupCodeLength: user?.two_factor_backup
      ? user.two_factor_backup.length
      : 0
  }
}

export const GET = async (req: NextRequest) => {
  const payload = await verifyHeaderCookie(req)
  const result = await get2FAStatus(payload?.uid)
  return NextResponse.json(result)
}

禁用 2FA

/user/setting/2fa/disable

用户禁用 2FA

import { prisma } from '~/prisma/index'
import { NextRequest, NextResponse } from 'next/server'
import { verifyHeaderCookie } from '~/middleware/_verifyHeaderCookie'

const disable2FA = async (uid: number) => {
  await prisma.user.update({
    where: { id: uid },
    data: {
      enable_2fa: false,
      two_factor_secret: '',
      two_factor_backup: []
    }
  })

  return { success: true, message: '2FA 已禁用' }
}

export const POST = async (req: NextRequest) => {
  const payload = await verifyHeaderCookie(req)
  if (!payload) {
    return NextResponse.json('用户未登录')
  }

  const result = await disable2FA(payload.uid)
  return NextResponse.json(result)
}

登录

/auth/login

用户登录 API,登录 API 需要增加下面的部分,在用户启用 2FA 时,server 添加一个临时的 2FA token,这个 token 有两个作用

  1. 得知用户输入了正确的密码(2FA 在这里是密码 + Authenticator,必须确保用户输入密码之后再进入 2FA 流程)
  2. 辅助之后的 2FA 流程

登录过程还需要前端的配合,进行路由重定向工作,由 /login 重定向至 /login/2fa 等等

  if (user.enable_2fa) {
    const tempToken = generateKunStatelessToken(
      { id: user.id, require2FA: true },
      10 * 60
    )
    const cookie = await cookies()
    cookie.set('kun-galgame-patch-moe-2fa-token', tempToken, {
      httpOnly: true,
      sameSite: 'strict',
      maxAge: 10 * 60 * 1000
    })
    return {
      require2FA: true,
      id: user.id,
      name: user.name,
      avatar: user.avatar
    }
  }

这里有一个细节,我们使用了 generateKunStatelessToken 这个函数,它是服务端无状态的,并且是解码不出 uid 的,而是使用了 id,这有助于和 access-token 区分开来

这个 token 的 ttl 也只有十分钟,这个时间足够用户进行完 2FA 过程了

export const generateKunStatelessToken = (
  payload: Record<string, string | number | boolean>,
  expire: number
) => {
  const token = jwt.sign(payload, process.env.JWT_SECRET!, {
    expiresIn: expire
  })
  return token
}

检查临时 token

/auth/check-temp-token

用于检查临时令牌是否存在且合法,这里主要是为了检查用户是否已经进行过了登录过程,输入了正确的登录密码

只有成功输入登录密码的用户,才拥有 kun-galgame-patch-moe-2fa-token,而没有这个 token 的用户将会在接下来的 2FA 过程中认证失败

这里如果用户没有这个 token,会被前端路由跳转至 /login 进行登录过程,登录成功才会进入 2FA 过程,这也解释了上面提到的 辅助之后的 2FA 流程

import { NextRequest, NextResponse } from 'next/server'
import { parseCookies } from '~/utils/cookies'
import { verify2FA } from '~/app/api/utils/verify2FA'

export const GET = async (req: NextRequest) => {
  const tempToken = parseCookies(req.headers.get('cookie') ?? '')[
    'kun-galgame-patch-moe-2fa-token'
  ]
  if (!tempToken) {
    return NextResponse.json('未找到临时令牌')
  }

  const payload = verify2FA(tempToken)
  if (!payload) {
    return NextResponse.json('2FA 临时令牌已过期, 时效为 10 分钟')
  }

  return NextResponse.json(payload)
}

登录后 2FA 验证过程

/auth/verify-2fa

用于进行登录之后验证 2FA 是否成功,为什么没有设计为 /auth/login/verify-2fa 是因为现在只有这一个需要 2FA 的地方,感觉。。。不改也没有什么问题,嗯!一定是这样!

这里提供了两种验证方式,使用 passcode 验证或者使用 backupcodes 验证

passcode 验证是用户直接输入在 Authenticator 中获取到的验证码,然后填写以完成 2FA 流程

而 backupcodes 用于用户使用备份验证码完成 2FA 过程,在最初的 /user/setting/2fa/enable 过程中,保存了一组 backupcodes,这里进行的就是比对和删除已使用 backupcodes 的过程

import { z } from 'zod'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { kunParsePostBody } from '~/app/api/utils/parseQuery'
import { generateKunToken } from '~/app/api/utils/jwt'
import { prisma } from '~/prisma/index'
import { getRedirectConfig } from '~/app/api/admin/setting/redirect/getRedirectConfig'
import { Totp } from 'time2fa'
import { parseCookies } from '~/utils/cookies'
import { verify2FA } from '~/app/api/utils/verify2FA'
import { verifyLogin2FASchema } from '~/validations/auth'
import type { UserState } from '~/store/userStore'

export const verifyLogin2FA = async (
  input: z.infer<typeof verifyLogin2FASchema>,
  tempToken: string,
  uid: number
) => {
  const { token, isBackupCode } = input
  const payload = verify2FA(tempToken)
  if (!payload) {
    return '2FA 临时令牌已过期, 时效为 10 分钟'
  }

  const user = await prisma.user.findUnique({
    where: { id: uid }
  })

  if (!user || !user.enable_2fa) {
    return '用户未启用 2FA'
  }

  let isValid = false

  if (isBackupCode) {
    if (user.two_factor_backup.includes(token)) {
      isValid = true
      await prisma.user.update({
        where: { id: uid },
        data: {
          two_factor_backup: {
            set: user.two_factor_backup.filter((code) => code !== token)
          }
        }
      })
    }
  } else {
    isValid = Totp.validate({
      passcode: token,
      secret: user.two_factor_secret
    })
  }

  if (!isValid) {
    return '验证码无效'
  }

  const cookie = await cookies()
  cookie.delete('kun-galgame-patch-moe-temp-token')

  const accessToken = await generateKunToken(
    user.id,
    user.name,
    user.role,
    '30d'
  )
  cookie.set('kun-galgame-patch-moe-token', accessToken, {
    httpOnly: true,
    sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000
  })

  const redirectConfig = await getRedirectConfig()
  const responseData: UserState = {
    uid: user.id,
    name: user.name,
    avatar: user.avatar,
    bio: user.bio,
    moemoepoint: user.moemoepoint,
    role: user.role,
    dailyCheckIn: user.daily_check_in,
    dailyImageLimit: user.daily_image_count,
    dailyUploadLimit: user.daily_upload_size,
    enableEmailNotice: user.enable_email_notice,
    ...redirectConfig
  }

  return responseData
}

export const POST = async (req: NextRequest) => {
  const input = await kunParsePostBody(req, verifyLogin2FASchema)
  if (typeof input === 'string') {
    return NextResponse.json(input)
  }
  const tempToken = parseCookies(req.headers.get('cookie') ?? '')[
    'kun-galgame-patch-moe-2fa-token'
  ]
  if (!tempToken) {
    return NextResponse.json('未找到临时令牌')
  }
  const payload = verify2FA(tempToken)
  if (!payload) {
    return NextResponse.json('2FA 临时令牌已过期, 时效为 10 分钟')
  }

  const response = await verifyLogin2FA(input, tempToken, payload.id)
  return NextResponse.json(response)
}

编写前端逻辑

前端代码较多,这里不写了,可以前往我们仓库 https://github.com/KUN1007/kun-touchgal-next 查看

主要代码大概是下面这样的,核心在于

  1. 生成 otpauth:// URI,这一点是我们上一篇文章结尾提到的,现在我们将这个 URI 包含在二维码中了,只需要用 Authenticator 扫描即可添加
  2. 在合适的时机调用后端 API

然后贴一张完成图

image.png完成的代码去我们的仓库看吧,记得点个 star 哦

好了我们就说到这里,我去和莲继续恋爱了,欸嘿嘿嘿

#1

有在此论坛实装的计划吗

2025-05-29 - 14:36

评论

鲲

暂时没有,现在的后端需要重构一下才能加新功能

kohaku