跳到主要内容

安全最佳实践

本文档介绍 IM 开放平台对接过程中的安全建议和最佳实践。

Client Secret 保护​

核心原则​

重要安全警告

Client Secret 仅在服务端使用,严禁暴露在前端代码中!

  • ❌ 禁止在前端 JavaScript/TypeScript 代码中使用 Client Secret
  • ❌ 禁止在移动端 App 中硬编码 Client Secret
  • ❌ 禁止将 Client Secret 提交到代码仓库
  • ✅ 只能在后端服务中使用 Client Secret 进行签名计算

为什么不能在前端使用?​

前端代码(包括 JavaScript、移动端 App)可以被用户查看和反编译,如果 Client Secret 暴露:

  1. 身份冒充 - 攻击者可以伪装成你的应用调用 IM API
  2. 数据泄露 - 攻击者可以获取你应用的所有用户数据
  3. 恶意操作 - 攻击者可以以你应用的名义发送消息

正确的架构设计​

┌─────────────────────────────────────────────────────────────────┐
│ 正确的安全架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 前端/移动端 后端服务 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 用户界面 │ │ 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 泄露:

  1. 立即重置 - 在开发者中心重置 Client Secret
  2. 更新配置 - 更新所有使用该凭证的服务配置
  3. 检查日志 - 检查是否有异常 API 调用
  4. 审计影响 - 审计日志,确认影响范围
  5. 通知用户 - 如有必要,通知受影响的用户

签名机制安全​

签名算法​

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 API1 年确认安装时返回重新安装应用
Login TicketSSO 免登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 过期提前预警