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_id 和 client_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_id 和 client_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 和移动端。
这一节涉及到的几个实践:
- OAuth 流程:发起授权 → 回调获取 code → 换取 token → 获取用户信息
- GitHub/Google:主流平台的 OAuth 实现
- 账号关联:邮箱冲突处理,自动绑定或手动绑定
- 安全考虑:state 参数、redirect URI 验证、token 加密
- OAuth 库:arctic、@auth/core 简化实现
OAuth 登录降低了用户的注册门槛,AI 项目通常提供 GitHub 登录,因为开发者用户群体。使用成熟的 OAuth 库可以避免很多安全陷阱。
下一篇看 API Key 认证——开发者认证、密钥管理、权限控制。