概述
交友 App 要收发消息,得先登录环信 SDK,而登录环信需要账号密码,这一部分讲的就是服务端把这个凭证「下发」给 App 的那个接口:GET /huanxin/user。
纲要
- 业务场景:App 端环信 SDK 登录的凭证从哪来
- 双 token 体系:项目 JWT 与环信凭证各管各的
- 凭证下发在登录流程中的位置(时序图)
- 接口设计
- 请求/响应定义(表格 + JSON 示例)
- 密码直返 vs 服务端换 token:两种方案的取舍
- 完整代码链路
- HuanXinUserVo → HuanXinController → HuanXinService → UserApi
- 两种取凭证方式的对比(拼规则 vs 查库)
- 安全分析(这里重点)
- 全员共用 INIT_PASSWORD 的风险面
- 鉴权兜底:userId 必须从 token 取
- 生产改进方向:环信用户级 token
- 避坑清单与验证步骤
业务场景:App 凭什么登录环信
项目的 IM 能力整体托给了环信。客户端集成环信 SDK 后,要建立长连接、收推送消息,第一步就是调 SDK 的登录方法,而环信的登录需要用户名和密码。
问题在于:环信的账号体系是交友服务端自己规划的(环信 ID = "hx" + userId,初始密码统一为 123456),App 端不应该硬编码这套规则,也不应该自己猜密码。所以流程是:
- 用户在 App 上完成项目登录,拿到项目的 JWT token;
- App 拿着 JWT 调服务端的
/huanxin/user接口; - 服务端校验 JWT,查出当前用户的环信账号密码,返回;
- App 用这组凭证登录环信 SDK,长连接建立,开始接收消息。
这就是所谓的双 token 体系:项目 JWT 负责「你是项目的哪个用户」,环信凭证负责「你以哪个环信用户身份收发消息」。两套凭证各走各的通道,互不替代。
(环信账号是怎么注册出来的,属于上一节「将新用户注册到环信」,这里不重复展开。)
用 Mermaid 把下发凭证的时序画出来:
环信服务器 用户库(经Dubbo) mf-app-server 交友 App 环信服务器 用户库(经Dubbo) mf-app-server 交友 App #mermaid-svg-Wl6rKGSgjY6nwvaE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Wl6rKGSgjY6nwvaE .error-icon{fill:#552222;}#mermaid-svg-Wl6rKGSgjY6nwvaE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Wl6rKGSgjY6nwvaE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Wl6rKGSgjY6nwvaE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Wl6rKGSgjY6nwvaE .marker.cross{stroke:#333333;}#mermaid-svg-Wl6rKGSgjY6nwvaE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Wl6rKGSgjY6nwvaE p{margin:0;}#mermaid-svg-Wl6rKGSgjY6nwvaE .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Wl6rKGSgjY6nwvaE text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Wl6rKGSgjY6nwvaE .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Wl6rKGSgjY6nwvaE .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Wl6rKGSgjY6nwvaE #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Wl6rKGSgjY6nwvaE .sequenceNumber{fill:white;}#mermaid-svg-Wl6rKGSgjY6nwvaE #sequencenumber{fill:#333;}#mermaid-svg-Wl6rKGSgjY6nwvaE #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Wl6rKGSgjY6nwvaE .messageText{fill:#333;stroke:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Wl6rKGSgjY6nwvaE .labelText,#mermaid-svg-Wl6rKGSgjY6nwvaE .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .loopText,#mermaid-svg-Wl6rKGSgjY6nwvaE .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Wl6rKGSgjY6nwvaE .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Wl6rKGSgjY6nwvaE .noteText,#mermaid-svg-Wl6rKGSgjY6nwvaE .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Wl6rKGSgjY6nwvaE .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Wl6rKGSgjY6nwvaE .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Wl6rKGSgjY6nwvaE .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Wl6rKGSgjY6nwvaE .actorPopupMenu{position:absolute;}#mermaid-svg-Wl6rKGSgjY6nwvaE .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Wl6rKGSgjY6nwvaE .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Wl6rKGSgjY6nwvaE .actor-man circle,#mermaid-svg-Wl6rKGSgjY6nwvaE line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Wl6rKGSgjY6nwvaE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 手机号+验证码登录 JWT token GET /huanxin/user (Authorization: JWT) TokenInterceptor 校验 JWT userApi.findById(userId) User(hxUser, hxPassword) HuanXinUserVo(username, password) 环信 SDK 登录(hx1001/123456) 连接成功,长连接建立 后续推送即时消息
关键点:/huanxin/user 这一步发生在项目登录之后 、环信 SDK 登录之前。App 端的逻辑通常是「项目登录成功 → 自动调这个接口 → 自动登环信」,用户全程无感知。
接口设计
接口定义来自示例项目提供的接口文档,工程里的实现与之一致(下文代码即实读结果):
| 项目 | 内容 |
|---|---|
| 请求路径 | GET /huanxin/user |
| 请求方式 | GET |
| 请求参数 | 无(无路径参数、无 query 参数) |
| 鉴权方式 | 请求头 Authorization 携带项目 JWT,由拦截器统一校验 |
| 响应数据 | 环信用户名 + 环信密码,封装为 HuanXinUserVo |
响应示例:
json
{
"username": "hx1001",
"password": "123456"
}
不合法的 JWT 直接被拦截器挡下,客户端拿到 HTTP 401,响应体为空,凭证根本不会出现在响应里,这是安全底线(后文细说)。
方案对比:密码直返 vs 换取环信 token
示例实现走的是「密码直返」。但环信官方其实支持服务端用管理员身份换取用户级 token 再下发,两种方案摆在一起看:
| 维度 | 密码直返(示例实现) | 服务端换 token 下发 |
|---|---|---|
| 实现复杂度 | 低,纯本地拼装/查库,不依赖环信 API | 高,服务端要先调环信 REST API 换 token,还要处理 token 过期续期 |
| 客户端接入 | 环信 SDK 用账号密码登录,简单直接 | 环信 SDK 用 token 登录 |
| 泄露后果 | 密码是永久凭证,泄露即长期有效 | token 有有效期,泄露窗口有限 |
| 凭证模式隐蔽性 | 差,「hx+id / 123456」 的规律一旦被识破,全部用户沦陷 | 好,token 与单个用户绑定,无全局规律 |
| 适用场景 | 示例项目、演示环境 | 生产环境 |
取舍很清晰:密码直返等于把一组永久弱凭证 交到客户端手里,token 方案把凭证变成短期、用户级的,安全性高一个量级,代价是实现复杂度。示例项目选前者图省事,生产环境没有理由不选后者。
完整代码链路
改动集中在 mf-app-server 和 mf-model 两个模块,共四个类:
tree
mf
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── domain/User.java # hxUser / hxPassword 字段(注册环节写入)
│ └── vo/HuanXinUserVo.java # 本次新增:凭证下发 VO
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller/HuanXinController.java # 本次新增:GET /huanxin/user
│ ├── service/HuanXinService.java # 本次新增:查询凭证逻辑
│ └── interceptor
│ ├── TokenInterceptor.java # 已有:JWT 校验 + ThreadLocal 存用户
│ ├── UserHolder.java # 已有:ThreadLocal 工具
│ └── WebConfig.java # 已有:拦截器注册
├── mf-dubbo
│ └── mf-dubbo-interface
│ └── src/main/java/com/mf/dubbo/api/UserApi.java # findById(Long)
└── mf-commons
└── src/main/java/com/mf/commons/utils/Constants.java # HX_USER_PREFIX / INIT_PASSWORD
VO:HuanXinUserVo
java
package com.mf.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class HuanXinUserVo {
private String username;
private String password;
}
字段名对应环信 SDK 登录方法的两个入参,App 拿到 JSON 后直接透传给 SDK 即可。
Controller:HuanXinController
java
package com.mf.server.controller;
import com.mf.model.vo.HuanXinUserVo;
import com.mf.server.service.HuanXinService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/huanxin")
public class HuanXinController {
@Autowired
private HuanXinService huanXinService;
@GetMapping("/user")
public ResponseEntity user() {
HuanXinUserVo vo = huanXinService.findHuanXinUser();
return ResponseEntity.ok(vo);
}
}
方法没有任何参数,userId 不从请求里取,这在安全上是有意为之,后面会专门说明。
Service:HuanXinService
java
package com.mf.server.service;
import com.mf.dubbo.api.UserApi;
import com.mf.model.domain.User;
import com.mf.model.vo.HuanXinUserVo;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;
@Service
public class HuanXinService {
@DubboReference
private UserApi userApi;
/**
* 查询当前用户的环信账号
* 1、获取用户id,根据账号规则拼接
* 2、获取用户id,查询用户对象
*/
public HuanXinUserVo findHuanXinUser() {
Long userId = UserHolder.getUserId();
User user = userApi.findById(userId);
if(user == null) {
return null;
}
return new HuanXinUserVo(user.getHxUser(),user.getHxPassword());
}
}
查询走 Dubbo 远程调用:mf-dubbo 里的 UserApi#findById,实现侧是 MyBatis-Plus 的 selectById:
java
// mf-dubbo-interface 中的接口定义
package com.mf.dubbo.api;
import com.mf.model.domain.User;
public interface UserApi {
//根据手机号码查询用户
User findByMobile(String mobile);
//保存用户,返回用户id
Long save(User user);
//更新
void update(User user);
//根据id查询
User findById(Long userId);
//根据环信id查询用户
User findByHuanxin(String huanxinId);
}
java
// mf-dubbo 实现类中的方法体
@Override
public User findById(Long userId) {
return userMapper.selectById(userId);
}
User 实体里与环信相关的就两个字段,注册环节已写入:
java
// com/mf/model/domain/User.java(节选)
private Long id;
private String mobile;
private String password;
private String hxUser; // 环信用户名,如 hx1001
private String hxPassword; // 环信密码,初始值 123456
规则常量定义在 Constants:
java
// com/mf/commons/utils/Constants.java(节选)
public static final String INIT_PASSWORD = "123456";
public static final String HX_USER_PREFIX = "hx";
明明有 ThreadLocal,为什么还要查一次库?
TokenInterceptor 在 JWT 校验通过后,已经把用户对象塞进了 UserHolder(ThreadLocal),Service 里却还是调了一次 userApi.findById。多此一举吗?不是。看拦截器实际存了什么:
java
// TokenInterceptor#preHandle 节选
Claims claims = JwtUtils.getClaims(token);
String mobile = (String) claims.get("mobile");
// 数值字段按大小选型:小值反序列化为 Integer,真实雪花 ID(远超 2^31)为 Long,统一按 Object 读取再 toString
Object idObj = claims.get("id");
Long id = Long.valueOf(idObj.toString());
User user = new User();
user.setId(id);
user.setMobile(mobile);
UserHolder.set(user);
ThreadLocal 里的 User 只有 id 和 mobile 两个属性,JWT 里就存了这两样。hxUser/hxPassword 不在其中,必须回库查。这也是 JWT 方案的通病:token 里放多少字段,是无状态换取性能的权衡点。
两种取凭证的方式
示例项目里明确提了两种写法,工程选了第二种:
| 方式 | 写法 | 前提 |
|---|---|---|
| 按规则拼接 | new HuanXinUserVo(Constants.HX_USER_PREFIX + userId, Constants.INIT_PASSWORD) |
必须牢记环信 ID 规则,且所有用户密码相同 |
| 查库取值 | new HuanXinUserVo(user.getHxUser(), user.getHxPassword())(工程采用) |
注册环节已把 hxUser/hxPassword 落库 |
拼接方式少一次 RPC,但对「规则」有硬依赖,哪天环信 ID 规则变了、或者允许用户改密码了,拼接直接失效。查库方式拿到的永远是数据库里的实际值,与注册逻辑解耦,这是工程选它的原因。只有当库里查不到用户(user == null)时返回 null,响应体为空,App 端据此走异常分支。
安全分析
这个接口只有十几行代码,但值得逐条过一遍安全账。
全员同一个密码,意味着什么
INIT_PASSWORD = "123456" 是所有用户共享的。这个设计的风险不在「单用户密码弱」,而在于凭证模式可被归纳:
- 攻击者只要搞到自己一组凭证
hx1001/123456,就能推断出hx1002/123456大概率也是有效的; - 即时通讯 ID 规则(
hx+ 自增 userId)可枚举,等于整个用户群的环信凭证都被猜出来了。
也就是说,这里的「每个用户的凭证」实际上是一份全局凭证加一个可枚举的用户名序列 。缓解它的唯一硬约束是:凭证只能通过鉴权接口下发,且只返回当前 token 对应的那一个用户。
userId 从哪来:token,不是参数
回到 Controller,方法无参数。userId 的唯一来源是 JWT 解析后放进 ThreadLocal 的值:
#mermaid-svg-kSM55JP0fjPFKOE2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kSM55JP0fjPFKOE2 .error-icon{fill:#552222;}#mermaid-svg-kSM55JP0fjPFKOE2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kSM55JP0fjPFKOE2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kSM55JP0fjPFKOE2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kSM55JP0fjPFKOE2 .marker.cross{stroke:#333333;}#mermaid-svg-kSM55JP0fjPFKOE2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kSM55JP0fjPFKOE2 p{margin:0;}#mermaid-svg-kSM55JP0fjPFKOE2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 .cluster-label text{fill:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 .cluster-label span{color:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 .cluster-label span p{background-color:transparent;}#mermaid-svg-kSM55JP0fjPFKOE2 .label text,#mermaid-svg-kSM55JP0fjPFKOE2 span{fill:#333;color:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 .node rect,#mermaid-svg-kSM55JP0fjPFKOE2 .node circle,#mermaid-svg-kSM55JP0fjPFKOE2 .node ellipse,#mermaid-svg-kSM55JP0fjPFKOE2 .node polygon,#mermaid-svg-kSM55JP0fjPFKOE2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kSM55JP0fjPFKOE2 .rough-node .label text,#mermaid-svg-kSM55JP0fjPFKOE2 .node .label text,#mermaid-svg-kSM55JP0fjPFKOE2 .image-shape .label,#mermaid-svg-kSM55JP0fjPFKOE2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-kSM55JP0fjPFKOE2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kSM55JP0fjPFKOE2 .rough-node .label,#mermaid-svg-kSM55JP0fjPFKOE2 .node .label,#mermaid-svg-kSM55JP0fjPFKOE2 .image-shape .label,#mermaid-svg-kSM55JP0fjPFKOE2 .icon-shape .label{text-align:center;}#mermaid-svg-kSM55JP0fjPFKOE2 .node.clickable{cursor:pointer;}#mermaid-svg-kSM55JP0fjPFKOE2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kSM55JP0fjPFKOE2 .arrowheadPath{fill:#333333;}#mermaid-svg-kSM55JP0fjPFKOE2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kSM55JP0fjPFKOE2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kSM55JP0fjPFKOE2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kSM55JP0fjPFKOE2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kSM55JP0fjPFKOE2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kSM55JP0fjPFKOE2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kSM55JP0fjPFKOE2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kSM55JP0fjPFKOE2 .cluster text{fill:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 .cluster span{color:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kSM55JP0fjPFKOE2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kSM55JP0fjPFKOE2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-kSM55JP0fjPFKOE2 .icon-shape,#mermaid-svg-kSM55JP0fjPFKOE2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kSM55JP0fjPFKOE2 .icon-shape p,#mermaid-svg-kSM55JP0fjPFKOE2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kSM55JP0fjPFKOE2 .icon-shape .label rect,#mermaid-svg-kSM55JP0fjPFKOE2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kSM55JP0fjPFKOE2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kSM55JP0fjPFKOE2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kSM55JP0fjPFKOE2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无效
有效
请求头 Authorization
TokenInterceptor 校验 JWT
401, 拦截
解析 id/mobile 存入 ThreadLocal
Service 从 UserHolder 取 userId
只查并返回当前用户的凭证
如果接口设计成 GET /huanxin/user?userId=1002,就变成了标准的水平越权(IDOR)漏洞:任何人带上自己的合法 token,就能查询别人的环信凭证。参数驱动 vs 身份驱动,一字之差,漏洞之别。
传输与客户端两侧
- 必须 HTTPS。响应体里是明文密码,HTTP 明文传输等于把凭证广播给链路上的任何嗅探者。演示环境用 HTTP 是为了抓包方便,上生产必须切 HTTPS。
- 密码不能硬编码在 App 端。有人会想:「规则就是 hx+id 加 123456,App 自己拼不就行了?」,把规则和密码写死在客户端代码里,反编译即泄露,且改一次规则要发一个版本。走服务端下发,规则收敛在一处,轮换密码只动服务端。
生产改进方向:下发环信用户级 token
环信 REST API 支持用管理员 token 为指定用户换取用户级 token(授予 IM 用户身份的访问令牌,带过期时间)。改造思路:
java
// 改造示意(非示例项目代码):Service 侧改为服务端换 token
public HuanXinUserVo findHuanXinUser() {
Long userId = UserHolder.getUserId();
User user = userApi.findById(userId);
if (user == null) {
return null;
}
// 1. 用管理员账号调环信 POST /{org}/{app}/tokens 拿 admin token(可缓存至过期前)
// 2. 再调环信用户 token 接口,为 user.getHxUser() 换取用户级 token
// 3. VO 改为只下发 username + token,密码不出服务端
String userToken = huanXinTemplate.getUserToken(user.getHxUser());
return new HuanXinUserVo(user.getHxUser(), userToken);
}
密码从此不出服务器,泄露面收敛为「短期 token」;代价是服务端多一次外部调用和 token 生命周期管理。HuanXinTemplate 已经封装了与环信 REST API 的通信,加一个换取用户 token 的方法即可,改造路径是顺的。
避坑清单
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 接口不挂 JWT 校验 | 凭证接口裸奔,任何人可批量枚举他人环信凭证 | 确认 /huanxin/user 被 TokenInterceptor 的 /** 覆盖,不在排除路径里 |
| userId 从请求参数取 | 水平越权:拿到自己的 token 可查任意用户凭证 | userId 只从 UserHolder(ThreadLocal)取,Controller 不收参数 |
| HTTP 明文传输 | 凭证被中间人嗅探 | 生产强制 HTTPS |
| 密码/规则硬编码在 App 端 | 反编译泄露,规则变更需发版 | 凭证一律服务端下发 |
| 测试使用了未注册环信的账号 | 环信 SDK 登录失败,App 提示连接不上 | 先确认该用户已注册到环信(老用户先跑批量注册脚本) |
| AppKey 客户端与服务端配置不一致 | 环信 SDK 连接失败 | 客户端配置的 AppKey 必须与服务端 application.yml 中完全一致 |
验证步骤
按演示环境的实测路径走一遍:
bash
# 1. 带合法 JWT 调用,预期返回凭证
curl -H "Authorization: <项目JWT>" http://localhost:18080/huanxin/user
# 预期响应:{"username":"hx1","password":"123456"}
# 2. 不带 JWT 调用,预期 401,响应体为空
curl -i http://localhost:18080/huanxin/user
# 预期:HTTP/1.1 401
拿到凭证后,用已注册环信的账号在模拟器登录交友 App,进入消息页,日志打印「环信已连接」即代表整条链路(项目登录 → 凭证下发 → 环信 SDK 登录)全部走通。注意只能用已注册到环信的账号测试,示例实现用的 13800138005 就是之前批量注册过的用户。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 环信密码是环信自己生成、下发给我的 | 不是。密码是交友服务端在注册环信用户时自己设定 的(初始 123456),环信只是按提交值保存 |
| App 用示例项目的 JWT 直接登录环信 SDK | 不行。环信 SDK 只认环信账号密码(或环信 token),示例项目 JWT 只用于向交友服务端证明身份、换取凭证 |
| ThreadLocal 里已有用户,Service 不必再查库 | ThreadLocal 的 User 只有 id/mobile,hxUser/hxPassword 必须回库查 |
| 返回密码是笔误,应该返回加密后的密码 | 有意为之。环信 SDK 登录需要明文密码,App 要原样传给 SDK;要消除明文得换 token 方案,不是加解密能解决的 |
| 这个接口返回的是「我的消息」 | 它只返回登录环信的凭证。消息是环信 SDK 建立长连接之后由环信服务器推送的 |
官方文档
- 环信即时通信云 REST API 文档(用户注册、token 获取等管理接口)
- 环信即时通信 IM Android SDK 集成(客户端登录与消息接收)
总结
/huanxin/user干的事很纯粹:从 JWT 解出当前用户,把他在环信的账号密码交给 App,App 再拿去登录环信 SDK,它是项目 JWT 体系和环信凭证体系之间的换发窗口。- 工程实现选了「查库取值」而非「规则拼接」,让凭证读取与注册规则解耦。
- 全员共享
INIT_PASSWORD加可枚举的 ID 规则,是这套设计最大的安全软肋;接口鉴权 + userId 只从 token 取,是必须守住的两道底线。 - 生产化的正道是服务端换取环信用户级 token 下发,密码不出服务端;示例实现的短板心里要有数。
下一篇接着讲同章的用户体系集成:环信后台发送消息测试,凭证打通之后,用环信后台给用户推条消息验证链路。