You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

无密码魔法链接认证系统安全性及优化方案问询

无密码认证系统安全性与代码优化问询

问题背景

我正在开发一个用于基础CRUD操作的简易无密码认证系统,采用魔法链接登录方案,流程如下:

  1. 用户访问魔法链接登录页面
  2. 若邮箱合法,向用户邮箱发送验证链接
  3. 用户点击验证链接
  4. 若链接未过期且有效,将其存储为Cookie
  5. 基于Cookie信息实现Guest HOC(仅访客可见组件如登录页)和User HOC(仅登录用户可见组件如控制台)

技术栈:Fastify、Prisma、TypeScript、Zod(用于Schema校验),想了解该方案的安全性是否足够,以及代码中的问题和优化方向。

现有代码

export const app = fastify()

declare module 'fastify' {
   interface FastifyRequest {
      user: User
   }
}

app.register(fastifyCookie, {
   secret: process.env.COOKIE_SECRET,
   hook: 'onRequest',
   parseOptions: {},
})

app.setErrorHandler(function (error, _request, reply) {
   if (error instanceof NotFoundError) {
      reply.status(404).send(error.message)
   }

   if (error instanceof InvalidInputError) {
      reply.status(422).send(error.issues)
   }

   if (error instanceof TokenExpiredError) {
      reply.status(403).send({ error: 'Token expired' })
   }

   if (error instanceof JsonWebTokenError) {
      reply.status(404).send({ error: 'Invalid token' })

      // Token required for the request is invalid
   }

   if (process.env.NODE_ENV == 'development') {
      console.log(error.message)
   }

   reply.status(500).send('Internal server error')
})


const authTokenSchema = z.object({ email: z.string().email() })

const auth = async (request: FastifyRequest, reply: FastifyReply) => {
   try {
      const { authToken } = request.cookies

      if (!authToken) {
         throw new NotFoundError('Token')
      }

      const decodedToken = verify(authToken, process.env.JWT_SECRET)

      const { email } = authTokenSchema.parse(decodedToken)

      const user = await prisma.user.findFirst({
         where: {
            email,
            authToken,
         },
      })

      if (!user) {
         throw new NotFoundError('User')
      }

      request.user = user
   } catch (error) {
      reply.status(500).send({ error })
   }
}

app.post('/api/login', async (req, reply) => {
   const { email } = req.body as {
      email: string
   }

   try {
      const authToken = sign({ email }, process.env.JWT_SECRET, { expiresIn: '30m' })

      const isEmailFound = await prisma.user.findFirst({ where: { email } })

      if (isEmailFound) {
         await prisma.user.update({
            where: { email },
            data: { authToken },
         })
      } else {
         await prisma.user.create({
            data: {
               email,
               authToken,
            },
         })
      }

      await emailService.sendMagicLink(email, authToken)
      reply.status(200).send({
         success: true,
         email,
      })
   } catch (error) {
      reply.status(500).send({ error })
   }
})



app.get('/api/verify', async (req, reply) => {
   const { authToken } = req.query as { authToken: string }

   // TODO: parse schemą tokena 
   const data = verify(authToken, process.env.JWT_SECRET) as { email: string }
   console.log(authToken)
   const { email } = authTokenSchema.parse(data)

   reply
      .setCookie('authToken', authToken, {
         httpOnly: true,
         secure: process.env.NODE_ENV === 'production', // true on production
      })
      .status(200)
      .send({
         success: true,
         email,
      })
})

app.get('/auth', { preHandler: [auth] }, async (_req, reply) => {
   try {
      reply.status(200).send({ success: true })
   } catch (error) {
      reply.status(403).send({ error })
   }
})

const port = parseInt(process.env.PORT)

app.listen({ port }, (error, address) => {
   if (error) logger.error(error)

   logger.info(`Server listening on on ${address}`)
})

技术问询

  • 如何保障这套无密码认证流程的安全性?
  • 当前代码存在哪些潜在安全漏洞?
  • 无密码认证系统中,认证令牌的存储与处理有哪些最佳实践?
  • 针对基于Cookie和魔法链接的无密码认证方案,有哪些可提升安全性的改进建议?

解答

1. 保障无密码认证流程安全性的核心措施

  • 输入校验:所有用户输入(如邮箱、令牌)必须经过严格格式校验,拦截非法数据。
  • 令牌生命周期:使用短生命周期令牌(如30分钟),降低泄露后的风险窗口。
  • 传输安全:所有请求强制使用HTTPS,避免明文传输令牌。
  • 存储防护:Cookie配置httpOnly、secure、sameSite属性,防止XSS和CSRF攻击;数据库禁止存储明文令牌。
  • 双重验证:每次请求需同时验证令牌的签名有效性、过期时间,以及令牌在数据库中的当前有效性。
  • 请求限流:对登录接口添加速率限制,防止攻击者暴力枚举邮箱或发送垃圾邮件。
  • 日志脱敏:禁止在服务器日志中记录魔法链接中的令牌,避免敏感信息泄露。

