OAuth 登录

要点

  • OAuth 2.0 是第三方登录的标准协议,用户不需要把密码交给第三方应用
  • Authorization Code Flow 是最常用的 OAuth 流程,适合有服务端的场景
  • PKCE(Proof Key for Code Exchange)是 SPA 和移动端的安全增强
  • GitHub、Google、Microsoft 等主流平台都支持 OAuth 登录
  • 账号关联(已有账号 + 第三方登录)需要处理邮箱冲突
  • AI 项目通常提供 GitHub 登录,因为开发者用户群体

1. OAuth 2.0 流程

1.1 Authorization Code Flow

完整的 Authorization Code Flow:

1. 用户点击「使用 GitHub 登录」
2. 前端跳转到 GitHub 授权页面
3. 用户同意授权,GitHub 重定向回应用,带上 code
4. 服务端用 code 换取 access_token
5. 服务端用 access_token 获取用户信息
6. 创建或查找本地用户,生成 Session/JWT
7. 重定向到前端,带上认证信息

1.2 关键参数

  • client_id:应用的唯一标识,注册 OAuth App 时获得
  • client_secret:应用密钥,只有服务端知道
  • redirect_uri:授权回调地址,必须在 OAuth App 中配置
  • scope:请求的权限范围(如 user:email
  • state:防止 CSRF 攻击的随机字符串

2. GitHub OAuth

2.1 注册 OAuth App

在 GitHub Settings > Developer settings > OAuth Apps 创建应用:

  • Application name:应用名称
  • Homepage URL:应用首页
  • Authorization callback URL:回调地址(如 https://api.example.com/auth/github/callback

获得 client_idclient_secret

2.2 发起授权

import { Hono } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import { randomBytes } from 'crypto'
 
const app = new Hono()
 
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID!
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET!
const REDIRECT_URI = 'https://api.example.com/auth/github/callback'
 
// 发起 GitHub 授权
app.get('/auth/github', (c) => {
  // 生成 state 防止 CSRF
  const state = randomBytes(16).toString('hex')
 
  // 存储 state 到 Cookie
  setCookie(c, 'oauth_state', state, {
    httpOnly: true,
    secure: true,
    sameSite: 'Lax',
    maxAge: 600,  // 10 分钟
  })
 
  // 构造授权 URL
  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'user:email',
    state,
  })
 
  const authUrl = `https://github.com/login/oauth/authorize?${params}`
 
  return c.redirect(authUrl)
})

2.3 处理回调

app.get('/auth/github/callback', async (c) => {
  const code = c.req.query('code')
  const state = c.req.query('state')
 
  // 验证 state
  const savedState = getCookie(c, 'oauth_state')
  if (!state || state !== savedState) {
    return c.json({ error: 'Invalid state' }, 400)
  }
 
  // 用 code 换取 access_token
  const tokenResponse = await fetch(
    'https://github.com/login/oauth/access_token',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({
        client_id: GITHUB_CLIENT_ID,
        client_secret: GITHUB_CLIENT_SECRET,
        code,
        redirect_uri: REDIRECT_URI,
      }),
    }
  )
 
  const { access_token } = await tokenResponse.json()
 
  // 获取用户信息
  const userResponse = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${access_token}`,
    },
  })
 
  const githubUser = await userResponse.json()
 
  // 获取邮箱(如果 scope 包含 user:email)
  let email = githubUser.email
 
  if (!email) {
    const emailsResponse = await fetch('https://api.github.com/user/emails', {
      headers: {
        Authorization: `Bearer ${access_token}`,
      },
    })
 
    const emails = await emailsResponse.json()
    const primaryEmail = emails.find(
      (e: { primary: boolean; verified: boolean }) => e.primary && e.verified
    )
    email = primaryEmail?.email
  }
 
  if (!email) {
    return c.json({ error: 'Email not found' }, 400)
  }
 
  // 创建或查找本地用户
  const user = await findOrCreateUser({
    provider: 'github',
    providerId: githubUser.id.toString(),
    email,
    name: githubUser.name || githubUser.login,
    avatar: githubUser.avatar_url,
  })
 
  // 生成 JWT
  const token = await sign(
    {
      userId: user.id,
      email: user.email,
      exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
    },
    process.env.JWT_SECRET!
  )
 
  // 重定向到前端,带上 token
  return c.redirect(`https://app.example.com/auth/callback?token=${token}`)
})

2.4 用户存储

