无密码魔法链接认证系统安全性及优化方案问询
无密码认证系统安全性与代码优化问询
问题背景
我正在开发一个用于基础CRUD操作的简易无密码认证系统,采用魔法链接登录方案,流程如下:
- 用户访问魔法链接登录页面
- 若邮箱合法,向用户邮箱发送验证链接
- 用户点击验证链接
- 若链接未过期且有效,将其存储为Cookie
- 基于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攻击窃取Cookiesecure: 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
相关产品推荐
相关产品推荐