2. 当前代码的潜在安全漏洞

  • 输入校验缺失:/api/login直接用类型断言获取邮箱,未通过Zod校验非法格式;/api/verify的令牌未做Schema校验,存在非法数据注入风险。
  • 错误处理逻辑失效:auth中间件捕获所有错误并返回500,覆盖了全局错误处理中对令牌过期、无效等场景的针对性状态码返回,导致客户端无法正确判断错误类型。
  • Cookie配置不全:缺少sameSite属性(易受CSRF攻击)、未设置maxAge(Cookie生命周期与JWT过期时间不一致),未限制path(Cookie被所有路径携带,扩大攻击面)。
  • 明文存储令牌:数据库直接存储明文JWT,一旦数据库泄露,攻击者可直接使用令牌登录。
  • 旧令牌未失效:/api/verify仅验证JWT有效性,未检查数据库中该令牌是否为用户当前有效令牌,导致用户多次请求登录后,旧令牌仍可被用来设置Cookie。
  • 日志泄露风险:/api/verify中的console.log(authToken)会在日志中记录令牌,生产环境下极易泄露敏感信息。
  • 错误状态码错误:全局错误处理中JsonWebTokenError返回404(资源不存在),实际应为401/403(身份验证失败),误导客户端判断。

3. 认证令牌存储与处理的最佳实践

  • 令牌类型选择:魔法链接令牌建议使用随机生成的非JWT字符串(减少解码成本),或用JWT存储非敏感信息(如邮箱、过期时间),但需确保签名密钥安全。
  • 数据库存储:存储令牌的哈希值(如bcrypt),而非明文,即使数据库泄露,攻击者也无法直接使用哈希后的令牌。
  • 传输安全:魔法链接必须通过HTTPS发送,令牌作为URL参数时,需修改服务器日志配置,禁止记录该参数。
  • Cookie标准配置:
    • httpOnly: true:防止XSS攻击窃取Cookie
    • secure: true:仅在HTTPS环境下传输
    • sameSite: 'Strict':阻断跨站CSRF攻击
    • maxAge:与令牌过期时间保持一致
    • path: '/api':限制Cookie仅在API路径下携带
  • 令牌失效机制:
    • 用户登录新设备时,自动覆盖数据库中的旧令牌,使其失效
    • 提供登出功能,同步删除Cookie与数据库中的令牌
    • 支持多设备登录时,存储多个令牌并允许用户手动失效指定设备的令牌
  • 验证逻辑:每次请求需依次验证令牌的签名、过期时间,以及数据库中是否存在匹配的有效令牌。

4. 安全性改进建议

  • 完善输入校验:用Zod校验所有输入,例如/api/login的邮箱:
    const loginSchema = z.object({ email: z.string().email() })
    const { email } = loginSchema.parse(req.body)
    
    /api/verify的令牌也需添加Schema校验。
  • 修复错误处理:auth中间件不要直接返回500,而是将错误抛出,由全局错误处理接管:
    const auth = async (request: FastifyRequest, reply: FastifyReply) => {
      const { authToken } = request.cookies
      if (!authToken) throw new NotFoundError('Token')
      
      const decodedToken = verify(authToken, process.env.JWT_SECRET)
      const { email } = authTokenSchema.parse(decodedToken)
      
      const user = await prisma.user.findFirst({ where: { email, authToken } })
      if (!user) throw new NotFoundError('User')
      
      request.user = user
    }
    
  • 优化Cookie配置:
    reply.setCookie('authToken', authToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'Strict',
      maxAge: 30 * 60 * 1000, // 与JWT expiresIn一致
      path: '/api',
    })
    
  • 令牌哈希存储:生成随机令牌字符串,哈希后存储到数据库,魔法链接携带原令牌,验证时对比哈希:
    // 登录接口生成令牌
    const authToken = crypto.randomUUID()
    const hashedToken = await bcrypt.hash(authToken, 10)
    // 存储hashedToken到数据库,而非authToken
    
  • 增强/api/verify逻辑:验证令牌时检查数据库中是否存在该令牌,确保是用户当前有效令牌:
    const user = await prisma.user.findFirst({ where: { email, authToken } })
    if (!user) throw new InvalidInputError('Invalid or expired token')
    
  • 添加速率限制:使用@fastify/rate-limit插件限制/api/login的请求频率,例如1分钟内最多5次请求。
  • 删除敏感日志输出:移除console.log(authToken),生产环境禁止记录任何敏感信息。
  • 使用Fastify官方JWT插件:替换直接使用jsonwebtoken库,改用@fastify/jwt,贴合Fastify生态,简化验证逻辑。
  • 会话令牌分离:魔法链接令牌仅用于身份验证,验证通过后生成新的长期会话令牌(如24小时),魔法链接令牌使用后立即从数据库删除。

内容的提问来源于stack exchange,提问作者IvonaK

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 23:56:57