Java 项目实战: 交友项目 - 用户体系集成:查询用户环信账号密码

概述

交友 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 端不应该硬编码这套规则,也不应该自己猜密码。所以流程是:

  1. 用户在 App 上完成项目登录,拿到项目的 JWT token;
  2. App 拿着 JWT 调服务端的 /huanxin/user 接口;
  3. 服务端校验 JWT,查出当前用户的环信账号密码,返回;
  4. 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 建立长连接之后由环信服务器推送的

官方文档

总结

  • /huanxin/user 干的事很纯粹:从 JWT 解出当前用户,把他在环信的账号密码交给 App,App 再拿去登录环信 SDK,它是项目 JWT 体系和环信凭证体系之间的换发窗口。
  • 工程实现选了「查库取值」而非「规则拼接」,让凭证读取与注册规则解耦。
  • 全员共享 INIT_PASSWORD 加可枚举的 ID 规则,是这套设计最大的安全软肋;接口鉴权 + userId 只从 token 取,是必须守住的两道底线。
  • 生产化的正道是服务端换取环信用户级 token 下发,密码不出服务端;示例实现的短板心里要有数。

下一篇接着讲同章的用户体系集成:环信后台发送消息测试,凭证打通之后,用环信后台给用户推条消息验证链路。

相关推荐
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 发布评论
java·开发语言·交友
qq_364682151 小时前
录音APP怎么切换语言?多语言版本如何使用
java·服务器·前端
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 用户体系集成:根据环信id查询用户详情
java·lua·交友
摇滚侠2 小时前
《On Java 中文版 基础卷》阅读笔记 复用 13
java·笔记·python
2601_968900772 小时前
玻璃深加工的工艺窗口:4 道工序的温度与时间参数边界
android·java·javascript
薛之谦_2 小时前
用 Java 实现阿里云式 IAM 子用户管理:架构设计与表结构详解
java·python·阿里云
甜到心里的蛋糕2 小时前
抖音评论自动回复机器人部署实践:无头 Chrome + DeepSeek + NapCat(2C2G 云主机)
java·jvm·chrome·python·抖音·deepseek
Java爱好狂.2 小时前
阿里1658页Java面试题总结(含答案)
java·后端·高并发·架构师·java面试·java面试题·java八股文
Thanks_ks2 小时前
从 0.1+0.2 不等于 0.3 说起:聊聊浮点数精度问题
java·python·数值计算·精度丢失·bigdecimal·浮点数·ieee754