目标
本文的目的是给网站对接 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,需要实现的效果是
- 用户可以在设置页面设置是否启用 2FA
- 点击启用 2FA 则提示用户扫描二维码,以将网站的身份验证信息添加至 Google Authenticator 或 Microsoft Authenticator
- 将备用验证码展示给用户提示用户保存
- 下一次登陆时,如果用户启用了 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 有两个作用
- 得知用户输入了正确的密码(2FA 在这里是密码 + Authenticator,必须确保用户输入密码之后再进入 2FA 流程)
- 辅助之后的 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 查看
主要代码大概是下面这样的,核心在于
- 生成
otpauth://
URI,这一点是我们上一篇文章结尾提到的,现在我们将这个 URI 包含在二维码中了,只需要用 Authenticator 扫描即可添加 - 在合适的时机调用后端 API
然后贴一张完成图
完成的代码去我们的仓库看吧,记得点个 star 哦
好了我们就说到这里,我去和莲继续恋爱了,欸嘿嘿嘿