// 用户表
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  avatar: text('avatar'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
// OAuth 账号表
export const oauthAccounts = pgTable('oauth_accounts', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id),
  provider: text('provider').notNull(),  // github, google, microsoft
  providerId: text('provider_id').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
// 查找或创建用户
async function findOrCreateUser({
  provider,
  providerId,
  email,
  name,
  avatar,
}: {
  provider: string
  providerId: string
  email: string
  name: string
  avatar?: string
}) {
  // 查找已绑定的账号
  const existing = await db.query.oauthAccounts.findFirst({
    where: and(
      eq(oauthAccounts.provider, provider),
      eq(oauthAccounts.providerId, providerId)
    ),
    with: {
      user: true,
    },
  })
 
  if (existing) {
    return existing.user
  }
 
  // 查找同邮箱的用户
  const existingUser = await db.query.users.findFirst({
    where: eq(users.email, email),
  })
 
  if (existingUser) {
    // 绑定 OAuth 账号到已有用户
    await db.insert(oauthAccounts).values({
      userId: existingUser.id,
      provider,
      providerId,
    })
 
    return existingUser
  }
 
  // 创建新用户
  const [user] = await db.insert(users).values({
    email,
    name,
    avatar,
  }).returning()
 
  await db.insert(oauthAccounts).values({
    userId: user.id,
    provider,
    providerId,
  })
 
  return user
}

3. Google OAuth

3.1 注册 OAuth 2.0 Client

在 Google Cloud Console 创建 OAuth 2.0 Client ID:

  • Application type:Web application
  • Authorized redirect URIs:回调地址

获得 client_idclient_secret

3.2 发起授权

const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID!
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!
const GOOGLE_REDIRECT_URI = 'https://api.example.com/auth/google/callback'
 
app.get('/auth/google', (c) => {
  const state = randomBytes(16).toString('hex')
 
  setCookie(c, 'oauth_state', state, {
    httpOnly: true,
    secure: true,
    sameSite: 'Lax',
    maxAge: 600,
  })
 
  const params = new URLSearchParams({
    client_id: GOOGLE_CLIENT_ID,
    redirect_uri: GOOGLE_REDIRECT_URI,
    response_type: 'code',
    scope: 'openid email profile',
    state,
    access_type: 'offline',  // 获取 refresh_token
    prompt: 'consent',  // 每次都显示同意页面
  })
 
  const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`
 
  return c.redirect(authUrl)
})

3.3 处理回调

app.get('/auth/google/callback', async (c) => {
  const code = c.req.query('code')
  const state = c.req.query('state')
 
  // 验证 state
  const savedState = getCookie(c, 'oauth_state')
  if (!state || state !== savedState) {
    return c.json({ error: 'Invalid state' }, 400)
  }
 
  // 用 code 换取 tokens
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      code,
      redirect_uri: GOOGLE_REDIRECT_URI,
      grant_type: 'authorization_code',
    }),
  })
 
  const { access_token, id_token } = await tokenResponse.json()
 
  // 用 id_token 获取用户信息(Google 返回 JWT)
  const payload = JSON.parse(
    Buffer.from(id_token.split('.')[1], 'base64').toString()
  )
 
  const user = await findOrCreateUser({
    provider: 'google',
    providerId: payload.sub,
    email: payload.email,
    name: payload.name,
    avatar: payload.picture,
  })
 
  // 生成 JWT
  const token = await sign(
    {
      userId: user.id,
      email: user.email,
      exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
    },
    process.env.JWT_SECRET!
  )
 
  return c.redirect(`https://app.example.com/auth/callback?token=${token}`)
})

4. 账号关联

4.1 邮箱冲突

用户先用邮箱注册,后来又用同一个邮箱的 GitHub 登录:

1. 用户用 [email protected] 注册账号
2. 用户点击「使用 GitHub 登录」,GitHub 返回 [email protected]
3. 系统发现 [email protected] 已存在
4. 应该绑定到已有账号,还是创建新账号?

推荐策略:自动绑定到已有账号。

async function findOrCreateUser({ provider, providerId, email, name, avatar }) {
  // 1. 查找已绑定的 OAuth 账号
  const existing = await db.query.oauthAccounts.findFirst({
    where: and(
      eq(oauthAccounts.provider, provider),
      eq(oauthAccounts.providerId, providerId)
    ),
  })
 
  if (existing) {
    return existing.user
  }
 
  // 2. 查找同邮箱的用户
  const existingUser = await db.query.users.findFirst({
    where: eq(users.email, email),
  })
 
  if (existingUser) {
    // 绑定 OAuth 账号
    await db.insert(oauthAccounts).values({
      userId: existingUser.id,
      provider,
      providerId,
    })
 
    return existingUser
  }
 
  // 3. 创建新用户
  const [user] = await db.insert(users).values({
    email,
    name,
    avatar,
  }).returning()
 
  await db.insert(oauthAccounts).values({
    userId: user.id,
    provider,
    providerId,
  })
 
  return user
}

4.2 手动绑定

已登录用户可以绑定第三方账号:

app.get('/auth/github/bind', async (c) => {
  const user = c.get('user')
 
  const state = randomBytes(16).toString('hex')
 
  // 存储 state 和用户 ID
  await redis.setex(
    `oauth_state:${state}`,
    600,
    JSON.stringify({ userId: user.id })
  )
 
  setCookie(c, 'oauth_state', state, { ... })
 
  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: 'https://api.example.com/auth/github/bind/callback',
    scope: 'user:email',
    state,
  })
 
  return c.redirect(`https://github.com/login/oauth/authorize?${params}`)
})
 
