验证用户凭据

本指南介绍 AdonisJS 中的安全凭据验证。你将学习:

  • 为什么朴素的密码验证容易受到计时攻击
  • 如何使用 AuthFinder mixin 进行安全的凭据验证
  • 密码哈希如何自动处理
  • 如何处理验证错误

概述

在用户登录或获取 access token 之前,你需要验证他们的凭据。这通常意味着通过邮箱(或用户名)查找用户,并将提供的密码与存储的哈希值进行比对。

AdonisJS 提供了 AuthFinder mixin 来安全地处理这一过程。该 mixin 向你的 User 模型添加了一个 verifyCredentials 方法,在提供干净的凭据验证 API 的同时,防范计时攻击。

为什么安全验证很重要

一种朴素的凭据验证方法可能如下所示:

app/controllers/session_controller.ts
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 模型:

app/models/user.ts
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 静态方法来验证凭据:

app/controllers/session_controller.ts
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) 获取。
  • 所有其他请求会收到纯文本错误响应。

要自定义错误处理,请在你的 全局异常处理程序 中捕获该异常:

app/exceptions/handler.ts
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 钩子 ,在创建或更新用户时自动对密码进行哈希处理。你无需在模型或控制器中手动对密码进行哈希处理:

app/controllers/users_controller.ts
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 属性发生更改时对密码进行哈希处理,因此更新其他用户字段不会触发不必要的重新哈希。