第三方登录:微信/苹果/QQ怎么接?
当用户嫌注册太麻烦时,给他们一个"一键登录"的选项,转化率能翻倍。但这一键背后,藏着不少门道。
为什么要接第三方登录?
先说个真实场景:
用户下载了你的游戏,兴致勃勃打开,结果看到注册页面——要填邮箱、设密码、确认密码……手指悬在键盘上方,犹豫了三秒,然后关掉应用。
数据不会骗人:根据LoginRadius的调研报告,77%的用户认为"必须注册"是放弃使用应用的主要原因之一。而提供社交登录后,注册转化率平均能提升20-40%。
第三方登录解决的正是这个问题:让用户用已有的账号(微信、QQ、苹果ID等)直接登录你的平台,省去注册流程,降低门槛。
但接入第三方登录不是简单调几个API就完事的。你需要理解背后的授权机制、各平台的差异、以及如何把多种登录方式统一管理起来。
OAuth 2.0:第三方登录的"通行证系统"
几乎所有第三方登录都基于 OAuth 2.0 协议。听起来很技术,但原理其实很好理解。
想象一个酒店场景
你入住酒店,朋友想借你的房卡去健身房。你有两个选择:
- 把房卡直接给朋友 —— 风险太大,他能进你房间
- 去前台办一张临时健身卡 —— 只能进健身房,不能进房间
OAuth 2.0 就是第二种方案。用户不需要把微信密码告诉你,而是让微信发一张"临时健身卡"(Access Token),你只能用这张卡获取用户的基本信息,做不了其他操作。
OAuth 2.0 的四种角色
OAuth 2.0 定义了四种角色,理解它们是掌握协议的关键:
| 角色 | 说明 | 例子 |
|---|---|---|
| Resource Owner | 资源所有者,即用户 | 拥有微信账号的你 |
| Client | 客户端,需要获取资源的应用 | 你的游戏/网站 |
| Authorization Server | 授权服务器 | 微信的登录系统 |
| Resource Server | 资源服务器 | 微信的用户信息API |
四种授权模式
OAuth 2.0 定义了四种授权模式,适用于不同场景:
- 适用场景:有后端服务器的Web应用、移动应用
- 特点:Access Token只在后端传递,前端永远接触不到
- 适用场景:纯前端应用(已逐渐被PKCE模式替代)
- 特点:Access Token直接返回给前端,安全性较低
- 适用场景:官方应用、第一方应用
- 特点:用户直接把密码给客户端,风险极高
- 适用场景:服务间调用,无用户参与
- 特点:用于机器对机器的授权
授权码模式的完整流程
┌──────────┐ ┌──────────────────┐
│ │ │ │
│ 用户 │ │ Authorization │
│ (Resource│ │ Server │
│ Owner) │ │ (微信登录) │
│ │ │ │
└────┬─────┘ └────────┬─────────┘
│ │
│ 1. 点击"微信登录" │
▼ │
┌──────────┐ 2. 重定向到授权页(带client_id, scope等) │
│ │─────────────────────────────────────────────▶│
│ Client │ │
│ (你的应用)│◀─────────────────────────────────────────────│
│ │ 3. 用户确认授权 │
│ │ │
│ │◀─── 4. 回调,带上授权码(Authorization Code)────│
│ │ │
│ │ 5. 用授权码换Token(后端完成) │
│ │─────────────────────────────────────────────▶│
│ │ │
│ │◀─── 6. 返回Access Token + Refresh Token ─────│
└──────────┘ │
│ │
│ 7. 用Token获取用户信息 │
▼ │
┌──────────┐ │
│ Resource │ │
│ Server │ │
│(微信API) │ │
└──────────┘
五步流程详解
你的平台构造授权URL,重定向用户到微信授权页面:
// 构造授权URL示例
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?
appid=${APPID}
&redirect_uri=${encodeURIComponent('https://yourapp.com/callback')}
&response_type=code
&scope=snsapi_login
&state=${generateRandomState()}
#wechat_redirect`;
// 重定向用户
window.location.href = authUrl;
appid: 你的应用在微信开放平台的唯一标识redirect_uri: 授权成功后跳回的地址,必须与开放平台配置的一致response_type: 固定填code,表示要获取授权码scope: 请求的权限范围,snsapi_login是微信网页登录的固定值state: 防CSRF的关键参数,后面会细说
用户看到授权页面,显示"XX应用想要获取你的基本信息"。这一步完全在微信的界面上完成,你的平台接触不到用户的密码,也看不到授权过程。
用户点击"同意"后,微信会记录这次授权,并生成一个临时授权码。
微信把用户浏览器重定向回你指定的redirect_uri,并附上授权码:
https://yourapp.com/callback?code=061abc123&state=xyz789
- 有效期极短(通常5-10分钟)
- 只能使用一次
- 本身不包含任何用户信息,只是一个临时凭证
这一步必须在服务器端完成,用户完全看不到:
// Node.js 后端示例
const axios = require('axios');
async function exchangeCodeForToken(code) {
const tokenUrl = 'https://api.weixin.qq.com/sns/oauth2/access_token';
const response = await axios.get(tokenUrl, {
params: {
appid: process.env.WECHAT_APPID,
secret: process.env.WECHAT_SECRET, // ⚠️ 绝对不能暴露给前端
code: code,
grant_type: 'authorization_code'
}
});
return response.data;
// 返回: { access_token, expires_in, refresh_token, openid, scope, unionid }
}
async function getUserInfo(accessToken, openid) {
const userInfoUrl = 'https://api.weixin.qq.com/sns/userinfo';
const response = await axios.get(userInfoUrl, {
params: {
access_token: accessToken,
openid: openid
}
});
return response.data;
// 返回: { openid, nickname, sex, province, city, country, headimgurl, unionid }
}
为什么要绕这么一大圈?
你可能会问:为什么不直接让微信把Token返回给前端?
授权码模式下,Access Token永远只在你的后台和微信服务器之间传递,经过以下保护:
- 前端永远接触不到Access Token - 即使XSS攻击也无法窃取
- 应用密钥(Secret)不会暴露 - 换Token需要密钥,只能在后端完成
- 授权码一次性且短命 - 即使被截获,也很难在有效期内利用
- 全程HTTPS加密 - 防止中间人攻击
这就像你不会把银行卡密码写在明信片上寄出去,而是亲自去银行柜台办理业务一样。
Token的生命周期管理
Access Token不是永久有效的,需要管理其生命周期:
// Token响应示例
{
"access_token": "ACCESS_TOKEN", // 访问令牌
"expires_in": 7200, // 有效期(秒),通常2小时
"refresh_token": "REFRESH_TOKEN", // 刷新令牌,有效期30天
"openid": "OPENID",
"scope": "snsapi_userinfo",
"unionid": "UNIONID"
}
async function refreshAccessToken(refreshToken) {
const url = 'https://api.weixin.qq.com/sns/oauth2/refresh_token';
const response = await axios.get(url, {
params: {
appid: process.env.WECHAT_APPID,
grant_type: 'refresh_token',
refresh_token: refreshToken
}
});
return response.data; // 返回新的access_token
}
- 在Token快过期前(比如剩余10分钟)主动刷新
- 不要每次请求都刷新,有频率限制
- Refresh Token也有过期时间,过期后需要用户重新授权
微信登录:开放平台 vs 公众平台
微信登录有两种接入方式,很多新手容易搞混。
开放平台(移动应用/网站)
如果你的应用是:
- iOS/Android 原生App
- 独立网站(非微信内打开)
那就需要接入 微信开放平台 (open.weixin.qq.com)。
- 注册开放平台账号 - 需要企业资质认证
- 创建移动应用/网站应用 - 填写应用信息,提交审核
- 审核通过后获取AppID和AppSecret - 通常需要3-7个工作日
- 集成SDK - 下载微信官方SDK,集成到你的应用
// Android集成微信登录
class WechatLoginHelper(private val context: Context) {
private val api: IWXAPI by lazy {
IWXAPI.create(context, WECHAT_APP_ID, true).also { it.registerApp() }
}
fun login() {
val req = SendAuth.Req()
req.scope = "snsapi_userinfo"
req.state = generateState()
api.sendReq(req)
}
// 在WXEntryActivity中接收回调
fun handleResponse(resp: SendAuth.Resp): LoginResult? {
if (resp.errCode == BaseResp.ErrCode.ERR_OK) {
// 拿到code,发给后端换token
return LoginResult(code = resp.code, state = resp.state)
}
return null
}
}
openid: 用户在此应用下的唯一标识unionid: 跨应用统一标识(需开放平台绑定)- 昵称、头像、性别、地区等基本信息
公众平台(微信内网页)
如果你的应用是:
- 微信公众号内的网页(H5)
- 微信小程序
那就使用 公众平台 的网页授权接口。
| 方式 | scope参数 | 获取信息 | 用户体验 |
|---|---|---|---|
| 静默授权 | snsapi_base |
只能获取openid | 无感知,直接跳转 |
| 授权弹窗 | snsapi_userinfo |
可获取昵称头像等 | 需要用户确认 |
// 微信公众号网页授权
// 第一步:跳转到授权页面
function wechatAuth() {
const appId = 'YOUR_APPID';
const redirectUri = encodeURIComponent('https://yourapp.com/wechat/callback');
const scope = 'snsapi_userinfo'; // 或 snsapi_base
const state = generateRandomState();
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?
appid=${appId}
&redirect_uri=${redirectUri}
&response_type=code
&scope=${scope}
&state=${state}
#wechat_redirect`;
window.location.href = authUrl;
}
// 第二步:后端处理回调
router.get('/wechat/callback', async (req, res) => {
const { code, state } = req.query;
// 验证state防止CSRF
if (!validateState(state)) {
return res.status(400).send('Invalid state');
}
// 用code换token
const tokenData = await exchangeCodeForToken(code);
// 获取用户信息
const userInfo = await getUserInfo(tokenData.access_token, tokenData.openid);
// 创建或登录用户
const user = await findOrCreateUser(userInfo);
// 设置session
req.session.userId = user.id;
res.redirect('/');
});
UnionID:打通多应用的唯一标识
用户张三:
├── iOS App openid: o6_bmjrPTlm6_2sgVt7hMZOPxxxx
├── 安卓 App openid: o6_bmjrPTlm6_2sgVt7hMZOPyyyy
├── 公众号 openid: o6_bmjrPTlm6_2sgVt7hMZOPzzzz
└── 小程序 openid: o6_bmjrPTlm6_2sgVt7hMZOPwwww
这四个openid完全不同!
微信提供了跨应用的唯一标识 —— unionid。只要你的多个应用都绑定在同一个开放平台账号下,同一用户的 unionid 就是一样的。
用户张三:
├── iOS App: openid=xxx, unionid=o6_bmjrPTlm6_2sgVt7hMZO (相同)
├── 安卓 App: openid=yyy, unionid=o6_bmjrPTlm6_2sgVt7hMZO (相同)
├── 公众号: openid=zzz, unionid=o6_bmjrPTlm6_2sgVt7hMZO (相同)
└── 小程序: openid=www, unionid=o6_bmjrPTlm6_2sgVt7hMZO (相同)
- 在开放平台创建应用,获取unionid权限
- 将公众号/小程序绑定到开放平台账号下
- 授权时,返回数据中会包含unionid字段
- 如果你只有一个应用,用 openid 就够了
- 如果你有多端(App + 公众号 + 小程序),必须用 unionid 作为用户的唯一标识
- 数据库设计:
CREATE TABLE user_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '平台内部用户ID',
platform VARCHAR(20) NOT NULL COMMENT 'wechat_app/wechat_mp/wechat_mini',
openid VARCHAR(64) NOT NULL COMMENT '应用级openid',
unionid VARCHAR(64) COMMENT '跨应用unionid',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_openid (platform, openid),
KEY idx_unionid (unionid)
);
苹果登录:App Store 的强制要求
如果你的应用在 App Store 上架,并且支持第三方登录,必须同时提供"Sign in with Apple"。这是苹果的硬性规定,不接就可能被拒。
App Store Review Guidelines 4.8:如果应用使用第三方登录服务,必须同时提供Sign in with Apple作为同等选项。
苹果登录的特殊之处
苹果允许用户 隐藏真实邮箱。用户可以选择:
- 使用真实邮箱:
zhangsan@icloud.com - 使用苹果生成的匿名邮箱:
abc123@privaterelay.appleid.com
你的应用收到的是苹果中继邮箱,用户真实邮箱对你不可见。邮件发到这个地址后,苹果会自动转发到用户的真实邮箱。
苹果登录按钮要和其他第三方登录按钮(微信、QQ)放在同等位置:
- 不能藏得很深
- 按钮设计要符合苹果的Human Interface Guidelines
- 按钮样式有官方规范(黑色/白色/带边框三种)
用户在设置中可以随时停止使用苹果登录:
设置 > Apple ID > 密码与安全性 > 使用Apple ID的App > 停止使用Apple ID
你的应用需要处理这种情况——下次用户尝试登录时,应该能识别出是老用户重新授权,而不是新用户。
苹果登录技术实现
苹果登录也基于OAuth 2.0,但有一些独特的细节:
1. 用户点击"Sign in with Apple"
2. 调用Apple ID Auth API(原生)或跳转Web授权页
3. 用户用Face ID/Touch ID/密码确认
4. 苹果返回identity token (JWT格式)
5. 后端验证JWT,提取用户信息
import AuthenticationServices
class AppleLoginManager: NSObject, ASAuthorizationControllerDelegate {
func startAppleLogin() {
let provider = ASAuthorizationAppleIDProvider()
let request = provider.createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.performRequests()
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
return
}
// 获取用户信息
let userIdentifier = credential.user // 唯一标识
let fullName = credential.fullName
let email = credential.email // 可能为nil(非首次登录)
// Identity Token (JWT)
if let identityToken = credential.identityToken,
let tokenString = String(data: identityToken, encoding: .utf8) {
// 发给后端验证
sendToBackend(identityToken: tokenString)
}
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
print("Apple login failed: \(error)")
}
}
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
// 苹果的JWKS端点
const client = jwksClient({
jwksUri: 'https://appleid.apple.com/auth/keys'
});
async function verifyAppleToken(identityToken) {
// 解码JWT获取header
const decoded = jwt.decode(identityToken, { complete: true });
const kid = decoded.header.kid;
// 获取苹果的公钥
const key = await client.getSigningKey(kid);
const publicKey = key.getPublicKey();
// 验证签名
const payload = jwt.verify(identityToken, publicKey, {
algorithms: ['RS256'],
issuer: 'https://appleid.apple.com',
audience: 'your.bundle.id' // 你的App Bundle ID
});
return {
sub: payload.sub, // 用户唯一标识
email: payload.email, // 邮箱(可能为中继邮箱)
email_verified: payload.email_verified
};
}
苹果登录的坑
// 首次登录
{
sub: "001234.abcd1234abcd1234abcd1234abcd1234.1234",
email: "abc123@privaterelay.appleid.com",
email_verified: "true",
// ...还有姓名等信息
}
// 后续登录 - 邮箱字段可能不存在!
{
sub: "001234.abcd1234abcd1234abcd1234abcd1234.1234",
// email字段不返回了
}
用户撤销后重新授权,sub不变,但如果之前选了隐藏邮箱,这次可能:
- 还是同一个中继邮箱
- 也可能生成新的中继邮箱(较少见)
Web端需要自己实现OAuth流程:
// Web端苹果登录
function appleAuth() {
const clientId = 'your.service.id'; // Services ID
const redirectUri = encodeURIComponent('https://yourapp.com/apple/callback');
const state = generateRandomState();
const authUrl = `https://appleid.apple.com/auth/authorize?
client_id=${clientId}
&redirect_uri=${redirectUri}
&response_type=code%20id_token
&scope=email%20name
&response_mode=form_post
&state=${state}`;
window.location.href = authUrl;
}
QQ登录:腾讯的另一种选择
QQ登录接入的是 腾讯开放平台 (QQ互联, connect.qq.com)。
接入流程
- 申请QQ互联开发者账号
- 创建应用,获取APP ID和APP Key
- 网站需要备案,应用需要审核
- 集成SDK或直接调用API
QQ登录代码示例
function qqLogin() {
const appId = 'YOUR_QQ_APPID';
const redirectUri = encodeURIComponent('https://yourapp.com/qq/callback');
const state = generateRandomState();
const authUrl = `https://graph.qq.com/oauth2.0/authorize?
response_type=code
&client_id=${appId}
&redirect_uri=${redirectUri}
&state=${state}
&scope=get_user_info`;
window.location.href = authUrl;
}
async function handleQQCallback(code) {
// Step 1: 用code换access_token
const tokenResponse = await axios.get('https://graph.qq.com/oauth2.0/token', {
params: {
grant_type: 'authorization_code',
client_id: QQ_APP_ID,
client_secret: QQ_APP_KEY,
code: code,
redirect_uri: QQ_REDIRECT_URI
}
});
// QQ返回的是query string格式
const params = new URLSearchParams(tokenResponse.data);
const accessToken = params.get('access_token');
// Step 2: 获取openid(QQ特有步骤)
const openidResponse = await axios.get('https://graph.qq.com/oauth2.0/me', {
params: { access_token: accessToken }
});
// 返回格式: callback( {"client_id":"...","openid":"..."} );
const jsonStr = openidResponse.data.match(/callback\(\s*(\{.*\})\s*\)/)[1];
const { openid } = JSON.parse(jsonStr);
// Step 3: 获取用户信息
const userInfo = await axios.get('https://graph.qq.com/user/get_user_info', {
params: {
access_token: accessToken,
oauth_consumer_key: QQ_APP_ID,
openid: openid
}
});
return {
openid,
nickname: userInfo.data.nickname,
avatar: userInfo.data.figureurl_qq_2 || userInfo.data.figureurl_qq_1
};
}
QQ登录的特点
| 特性 | 说明 |
|---|---|
| openid | 64位字符串,每个应用独立 |
| unionid | 需要申请,用于打通腾讯系应用 |
| QQ号 | 无法获取,不要尝试展示QQ号 |
| 用户信息 | 昵称、头像、性别、地区等 |
openid: "A2B4C6D8E0F2A4B6C8D0E2F4A6B8C0D2" // 这是openid
QQ号: 12345678 // 这个你永远拿不到
三大平台对比
| 特性 | 微信 | 苹果 | |
|---|---|---|---|
| 唯一标识 | openid/unionid | user identifier(sub) | openid |
| 跨应用标识 | unionid(需绑定开放平台) | 无 | unionid(需申请) |
| 邮箱 | 可获取(需授权) | 可能为中继邮箱 | 不提供 |
| 手机号 | 可获取(需特殊申请) | 不提供 | 不提供 |
| 审核要求 | 需要企业认证 | 需要Apple开发者账号 | 需要网站备案 |
| 强制要求 | 无 | App Store必须 | 无 |
| 用户群 | 中国用户为主 | iOS用户全球 | 年轻用户较多 |
统一抽象:屏蔽平台差异
不同平台的返回数据格式各异,需要统一抽象:
数据库设计
-- 用户主表
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(64),
avatar VARCHAR(512),
phone VARCHAR(20),
email VARCHAR(128),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 第三方账号绑定表
CREATE TABLE third_party_auths (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
platform ENUM('wechat_app', 'wechat_mp', 'wechat_mini', 'apple', 'qq', 'weibo') NOT NULL,
platform_user_id VARCHAR(128) NOT NULL COMMENT 'openid或sub',
union_id VARCHAR(128) COMMENT '跨应用标识',
access_token TEXT COMMENT '加密存储',
refresh_token TEXT COMMENT '加密存储',
token_expires_at TIMESTAMP,
extra_data JSON COMMENT '存储额外信息',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_user (platform, platform_user_id),
KEY idx_user_id (user_id),
KEY idx_union_id (union_id)
);
统一登录接口设计
// 统一登录服务
class ThirdPartyAuthService {
async login(platform, authData) {
let userInfo;
switch (platform) {
case 'wechat_app':
userInfo = await this.verifyWechatApp(authData.code);
break;
case 'wechat_mp':
userInfo = await this.verifyWechatMp(authData.code);
break;
case 'apple':
userInfo = await this.verifyApple(authData.identityToken);
break;
case 'qq':
userInfo = await this.verifyQQ(authData.code);
break;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
// 统一处理:查找或创建用户
return this.findOrCreateUser(platform, userInfo);
}
async findOrCreateUser(platform, userInfo) {
// 1. 先用unionId查找(如果有)
if (userInfo.unionId) {
const existing = await db.query(
'SELECT user_id FROM third_party_auths WHERE union_id = ?',
[userInfo.unionId]
);
if (existing) {
return this.getUserById(existing.user_id);
}
}
// 2. 用platform_user_id查找
const existing = await db.query(
'SELECT user_id FROM third_party_auths WHERE platform = ? AND platform_user_id = ?',
[platform, userInfo.platformUserId]
);
if (existing) {
return this.getUserById(existing.user_id);
}
// 3. 创建新用户
const userId = await this.createUser(userInfo);
// 4. 绑定第三方账号
await db.query(`
INSERT INTO third_party_auths
(user_id, platform, platform_user_id, union_id, extra_data)
VALUES (?, ?, ?, ?, ?)
`, [userId, platform, userInfo.platformUserId, userInfo.unionId, JSON.stringify(userInfo)]);
return this.getUserById(userId);
}
}
账号绑定与合并:当用户有多种登录方式
用户可能今天用微信登录,明天用苹果登录,后天又用QQ登录。怎么处理?
三种场景
用户 → 微信登录 → 检测openid未绑定 → 创建新用户 → 绑定openid
用户(已登录) → QQ登录 → 检测openid未绑定 → 提示"是否绑定到当前账号?"
用户微信登录 → 账号A
用户苹果登录 → 账号B
用户发现是两个账号 → 申请合并
账号合并策略
用户登录时,检测是否有信息重叠:
async function checkDuplicateAccount(currentUserId, newPlatformUserInfo) {
const duplicates = [];
// 1. 检测手机号重叠
if (newPlatformUserInfo.phone) {
const byPhone = await db.query(
'SELECT id FROM users WHERE phone = ? AND id != ?',
[newPlatformUserInfo.phone, currentUserId]
);
if (byPhone) duplicates.push({ type: 'phone', user: byPhone });
}
// 2. 检测邮箱重叠
if (newPlatformUserInfo.email) {
const byEmail = await db.query(
'SELECT id FROM users WHERE email = ? AND id != ?',
[newPlatformUserInfo.email, currentUserId]
);
if (byEmail) duplicates.push({ type: 'email', user: byEmail });
}
// 3. 检测unionId重叠
if (newPlatformUserInfo.unionId) {
const byUnionId = await db.query(`
SELECT user_id FROM third_party_auths
WHERE union_id = ? AND user_id != ?
`, [newPlatformUserInfo.unionId, currentUserId]);
if (byUnionId) duplicates.push({ type: 'unionid', user: byUnionId });
}
return duplicates;
}
在个人中心提供绑定功能:
// 绑定新的第三方账号
async function bindThirdParty(userId, platform, authData) {
// 1. 验证第三方账号
const userInfo = await this.verifyPlatform(platform, authData);
// 2. 检查是否已被其他账号绑定
const existing = await db.query(`
SELECT user_id FROM third_party_auths
WHERE platform = ? AND platform_user_id = ?
`, [platform, userInfo.platformUserId]);
if (existing && existing.user_id !== userId) {
throw new Error('该账号已被其他用户绑定');
}
// 3. 创建绑定
await db.query(`
INSERT INTO third_party_auths
(user_id, platform, platform_user_id, union_id)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at = NOW()
`, [userId, platform, userInfo.platformUserId, userInfo.unionId]);
}
即使用户自己没发现,客服后台也应该有合并能力:
-- 合并账号存储过程(简化版)
DELIMITER //
CREATE PROCEDURE merge_users(
IN primary_user_id BIGINT,
IN secondary_user_id BIGINT
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
-- 1. 迁移第三方账号绑定
UPDATE third_party_auths
SET user_id = primary_user_id
WHERE user_id = secondary_user_id;
-- 2. 迁移其他业务数据(订单、收藏等)
-- ...根据实际业务添加
-- 3. 删除从账号
DELETE FROM users WHERE id = secondary_user_id;
COMMIT;
END //
DELIMITER ;
安全要点:这些坑千万别踩
第三方登录虽然方便,但安全疏忽可能导致严重后果。
1. State参数防CSRF
- 攻击者构造恶意链接:
https://yourapp.com/callback?code=ATTACKER_CODE&state=fake - 诱导已登录用户点击
- 用户的账号被绑定到攻击者的第三方账号
// 登录前生成state
function generateState() {
const state = crypto.randomBytes(16).toString('hex');
// 存入session
req.session.oauthState = state;
return state;
}
// 回调时验证
function validateState(state) {
const savedState = req.session.oauthState;
delete req.session.oauthState; // 用完即焚
if (!savedState || savedState !== state) {
throw new Error('Invalid state parameter');
}
return true;
}
2. Access Token不要存前端
// ❌ 把token返回给前端
res.json({
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token
});
// ✅ Token只在后端使用,前端只拿session
res.json({
userId: user.id,
nickname: user.nickname,
avatar: user.avatar
});
// Token存入数据库或Redis,与session关联
3. Token安全存储
即使存在后端,也要加密:
const crypto = require('crypto');
const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY; // 32字节密钥
const IV_LENGTH = 16;
function encryptToken(token) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let encrypted = cipher.update(token);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
function decryptToken(encryptedToken) {
const [ivHex, encryptedHex] = encryptedToken.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encryptedHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
4. 敏感操作二次验证
用户用第三方登录后,执行敏感操作需要二次验证:
async function performSensitiveAction(userId, action, verificationCode) {
// 1. 检查是否需要二次验证
const loginMethod = await getUserLoginMethod(userId);
if (loginMethod.isThirdParty) {
// 2. 验证短信/邮箱验证码
const isValid = await verifyCode(userId, verificationCode);
if (!isValid) {
throw new Error('验证码错误');
}
}
// 3. 执行敏感操作
return executeAction(action);
}
5. 定期清理过期Token
// 定时任务:清理过期的token
async function cleanupExpiredTokens() {
const result = await db.query(`
DELETE FROM third_party_auths
WHERE token_expires_at < NOW() - INTERVAL 30 DAY
`);
console.log(`Cleaned up ${result.affectedRows} expired tokens`);
}
// 每天凌晨3点执行
cron.schedule('0 3 * * *', cleanupExpiredTokens);
6. 监控异常登录
async function detectAbnormalLogin(userId, platform, ip) {
// 1. 检查IP地理位置突变
const lastLogin = await getLastLogin(userId);
if (lastLogin) {
const distance = calculateDistance(lastLogin.ip, ip);
const timeDiff = Date.now() - lastLogin.timestamp;
// 如果短时间内跨越超大距离(如1小时内从北京到纽约)
if (distance > 5000 && timeDiff < 3600000) {
await sendSecurityAlert(userId, '异地登录异常', { lastLogin, currentIp: ip });
// 可选:强制登出,要求重新验证
}
}
// 2. 检查登录频率异常
const recentLogins = await getRecentLogins(userId, 24 * 60 * 60 * 1000);
if (recentLogins.length > 50) {
await sendSecurityAlert(userId, '登录频率异常', { count: recentLogins.length });
}
// 3. 记录本次登录
await recordLogin(userId, platform, ip, Date.now());
}
7. HTTPS是底线
所有OAuth流程必须通过HTTPS进行:
- 授权URL必须是HTTPS
- 回调URL必须是HTTPS
- Token请求必须是HTTPS
- 资源请求必须是HTTPS
苹果和微信等平台强制要求回调URL必须是HTTPS,本地开发时需要使用ngrok等工具:
# 开发环境使用ngrok
ngrok http 3000
# 会生成类似 https://abc123.ngrok.io 的HTTPS地址
常见问题FAQ
Q1: 用户换手机/微信号后,还能登录吗?
- 换手机但微信/QQ号没变: openid/unionid不变,正常登录
- 换了新的微信号: 会生成新的openid,需要重新绑定账号
- 建议: 引导用户绑定手机号/邮箱作为备用登录方式
Q2: 用户授权后又取消授权怎么办?
- 微信: 用户可以在"设置-隐私-授权管理"中取消
- 苹果: 用户可以在设置中停止使用
- QQ: 用户可以在QQ设置中取消
- 取消后,下次登录会重新弹出授权页面
- 用unionid或手机号关联老用户,不会创建新账号
Q3: 如何处理Token过期?
async function handleExpiredToken(userId, platform) {
const auth = await getThirdPartyAuth(userId, platform);
if (auth.refreshToken) {
// 有refresh token,尝试刷新
try {
const newToken = await refreshAccessToken(platform, auth.refreshToken);
await updateToken(userId, platform, newToken);
return newToken;
} catch (e) {
// refresh token也过期了,需要重新授权
throw new Error('TOKEN_EXPIRED');
}
} else {
// 没有refresh token,需要重新授权
throw new Error('TOKEN_EXPIRED');
}
}
Q4: 第三方平台API改了怎么办?
- 抽象隔离: 把第三方API调用封装在独立模块,修改时只改一处
- 版本管理: 关注平台更新公告,提前测试新版本API
- 降级方案: 如果第三方登录不可用,提供账号密码登录作为备选
// 抽象工厂模式
class ThirdPartyLoginFactory {
static create(platform) {
switch (platform) {
case 'wechat_app': return new WechatAppLogin();
case 'wechat_mp': return new WechatMpLogin();
case 'apple': return new AppleLogin();
case 'qq': return new QQLogin();
default: throw new Error(`Unknown platform: ${platform}`);
}
}
}
// 统一接口
interface ThirdPartyLogin {
getAuthUrl(state: string): string;
exchangeCodeForToken(code: string): Promise<TokenData>;
getUserInfo(token: string): Promise<UserInfo>;
refreshToken(refreshToken: string): Promise<TokenData>;
}
Q5: 一个用户最多绑定多少个第三方账号?
- 前端限制显示数量(如最多5个),避免界面混乱
- 后端不设硬性限制,保持灵活性
- 同一平台只能绑定一个(如只能绑定一个微信号)
// 检查绑定限制
async function canBindPlatform(userId, platform) {
// 同一平台只能绑定一个
const existing = await db.query(`
SELECT id FROM third_party_auths
WHERE user_id = ? AND platform = ?
`, [userId, platform]);
if (existing) {
throw new Error('该平台已绑定,请先解绑');
}
return true;
}
总结
第三方登录看似简单,实则涉及协议理解、平台差异、账号体系设计等多个层面。让我们回顾核心要点:
技术层面
- 理解OAuth 2.0授权码流程 - 知道每一步在做什么,为什么这样设计
- Token生命周期管理 - 过期刷新、安全存储、定期清理
- 统一抽象接口 - 屏蔽平台差异,方便扩展新平台
平台差异
| 平台 | 必须掌握的坑 |
|---|---|
| 微信 | 开放平台vs公众平台,unionid打通多端 |
| 苹果 | 强制要求,隐藏邮箱,首次登录才返回邮箱 |
| openid不是QQ号,需要额外请求获取openid |
安全层面
- State参数防CSRF - 每次授权都生成随机state
- Token不存前端 - 只在后端使用,前端只拿session
- 敏感操作二次验证 - 修改密码/绑定手机要短信验证
- 监控异常登录 - IP突变、频率异常要及时告警
产品层面
- 账号绑定入口 - 让用户可以主动绑定/解绑
- 账号合并能力 - 后台要有合并重复账号的能力
- 降级方案 - 第三方登录不可用时,有备选方案
接入第三方登录,本质是用用户体验换系统复杂度。设计好了,用户爽,你也省心;设计不好,账号混乱,用户投诉不断。
💬 评论 (0)