app.get('/auth/github/bind/callback', async (c) => {
  const state = getCookie(c, 'oauth_state')
  const code = c.req.query('code')
 
  // 验证 state,获取用户 ID
  const stateData = await redis.get(`oauth_state:${state}`)
  const { userId } = JSON.parse(stateData!)
 
  // 获取 GitHub 用户信息
  const githubUser = await getGitHubUser(code)
 
  // 检查是否已被其他账号绑定
  const existing = await db.query.oauthAccounts.findFirst({
    where: and(
      eq(oauthAccounts.provider, 'github'),
      eq(oauthAccounts.providerId, githubUser.id.toString())
    ),
  })
 
  if (existing) {
    return c.json({ error: 'GitHub account already bound' }, 400)
  }
 
  // 绑定到当前用户
  await db.insert(oauthAccounts).values({
    userId,
    provider: 'github',
    providerId: githubUser.id.toString(),
  })
 
  return c.redirect('https://app.example.com/settings?bound=success')
})

5. 安全考虑

5.1 State 参数

State 参数防止 CSRF 攻击:

// 生成随机 state
const state = randomBytes(16).toString('hex')
 
// 存储到 Cookie 或 Redis
setCookie(c, 'oauth_state', state, { ... })
 
// 回调时验证
if (state !== getCookie(c, 'oauth_state')) {
  return c.json({ error: 'Invalid state' }, 400)
}

5.2 Redirect URI 验证

OAuth 平台会验证 redirect_uri 是否匹配,但应用也要验证:

// ❌ 危险:直接使用用户输入
const redirectUri = c.req.query('redirect_uri')
 
// ✅ 安全:使用配置的值
const redirectUri = 'https://api.example.com/auth/github/callback'

5.3 Token 安全

Access token 和 refresh token 要安全存储:

// ❌ 不要存储到数据库明文
await db.insert(oauthTokens).values({
  accessToken: '...',  // 危险
})
 
// ✅ 加密存储
import { encrypt, decrypt } from './lib/crypto'
 
await db.insert(oauthTokens).values({
  accessToken: encrypt('...'),
})

6. 使用 OAuth 库

手动实现 OAuth 流程容易出错,推荐使用成熟的库:

6.1 arctic

import { GitHub, Google } from 'arctic'
 
const github = new GitHub(
  process.env.GITHUB_CLIENT_ID!,
  process.env.GITHUB_CLIENT_SECRET!,
)
 
// 发起授权
const state = generateState()
const url = await github.createAuthorizationURL(state, {
  scopes: ['user:email'],
})
 
// 处理回调
const tokens = await github.validateAuthorizationCode(code)
const userResponse = await fetch('https://api.github.com/user', {
  headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
const githubUser = await userResponse.json()

6.2 @auth/core

import { Auth } from '@auth/core'
import GitHub from '@auth/core/providers/github'
import Google from '@auth/core/providers/google'
 
const auth = new Auth({
  providers: [
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
})

总结

OAuth 2.0 是第三方登录的标准协议。Authorization Code Flow 适合有服务端的场景,PKCE 适合 SPA 和移动端。

这一节涉及到的几个实践:

  1. OAuth 流程:发起授权 → 回调获取 code → 换取 token → 获取用户信息
  2. GitHub/Google:主流平台的 OAuth 实现
  3. 账号关联:邮箱冲突处理,自动绑定或手动绑定
  4. 安全考虑:state 参数、redirect URI 验证、token 加密
  5. OAuth 库:arctic、@auth/core 简化实现

OAuth 登录降低了用户的注册门槛,AI 项目通常提供 GitHub 登录,因为开发者用户群体。使用成熟的 OAuth 库可以避免很多安全陷阱。

下一篇看 API Key 认证——开发者认证、密钥管理、权限控制。