社交身份验证

本指南介绍如何使用 Ally 包在 AdonisJS 中实现社交身份验证。你将学习:

  • 如何安装并配置带有 OAuth 提供者的 Ally
  • 如何将用户重定向到提供者并处理回调
  • 如何从提供者获取用户信息
  • 如何创建或查找用户并登录
  • 如何为 SPA 和移动应用使用无状态身份验证
  • 如何创建自定义的社交驱动

概述

社交身份验证允许用户使用他们在 GitHub、Google、X(原 Twitter)或 Discord 等服务的现有账户登录。用户无需创建新的用户名和密码,而是授权你的应用从提供者处访问他们的个人资料信息。

AdonisJS 通过 @adonisjs/ally 包提供社交身份验证。Ally 处理 OAuth 流程(将用户重定向、用授权码换取令牌、获取用户数据),并提供跨不同提供者的统一 API。它支持 OAuth 1.0(Twitter)和 OAuth 2.0(X、GitHub、Google 以及大多数其他提供者)。

Ally 不会在你的数据库中存储用户或令牌。它处理 OAuth 流程并返回用户信息,然后你可以使用这些信息在你的数据库中创建或查找用户,并通过一个 auth 守卫 让他们登录。

安装

使用 add 命令安装并配置该包:

node ace add @adonisjs/ally

# 在安装期间指定提供者
node ace add @adonisjs/ally --providers=github --providers=google
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/ally 包。

  2. adonisrc.ts 文件内注册以下服务提供者。

    {
      providers: [
        // ...其他提供者
        () => import('@adonisjs/ally/ally_provider')
      ]
    }
  1. 创建 config/ally.ts,包含所选提供者的配置。

  2. 为每个提供者定义 CLIENT_IDCLIENT_SECRET 的环境变量。

配置

config/ally.ts 中配置你的 OAuth 提供者。每个提供者都需要一个客户端 ID、客户端密钥和回调 URL:

config/ally.ts
import env from '#start/env'
import { defineConfig, services } from '@adonisjs/ally'

export default defineConfig({
  github: services.github({
    clientId: env.get('GITHUB_CLIENT_ID'),
    clientSecret: env.get('GITHUB_CLIENT_SECRET'),
    callbackUrl: 'http://localhost:3333/github/callback',
  }),
  google: services.google({
    clientId: env.get('GOOGLE_CLIENT_ID'),
    clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
    callbackUrl: 'http://localhost:3333/google/callback',
  }),
})

在提供者处注册回调 URL

OAuth 提供者要求你在他们的开发者控制台中注册你的回调 URL。例如,要使用 GitHub 身份验证:

  1. 前往 GitHub Developer Settings
  2. 创建一个新的 OAuth App
  3. 将 Authorization callback URL 设置为与你的配置中的 callbackUrl 相匹配

你配置中的回调 URL 必须与你在该提供者处注册的完全一致。

将用户重定向到提供者

创建一个将用户重定向到 OAuth 提供者的路由。使用 ally.use() 获取驱动实例,并调用 redirect()

在重定向之前,将 redirect.previousUrl 存储到 session 中,这样错误处理程序和 back() 调用会将用户重定向回你的应用中的正确页面,而不是 OAuth 提供者的域名。

start/routes.ts
import router from '@adonisjs/core/services/router'

router.get('/github/redirect', ({ ally, session }) => {
  session.put('redirect.previousUrl', '/login')
  return ally.use('github').redirect()
})

请求 scopes

scopes 定义了你的应用可以访问哪些数据。不同的提供者拥有不同的可用 scopes。你可以在 config/ally.ts 中配置它们,或在重定向时指定:

config/ally.ts
github: services.github({
  clientId: env.get('GITHUB_CLIENT_ID'),
  clientSecret: env.get('GITHUB_CLIENT_SECRET'),
  callbackUrl: 'http://localhost:3333/github/callback',
  scopes: ['user:email', 'read:user'],
}),
start/routes.ts
router.get('/github/redirect', ({ ally }) => {
  return ally.use('github').redirect((request) => {
    request.scopes(['user:email', 'read:user'])
  })
})

添加查询参数

某些提供者接受附加参数。例如,Google 的 prompt 参数控制同意屏幕的行为:

start/routes.ts
router.get('/google/redirect', ({ ally }) => {
  return ally.use('google').redirect((request) => {
    request.param('prompt', 'select_account')
    request.param('access_type', 'offline')
  })
})

要移除在配置中设置的参数,使用 clearParam

start/routes.ts
router.get('/google/redirect', ({ ally }) => {
  return ally.use('google').redirect((request) => {
    request.clearParam('prompt')
  })
})

处理回调

