验证用户凭据
在 AdonisJS 应用中,验证用户凭据与身份认证层是解耦的。这确保你可以在不限制验证用户凭据选项的前提下,继续使用身份认证守卫。
默认情况下,我们提供了安全的 API 来查找用户并验证其密码。不过,你也可以实现额外的验证用户方式,例如向其手机号发送 OTP 或使用 2FA(双因素认证)。
在本指南中,我们将介绍先通过 UID 查找用户,并在将其标记为已登录之前验证其密码的过程。
基础示例
你可以直接使用 User 模型来查找用户并验证其密码。在以下示例中,我们通过 email 查找用户,并使用 hash 服务来验证密码哈希。
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'])
/**
* 通过 email 查找用户。如果用户不存在,
* 则返回错误
*/
const user = await User.findBy('email', email)
if (!user) {
return response.abort('Invalid credentials')
}
/**
* 使用 hash 服务验证密码
*/
const isPasswordValid = await hash.verify(user.password, password)
if (!isPasswordValid) {
return response.abort('Invalid credentials')
}
/**
* 现在登录该用户,或为其创建令牌
*/
// ...
}
}
上述做法存在的问题
我们在上述示例中编写的代码容易受到时序攻击(timing attack) 的攻击。在身份认证场景下,攻击者可以通过观察应用的响应时间来判断其提供的凭据中是 email 还是密码不正确。我们建议你使用 AuthFinder mixin 来防范时序攻击,并获得更好的开发体验。
根据上述实现:
-
如果用户的 email 不正确,请求所花费的时间会更短。这是因为当我们找不到用户时,不会去验证密码哈希。
-
如果 email 存在但密码不正确,请求所花费的时间会更长。这是因为密码哈希算法本身就很慢。
响应时间的差异足以让攻击者找到一个有效的 email 地址,并尝试不同的密码组合。
使用 Auth finder mixin
为防止时序攻击,我们建议你在 User 模型上使用 AuthFinder mixin。
Auth finder mixin 会为所应用的模型添加 findForAuth 和 verifyCredentials 方法。verifyCredentials 方法提供了一个能防范时序攻击的 API,用于查找用户并验证其密码。
你可以按如下方式导入并在模型上应用该 mixin。
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方法接受第一个参数,即一个返回哈希器(hasher)的回调函数。在上面的示例中,我们使用了scrypt哈希器。不过,你也可以将其替换为其他哈希器。 -
其次,它接受一个包含以下属性的配置对象。
uids:可用于唯一标识用户的模型属性数组。如果你为用户分配了用户名或手机号,也可以将它们用作 UID。passwordColumnName:保存用户密码的模型属性名称。
-
最后,你可以将
withAuthFinder方法的返回值作为mixin 应用在 User 模型上。
验证凭据
一旦你应用了 Auth finder mixin,就可以用以下代码片段替换 SessionController.store 方法中的所有代码。
import { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
export default class SessionController {
async store({ request, response }: HttpContext) {
async store({ request }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
/**
* 通过 email 查找用户。如果用户不存在,
* 则返回错误
*/
const user = await User.findBy('email', email)
if (!user) {
response.abort('Invalid credentials')
}
/**
* 使用 hash 服务验证密码
*/
await hash.verify(user.password, password)
const user = await User.verifyCredentials(email, password)
/**
* 现在登录该用户,或为其创建令牌
*/
}
}
处理异常
如果凭据无效,verifyCredentials 方法会抛出 E_INVALID_CREDENTIALS 异常。
该异常会被自动处理,并使用以下内容协商(content negotiation)规则转换为响应。
-
带有
Accept=application/json请求头的 HTTP 请求会收到一个错误消息数组。数组中的每个元素都是一个包含message属性的对象。 -
带有
Accept=application/vnd.api+json请求头的 HTTP 请求会收到一个按照 JSON API 规范格式化的错误消息数组。 -
如果你使用会话,用户将被重定向到表单页面,并通过会话闪存消息(session 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 钩子,在 INSERT 和 UPDATE 调用期间自动对用户的密码进行哈希处理。因此,你无需在模型中手动执行密码哈希。