验证用户凭据
本指南介绍 AdonisJS 中的安全凭据验证。你将学习:
- 为什么朴素的密码验证容易受到计时攻击
- 如何使用 AuthFinder mixin 进行安全的凭据验证
- 密码哈希如何自动处理
- 如何处理验证错误
概述
在用户登录或获取 access token 之前,你需要验证他们的凭据。这通常意味着通过邮箱(或用户名)查找用户,并将提供的密码与存储的哈希值进行比对。
AdonisJS 提供了 AuthFinder mixin 来安全地处理这一过程。该 mixin 向你的 User 模型添加了一个 verifyCredentials 方法,在提供干净的凭据验证 API 的同时,防范计时攻击。
为什么安全验证很重要
一种朴素的凭据验证方法可能如下所示:
import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
/**
* 通过邮箱查找用户
*/
const user = await User.findBy('email', email)
if (!user) {
return response.abort('Invalid credentials')
}
/**
* 验证密码
*/
const isPasswordValid = await hash.verify(user.password, password)
if (!isPasswordValid) {
return response.abort('Invalid credentials')
}
// 登录用户...
}
}
这段代码容易受到 计时攻击(timing attacks) 的攻击。攻击者可以测量响应时间来确定某个邮箱是否存在于你的数据库中:
- 当邮箱不存在时,由于没有进行密码哈希,响应会快速返回。
- 当邮箱存在但密码错误时,由于密码哈希算法故意设计得较慢,响应需要更长时间。
这种时间差异足以让攻击者枚举出有效的邮箱地址,然后他们可以针对这些地址进行密码攻击。
使用 AuthFinder mixin
AuthFinder mixin 通过始终执行密码哈希比对来解决计时攻击问题,即使用户不存在也是如此。这确保了无论邮箱是否有效,响应时间都保持一致。
要使用 mixin,请将其应用到你的 User 模型:
import { DateTime } from 'luxon'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: number
@column()
declare fullName: string | null
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
withAuthFinder 方法接受两个参数。第一个是一个回调,返回用于密码验证的哈希器(本例中是 scrypt,但你也可以使用任何已配置的哈希器)。第二个是一个配置对象,包含以下属性:
| 属性 | 描述 |
|---|---|
uids | 可用于标识用户的模型属性数组。如果你的应用允许通过用户名或电话号码登录,请将那些字段包含在此处。 |
passwordColumnName | 存储哈希密码的模型属性。 |
验证凭据
应用 mixin 后,使用 verifyCredentials 静态方法来验证凭据:
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
// 登录用户...
}
}
verifyCredentials 方法通过提供的 UID(本例中是邮箱)查找用户,验证密码,并返回用户实例。如果凭据无效,它会抛出 E_INVALID_CREDENTIALS 异常。
处理验证错误
当凭据无效时,verifyCredentials 会抛出
E_INVALID_CREDENTIALS
异常。该异常是可自处理的(self-handling),并基于内容协商(content negotiation)转换为适当的 HTTP 响应:
- 带有
Accept: application/json的请求会收到一个带有message属性的错误对象数组。 - 带有
Accept: application/vnd.api+json的请求会收到按 JSON API 规范格式化的错误。 - 使用会话的请求会被重定向返回,错误可通过 闪现消息(flash messages) 获取。
- 所有其他请求会收到纯文本错误响应。
要自定义错误处理,请在你的 全局异常处理程序 中捕获该异常:
import { errors } from '@adonisjs/auth'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_INVALID_CREDENTIALS) {
return ctx.response
.status(error.status)
.send(error.getResponseMessage(error, ctx))
}
return super.handle(error, ctx)
}
}
自动密码哈希
AuthFinder mixin 注册了一个 beforeSave 钩子 ,在创建或更新用户时自动对密码进行哈希处理。你无需在模型或控制器中手动对密码进行哈希处理:
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async store({ request }: HttpContext) {
const data = request.only(['email', 'password', 'fullName'])
/**
* 密码在保存前会自动哈希处理
*/
const user = await User.create(data)
return user
}
}
该钩子仅在 password 属性发生更改时对密码进行哈希处理,因此更新其他用户字段不会触发不必要的重新哈希。