在用户授权(或拒绝)访问后,提供者会将他们重定向到你的回调 URL。在驱动上调用 user() 以用授权码换取访问令牌并获取用户的个人资料。

start/routes.ts
import router from '@adonisjs/core/services/router'

router.get('/github/callback', async ({ ally }) => {
  const github = ally.use('github')
  const githubUser = await github.user()

  return githubUser
})

如果在回调过程中出现问题(用户拒绝访问、state 令牌不匹配,或缺少授权码),user() 方法会抛出一个自行处理的异常。该异常会自动对响应进行内容协商:

  • 带有 session 的 HTML 请求:错误消息会被闪现(flash)到 session 中,用户被重定向回去。如果你在 OAuth 重定向之前在 session 中存储了 redirect.previousUrl,用户会返回到该页面。
  • JSON 请求:返回一个带有适当状态码的 JSON 错误响应。
  • JSONAPI 请求:返回一个 JSONAPI 格式的错误响应。

如果你需要自己处理特定的错误情况,仍然可以在调用 user() 之前对其进行检查。

start/routes.ts
router.get('/github/callback', async ({ ally, response }) => {
  const github = ally.use('github')

  if (github.accessDenied()) {
    return response.redirect().toPath('/login?error=access_denied')
  }

  const githubUser = await github.user()
  return githubUser
})

用户属性

user() 方法返回一个规范化的用户对象,在不同提供者之间具有一致的属性:

