校验用户凭证

验证用户凭据

在 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 会为所应用的模型添加 findForAuthverifyCredentials 方法。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 钩子,在 INSERTUPDATE 调用期间自动对用户的密码进行哈希处理。因此,你无需在模型中手动执行密码哈希。