第三方登录:微信/苹果/QQ怎么接?

当用户嫌注册太麻烦时,给他们一个"一键登录"的选项,转化率能翻倍。但这一键背后,藏着不少门道。


为什么要接第三方登录?

先说个真实场景:

用户下载了你的游戏,兴致勃勃打开,结果看到注册页面——要填邮箱、设密码、确认密码……手指悬在键盘上方,犹豫了三秒,然后关掉应用。

数据不会骗人:根据LoginRadius的调研报告,77%的用户认为"必须注册"是放弃使用应用的主要原因之一。而提供社交登录后,注册转化率平均能提升20-40%

第三方登录解决的正是这个问题:让用户用已有的账号(微信、QQ、苹果ID等)直接登录你的平台,省去注册流程,降低门槛。

但接入第三方登录不是简单调几个API就完事的。你需要理解背后的授权机制、各平台的差异、以及如何把多种登录方式统一管理起来。


OAuth 2.0:第三方登录的"通行证系统"

几乎所有第三方登录都基于 OAuth 2.0 协议。听起来很技术,但原理其实很好理解。

想象一个酒店场景

你入住酒店,朋友想借你的房卡去健身房。你有两个选择:

  1. 把房卡直接给朋友 —— 风险太大,他能进你房间
  2. 去前台办一张临时健身卡 —— 只能进健身房,不能进房间

OAuth 2.0 就是第二种方案。用户不需要把微信密码告诉你,而是让微信发一张"临时健身卡"(Access Token),你只能用这张卡获取用户的基本信息,做不了其他操作。

OAuth 2.0 的四种角色

OAuth 2.0 定义了四种角色,理解它们是掌握协议的关键:

角色 说明 例子
Resource Owner 资源所有者,即用户 拥有微信账号的你
Client 客户端,需要获取资源的应用 你的游戏/网站
Authorization Server 授权服务器 微信的登录系统
Resource Server 资源服务器 微信的用户信息API

四种授权模式

OAuth 2.0 定义了四种授权模式,适用于不同场景:

授权码模式的完整流程

┌──────────┐                                      ┌──────────────────┐
│          │                                      │                  │
│   用户   │                                      │  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;

用户看到授权页面,显示"XX应用想要获取你的基本信息"。这一步完全在微信的界面上完成,你的平台接触不到用户的密码,也看不到授权过程。

用户点击"同意"后,微信会记录这次授权,并生成一个临时授权码。

微信把用户浏览器重定向回你指定的redirect_uri,并附上授权码:

https://yourapp.com/callback?code=061abc123&state=xyz789

这一步必须在服务器端完成,用户完全看不到:

// 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永远只在你的后台和微信服务器之间传递,经过以下保护:

  1. 前端永远接触不到Access Token - 即使XSS攻击也无法窃取
  2. 应用密钥(Secret)不会暴露 - 换Token需要密钥,只能在后端完成
  3. 授权码一次性且短命 - 即使被截获,也很难在有效期内利用
  4. 全程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
}

微信登录:开放平台 vs 公众平台

微信登录有两种接入方式,很多新手容易搞混。

开放平台(移动应用/网站)

如果你的应用是:

那就需要接入 微信开放平台 (open.weixin.qq.com)。

  1. 注册开放平台账号 - 需要企业资质认证
  2. 创建移动应用/网站应用 - 填写应用信息,提交审核
  3. 审核通过后获取AppID和AppSecret - 通常需要3-7个工作日
  4. 集成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
    }
}

公众平台(微信内网页)

如果你的应用是:

那就使用 公众平台 的网页授权接口。

方式 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 (相同)
  1. 在开放平台创建应用,获取unionid权限
  2. 将公众号/小程序绑定到开放平台账号下
  3. 授权时,返回数据中会包含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作为同等选项。

苹果登录的特殊之处

苹果允许用户 隐藏真实邮箱。用户可以选择:

你的应用收到的是苹果中继邮箱,用户真实邮箱对你不可见。邮件发到这个地址后,苹果会自动转发到用户的真实邮箱。

苹果登录按钮要和其他第三方登录按钮(微信、QQ)放在同等位置:

用户在设置中可以随时停止使用苹果登录:

设置 > 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)。

接入流程

  1. 申请QQ互联开发者账号
  2. 创建应用,获取APP ID和APP Key
  3. 网站需要备案,应用需要审核
  4. 集成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  // 这个你永远拿不到

三大平台对比

特性 微信 苹果 QQ
唯一标识 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

  1. 攻击者构造恶意链接: https://yourapp.com/callback?code=ATTACKER_CODE&state=fake
  2. 诱导已登录用户点击
  3. 用户的账号被绑定到攻击者的第三方账号
// 登录前生成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,本地开发时需要使用ngrok等工具:

# 开发环境使用ngrok
ngrok http 3000
# 会生成类似 https://abc123.ngrok.io 的HTTPS地址

常见问题FAQ

Q1: 用户换手机/微信号后,还能登录吗?

Q2: 用户授权后又取消授权怎么办?

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改了怎么办?

  1. 抽象隔离: 把第三方API调用封装在独立模块,修改时只改一处
  2. 版本管理: 关注平台更新公告,提前测试新版本API
  3. 降级方案: 如果第三方登录不可用,提供账号密码登录作为备选
// 抽象工厂模式
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: 一个用户最多绑定多少个第三方账号?

// 检查绑定限制
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;
}

总结

第三方登录看似简单,实则涉及协议理解、平台差异、账号体系设计等多个层面。让我们回顾核心要点:

技术层面

  1. 理解OAuth 2.0授权码流程 - 知道每一步在做什么,为什么这样设计
  2. Token生命周期管理 - 过期刷新、安全存储、定期清理
  3. 统一抽象接口 - 屏蔽平台差异,方便扩展新平台

平台差异

平台 必须掌握的坑
微信 开放平台vs公众平台,unionid打通多端
苹果 强制要求,隐藏邮箱,首次登录才返回邮箱
QQ openid不是QQ号,需要额外请求获取openid

安全层面

  1. State参数防CSRF - 每次授权都生成随机state
  2. Token不存前端 - 只在后端使用,前端只拿session
  3. 敏感操作二次验证 - 修改密码/绑定手机要短信验证
  4. 监控异常登录 - IP突变、频率异常要及时告警

产品层面

  1. 账号绑定入口 - 让用户可以主动绑定/解绑
  2. 账号合并能力 - 后台要有合并重复账号的能力
  3. 降级方案 - 第三方登录不可用时,有备选方案

接入第三方登录,本质是用用户体验换系统复杂度。设计好了,用户爽,你也省心;设计不好,账号混乱,用户投诉不断。


延伸阅读


💬 评论 (0)

0/500
排序: