安全最佳实践
本文档介绍 IM 开放平台对接过程中的安全建议和最佳实践。
Client Secret 保护
核心原则
重要安全警告
Client Secret 仅在服务端使用,严禁暴露在前端代码中!
- ❌ 禁止在前端 JavaScript/TypeScript 代码中使用 Client Secret
- ❌ 禁止在移动端 App 中硬编码 Client Secret
- ❌ 禁止将 Client Secret 提交到代码仓库
- ✅ 只能在后端服务中使用 Client Secret 进行签名计算
为什么不能在前端使用?
前端代码(包括 JavaScript、移动端 App)可以被用户查看和反编译,如果 Client Secret 暴露:
- 身份冒充 - 攻击者可以伪装成你的应用调用 IM API
- 数据泄露 - 攻击者可以获取你应用的所有用户数据
- 恶意操作 - 攻击者可以以你应用的名义发送消息
正确的架构设计
┌─────────────────────────────────────────────────────────────────┐
│ 正确的安全架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 前端/移动端 后端服务 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 用户界面 │ │ API 服务 │ │
│ │ │ ──────> │ │ │
│ │ ❌ 无 Secret │ HTTP请求 │ ✅ 存储 Secret │ │
│ │ ❌ 无签名计算 │ │ ✅ 签名计算 │ │
│ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ │ 带签名的请求 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ IM 开放平台 │ │
│ │ 验证签名 │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
存储安全
# ❌ 错误:明文存储在代码中
im:
client-secret: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
# ✅ 正确:使用环境变量
im:
client-secret: ${IM_CLIENT_SECRET}
安全建议
| 建议 | 说明 |
|---|---|
| 不要硬编码 | 不要将 Client Secret 写在代码中 |
| 使用环境变量 | 通过环境变量或配置中心(如 Nacos)管理 |
| 限制访问权限 | 只有必要的服务和人员可以访问 |
| 定期轮换 | 建议定期更换 Client Secret |
| 监控异常 | 监控 API 调用异常,及时发现泄露 |
泄露处理
如果 Client Secret 泄露:
- 立即重置 - 在开发者中心重置 Client Secret
- 更新配置 - 更新所有使用该凭证的服务配置
- 检查日志 - 检查是否有异常 API 调用
- 审计影响 - 审计日志,确认影响范围
- 通知用户 - 如有必要,通知受影响的用户
签名机制安全
签名算法
IM 开放平台使用 SHA256 签名机制:
sign = SHA256(clientId + timestamp + clientSecret)
安全特性
签名机制提供以下安全保障:
| 特性 | 说明 | 安全价值 |
|---|---|---|
| 不传输密钥 | Client Secret 仅在服务端参与签名计算,不通过网络传输 | 防止密钥泄露 |
| 时间戳防重放 | 签名有效期 10 分钟,过期签名无效 | 防止重放攻击 |
| 时钟偏差容忍 | 允许 1 分钟的时钟偏差 | 兼容网络延迟 |
| 单向哈希 | SHA256 不可逆,无法从签名反推密钥 | 保护密钥安全 |
签名验证流程
签名验证代码示例
// IM 后端验证逻辑
public void verifySign(String clientId, Long timestamp, String sign) {
// 1. 验证 Client ID 是否存在
ImApp app = appMapper.selectByClientId(clientId);
if (app == null) {
throw new ServiceException("无效的 Client ID");
}
// 2. 验证时间戳是否在有效期内(10分钟,允许1分钟时钟偏差)
long currentTimestamp = System.currentTimeMillis() / 1000;
long timeDiff = currentTimestamp - timestamp;
if (timeDiff > 600 || timeDiff < -60) {
throw new ServiceException("签名已过期");
}
// 3. 重新计算签名进行验证
String calculatedSign = SecureUtil.sha256(
clientId + timestamp + app.getClientSecret()
);
if (!calculatedSign.equals(sign)) {
throw new ServiceException("签名验证失败");
}
}
签名生成注意事项
// ✅ 正确:使用秒级时间戳(10位数字)
Long timestamp = System.currentTimeMillis() / 1000;
// 示例:1704067200
// ❌ 错误:使用毫秒级时间戳(13位数字)
Long timestamp = System.currentTimeMillis();
// 示例:1704067200000
Token 管理
Token 类型对比
| Token 类型 | 用途 | 有效期 | 获取方式 | 刷新方式 |
|---|---|---|---|---|
| Bot Token | 调用 Bot API | 1 年 | 确认安装时返回 | 重新安装应用 |
| Login Ticket | SSO 免登 | 5 分钟 | 用户点击应用时生成 | 一次性使用,不可刷新 |
| User Access Token | 用户授权 | 根据配置 | OAuth 授权流程 | 使用 Refresh Token |
Bot Token 管理
有效期说明
- 默认有效期: 1 年(31536000 秒)
- 过期处理: Token 过期后需要重新安装应用获取新 Token
- 提前预警: 建议在 Token 过期前 30 天提醒管理员
缓存策略
@Service
public class BotTokenService {
@Autowired
private WorkspaceBindingMapper bindingMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String TOKEN_CACHE_PREFIX = "im:bot_token:";
/**
* 获取 Bot Token(带缓存)
*/
public String getBotToken(Long workspaceId) {
String cacheKey = TOKEN_CACHE_PREFIX + workspaceId;
// 1. 先从 Redis 缓存获取
String cachedToken = redisTemplate.opsForValue().get(cacheKey);
if (cachedToken != null) {
return cachedToken;
}
// 2. 缓存未命中,从数据库获取
WorkspaceBinding binding = bindingMapper.selectByWorkspaceId(workspaceId);
if (binding == null || binding.getBotToken() == null) {
throw new ServiceException("工作空间未绑定或 Token 不存在");
}
// 3. 检查 Token 是否过期
if (binding.getBotTokenExpiresAt() != null
&& binding.getBotTokenExpiresAt().isBefore(LocalDateTime.now())) {
throw new ServiceException("Bot Token 已过期,请重新安装应用");
}
// 4. 写入缓存(缓存时间略短于实际过期时间)
long ttlSeconds = calculateTtl(binding.getBotTokenExpiresAt());
redisTemplate.opsForValue().set(cacheKey, binding.getBotToken(),
Duration.ofSeconds(Math.min(ttlSeconds, 86400))); // 最多缓存1天
return binding.getBotToken();
}
/**
* 清除 Token 缓存(应用卸载时调用)
*/
public void clearTokenCache(Long workspaceId) {
String cacheKey = TOKEN_CACHE_PREFIX + workspaceId;
redisTemplate.delete(cacheKey);
}
private long calculateTtl(LocalDateTime expiresAt) {
if (expiresAt == null) {
return 86400; // 默认1天
}
return Duration.between(LocalDateTime.now(), expiresAt).getSeconds();
}
}
Token 刷新机制
Bot Token 目前不支持自动刷新,需要重新安装应用获取新 Token:
/**
* Token 过期处理
*/
public void handleTokenExpired(Long workspaceId) {
// 1. 清除缓存
clearTokenCache(workspaceId);
// 2. 更新数据库状态
bindingMapper.updateBindStatus(workspaceId, BindStatus.TOKEN_EXPIRED);
// 3. 通知管理员
notificationService.notifyAdmin(
"IM 应用 Token 已过期",
"工作空间 " + workspaceId + " 的 Bot Token 已过期,请重新安装应用"
);
}
Token 安全存储
// 数据库存储示例
@Entity
@Table(name = "workspace_bindings")
public class WorkspaceBinding implements Serializable {
@Column(name = "bot_token", length = 1024)
private String botToken; // 建议加密存储
@Column(name = "bot_token_expires_at")
private LocalDateTime botTokenExpiresAt;
@Column(name = "bind_status")
private Integer bindStatus; // 1-正常, 2-Token过期, 0-已解绑
}
Token 使用建议
| 建议 | 说明 |
|---|---|
| 安全传输 | 始终使用 HTTPS 传输 Token |
| 最小权限 | 只申请必要的权限范围 |
| 及时清理 | 应用卸载时清理相关 Token 和缓存 |
| 异常监控 | 监控 Token 使用异常,如频繁 401 错误 |
| 过期预警 | 在 Token 过期前提前预警 |
回调安全
回调地址配置
建议将回调地址配置为后端接口:
# ✅ 推荐:后端接口
http://your-app.com/api/im/install/callback
# ❌ 不推荐:前端页面
http://your-app.com/im/install
回调参数验证
@GetMapping("/api/im/install/callback")
public String handleCallback(
@RequestParam("workspace_id") Long workspaceId,
@RequestParam("timestamp") Long timestamp,
@RequestParam("sign") String sign,
HttpSession session
) {
// 1. 验证必要参数
if (workspaceId == null || timestamp == null || sign == null) {
throw new IllegalArgumentException("缺少必要参数");
}
// 2. 验证时间戳(可选,增强安全性)
long currentTimestamp = System.currentTimeMillis() / 1000;
if (Math.abs(currentTimestamp - timestamp) > 600) {
throw new IllegalArgumentException("请求已过期");
}
// 3. 保存到 Session,后续确认安装时使用
session.setAttribute("install_timestamp", timestamp);
session.setAttribute("install_sign", sign);
return "redirect:/im/install-confirm";
}
数据安全
敏感数据处理
// 用户手机号脱敏
public class TicketVerifyResponse {
private String imUserPhone; // 脱敏:138****1234
private String imUserPhoneFull; // 完整:13812341234(按需返回)
}
日志安全
// ❌ 错误:记录敏感信息
log.info("签名验证: clientSecret={}", clientSecret);
log.info("Token: {}", botToken);
// ✅ 正确:不记录敏感信息
log.info("签名验证: clientId={}, timestamp={}", clientId, timestamp);
log.info("Token 验证成功: workspaceId={}", workspaceId);
常见安全问题
1. 签名验证失败
可能原因:
- Client Secret 不正确
- 时间戳格式错误(应为秒级)
- 签名计算方式错误
- 字符串拼接顺序错误
排查方法:
// 打印签名内容(仅调试环境)
String signContent = clientId + timestamp + clientSecret;
log.debug("签名内容: {}", signContent);
log.debug("计算签名: {}", SecureUtil.sha256(signContent));
2. 时间戳过期
可能原因:
- 服务器时间不同步
- 网络延迟过大
- 签名生成后未及时使用
解决方案:
# 同步服务器时间
ntpdate pool.ntp.org
# 或使用 chrony
systemctl start chronyd
3. Token 无效
可能原因:
- Token 已过期
- Token 被重置
- 应用已卸载
处理方式:
try {
imApiClient.sendMessage(botToken, request);
} catch (UnauthorizedException e) {
// Token 无效,需要重新安装应用
log.warn("Bot Token 无效,需要重新安装应用");
handleTokenExpired(workspaceId);
}
安全检查清单
开发阶段
- Client Secret 不在代码中硬编码
- Client Secret 不在前端代码中使用
- 使用环境变量管理敏感配置
- 签名计算使用秒级时间戳
- 回调地址配置为后端接口
- 日志不记录敏感信息
部署阶段
- 使用 HTTPS 传输
- 配置防火墙规则
- 服务器时间同步
- 配置访问日志
- Token 加密存储
运维阶段
- 监控 API 调用异常
- 定期审计访问日志
- 定期轮换 Client Secret
- 及时处理安全告警
- Token 过期提前预警