PropertyDescription
id来自提供者的唯一标识符
email用户的邮箱地址(如果未请求或不可用,可能为 null
emailVerificationStateverifiedunverifiedunsupported 之一
name用户的显示名称
nickName用户名或手柄(如果提供者不支持昵称,则与 name 相同)
avatarUrl用户头像图片的 URL
token用于发起 API 调用的访问令牌对象
original来自提供者的原始响应

邮箱验证状态

不同的提供者处理邮箱验证的方式不同。在信任邮箱之前,请检查 emailVerificationState

  • verified:提供者已验证该邮箱地址
  • unverified:邮箱存在但未被验证
  • unsupported:提供者不共享验证状态

访问令牌

token 属性包含用于向提供者发起额外 API 调用的 OAuth 令牌:

PropertyProtocolDescription
tokenOAuth 1 & 2访问令牌字符串
secret仅 OAuth 1令牌密钥(Twitter 使用)
typeOAuth 2令牌类型(通常为 bearer
refreshTokenOAuth 2用于获取新访问令牌的令牌
expiresAtOAuth 2令牌过期的 DateTime
expiresInOAuth 2距离过期的秒数

原始响应

通过 original 属性访问提供者特定的数据:

start/routes.ts
const githubUser = await github.user()
console.log(githubUser.original)

创建用户并登录

Ally 提供用户信息,但不会创建用户或 session。在从提供者处获取用户数据后,你需要:

  1. 在你的数据库中查找或创建用户
  2. 使用一个 auth 守卫让他们登录

使用 session 守卫

对于服务端渲染的应用,在社交身份验证之后创建一个 session。回调期间的错误(拒绝访问、state 不匹配)会被自行处理,并将用户带着闪现消息重定向回去。

start/routes.ts
import User from '#models/user'
import router from '@adonisjs/core/services/router'

router.get('/github/callback', async ({ ally, auth, response }) => {
  const githubUser = await ally.use('github').user()

  /**
   * 查找现有用户或创建一个新用户
   */
  const user = await User.firstOrCreate(
    { email: githubUser.email },
    {
      email: githubUser.email,
      fullName: githubUser.name,
      /**
       * 生成一个随机密码,因为社交用户
       * 不会使用基于密码的登录
       */
      password: crypto.randomUUID(),
    }
  )

  /**
   * 为用户创建一个 session
   */
  await auth.use('web').login(user)

  /**
   * 重定向到用户试图访问的页面,
   * 如果没有存储目标 URL,则重定向到 /dashboard
   */
  return response.redirect().toIntended('/dashboard')
})

使用 access tokens 守卫

对于 API 和移动应用,在社交身份验证之后签发一个 access token。

start/routes.ts
import User from '#models/user'
import router from '@adonisjs/core/services/router'

router.get('/github/callback', async ({ ally }) => {
  const github = ally.use('github')

  if (github.accessDenied()) {
    return { error: 'access_denied' }
  }

  if (github.stateMisMatch()) {
    return { error: 'state_mismatch' }
  }

  if (github.hasError()) {
    return { error: github.getError() }
  }

  const githubUser = await github.user()

  const user = await User.firstOrCreate(
    { email: githubUser.email },
    {
      email: githubUser.email,
      fullName: githubUser.name,
      password: crypto.randomUUID(),
    }
  )

  /**
   * 为用户创建一个 access token
   */
  const token = await User.accessTokens.create(user)

  return {
    type: 'bearer',
    value: token.value!.release(),
  }
})

禁止某个提供者的注册

当一个提供者只应用于登录或账户关联,而不用于创建新的本地账户时,使用 disallowLocalSignup 选项。当你曾在历史上允许使用某个提供者进行注册,但现在希望限制它只能用于登录时,这会很有帮助。

config/ally.ts
export default defineConfig({
  github: services.github({
    clientId: env.get('GITHUB_CLIENT_ID'),
    clientSecret: env.get('GITHUB_CLIENT_SECRET'),
    callbackUrl: 'http://localhost:3333/github/callback',
    disallowLocalSignup: true,
  }),
})

启用 disallowLocalSignup 后,调用 ally.use(provider, { intent: 'signup' }) 会抛出一个状态码为 403 的异常。

start/routes.ts
router.get('/github/signup/redirect', ({ ally }) => {
  return ally.use('github', { intent: 'signup' }).redirect()
})

无状态身份验证

默认情况下,Ally 使用一个存储在 cookie 中的 CSRF 令牌来防止跨站请求伪造。如果你的应用无法使用 cookie(例如,使用 webview 的移动应用),请启用无状态模式:

start/routes.ts
router.get('/github/redirect', ({ ally }) => {
  return ally.use('github').stateless().redirect()
})

router.get('/github/callback', async ({ ally }) => {
  const github = ally.use('github').stateless()

  // 处理回调...
  const user = await github.user()
})

重定向和回调都必须使用无状态模式。在没有 CSRF 检查的情况下,请确保你的应用有其他保护机制,防止未经授权的 OAuth 流程。

从已有令牌获取用户

如果你已经拥有一个访问令牌(例如,来自移动应用的原生 OAuth 流程),可以直接获取用户信息:

start/routes.ts
router.post('/auth/github', async ({ request, ally }) => {
  const { accessToken } = request.only(['accessToken'])

  const user = await ally.use('github').userFromToken(accessToken)

  return user
})

对于 OAuth 1 提供者(Twitter),使用 userFromTokenAndSecret

start/routes.ts
const user = await ally.use('twitter').userFromTokenAndSecret(token, secret)

动态选择提供者

使用路由参数通过一个单一路由处理多个提供者:

start/routes.ts
router
  .get('/:provider/redirect', ({ ally, params }) => {
    if (!ally.has(params.provider)) {
      return 'Unknown provider'
    }

    return ally.use(params.provider).redirect()
  })
  .where('provider', /github|google|twitter/)

router
  .get('/:provider/callback', async ({ ally, params }) => {
    if (!ally.has(params.provider)) {
      return 'Unknown provider'
    }

    const driver = ally.use(params.provider)
    // 处理回调...
  })
  .where('provider', /github|google|twitter/)

你还可以在运行时检查已配置的提供者:

start/routes.ts
router.get('/login', ({ ally }) => {
  return {
    providers: ally.configuredProviderNames(),
    signupProviders: ally.signupProviderNames(),
  }
})

提供者配置参考

每个提供者接受特定的配置选项。以下示例展示了每个内置提供者的所有可用选项。

GitHub
github: services.github({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['user', 'gist'],
  login: 'adonisjs',
  allowSignup: true,
})
Google
google: services.google({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['userinfo.email', 'calendar.events'],
  prompt: 'select_account',
  accessType: 'offline',
  hostedDomain: 'adonisjs.com',
  display: 'page',
})
Twitter
twitter: services.twitter({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
})
X
twitterX: services.twitterX({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['tweet.read', 'users.read', 'users.email'],
})
Discord
discord: services.discord({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['identify', 'email'],
  prompt: 'consent',
  guildId: '',
  disableGuildSelect: false,
  permissions: 10,
})
LinkedIn (OpenID Connect)
linkedinOpenidConnect: services.linkedinOpenidConnect({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['openid', 'profile', 'email'],
})
Facebook
facebook: services.facebook({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['email', 'user_photos'],
  userFields: ['first_name', 'picture', 'email'],
  display: '',
  authType: '',
})
Spotify
spotify: services.spotify({
  clientId: '',
  clientSecret: '',
  callbackUrl: '',
  disallowLocalSignup: false,
  scopes: ['user-read-email', 'streaming'],
  showDialog: false,
})

创建自定义驱动

如果你需要集成 Ally 未包含的提供者,可以创建一个自定义驱动。AdonisJS 提供了一个用于构建和发布自定义驱动的 starter kit 。有关实现的详细信息,请参阅该 starter kit 的 README。