双Token认证机制与接入流程
1. 系统架构概览
OpenIM权限验证机制采用分层架构设计,主要包含以下组件:
- Chat系统:负责用户注册、登录认证、API Token管理
- OpenIM Server:负责IM功能、WebSocket连接认证、用户Token管理
- openim-sdk-core:客户端SDK,负责与服务端的通信和Token管理
- 设备端应用:Android/iOS等客户端应用
涉及存储结构如下:
Hash存储多Token状态
• token1: 0 (正常)
• token2: 1 (无效)
• token3: 2 (被踢)
• token4: 3 (过期)"] end subgraph "OpenIM Admin Token缓存" AdminTokenCache["CHAT:IM_TOKEN:{adminUserID}
String存储
TTL: 4分钟
管理员系统调用凭证"] end subgraph "OpenIM User Token缓存" UserTokenCache["UID_PID_TOKEN_STATUS:{userID}:{platform}
Hash存储平台Token
• token1: 1 (正常)
• token2: 2 (被踢)"] end subgraph "验证码缓存" VerifyCache["验证码临时缓存
TTL: 300秒
account:code 键值对"] end end subgraph "MongoDB持久化层" subgraph "Chat数据库" RegistersDB[("registers集合
用户注册记录
• user_id (主键)
• device_id
• ip
• platform
• account_type")] AccountsDB[("accounts集合
用户账户密码
• user_id (主键)
• password (MD5)
• create_time")] AttributesDB[("attributes集合
用户详细属性
• user_id (主键)
• phone_number
• nickname")] CredentialsDB[("credentials集合
登录凭证管理
• user_id
• account
• type (手机/邮箱)")] VerifyCodesDB[("verify_codes集合
验证码记录
• _id (主键)
• account
• code
• used")] LoginRecordsDB[("user_login_records集合
登录历史记录
• user_id
• login_time
• ip
• platform")] end subgraph "OpenIM数据库" UsersDB[("users集合
IM用户基本信息
• user_id (主键)
• nickname
• face_url
• app_manger_level")] end end %% 数据流连接 ChatAPI --> ChatTokenCache ChatAPI --> RegistersDB ChatAPI --> AccountsDB ChatAPI --> AttributesDB OpenIMAPI --> AdminTokenCache OpenIMAPI --> UserTokenCache OpenIMAPI --> UsersDB WebSocket --> UserTokenCache SDK --> UserTokenCache ChatTokenCache -.-> AccountsDB AdminTokenCache -.-> UsersDB UserTokenCache -.-> UsersDB VerifyCache -.-> VerifyCodesDB style ChatTokenCache fill:#e1f5fe style AdminTokenCache fill:#f3e5f5 style UserTokenCache fill:#e8f5e8 style VerifyCache fill:#fff3e0
2. 核心数据库表结构
2.1 Chat系统数据库表
2.1.1 registers - 用户注册记录表
表作用: 记录用户注册时的详细信息,用于统计分析和安全追踪。
集合名称 : registers
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
user_id |
string | 是 | 用户ID,业务主键 |
device_id |
string | 是 | 设备ID |
ip |
string | 是 | 注册IP地址 |
platform |
string | 是 | 注册平台:iOS、Android、Web等 |
account_type |
string | 是 | 账户类型:phone、email、account等 |
mode |
string | 是 | 注册模式 |
create_time |
time.Time | 是 | 注册时间 |
索引设计:
javascript
db.registers.createIndex({"user_id": 1}, {unique: true})
db.registers.createIndex({"ip": 1})
2.1.2 accounts - 用户账户表
表作用: 存储用户的登录账户和密码信息,用于身份验证。
集合名称 : accounts
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
user_id |
string | 是 | 用户ID,业务主键 |
password |
string | 是 | 用户密码(MD5加密存储) |
create_time |
time.Time | 是 | 账户创建时间 |
change_time |
time.Time | 是 | 密码最后修改时间 |
operator_user_id |
string | 否 | 操作者用户ID |
索引设计:
javascript
db.accounts.createIndex({"user_id": 1}, {unique: true})
2.1.3 attributes - 用户属性表
表作用: 存储用户的详细属性信息,包括个人信息、偏好设置、权限控制等。
集合名称 : attributes
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
user_id |
string | 是 | 用户ID,业务主键 |
account |
string | 否 | 账号名称 |
phone_number |
string | 否 | 手机号码 |
area_code |
string | 否 | 区号 |
email |
string | 否 | 邮箱地址 |
nickname |
string | 否 | 用户昵称 |
face_url |
string | 否 | 头像URL |
gender |
int32 | 否 | 性别:1=男,2=女,0=未知 |
create_time |
time.Time | 是 | 创建时间 |
change_time |
time.Time | 是 | 最后修改时间 |
birth_time |
time.Time | 否 | 出生日期 |
level |
int32 | 否 | 用户等级 |
allow_vibration |
int32 | 否 | 是否允许震动:0=不允许,1=允许 |
allow_beep |
int32 | 否 | 是否允许提示音:0=不允许,1=允许 |
allow_add_friend |
int32 | 否 | 是否允许被添加好友:0=不允许,1=允许 |
global_recv_msg_opt |
int32 | 否 | 全局接收消息选项:0=正常接收,1=不接收,2=仅在线接收 |
register_type |
int32 | 否 | 注册类型:1=手机号,2=邮箱,3=账号 |
索引设计:
javascript
db.attributes.createIndex({"user_id": 1}, {unique: true})
db.attributes.createIndex({"phone_number": 1})
db.attributes.createIndex({"email": 1})
2.1.4 credentials - 用户凭证表
表作用: 管理用户的登录凭证信息,支持多种登录方式(手机号、邮箱等)。
集合名称 : credentials
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
user_id |
string | 是 | 用户ID |
account |
string | 是 | 登录账号(手机号或邮箱) |
type |
int | 是 | 凭证类型:1=手机号,2=邮箱,3=账户名 |
allow_change |
bool | 是 | 是否允许修改 |
create_time |
time.Time | 是 | 创建时间 |
索引设计:
javascript
db.credentials.createIndex({"user_id": 1, "type": 1}, {unique: true})
db.credentials.createIndex({"account": 1}, {unique: true})
2.1.5 verify_codes - 验证码表
表作用: 存储各种验证码信息,用于用户注册、登录、修改密码等操作的安全验证。
集合名称 : verify_codes
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
_id |
string | 是 | 验证码ID,业务主键 |
account |
string | 是 | 关联的账号(手机号或邮箱) |
platform |
string | 是 | 平台标识 |
code |
string | 是 | 验证码内容 |
duration |
uint | 是 | 有效期(秒) |
count |
int | 是 | 使用次数 |
used |
bool | 是 | 是否已使用 |
create_time |
time.Time | 是 | 创建时间 |
索引设计:
javascript
db.verify_codes.createIndex({"_id": 1}, {unique: true})
db.verify_codes.createIndex({"account": 1})
db.verify_codes.createIndex({"create_time": 1}, {expireAfterSeconds: 300})
2.1.6 user_login_records - 用户登录记录表
表作用: 记录用户每次登录的详细信息,用于安全监控和行为分析。
集合名称 : user_login_records
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
user_id |
string | 是 | 用户ID |
login_time |
time.Time | 是 | 登录时间 |
ip |
string | 是 | 登录IP地址 |
device_id |
string | 是 | 设备ID |
platform |
string | 是 | 登录平台:iOS、Android、Web等 |
索引设计:
javascript
db.user_login_records.createIndex({"user_id": 1})
db.user_login_records.createIndex({"login_time": 1})
2.2 OpenIM Server数据库表
2.2.1 users - 用户基本信息表
表作用: 存储用户的基本信息,包括昵称、头像、管理权限、全局消息接收设置等核心数据。
集合名称 : users
字段名 | 类型 | 是否必需 | 描述 |
---|---|---|---|
user_id |
string | 是 | 用户唯一标识符,业务主键 |
nickname |
string | 否 | 用户昵称,显示名称 |
face_url |
string | 否 | 用户头像URL地址 |
ex |
string | 否 | 扩展字段,JSON格式存储自定义信息 |
app_manger_level |
int32 | 是 | 应用管理员级别:0=普通用户,1=管理员,2=超级管理员 |
global_recv_msg_opt |
int32 | 是 | 全局接收消息选项:0=正常接收,1=不接收,2=仅在线接收 |
create_time |
time.Time | 是 | 账户创建时间 |
索引设计:
javascript
db.users.createIndex({"user_id": 1}, {unique: true})
3. Token系统详解
OpenIM系统实现了三种不同类型的Token,每种Token都有其特定的用途和验证机制。
3.1 Chat Token - Chat系统API认证
3.1.1 类型和用途
- 用途:Chat系统REST API认证,用于访问用户管理、配置管理等功能
- 生成者:Chat Admin RPC服务
- 验证者:Chat系统中间件
- 适用场景:管理后台、Chat API调用
3.1.2 Token结构
go
// chat/pkg/common/tokenverify/token_verify.go:29
type claims struct {
UserID string // 用户ID
UserType int32 // 用户类型: 1-普通用户, 2-管理员
PlatformID int32 // Chat系统中固定为0
jwt.RegisteredClaims
}
3.1.3 缓存策略
go
// Redis缓存键: CHAT_UID_TOKEN_STATUS:{userID}
// 存储结构: Hash表
{
"token0": 0, // 0-正常状态Token,表示该Token有效且用户会话处于活跃状态。用户可以使用此Token进行所有授权操作。
"token1": 1, // 1-无效Token,表示该Token已被系统标记为不可用。通常由于安全原因或管理员操作导致
"token2": 2, // 2-被踢下线的Token,表示该Token对应的会话已被新登录实例强制终止
"token3": 3, // 3-过期Token,表示该Token已超过其有效期限。系统会自动清理过期Token。
}
3.2 OpenIM Admin Token - 管理员系统调用
3.2.1 类型和用途
- 用途:Chat系统调用OpenIM Server管理接口的凭证
- 生成者:OpenIM Server Auth RPC服务
- 验证者:OpenIM Server Auth中间件
- 适用场景:系统间管理调用、用户注册、Token生成
3.2.2 Token结构
go
// open-im-server/pkg/common/storage/controller/auth.go
// 使用tools/tokenverify包的Claims结构
type Claims struct {
UserID string // 管理员用户ID(如: imAdmin)
PlatformID int // 管理员平台ID(AdminPlatformID)
jwt.RegisteredClaims
}
3.2.3 缓存策略
go
// Redis缓存键: CHAT:IM_TOKEN:{adminUserID}
// 存储结构: String
// TTL: 4分钟 (可配置)
3.3 OpenIM User Token - 客户端IM连接
3.3.1 类型和用途
- 用途:客户端WebSocket连接认证,用于IM消息收发
- 生成者:OpenIM Server Auth RPC服务
- 验证者:OpenIM Server WebSocket网关
- 适用场景:客户端SDK连接、消息通信
3.3.2 Token结构
go
// 使用tools/tokenverify包的Claims结构
type Claims struct {
UserID string // 用户ID
PlatformID int // 具体平台ID(iOS=1, Android=2, Web=5等)
jwt.RegisteredClaims
}
3.3.3 缓存策略
go
// Redis缓存键: UID_PID_TOKEN_STATUS:{userID}:{platformName}
// 存储结构: Hash表
{
"token1": 1, // 1-正常状态
"token2": 2, // 2-被踢下线
}
// 示例: UID_PID_TOKEN_STATUS:2752796792:Android
3.4 三种Token详细对比
Token类型 | 生成系统 | 验证系统 | 平台ID | 缓存键格式 | 主要用途 |
---|---|---|---|---|---|
Chat Token | Chat Admin | Chat中间件 | 0 | CHAT_UID_TOKEN_STATUS:{userID} | Chat API认证 |
OpenIM Admin Token | OpenIM Auth | OpenIM Auth | AdminPlatformID | CHAT:IM_TOKEN:{adminUserID} | 系统管理调用 |
OpenIM User Token | OpenIM Auth | OpenIM Auth | 具体平台ID | UID_PID_TOKEN_STATUS:{userID}:{platform} | WebSocket连接 |
3.5 Token使用场景矩阵
场景 | Chat Token | Admin Token | User Token | 备注 |
---|---|---|---|---|
用户登录Chat系统 | ✅ | ❌ | ❌ | 用户身份认证 |
管理后台登录 | ✅ | ✅ | ❌ | 双Token机制 |
SDK连接WebSocket | ❌ | ❌ | ✅ | IM功能必需 |
Chat系统调用OpenIM | ❌ | ✅ | ❌ | 系统间调用 |
用户注册流程 | ✅ | ✅ | ✅ | 完整注册需要 |
客户端消息收发 | ❌ | ❌ | ✅ | IM核心功能 |
用户信息管理 | ✅ | ❌ | ❌ | Chat API操作 |
强制下线操作 | ❌ | ✅ | ❌ | 管理员权限 |
4. 注册流程源码分析
4.1 第一阶段:Android客户端用户交互
4.1.1 用户界面 - RegisterActivity.java
java
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/ui/login/RegisterActivity.java:44
private void listener() {
// 1. 监听文本变化,实时验证手机号格式
view.edt1.addTextChangedListener(this);
view.protocol.setOnCheckedChangeListener((buttonView, isChecked) -> submitEnabled());
view.clear.setOnClickListener(v -> view.edt1.setText(""));
// 2. 提交按钮点击事件 - 发送验证码
view.submit.setOnClickListener(v -> {
if (vm.isPhone.val()) {
// 验证手机号格式
if (!RegexValid.isValidPhoneNumber(vm.account.val())) {
toast(getString(io.openim.android.ouicore.R.string.valid_phone_num));
return;
}
}
// 设置区号并发送验证码 (usedFor: 1-注册 2-重置密码)
vm.areaCode.setValue("+" + view.countryCode.getSelectedCountryCode());
vm.getVerificationCode(vm.isFindPassword ? 2 : 1);
});
}
4.1.2 业务逻辑层 - LoginVM.java
发送验证码流程:
java
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:119
public void getVerificationCode(int usedFor) {
// 1. 构建验证码请求参数
Parameter parameter = getParameter(null, usedFor);
WaitDialog waitDialog = showWait();
// 2. 调用Chat系统API发送验证码
N.API(OpenIMService.class)
.getVerificationCode(parameter.buildJsonBody())
.map(OpenIMService.turn(Object.class))
.compose(N.IOMain())
.subscribe(new NetObserver<Object>(getContext()) {
@Override
public void onSuccess(Object o) {
getIView().succ(o); // 通知UI发送成功
}
@Override
protected void onFailure(Throwable e) {
getIView().err(e.getMessage()); // 通知UI发送失败
}
});
}
用户注册流程:
java
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:252
public void register() {
String pwdValue = pwd.getValue();
// 1. 客户端密码格式验证
if (!RegexValid.isValidPassword(pwdValue)) {
toast(BaseApp.inst().getString(R.string.password_valid_tips));
return;
}
// 2. 构建注册请求参数
Parameter parameter = new Parameter();
parameter.add("verifyCode", verificationCode); // 验证码
parameter.add("platform", Platform.ANDROID); // 平台标识
parameter.add("autoLogin", true); // 自动登录标志
// 3. 构建用户基本信息
Map<String, String> user = new HashMap<>();
user.put("password", md5(pwdValue)); // 密码MD5加密
user.put("nickname", nickName.getValue()); // 昵称
user.put("areaCode", areaCode.val()); // 区号
if (isPhone.val()) {
user.put("phoneNumber", account.getValue()); // 手机号
} else {
user.put("email", account.getValue()); // 邮箱
}
parameter.add("user", user);
// 4. 调用Chat系统注册API
WaitDialog waitDialog = showWait();
N.API(OpenIMService.class)
.register(parameter.buildJsonBody())
.map(OpenIMService.turn(LoginCertificate.class))
.compose(N.IOMain())
.subscribe(new NetObserver<LoginCertificate>(context.get()) {
@Override
public void onSuccess(LoginCertificate o) {
// 5. 注册成功,缓存登录凭证
Log.d(TAG, "Register success, caching credentials");
o.cache(getContext());
getIView().jump(); // 跳转到主界面
}
@Override
protected void onFailure(Throwable e) {
getIView().toast(e.getMessage());
}
});
}
4.1.3 网络接口定义 - OpenIMService.java
java
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/repository/OpenIMService.java
public interface OpenIMService {
// 用户注册接口
@POST("account/register")
Observable<ResponseBody> register(@Body RequestBody requestBody);
// 发送验证码接口
@POST("account/code/send")
Observable<ResponseBody> getVerificationCode(@Body RequestBody requestBody);
// 验证验证码接口
@POST("account/code/verify")
Observable<ResponseBody> checkVerificationCode(@Body RequestBody requestBody);
// 通用响应转换器
static <T> Function<ResponseBody, T> turn(Class<T> tClass) {
return responseBody -> {
String body = responseBody.string();
Base<T> base = GsonHel.dataObject(body, tClass);
if (base.errCode == 0)
return null == base.data ? tClass.newInstance() : base.data;
throw new RXRetrofitException(base.errCode, base.errDlt);
};
}
}
4.2 第二阶段:Chat系统处理
4.2.1 验证码生成与管理流程
HTTP入口 - 发送验证码接口
go
// chat/internal/api/chat/chat.go:45
func (o *Api) SendVerifyCode(c *gin.Context) {
// 1. 解析HTTP请求参数
req, err := a2r.ParseRequest[chatpb.SendVerifyCodeReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 2. 获取客户端IP地址用于频率限制
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, err)
return
}
req.Ip = ip
// 3. 调用RPC服务发送验证码
_, err = o.chatClient.SendVerifyCode(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
apiresp.GinSuccess(c, nil)
}
RPC服务 - 验证码生成逻辑
go
// chat/internal/rpc/chat/login.go:89
func (o *chatSvr) SendVerifyCode(ctx context.Context, req *chat.SendVerifyCodeReq) (*chat.SendVerifyCodeResp, error) {
// 1. IP频率限制检查
if err := o.Admin.CheckSendCode(ctx, req.Ip, req.Account); err != nil {
return nil, err
}
// 2. 根据使用场景验证账户状态
switch req.UsedFor {
case constant.VerificationCodeForRegister:
// 注册场景:检查账户是否已存在
_, err := o.Database.GetUserByAccount(ctx, req.Account)
if err == nil {
return nil, errs.ErrRegisteredAlready.WrapMsg("account already registered")
}
if !dbutil.IsDBNotFound(err) {
return nil, err
}
case constant.VerificationCodeForResetPassword:
// 重置密码场景:检查账户是否存在
_, err := o.Database.GetUserByAccount(ctx, req.Account)
if err != nil {
if dbutil.IsDBNotFound(err) {
return nil, errs.ErrUserNotFound.WrapMsg("account not found")
}
return nil, err
}
}
// 3. 生成6位数字验证码
code := o.genVerifyCode()
// 4. 保存验证码到Redis(5分钟过期)
if err := o.Database.SetVerifyCode(ctx, req.Account, code, req.UsedFor); err != nil {
return nil, err
}
// 5. 发送验证码(短信/邮件)
if err := o.sendVerifyCode(ctx, req.Account, code); err != nil {
return nil, err
}
return &chat.SendVerifyCodeResp{}, nil
}
// 生成6位随机数字验证码
func (o *chatSvr) genVerifyCode() string {
return fmt.Sprintf("%06d", rand.Intn(1000000))
}
验证码存储结构
go
// chat/pkg/common/db/database/chat.go:156
func (o *ChatDatabase) SetVerifyCode(ctx context.Context, account string, code string, usedFor int32) error {
// 构建Redis Key:verify_code:{account}:{usedFor}
key := o.verifyCodeKey(account, usedFor)
// 存储验证码,5分钟过期
return o.cache.SetEx(ctx, key, code, time.Minute*5)
}
func (o *ChatDatabase) verifyCodeKey(account string, usedFor int32) string {
return fmt.Sprintf("verify_code:%s:%d", account, usedFor)
}
// 验证码验证
func (o *ChatDatabase) VerifyCode(ctx context.Context, account string, code string, usedFor int32) error {
key := o.verifyCodeKey(account, usedFor)
// 从Redis获取验证码
storedCode, err := o.cache.Get(ctx, key)
if err != nil {
if errors.Is(err, redis.Nil) {
return errs.ErrVerifyCodeExpired.WrapMsg("verify code expired or not exist")
}
return err
}
// 验证码比对
if storedCode != code {
return errs.ErrVerifyCodeWrong.WrapMsg("verify code wrong")
}
// 验证成功后删除验证码(一次性使用)
_ = o.cache.Del(ctx, key)
return nil
}
验证码发送服务
go
// chat/internal/rpc/chat/login.go:145
func (o *chatSvr) sendVerifyCode(ctx context.Context, account, code string) error {
// 判断是手机号还是邮箱
if strings.Contains(account, "@") {
// 邮箱验证码发送
return o.sendEmailCode(ctx, account, code)
} else {
// 短信验证码发送
return o.sendSMSCode(ctx, account, code)
}
}
func (o *chatSvr) sendSMSCode(ctx context.Context, phoneNumber, code string) error {
// 调用第三方短信服务API
// 这里可以集成阿里云、腾讯云等短信服务
log.ZInfo(ctx, "Sending SMS code", "phone", phoneNumber, "code", code)
// 实际项目中需要调用真实的短信API
// return smsProvider.SendCode(phoneNumber, code)
return nil
}
func (o *chatSvr) sendEmailCode(ctx context.Context, email, code string) error {
// 调用邮件服务发送验证码
log.ZInfo(ctx, "Sending email code", "email", email, "code", code)
// 实际项目中需要调用真实的邮件API
// return emailProvider.SendCode(email, code)
return nil
}
4.2.2 用户注册完整流程
HTTP入口 - 用户注册接口
go
// chat/internal/api/chat/chat.go:91
func (o *Api) RegisterUser(c *gin.Context) {
// 1. 解析HTTP请求参数
req, err := a2r.ParseRequest[chatpb.RegisterUserReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 2. 获取客户端IP地址用于安全检查
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, err)
return
}
req.Ip = ip
// 3. 获取OpenIM管理员Token,用于后续调用OpenIM Server
// ImAdminTokenWithDefaultAdmin 是Chat系统调用OpenIM Server的关键认证机制
//
// **Token获取流程:**
// 1. 首先检查本地内存缓存(4分钟TTL)
// 2. 如果缓存未命中,检查Redis缓存(CHAT:IM_TOKEN:{adminUserID})
// 3. 如果Redis也未命中,调用OpenIM Server获取新Token
// 4. 将新Token同时缓存到内存和Redis中
//
// **Token存储结构:**
// - 内存缓存:map[userID]*authToken{token, timeout}
// - Redis缓存:Key="CHAT:IM_TOKEN:{adminUserID}", Value=token, TTL=4分钟
//
// **安全验证:**
// - 使用系统配置的Secret验证身份
// - 验证adminUserID是否在IMAdminUserID列表中
// - 确认管理员用户在OpenIM Server中存在
//
// **Token特性:**
// - JWT格式,包含UserID、PlatformID(AdminPlatformID)、过期时间
// - 管理员Token不受多端登录策略限制
// - 跳过Redis状态检查,提升性能
imToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
apiCtx := mctx.WithApiToken(c, imToken)
rpcCtx := o.WithAdminUser(c)
// 4. 检查用户是否已经在Chat系统中注册
checkResp, err := o.chatClient.CheckUserExist(rpcCtx, &chatpb.CheckUserExistReq{User: req.User})
if err != nil {
log.ZDebug(rpcCtx, "CheckUserExist error", errs.Unwrap(err))
apiresp.GinError(c, err)
return
}
// 5. 处理数据一致性问题
if checkResp.IsRegistered {
// 检查用户在OpenIM Server中是否存在
isUserNotExist, err := o.imApiCaller.AccountCheckSingle(apiCtx, checkResp.Userid)
if err != nil {
apiresp.GinError(c, err)
return
}
// 如果用户在Chat存在但OpenIM不存在,删除Chat中的记录
if isUserNotExist {
_, err := o.chatClient.DelUserAccount(rpcCtx, &chatpb.DelUserAccountReq{UserIDs: []string{checkResp.Userid}})
log.ZDebug(c, "Deleted inconsistent user data", checkResp.Userid)
if err != nil {
apiresp.GinError(c, err)
return
}
}
}
// 6. 在Chat系统中注册用户
respRegisterUser, err := o.chatClient.RegisterUser(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
// 7. 在OpenIM Server中注册用户
//
// **为什么需要在OpenIM Server中注册?**
// Chat系统和OpenIM Server是两个独立的系统:
// - Chat系统:负责用户账户管理、认证、业务逻辑
// - OpenIM Server:负责实时消息通信、WebSocket连接、消息路由
//
// **OpenIM Server注册的作用:**
// 1. 创建用户在IM系统中的身份标识
// 2. 建立用户与WebSocket网关的连接能力
// 3. 初始化用户的消息接收和发送权限
// 4. 为后续的好友关系、群组关系建立基础
//
// **注册数据结构:**
// - UserID: 与Chat系统保持一致的用户唯一标识
// - Nickname: 用户昵称,用于消息显示
// - FaceURL: 用户头像,用于界面展示
// - CreateTime: 创建时间戳
// - AppMangerLevel: 应用管理级别(默认为普通用户)
// - GlobalRecvMsgOpt: 全局消息接收选项
//
// **权限验证:**
// - 只有管理员才能调用OpenIM Server的用户注册接口
// - 使用ImAdminToken进行身份验证
// - 检查用户ID格式(不能包含':'字符)
// - 验证用户是否已在OpenIM Server中存在
userInfo := &sdkws.UserInfo{
UserID: respRegisterUser.UserID,
Nickname: req.User.Nickname,
FaceURL: req.User.FaceURL,
CreateTime: time.Now().UnixMilli(),
}
err = o.imApiCaller.RegisterUser(apiCtx, []*sdkws.UserInfo{userInfo})
if err != nil {
apiresp.GinError(c, err)
return
}
// 8. 建立默认社交关系
if resp, err := o.adminClient.FindDefaultFriend(rpcCtx, &admin.FindDefaultFriendReq{}); err == nil {
_ = o.imApiCaller.ImportFriend(apiCtx, respRegisterUser.UserID, resp.UserIDs)
}
if resp, err := o.adminClient.FindDefaultGroup(rpcCtx, &admin.FindDefaultGroupReq{}); err == nil {
_ = o.imApiCaller.InviteToGroup(apiCtx, respRegisterUser.UserID, resp.GroupIDs)
}
// 9. 生成用户ImToken(如果启用自动登录)
var resp apistruct.UserRegisterResp
if req.AutoLogin {
resp.ImToken, err = o.imApiCaller.GetUserToken(apiCtx, respRegisterUser.UserID, req.Platform)
if err != nil {
apiresp.GinError(c, err)
return
}
}
resp.ChatToken = respRegisterUser.ChatToken
resp.UserID = respRegisterUser.UserID
apiresp.GinSuccess(c, &resp)
}
ImAdminTokenWithDefaultAdmin获取完整流程
go
// chat/pkg/common/imapi/caller.go:75
func (c *Caller) ImAdminTokenWithDefaultAdmin(ctx context.Context) (string, error) {
return c.GetAdminTokenCache(ctx, c.defaultIMUserID)
}
func (c *Caller) GetAdminTokenCache(ctx context.Context, userID string) (string, error) {
// 1. 读锁检查内存缓存
c.lock.RLock()
t, ok := c.tokenCache[userID]
c.lock.RUnlock()
// 2. 内存缓存命中且未过期
if ok && !t.timeout.Before(time.Now()) {
return t.token, nil
}
// 3. 写锁更新缓存
c.lock.Lock()
defer c.lock.Unlock()
// 4. 双重检查避免并发问题
t, ok = c.tokenCache[userID]
if ok && !t.timeout.Before(time.Now()) {
return t.token, nil
}
// 5. 从Redis获取Token
token, err := c.tokenDB.GetIMToken(ctx, userID)
if err != nil && !errors.Is(err, redis.Nil) {
return "", err
}
if errors.Is(err, redis.Nil) {
// 6. Redis缓存未命中,调用OpenIM Server获取新Token
token, err = c.GetAdminTokenServer(ctx, userID)
if err != nil {
return "", err
}
}
// 7. 更新内存缓存(4分钟TTL)
t = &authToken{token: token, timeout: time.Now().Add(time.Minute * 4)}
c.tokenCache[userID] = t
return t.token, nil
}
func (c *Caller) GetAdminTokenServer(ctx context.Context, userID string) (string, error) {
// 8. 调用OpenIM Server Auth服务获取管理员Token
resp, err := getAdminToken.Call(ctx, c.imApi, &auth.GetAdminTokenReq{
Secret: c.imSecret, // 系统密钥验证
UserID: userID, // 管理员用户ID
})
if err != nil {
return "", err
}
// 9. 将Token缓存到Redis
err = c.tokenDB.SetIMToken(ctx, userID, resp.Token)
if err != nil {
log.ZWarn(ctx, "set im admin token to redis failed", err, "userID", userID)
}
return resp.Token, nil
}
用户注册RPC服务主流程
go
// chat/internal/rpc/chat/login.go:258
func (o *chatSvr) RegisterUser(ctx context.Context, req *chat.RegisterUserReq) (*chat.RegisterUserResp, error) {
// 1. 权限检查和上下文设置
isAdmin, err := o.Admin.CheckNilOrAdmin(ctx)
ctx = o.WithAdminUser(ctx)
if err != nil {
return nil, err
}
// 2. 验证注册信息(手机号/邮箱/账户名格式检查)
if err = o.checkRegisterInfo(ctx, req.User, isAdmin); err != nil {
return nil, err
}
var usedInvitationCode bool
if !isAdmin {
// 3. 非管理员用户的注册权限检查
if !o.AllowRegister {
return nil, errs.ErrNoPermission.WrapMsg("register user is disabled")
}
if req.User.UserID != "" {
return nil, errs.ErrNoPermission.WrapMsg("only admin can set user id")
}
// 4. IP频率限制检查
if err := o.Admin.CheckRegister(ctx, req.Ip); err != nil {
return nil, err
}
// 5. 邀请码检查(如果系统配置需要)
conf, err := o.Admin.GetConfig(ctx)
if err != nil {
return nil, err
}
if val := conf[constant.NeedInvitationCodeRegisterConfigKey]; datautil.Contain(strings.ToLower(val), "1", "true", "yes") {
usedInvitationCode = true
if req.InvitationCode == "" {
return nil, errs.ErrArgs.WrapMsg("invitation code is empty")
}
if err := o.Admin.CheckInvitationCode(ctx, req.InvitationCode); err != nil {
return nil, err
}
}
// 6. 验证码验证
if req.User.Email == "" {
// 手机号注册验证码检查
if _, err := o.verifyCode(ctx, o.verifyCodeJoin(req.User.AreaCode, req.User.PhoneNumber), req.VerifyCode, phone); err != nil {
return nil, err
}
} else {
// 邮箱注册验证码检查
if _, err := o.verifyCode(ctx, req.User.Email, req.VerifyCode, mail); err != nil {
return nil, err
}
}
}
// 7. 生成用户ID(如果没有指定)
if req.User.UserID == "" {
for i := 0; i < 20; i++ {
userID := o.genUserID() // 生成10位数字ID
_, err := o.Database.GetUser(ctx, userID)
if err == nil {
continue // ID已存在,重新生成
} else if dbutil.IsDBNotFound(err) {
req.User.UserID = userID
break
} else {
return nil, err
}
}
if req.User.UserID == "" {
return nil, errs.ErrInternalServer.WrapMsg("gen user id failed")
}
}
// 8. 构建凭证信息
var (
credentials []*chatdb.Credential
registerType int32
)
if req.User.PhoneNumber != "" {
registerType = constant.PhoneRegister
credentials = append(credentials, &chatdb.Credential{
UserID: req.User.UserID,
Account: BuildCredentialPhone(req.User.AreaCode, req.User.PhoneNumber),
Type: constant.CredentialPhone,
AllowChange: true,
})
}
if req.User.Account != "" {
credentials = append(credentials, &chatdb.Credential{
UserID: req.User.UserID,
Account: req.User.Account,
Type: constant.CredentialAccount,
AllowChange: true,
})
registerType = constant.AccountRegister
}
if req.User.Email != "" {
registerType = constant.EmailRegister
credentials = append(credentials, &chatdb.Credential{
UserID: req.User.UserID,
Account: req.User.Email,
Type: constant.CredentialEmail,
AllowChange: true,
})
}
// 9. 构建数据库记录对象
now := time.Now()
register := &chatdb.Register{
UserID: req.User.UserID,
DeviceID: req.DeviceID,
IP: req.Ip,
Platform: constantpb.PlatformID2Name[int(req.Platform)],
AccountType: "",
Mode: constant.UserMode,
CreateTime: now,
}
account := &chatdb.Account{
UserID: req.User.UserID,
Password: req.User.Password,
OperatorUserID: mcontext.GetOpUserID(ctx),
ChangeTime: now,
CreateTime: now,
}
attribute := &chatdb.Attribute{
UserID: req.User.UserID,
Account: req.User.Account,
PhoneNumber: req.User.PhoneNumber,
AreaCode: req.User.AreaCode,
Email: req.User.Email,
Nickname: req.User.Nickname,
FaceURL: req.User.FaceURL,
Gender: req.User.Gender,
BirthTime: time.UnixMilli(req.User.Birth),
ChangeTime: now,
CreateTime: now,
AllowVibration: constant.DefaultAllowVibration,
AllowBeep: constant.DefaultAllowBeep,
AllowAddFriend: constant.DefaultAllowAddFriend,
RegisterType: registerType,
}
// 10. 事务性保存到数据库
if err := o.Database.RegisterUser(ctx, register, account, attribute, credentials); err != nil {
return nil, err
}
// 11. 处理邀请码使用记录
if usedInvitationCode {
if err := o.Admin.UseInvitationCode(ctx, req.User.UserID, req.InvitationCode); err != nil {
log.ZError(ctx, "UseInvitationCode", err, "userID", req.User.UserID, "invitationCode", req.InvitationCode)
}
}
// 12. 生成ChatToken(如果启用自动登录)
var resp chat.RegisterUserResp
if req.AutoLogin {
chatToken, err := o.Admin.CreateToken(ctx, req.User.UserID, constant.NormalUser)
if err == nil {
resp.ChatToken = chatToken.Token
} else {
log.ZError(ctx, "Admin CreateToken Failed", err, "userID", req.User.UserID, "platform", req.Platform)
}
}
resp.UserID = req.User.UserID
return &resp, nil
}
数据库事务操作 - database/chat.go
go
// chat/pkg/common/db/database/chat.go:214
func (o *ChatDatabase) RegisterUser(ctx context.Context, register *chatdb.Register, account *chatdb.Account, attribute *chatdb.Attribute, credentials []*chatdb.Credential) error {
// 使用MongoDB事务确保数据一致性
return o.tx.Transaction(ctx, func(ctx context.Context) error {
// 1. 创建注册记录
if err := o.register.Create(ctx, register); err != nil {
return err
}
// 2. 创建用户账户(密码)
if err := o.account.Create(ctx, account); err != nil {
return err
}
// 3. 创建用户属性
if err := o.attribute.Create(ctx, attribute); err != nil {
return err
}
// 4. 创建登录凭证
if err := o.credential.Create(ctx, credentials...); err != nil {
return err
}
return nil
})
}
4.3 第三阶段:OpenIM Server集成
4.3.1 Chat系统调用OpenIM - caller.go
go
// chat/pkg/common/imapi/caller.go:175
func (c *Caller) RegisterUser(ctx context.Context, users []*sdkws.UserInfo) error {
// 调用OpenIM Server用户注册接口
_, err := registerUser.Call(ctx, c.imApi, &user.UserRegisterReq{
Users: users,
})
return err
}
获取UserToken
go
// chat/pkg/common/imapi/caller.go:126
func (c *Caller) GetUserToken(ctx context.Context, userID string, platformID int32) (string, error) {
// 为用户生成IM Token用于WebSocket连接
resp, err := getuserToken.Call(ctx, c.imApi, &auth.GetUserTokenReq{
PlatformID: platformID,
UserID: userID,
})
if err != nil {
return "", err
}
return resp.Token, nil
}
获取AdminToken
go
// open-im-server/internal/rpc/auth/auth.go:142
func (s *authServer) GetAdminToken(ctx context.Context, req *pbauth.GetAdminTokenReq) (*pbauth.GetAdminTokenResp, error) {
// 1. 验证系统密钥
if req.Secret != s.config.Share.Secret {
return nil, errs.ErrNoPermission.WrapMsg("secret invalid")
}
// 2. 验证用户ID是否在管理员列表中
if !datautil.Contain(req.UserID, s.config.Share.IMAdminUserID...) {
return nil, errs.ErrArgs.WrapMsg("userID is error")
}
// 3. 验证管理员用户是否存在
if err := s.userClient.CheckUser(ctx, []string{req.UserID}); err != nil {
return nil, err
}
// 4. 生成JWT Token
token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(constant.AdminPlatformID))
if err != nil {
return nil, err
}
return &pbauth.GetAdminTokenResp{
Token: token,
ExpireTimeSeconds: s.config.RpcConfig.TokenPolicy.Expire * 24 * 60 * 60,
}, nil
}
4.3.2 OpenIM Server用户注册 - user.go
go
// open-im-server/internal/rpc/user/user.go:453
func (s *userServer) UserRegister(ctx context.Context, req *pbuser.UserRegisterReq) (resp *pbuser.UserRegisterResp, err error) {
resp = &pbuser.UserRegisterResp{}
// 1. 参数验证
if len(req.Users) == 0 {
return nil, errs.ErrArgs.WrapMsg("users is empty")
}
// 2. 管理员权限检查
if err = authverify.CheckAdmin(ctx, s.config.Share.IMAdminUserID); err != nil {
return nil, err
}
// 3. 检查用户ID重复
if datautil.DuplicateAny(req.Users, func(e *sdkws.UserInfo) string { return e.UserID }) {
return nil, errs.ErrArgs.WrapMsg("userID repeated")
}
// 4. 用户ID格式验证
userIDs := make([]string, 0)
for _, user := range req.Users {
if user.UserID == "" {
return nil, errs.ErrArgs.WrapMsg("userID is empty")
}
if strings.Contains(user.UserID, ":") {
return nil, errs.ErrArgs.WrapMsg("userID contains ':' is invalid userID")
}
userIDs = append(userIDs, user.UserID)
}
// 5. 检查用户是否已存在
exist, err := s.db.IsExist(ctx, userIDs)
if err != nil {
return nil, err
}
if exist {
return nil, servererrs.ErrRegisteredAlready.WrapMsg("userID registered already")
}
// 6. 注册前webhook
if err := s.webhookBeforeUserRegister(ctx, &s.config.WebhooksConfig.BeforeUserRegister, req); err != nil {
return nil, err
}
// 7. 构建用户数据
now := time.Now()
users := make([]*tablerelation.User, 0, len(req.Users))
for _, user := range req.Users {
users = append(users, &tablerelation.User{
UserID: user.UserID,
Nickname: user.Nickname,
FaceURL: user.FaceURL,
Ex: user.Ex,
CreateTime: now,
AppMangerLevel: user.AppMangerLevel,
GlobalRecvMsgOpt: user.GlobalRecvMsgOpt,
})
}
// 8. 保存到OpenIM数据库
if err := s.db.Create(ctx, users); err != nil {
return nil, err
}
// 9. 更新监控指标
prommetrics.UserRegisterCounter.Add(float64(len(users)))
// 10. 注册后webhook
s.webhookAfterUserRegister(ctx, &s.config.WebhooksConfig.AfterUserRegister, req)
return resp, nil
}
4.4 注册的系统交互时序图
以下时序图展示了OpenIM设备注册的完整流程,包括验证码发送和用户注册两个主要阶段:
RegisterActivity participant SDK as open-im-sdk-android
LoginVM participant ChatAPI as Chat System
HTTP API participant ChatRPC as Chat System
RPC Service participant Redis as Redis Cache
验证码/Token存储 participant ChatDB as Chat Database
MongoDB participant OpenIMAPI as OpenIM Server
RPC API participant OpenIMDB as OpenIM Database
MongoDB Note over Client,OpenIMDB: === 阶段1: 验证码发送流程 === Client->>SDK: 1.1 用户点击发送验证码
vm.getVerificationCode(1) SDK->>ChatAPI: 1.2 POST /account/code/send
{"account":"手机号","usedFor":1} ChatAPI->>ChatRPC: 1.3 SendVerifyCode RPC调用
检查IP频率限制 ChatRPC->>ChatDB: 1.4 验证账户状态
检查是否已注册 ChatDB-->>ChatRPC: 1.5 返回查询结果 ChatRPC->>ChatRPC: 1.6 生成6位验证码
fmt.Sprintf("%06d", rand.Intn(1000000)) ChatRPC->>Redis: 1.7 存储验证码
Key: verify_code:{account}:1
TTL: 5分钟 Redis-->>ChatRPC: 1.8 存储成功 ChatRPC->>ChatRPC: 1.9 发送短信/邮件
调用第三方服务 ChatRPC-->>ChatAPI: 1.10 发送成功响应 ChatAPI-->>SDK: 1.11 HTTP 200 OK SDK-->>Client: 1.12 UI显示发送成功
启动倒计时 Note over Client,OpenIMDB: === 阶段2: 用户注册流程 === Client->>SDK: 2.1 用户输入信息并注册
vm.register() SDK->>SDK: 2.2 客户端验证
密码格式、昵称等 SDK->>ChatAPI: 2.3 POST /account/register
{"user":{...},"verifyCode":"123456","platform":2} ChatAPI->>ChatAPI: 2.4 获取管理员Token
ImAdminTokenWithDefaultAdmin() ChatAPI->>OpenIMAPI: 2.5 获取Admin Token
GetAdminToken(secret, adminUserID) OpenIMAPI->>OpenIMAPI: 2.6 验证密钥和管理员身份 OpenIMAPI->>OpenIMDB: 2.7 检查管理员用户存在性 OpenIMDB-->>OpenIMAPI: 2.8 用户存在确认 OpenIMAPI->>Redis: 2.9 缓存Admin Token
Key: CHAT:IM_TOKEN:{adminUserID}
TTL: 4分钟 Redis-->>OpenIMAPI: 2.10 缓存成功 OpenIMAPI-->>ChatAPI: 2.11 返回Admin Token
JWT格式 ChatAPI->>ChatRPC: 2.12 RegisterUser RPC调用
携带所有注册信息 ChatRPC->>Redis: 2.13 验证验证码
Key: verify_code:{account}:1 Redis-->>ChatRPC: 2.14 返回存储的验证码 ChatRPC->>ChatRPC: 2.15 验证码比对
验证成功后删除 ChatRPC->>Redis: 2.16 删除已使用的验证码
确保一次性使用 ChatRPC->>ChatRPC: 2.17 生成用户ID
10位数字ID ChatRPC->>ChatDB: 2.18 事务性保存用户数据
Register+Account+Attribute+Credential Note over ChatDB: MongoDB事务操作
register: 注册记录
account: 密码信息
attribute: 用户属性
credential: 登录凭证 ChatDB-->>ChatRPC: 2.19 保存成功确认 ChatRPC->>ChatRPC: 2.20 生成Chat Token
JWT: {userID, userType:1, platformID:0} ChatRPC->>Redis: 2.21 缓存Chat Token状态
Key: CHAT_UID_TOKEN_STATUS:{userID}
Value: {token: 1} Redis-->>ChatRPC: 2.22 缓存成功 ChatRPC-->>ChatAPI: 2.23 返回注册结果
{userID, chatToken} ChatAPI->>OpenIMAPI: 2.24 在OpenIM Server注册用户
RegisterUser([UserInfo]) OpenIMAPI->>OpenIMAPI: 2.25 管理员权限检查
验证Admin Token OpenIMAPI->>OpenIMAPI: 2.26 用户数据验证
UserID格式、重复检查 OpenIMAPI->>OpenIMDB: 2.27 检查用户是否已存在 OpenIMDB-->>OpenIMAPI: 2.28 用户不存在确认 OpenIMAPI->>OpenIMDB: 2.29 保存IM用户信息
User{UserID, Nickname, FaceURL...} OpenIMDB-->>OpenIMAPI: 2.30 保存成功 OpenIMAPI-->>ChatAPI: 2.31 IM用户注册成功 ChatAPI->>OpenIMAPI: 2.32 获取用户IM Token
GetUserToken(userID, platformID) OpenIMAPI->>OpenIMAPI: 2.33 验证请求权限
只有管理员可调用 OpenIMAPI->>OpenIMAPI: 2.34 生成用户IM Token
JWT: {userID, platformID:2} OpenIMAPI->>Redis: 2.35 缓存IM Token状态
Key: UID_PID_TOKEN_STATUS:{userID}:Android
Value: {token: 1} Redis-->>OpenIMAPI: 2.36 缓存成功 OpenIMAPI-->>ChatAPI: 2.37 返回IM Token ChatAPI-->>SDK: 2.38 返回完整注册结果
{userID, chatToken, imToken} SDK->>SDK: 2.39 缓存登录凭证
LoginCertificate.cache() SDK-->>Client: 2.40 注册成功,跳转主界面 Note over Client,OpenIMDB: === Token缓存结构说明 === Note over Redis: Chat Token: CHAT_UID_TOKEN_STATUS:{userID}
Admin Token: CHAT:IM_TOKEN:{adminUserID}
IM Token: UID_PID_TOKEN_STATUS:{userID}:{platform}
验证码: verify_code:{account}:{usedFor}
5. 登录流程源码分析
5.1 第一阶段:Android客户端用户交互
5.1.1 用户界面 - LoginActivity.java
java
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/ui/login/LoginActivity.java:177-253
view.submit.setOnClickListener(v -> {
vm.areaCode.setValue("+"+view.loginContent.countryCode.getSelectedCountryCode());
waitDialog.show();
vm.login(isVCLogin ? vm.pwd.getValue() : null, 3);
});
关键点分析:
- 设置区号:从国家代码选择器获取区号
- 显示等待对话框:提供用户反馈
- 调用ViewModel的login方法:传入验证码和用途标识(3表示登录)
5.1.2 登录业务逻辑 - LoginVM.java
LoginViewModel负责处理登录的核心业务逻辑:
java
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:51-85
public void login(String verificationCode, int usedFor) {
// 1. 保存登录类型到本地缓存
SharedPreferencesUtil.get(BaseApp.inst())
.setCache(Constants.K_LOGIN_TYPE, isPhone.val() ? 0 : 1);
// 2. 构建登录参数
Parameter parameter = getParameter(verificationCode, usedFor);
// 3. 调用Chat系统登录API
N.API(OpenIMService.class)
.login(parameter.buildJsonBody())
.compose(N.IOMain())
.map(OpenIMService.turn(LoginCertificate.class))
.subscribe(new NetObserver<LoginCertificate>(getContext()) {
@Override
public void onSuccess(LoginCertificate loginCertificate) {
// 4. 获取到ChatToken和ImToken后,连接OpenIM SDK
try {
OpenIMClient.getInstance().login(new OnBase<String>() {
@Override
public void onError(int code, String error) {
getIView().err(error);
}
@Override
public void onSuccess(String data) {
// 5. SDK连接成功,缓存登录信息
loginCertificate.cache(getContext());
BaseApp.inst().loginCertificate = loginCertificate;
getIView().jump();
}
}, loginCertificate.userID, loginCertificate.imToken);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
protected void onFailure(Throwable e) {
getIView().err(e.getMessage());
}
});
}
参数构建详解:
java
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:94-110
@NonNull
private Parameter getParameter(String verificationCode, int usedFor) {
Parameter parameter = new Parameter().add("password",
TextUtils.isEmpty(verificationCode) ? md5(pwd.val()) : null)
.add("platform", 2).add("usedFor", usedFor)
.add("operationID", System.currentTimeMillis() + "")
.add("verifyCode", verificationCode);
if (isPhone.val()) {
parameter.add("phoneNumber", account.getValue());
parameter.add("areaCode", areaCode.val());
} else
parameter.add("email", account.getValue());
return parameter;
}
关键参数说明:
platform
: 平台ID,Android固定为2usedFor
: 用途标识,登录为3password
: 密码MD5哈希(验证码登录时为null)verifyCode
: 验证码(密码登录时为null)phoneNumber/email
: 登录账号operationID
: 操作ID,用于链路追踪
5.1.3 API接口定义 - OpenIMService.java
java
// open-im-android-demo2/Demo/app/src/main/java/io/openim/android/demo/repository/OpenIMService.java:18-19
@POST("account/login")
Observable<ResponseBody> login(@Body RequestBody requestBody);
使用Retrofit定义RESTful API接口,支持RxJava响应式编程。
5.2 第二阶段:Chat API网关处理
5.2.1 HTTP请求路由 - chat.go
Chat API网关接收到登录请求后进行处理:
go
// chat/internal/api/chat/chat.go:197-247
func (o *Api) Login(c *gin.Context) {
// 1. 解析登录请求参数(账号/手机/邮箱 + 密码/验证码)
req, err := a2r.ParseRequest[chatpb.LoginReq](c)
if err != nil {
apiresp.GinError(c, err)
return
}
// 2. 获取客户端IP地址,用于安全检查和登录记录
ip, err := o.GetClientIP(c)
if err != nil {
apiresp.GinError(c, err)
return
}
req.Ip = ip
// 3. 调用Chat RPC服务进行用户认证,获取ChatToken
resp, err := o.chatClient.Login(c, req)
if err != nil {
apiresp.GinError(c, err)
return
}
// 4. 获取OpenIM管理员Token,用于调用OpenIM Server的管理接口
adminToken, err := o.imApiCaller.ImAdminTokenWithDefaultAdmin(c)
if err != nil {
apiresp.GinError(c, err)
return
}
apiCtx := mctx.WithApiToken(c, adminToken)
// 5. 使用管理员Token调用OpenIM Server,为用户生成ImToken
imToken, err := o.imApiCaller.GetUserToken(apiCtx, resp.UserID, req.Platform)
if err != nil {
apiresp.GinError(c, err)
return
}
// 6. 返回登录结果,包含两个Token
apiresp.GinSuccess(c, &apistruct.LoginResp{
ImToken: imToken, // OpenIM Server WebSocket连接Token
UserID: resp.UserID,
ChatToken: resp.ChatToken, // Chat系统API认证Token
})
}
双Token机制详解:
- ChatToken: 用于Chat系统REST API认证,访问用户管理、配置等功能
- ImToken: 用于OpenIM Server WebSocket连接认证,进行实时消息通信
5.3 第三阶段:Chat RPC服务认证
5.3.1 用户认证核心逻辑 - login.go
Chat RPC服务负责核心的用户认证逻辑,这是登录流程的关键环节:
go
// chat/internal/rpc/chat/login.go:434-511
func (o *chatSvr) Login(ctx context.Context, req *chat.LoginReq) (*chat.LoginResp, error) {
resp := &chat.LoginResp{}
// 1. 参数验证:密码和验证码至少要有一个
if req.Password == "" && req.VerifyCode == "" {
return nil, errs.ErrArgs.WrapMsg("password or code must be set")
}
var (
err error
credential *chatdb.Credential // 用户凭证信息
acc string // 登录账号
)
// 2. 根据不同的登录方式构建账号字符串
switch {
case req.Account != "":
// 用户名登录
acc = req.Account
case req.PhoneNumber != "":
// 手机号登录
if req.AreaCode == "" {
return nil, errs.ErrArgs.WrapMsg("area code must")
}
if !strings.HasPrefix(req.AreaCode, "+") {
req.AreaCode = "+" + req.AreaCode
}
if _, err := strconv.ParseUint(req.AreaCode[1:], 10, 64); err != nil {
return nil, errs.ErrArgs.WrapMsg("area code must be number")
}
// 构建手机号凭证格式:+86 13800000000
acc = BuildCredentialPhone(req.AreaCode, req.PhoneNumber)
case req.Email != "":
// 邮箱登录
acc = req.Email
default:
return nil, errs.ErrArgs.WrapMsg("account or phone number or email must be set")
}
// 3. 根据账号查询用户凭证信息
credential, err = o.Database.TakeCredentialByAccount(ctx, acc)
if err != nil {
if dbutil.IsDBNotFound(err) {
return nil, eerrs.ErrAccountNotFound.WrapMsg("user unregistered")
}
return nil, err
}
// 4. IP和用户登录频率检查
if err := o.Admin.CheckLogin(ctx, credential.UserID, req.Ip); err != nil {
return nil, err
}
var verifyCodeID *string
// 5. 根据登录方式进行认证
if req.Password == "" {
// 验证码登录方式
var id string
if req.Email == "" {
// 手机验证码登录
account := o.verifyCodeJoin(req.AreaCode, req.PhoneNumber)
id, err = o.verifyCode(ctx, account, req.VerifyCode, phone)
if err != nil {
return nil, err
}
} else {
// 邮箱验证码登录
account := req.Email
id, err = o.verifyCode(ctx, account, req.VerifyCode, mail)
if err != nil {
return nil, err
}
}
if id != "" {
verifyCodeID = &id
}
} else {
// 密码登录方式
account, err := o.Database.TakeAccount(ctx, credential.UserID)
if err != nil {
return nil, err
}
// 密码比对(MD5哈希值比较)
if account.Password != req.Password {
return nil, eerrs.ErrPassword.Wrap()
}
}
// 6. 生成ChatToken
chatToken, err := o.Admin.CreateToken(ctx, credential.UserID, constant.NormalUser)
if err != nil {
return nil, err
}
// 7. 记录登录日志
record := &chatdb.UserLoginRecord{
UserID: credential.UserID,
LoginTime: time.Now(),
IP: req.Ip,
DeviceID: req.DeviceID,
Platform: constantpb.PlatformIDToName(int(req.Platform)),
}
if err := o.Database.LoginRecord(ctx, record, verifyCodeID); err != nil {
return nil, err
}
// 8. 清理已使用的验证码
if verifyCodeID != nil {
if err := o.Database.DelVerifyCode(ctx, *verifyCodeID); err != nil {
return nil, err
}
}
// 9. 返回登录成功响应
resp.UserID = credential.UserID
resp.ChatToken = chatToken.Token
return resp, nil
}
5.3.2 关键认证机制详解
账号凭证构建策略:
- 用户名登录: 直接使用账号字符串
- 手机号登录 : 格式化为
+{区号} {手机号}
(如:+86 13800000000) - 邮箱登录: 直接使用邮箱地址
双重认证支持:
- 密码认证: MD5哈希值比对,适用于常规登录
- 验证码认证: 动态验证码验证,适用于快捷登录或找回密码
安全防护机制:
- IP频率限制: 防止暴力破解攻击
- 验证码一次性使用: 防止重放攻击
- 登录日志记录: 支持安全审计和异常检测
5.4 第四阶段:OpenIM Admin Token生成
5.4.1 管理员Token请求 - auth.go
go
// open-im-server/internal/rpc/auth/auth.go:200-236
func (s *authServer) GetAdminToken(ctx context.Context, req *pbauth.GetAdminTokenReq) (*pbauth.GetAdminTokenResp, error) {
resp := pbauth.GetAdminTokenResp{}
// 验证系统密钥
if req.Secret != s.config.Share.Secret {
return nil, errs.ErrNoPermission.WrapMsg("secret invalid")
}
// 验证用户ID是否在管理员列表中
if !datautil.Contain(req.UserID, s.config.Share.IMAdminUserID...) {
return nil, errs.ErrArgs.WrapMsg("userID is error.", "userID", req.UserID, "adminUserID", s.config.Share.IMAdminUserID)
}
// 验证管理员用户是否存在
if err := s.userClient.CheckUser(ctx, []string{req.UserID}); err != nil {
return nil, err
}
// 生成管理员Token
token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(constant.AdminPlatformID))
if err != nil {
return nil, err
}
// 记录管理员登录指标
prommetrics.UserLoginCounter.Inc()
// 构建响应
resp.Token = token
resp.ExpireTimeSeconds = s.config.RpcConfig.TokenPolicy.Expire * 24 * 60 * 60
return &resp, nil
}
安全验证层级:
- 系统密钥验证: 确保请求来自可信系统
- 管理员身份验证: 确认用户ID在管理员列表中
- 用户存在性验证: 确认管理员用户存在于系统中
5.5 第五阶段:用户ImToken生成
5.5.1 用户Token生成 - auth.go
go
// open-im-server/internal/rpc/auth/auth.go:267-303
func (s *authServer) GetUserToken(ctx context.Context, req *pbauth.GetUserTokenReq) (*pbauth.GetUserTokenResp, error) {
// 验证调用者是否为管理员
if err := authverify.CheckAdmin(ctx, s.config.Share.IMAdminUserID); err != nil {
return nil, err
}
// 禁止为管理员平台生成Token
if req.PlatformID == constant.AdminPlatformID {
return nil, errs.ErrNoPermission.WrapMsg("platformID invalid. platformID must not be adminPlatformID")
}
resp := pbauth.GetUserTokenResp{}
// 禁止为管理员用户生成普通Token
if authverify.IsManagerUserID(req.UserID, s.config.Share.IMAdminUserID) {
return nil, errs.ErrNoPermission.WrapMsg("don't get Admin token")
}
// 获取用户信息并验证
user, err := s.userClient.GetUserInfo(ctx, req.UserID)
if err != nil {
return nil, err
}
// 应用级账号不能获取Token
if user.AppMangerLevel >= constant.AppNotificationAdmin {
return nil, errs.ErrArgs.WrapMsg("app account can`t get token")
}
// 生成用户Token
token, err := s.authDatabase.CreateToken(ctx, req.UserID, int(req.PlatformID))
if err != nil {
return nil, err
}
resp.Token = token
resp.ExpireTimeSeconds = s.config.RpcConfig.TokenPolicy.Expire * 24 * 60 * 60
return &resp, nil
}
权限控制层级:
- 管理员权限验证: 只有管理员才能为用户生成Token
- 平台限制: 不能为管理员平台生成普通用户Token
- 用户类型检查: 防止权限提升攻击
- 应用账号保护: 特殊账号的额外保护
5.5.2 Token生成和缓存机制
Token的生成涉及复杂的多端登录策略和缓存机制(具体实现在controller.AuthDatabase中):
Token状态管理:
- NormalToken(1): 正常有效状态
- KickedToken(2): 被踢下线状态
- 缓存键格式 :
UID_PID_TOKEN_STATUS:{userID}:{platformName}
多端登录策略:
- DefaultNotKick: 允许多端同时在线
- AllLoginButSameTermKick: 同终端互踢
- AllLoginButSameClassKick: 同类别互踢
5.6 第六阶段:设备端Token保存
5.6.1 登录凭证缓存 - LoginCertificate.java
java
// open-im-android-demo2/OUIKit/OUICore/src/main/java/io/openim/android/ouicore/entity/LoginCertificate.java:32-40
public void cache(Context context) {
Log.d(TAG, "LoginCertificate cache context:" + context);
SharedPreferencesUtil.get(context).setCache("user.LoginCertificate",
GsonHel.toJson(this));
}
public static LoginCertificate getCache(Context context) {
String u = SharedPreferencesUtil.get(context).getString("user.LoginCertificate");
if (u.isEmpty()) {
Log.d(TAG, "LoginCertificate getCache null, context:" + context);
return null;
}
Log.d(TAG, "LoginCertificate getCache ok. context:" + context);
return GsonHel.fromJson(u, LoginCertificate.class);
}
LoginCertificate结构:
java
// open-im-android-demo2/OUIKit/OUICore/src/main/java/io/openim/android/ouicore/entity/LoginCertificate.java:13-26
public class LoginCertificate {
public String nickname; // 用户昵称
public String faceURL; // 头像URL
public String userID; // 用户ID
public String imToken; // IM连接Token
public String chatToken; // Chat API Token
public boolean allowAddFriend; // 允许添加好友
public boolean allowBeep; // 允许提示音
public boolean allowVibration; // 允许振动
public int globalRecvMsgOpt; // 全局消息接收选项
}
5.6.2 OpenIM SDK完整登录流程 - LoginVM.java
Chat系统认证成功后,Android应用需要调用OpenIM SDK的login接口建立IM连接:
java
// open-im-android-demo/Demo/app/src/main/java/io/openim/android/demo/vm/LoginVM.java:51-85
public void login(String verificationCode, int usedFor) {
// 1. 保存登录类型到本地缓存
SharedPreferencesUtil.get(BaseApp.inst())
.setCache(Constants.K_LOGIN_TYPE, isPhone.val() ? 0 : 1);
// 2. 构建登录参数并调用Chat系统API
Parameter parameter = getParameter(verificationCode, usedFor);
N.API(OpenIMService.class)
.login(parameter.buildJsonBody())
.compose(N.IOMain())
.map(OpenIMService.turn(LoginCertificate.class))
.subscribe(new NetObserver<LoginCertificate>(getContext()) {
@Override
public void onSuccess(LoginCertificate loginCertificate) {
// 3. Chat登录成功后,立即调用OpenIM SDK登录
try {
/**
* OpenIM SDK登录核心调用
* 参数说明:
* - callback: 登录结果回调接口
* - userID: 用户唯一标识,与Chat系统保持一致
* - imToken: IM连接Token,用于WebSocket认证
*
* 功能作用:
* 1. 建立与OpenIM Server的WebSocket长连接
* 2. 初始化SDK内部各种管理器和服务
* 3. 启动消息同步、会话管理等后台服务
* 4. 验证Token并处理多端登录策略
*/
OpenIMClient.getInstance().login(new OnBase<String>() {
@Override
public void onError(int code, String error) {
// SDK登录失败处理
log.ZError("SDK login failed", "code", code, "error", error);
getIView().err(error);
}
@Override
public void onSuccess(String data) {
// 4. SDK登录成功,缓存完整登录信息
log.ZInfo("SDK login success", "userID", loginCertificate.userID);
// 缓存登录凭证到本地存储
loginCertificate.cache(getContext());
// 设置全局登录状态
BaseApp.inst().loginCertificate = loginCertificate;
// 通知UI层登录成功
getIView().jump();
}
}, loginCertificate.userID, loginCertificate.imToken);
} catch (Exception e) {
log.ZError("SDK login exception", e);
getIView().err("SDK登录异常: " + e.getMessage());
}
}
@Override
protected void onFailure(Throwable e) {
// Chat系统登录失败
getIView().err(e.getMessage());
}
});
}
OpenIM SDK登录核心机制:
-
双层认证设计:
- Chat Token: 用于Chat系统REST API调用
- IM Token: 用于OpenIM Server WebSocket连接
-
SDK初始化流程:
java// SDK内部初始化关键步骤 OpenIMClient.getInstance().login() -> { // 1. 验证登录状态,防止重复登录 // 2. 保存用户认证信息(userID + imToken) // 3. 初始化各种管理器:消息、会话、好友、群组等 // 4. 启动后台服务:长连接、消息同步、事件处理 // 5. 建立WebSocket连接到OpenIM Server // 6. 处理多端登录冲突和Token验证 }
-
连接建立顺序:
rustAndroid应用 -> Chat系统认证 -> 获取双Token -> SDK初始化 -> WebSocket连接 -> 消息服务启动
-
错误处理机制:
- Chat认证失败: 直接返回错误,不进行SDK初始化
- SDK初始化失败: 回滚登录状态,清理临时数据
- WebSocket连接失败: 自动重连机制,支持断网恢复
5.7 第七阶段:openim-sdk-core登录
5.7.1 SDK初始化检查 - userRelated.go
go
// openim-sdk-core/open_im_sdk/userRelated.go:474-500
func (u *UserContext) login(ctx context.Context, userID, token string) error {
// 1. 检查是否已经登录,避免重复登录
if u.getLoginStatus(ctx) == Logged {
return sdkerrs.ErrLoginRepeat
}
// 2. 设置登录状态为登录中
u.setLoginStatus(Logging)
log.ZDebug(ctx, "login start... ", "userID", userID, "token", token)
t1 := time.Now() // 记录登录开始时间,用于统计登录耗时
// 3. 保存用户认证信息
u.info.UserID = userID
u.info.Token = token
// 4. 初始化各种模块和服务
if err := u.initialize(ctx, userID); err != nil {
return err
}
// 5. 启动各种后台服务和协程
u.run(ctx)
// 6. 设置登录状态为已登录
u.setLoginStatus(Logged)
log.ZDebug(ctx, "login success...", "login cost time: ", time.Since(t1))
return nil
}
5.7.2 启动后台服务 - userRelated.go
go
// openim-sdk-core/open_im_sdk/userRelated.go:584-600
func (u *UserContext) run(ctx context.Context) {
// 1. 启动长连接管理器(包含读写pump和心跳)
u.longConnMgr.Run(ctx, u.fgCtx)
// 2. 启动消息同步器监听协程
go u.msgSyncer.DoListener(ctx)
// 3. 启动会话事件处理协程
go u.conversation.ConsumeConversationEventLoop(ctx)
// 4. 启动登出监听协程
go u.logoutListener(ctx)
}
5.7.3 长连接管理器启动 - long_conn_mgr.go
go
// openim-sdk-core/internal/interaction/long_conn_mgr.go:163-168
func (c *LongConnMgr) Run(ctx, fgCtx context.Context) {
go c.readPump(ctx, fgCtx) // 启动读消息协程
go c.writePump(ctx) // 启动写消息协程
go c.heartbeat(ctx, fgCtx) // 启动心跳协程
}
关键协程说明:
- readPump: 负责从WebSocket连接读取消息,处理重连逻辑
- writePump: 负责向WebSocket连接发送消息,处理发送队列
- heartbeat: 负责心跳检测,保持连接活跃状态
5.7.4 读消息协程启动与重连机制 - long_conn_mgr.go
readPump是长连接的核心协程,负责维护WebSocket连接和处理重连:
go
// openim-sdk-core/internal/interaction/long_conn_mgr.go:178-242
func (c *LongConnMgr) readPump(ctx context.Context, fgCtx context.Context) {
// panic恢复机制,确保程序不会因为panic而崩溃
defer func() {
if r := recover(); r != nil {
err := fmt.Sprintf("panic: %+v\n%s", r, debug.Stack())
log.ZWarn(ctx, "readPump panic", nil, "panic info", err)
}
}()
// 退出时清理资源
defer func() {
_ = c.close() // 关闭连接
log.ZWarn(c.ctx, "readPump closed", c.closedErr)
}()
connNum := 0 // 连接计数器,用于统计重连次数
// 主循环:持续读取消息
for {
// 检查上下文状态,支持优雅退出
select {
case <-ctx.Done():
// 主上下文取消,通常是SDK退出登录
c.closedErr = ctx.Err()
log.ZInfo(c.ctx, "readPump done, sdk logout.....")
return
case <-fgCtx.Done():
// 前台上下文取消,应用切换到后台
c.closedErr = context.Cause(fgCtx)
log.ZInfo(c.ctx, "SDK transitioning from foreground to background, read message goroutine ended.")
return
default:
// 继续执行读取逻辑
}
// 为每次操作生成新的操作ID,便于日志追踪
ctx = ccontext.WithOperationID(ctx, utils.OperationIDGenerator())
// 尝试重连或确保连接可用 - 关键步骤
needRecon, err := c.reConn(ctx, &connNum)
if !needRecon {
// 不需要重连,但有错误,说明是致命错误,需要退出
c.closedErr = err
return
}
if err != nil {
// 重连失败,等待一段时间后重试
log.ZWarn(c.ctx, "reConn", err)
time.Sleep(c.reconnectStrategy.GetSleepInterval()) // 使用重连策略的等待间隔
continue
}
// 连接建立成功,开始读取消息
// 设置连接参数
c.conn.SetReadLimit(maxMessageSize) // 设置最大消息大小限制
_ = c.conn.SetReadDeadline(pongWait) // 设置读取超时时间
// 读取WebSocket消息并处理...
}
}
5.7.5 WebSocket连接建立与Token携带 - long_conn_mgr.go
reConn方法是建立WebSocket连接的核心,这里展示了token如何通过URL参数传递给服务端:
go
// openim-sdk-core/internal/interaction/long_conn_mgr.go:938-970
func (c *LongConnMgr) reConn(ctx context.Context, num *int) (needRecon bool, err error) {
// 检查连接状态,如果已连接则直接返回
if c.IsConnected() {
return true, nil
}
c.connWrite.Lock() // 获取写锁,确保连接建立的原子性
defer c.connWrite.Unlock() // 释放写锁
// 通知监听器开始连接
c.listener().OnConnecting()
c.SetConnectionStatus(Connecting)
// 构建WebSocket连接URL,关键:token通过URL参数传递
url := fmt.Sprintf("%s?sendID=%s&token=%s&platformID=%d&operationID=%s&isBackground=%t",
ccontext.Info(ctx).WsAddr(), // WebSocket服务器地址
ccontext.Info(ctx).UserID(), // 用户ID
ccontext.Info(ctx).Token(), // 关键:ImToken通过URL参数传递
ccontext.Info(ctx).PlatformID(), // 平台ID
ccontext.Info(ctx).OperationID(), // 操作ID,用于链路追踪
c.GetBackground()) // 是否后台状态
// 如果启用压缩,添加压缩参数
if c.IsCompression {
url += fmt.Sprintf("&compression=%s", "gzip")
}
log.ZDebug(ctx, "conn start", "url", url)
// 发起WebSocket连接
resp, err := c.conn.Dial(url, nil)
if err != nil {
c.SetConnectionStatus(Closed)
// 处理连接失败响应
if resp != nil {
body, err := io.ReadAll(resp.Body)
if err != nil {
return true, err
}
log.ZInfo(ctx, "reConn resp", "body", string(body))
// 解析错误响应
var apiResp struct {
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
ErrDlt string `json:"errDlt"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return true, err
}
err = errs.NewCodeError(apiResp.ErrCode, apiResp.ErrMsg).WithDetail(apiResp.ErrDlt).Wrap()
ccontext.GetApiErrCodeCallback(ctx).OnError(ctx, err)
// 检查Token相关错误,这些错误不需要重连
switch apiResp.ErrCode {
case
errs.TokenExpiredError, // Token过期
errs.TokenInvalidError, // Token无效
errs.TokenMalformedError, // Token格式错误
errs.TokenNotValidYetError, // Token尚未生效
errs.TokenUnknownError, // Token未知错误
errs.TokenNotExistError, // Token不存在
errs.TokenKickedError: // Token被踢下线
return false, err // 不需要重连,直接返回错误
default:
return true, err // 其他错误,可以重连
}
}
c.listener().OnConnectFailed(sdkerrs.NetworkError, err.Error())
return true, err
}
// 连接建立成功后的处理
if err := c.writeConnFirstSubMsg(ctx); err != nil {
log.ZError(ctx, "first write user online sub info error", err)
ccontext.GetApiErrCodeCallback(ctx).OnError(ctx, err)
c.listener().OnConnectFailed(sdkerrs.NetworkError, err.Error())
c.conn.Close()
return true, err
}
// 通知连接成功
c.listener().OnConnectSuccess()
c.sub.onConnSuccess()
c.ctx = newContext(c.conn.LocalAddr())
c.ctx = context.WithValue(ctx, "ConnContext", c.ctx)
c.SetConnectionStatus(Connected)
// 设置心跳处理器
c.conn.SetPongHandler(c.pongHandler)
c.conn.SetPingHandler(c.pingHandler)
*num++
log.ZInfo(c.ctx, "long conn establish success", "localAddr", c.conn.LocalAddr(), "connNum", *num)
// 重置重连策略
c.reconnectStrategy.Reset()
// 通知消息同步器连接已建立
_ = common.DispatchConnected(ctx, c.pushMsgAndMaxSeqCh)
return true, nil
}
WebSocket URL构建关键点:
-
Token传递方式 : 通过URL查询参数
token
字段携带ImToken -
必要参数:
sendID
: 用户ID,标识连接的所有者token
: ImToken,用于身份验证(阶段5生成的)platformID
: 平台ID,区分不同设备类型operationID
: 操作ID,用于链路追踪isBackground
: 后台状态标识compression
: 压缩类型(可选)
-
错误处理: 详细的Token相关错误处理,区分可重连和不可重连的错误
连接URL示例:
ini
ws://localhost:10003?sendID=user123&token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&platformID=2&operationID=1640123456789&isBackground=false&compression=gzip
5.8 第八阶段:SDK HTTP请求Token认证与API网关验证
登录成功后,SDK在进行HTTP API调用时需要携带Token进行身份认证,OpenIM Server的API网关会对这些请求进行统一的Token验证。
5.8.1 SDK HTTP客户端Token携带 - http_client.go
SDK的HTTP客户端在发送API请求时会自动携带Token:
go
// openim-sdk-core/pkg/network/http_client.go:90-110
func ApiPost(ctx context.Context, api string, req, resp any) (err error) {
// 提取operationID并验证
operationID, _ := ctx.Value("operationID").(string)
if operationID == "" {
err := sdkerrs.ErrArgs.WrapMsg("call api operationID is empty")
log.ZError(ctx, "ApiRequest", err, "type", "ctx not set operationID")
return err
}
// 序列化请求对象为JSON
reqBody, err := json.Marshal(req)
if err != nil {
log.ZError(ctx, "ApiRequest", err, "type", "json.Marshal(req) failed")
return sdkerrs.ErrSdkInternal.WrapMsg("json.Marshal(req) failed " + err.Error())
}
// 构建完整的API URL并创建HTTP请求
ctxInfo := ccontext.Info(ctx)
reqUrl := ctxInfo.ApiAddr() + api
request, err := http.NewRequestWithContext(ctx, http.MethodPost, reqUrl, bytes.NewReader(reqBody))
if err != nil {
log.ZError(ctx, "ApiRequest", err, "type", "http.NewRequestWithContext failed")
return sdkerrs.ErrSdkInternal.WrapMsg("sdk http.NewRequestWithContext failed " + err.Error())
}
// 设置关键的请求头信息
log.ZDebug(ctx, "ApiRequest", "url", reqUrl, "token", ctxInfo.Token(), "body", string(reqBody))
request.ContentLength = int64(len(reqBody))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("operationID", operationID)
request.Header.Set("token", ctxInfo.Token()) // 关键:携带认证Token
request.Header.Set("Accept-Encoding", "gzip")
// 发送请求并处理响应...
}
关键点分析:
- Token来源: 从上下文信息中获取登录时缓存的ImToken
- 请求头设置 : 通过
token
请求头携带认证信息 - 链路追踪 : 通过
operationID
实现请求的全链路追踪 - 内容协商: 支持gzip压缩以优化传输效率
5.8.2 API网关统一Token验证 - router.go
OpenIM Server的API网关对所有API请求进行统一的Token验证:
go
// open-im-server/internal/api/router.go:130-153
func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, config *Config) (*gin.Engine, error) {
// 建立各种RPC连接...
authConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Auth)
if err != nil {
return nil, err
}
// 初始化Gin路由器
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 注册全局中间件,包括Token解析中间件
r.Use(prommetricsGin(), gin.RecoveryWithWriter(gin.DefaultErrorWriter, mw.GinPanicErr),
mw.CorsHandler(), mw.GinParseOperationID(),
GinParseToken(rpcli.NewAuthClient(authConn))) // 核心:Token解析中间件
// 注册各种业务路由...
return r, nil
}
// GinParseToken Token解析中间件实现
func GinParseToken(authClient *rpcli.AuthClient) gin.HandlerFunc {
return func(c *gin.Context) {
switch c.Request.Method {
case http.MethodPost:
// 1. 检查API白名单,部分接口无需Token验证
for _, wApi := range Whitelist {
if strings.HasPrefix(c.Request.URL.Path, wApi) {
c.Next() // 白名单接口直接放行
return
}
}
// 2. 从请求头中提取Token
token := c.Request.Header.Get(constant.Token)
if token == "" {
log.ZWarn(c, "header get token error", servererrs.ErrArgs.WrapMsg("header must have token"))
apiresp.GinError(c, servererrs.ErrArgs.WrapMsg("header must have token"))
c.Abort() // 终止请求处理
return
}
// 3. 调用认证服务解析Token
resp, err := authClient.ParseToken(c, token)
if err != nil {
apiresp.GinError(c, err)
c.Abort() // Token验证失败,终止请求
return
}
// 4. 将用户信息设置到请求上下文中
c.Set(constant.OpUserPlatform, constant.PlatformIDToName(int(resp.PlatformID)))
c.Set(constant.OpUserID, resp.UserID)
c.Next() // 继续处理请求
}
kickTokenFunc(kickClients)
}
}
5.8.3 认证服务Token解析实现 - auth.go
认证服务提供Token解析的具体实现:
go
// open-im-server/internal/rpc/auth/auth.go:354-369
func (s *authServer) ParseToken(ctx context.Context, req *pbauth.ParseTokenReq) (resp *pbauth.ParseTokenResp, error) {
resp = &pbauth.ParseTokenResp{}
// 调用内部Token解析方法
claims, err := s.parseToken(ctx, req.Token)
if err != nil {
return nil, err
}
// 构建响应,返回Token中的用户信息
resp.UserID = claims.UserID // 用户ID
resp.PlatformID = int32(claims.PlatformID) // 平台ID
resp.ExpireTimeSeconds = claims.ExpiresAt.Unix() // Token过期时间
return resp, nil
}
// parseToken Token解析的核心逻辑
func (s *authServer) parseToken(ctx context.Context, tokensString string) (claims *tokenverify.Claims, err error) {
// 1. 解析JWT Token,验证签名和格式
claims, err = tokenverify.GetClaimFromToken(tokensString, authverify.Secret(s.config.Share.Secret))
if err != nil {
return nil, err
}
// 2. 检查是否为管理员Token(管理员Token享有特殊权限)
isAdmin := authverify.IsManagerUserID(claims.UserID, s.config.Share.IMAdminUserID)
if isAdmin {
return claims, nil // 管理员Token直接通过,跳过Redis状态检查
}
// 3. 从Redis获取普通用户Token的状态映射
m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID)
if err != nil {
return nil, err
}
// 4. Token不存在于Redis中(可能已过期或被删除)
if len(m) == 0 {
return nil, servererrs.ErrTokenNotExist.Wrap()
}
// 5. 检查特定Token的状态
if v, ok := m[tokensString]; ok {
switch v {
case constant.NormalToken:
return claims, nil // Token状态正常
case constant.KickedToken:
return nil, servererrs.ErrTokenKicked.Wrap() // Token已被踢下线
default:
return nil, errs.Wrap(errs.ErrTokenUnknown) // 未知Token状态
}
}
return nil, servererrs.ErrTokenNotExist.Wrap()
}
Token验证安全机制:
- JWT签名验证: 使用系统密钥验证Token的完整性和真实性
- 管理员特权: 系统管理员Token跳过Redis状态检查,提高性能
- 状态实时检查: 从Redis查询Token的实时状态,支持踢人等操作
- 多状态支持: 区分正常Token、被踢Token和未知状态
- 过期自动清理: 过期Token从Redis中自动清理
5.8.4 认证客户端RPC调用 - auth.go
API网关通过RPC客户端调用认证服务:
go
// open-im-server/pkg/rpcli/auth.go:25-27
func (x *AuthClient) ParseToken(ctx context.Context, token string) (*auth.ParseTokenResp, error) {
return x.AuthClient.ParseToken(ctx, &auth.ParseTokenReq{Token: token})
}
RPC调用特点:
- 简化接口: 封装了gRPC调用的复杂性
- 类型安全: 强类型的请求和响应结构
- 错误传播: 透明的错误传播机制
- 性能优化: 连接复用和负载均衡
5.9 第九阶段:WebSocket连接建立与多端登录处理
5.9.1 WebSocket连接处理 - ws_server.go
go
// open-im-server/internal/msggateway/ws_server.go:768-834
func (ws *WsServer) wsHandler(w http.ResponseWriter, r *http.Request) {
// 创建连接上下文
connContext := newContext(w, r)
// 检查连接数限制
if ws.onlineUserConnNum.Load() >= ws.wsMaxConnNum {
httpError(connContext, servererrs.ErrConnOverMaxNumLimit.WrapMsg("over max conn num limit"))
return
}
// 解析必要参数(用户ID、令牌等)
err := connContext.ParseEssentialArgs()
if err != nil {
httpError(connContext, err)
return
}
// 调用认证服务解析令牌
resp, err := ws.authClient.ParseToken(connContext, connContext.GetToken())
if err != nil {
// 根据上下文判断是否需要通过WebSocket发送错误
shouldSendError := connContext.ShouldSendResp()
if shouldSendError {
// 尝试建立WebSocket连接发送错误
wsLongConn := newGWebSocket(WebSocket, ws.handshakeTimeout, ws.writeBufferSize)
if err := wsLongConn.RespondWithError(err, w, r); err == nil {
return
}
}
// 通过HTTP返回错误
httpError(connContext, err)
return
}
// 验证认证响应的匹配性
err = ws.validateRespWithRequest(connContext, resp)
if err != nil {
httpError(connContext, err)
return
}
log.ZDebug(connContext, "new conn", "token", connContext.GetToken())
// 创建WebSocket长连接
wsLongConn := newGWebSocket(WebSocket, ws.handshakeTimeout, ws.writeBufferSize)
if err := wsLongConn.GenerateLongConn(w, r); err != nil {
log.ZWarn(connContext, "long connection fails", err)
return
} else {
// 检查是否需要发送成功响应
shouldSendSuccessResp := connContext.ShouldSendResp()
if shouldSendSuccessResp {
if err := wsLongConn.RespondWithSuccess(); err != nil {
return
}
}
}
// 从对象池获取客户端对象并重置状态
client := ws.clientPool.Get().(*Client)
client.ResetClient(connContext, wsLongConn, ws)
// 注册客户端并启动消息处理循环
ws.registerChan <- client
go client.readMessage()
}
5.9.2 Token验证逻辑 - auth.go
go
// open-im-server/internal/rpc/auth/auth.go:312-346
func (s *authServer) parseToken(ctx context.Context, tokensString string) (claims *tokenverify.Claims, err error) {
// 解析JWT Token
claims, err = tokenverify.GetClaimFromToken(tokensString, authverify.Secret(s.config.Share.Secret))
if err != nil {
return nil, err
}
// 检查是否为管理员Token
isAdmin := authverify.IsManagerUserID(claims.UserID, s.config.Share.IMAdminUserID)
if isAdmin {
return claims, nil // 管理员Token直接通过,跳过Redis状态检查
}
// 从Redis获取Token状态映射
m, err := s.authDatabase.GetTokensWithoutError(ctx, claims.UserID, claims.PlatformID)
if err != nil {
return nil, err
}
// Token不存在于Redis中
if len(m) == 0 {
return nil, servererrs.ErrTokenNotExist.Wrap()
}
// 检查特定Token的状态
if v, ok := m[tokensString]; ok {
switch v {
case constant.NormalToken:
return claims, nil
case constant.KickedToken:
return nil, servererrs.ErrTokenKicked.Wrap()
default:
return nil, errs.Wrap(errs.ErrTokenUnknown)
}
}
return nil, servererrs.ErrTokenNotExist.Wrap()
}
Token验证流程:
- JWT解析: 验证Token格式和签名
- 管理员特权: 管理员Token跳过Redis检查
- 状态查询: 从Redis查询Token状态
- 状态验证: 检查Token是否正常、被踢或过期
5.9.3 多端登录策略处理核心机制 - ws_server.go
go
// open-im-server/internal/msggateway/ws_server.go:635-688
func (ws *WsServer) multiTerminalLoginChecker(clientOK bool, oldClients []*Client, newClient *Client) {
// 根据多端登录策略执行相应逻辑
switch ws.msgGatewayConfig.Share.MultiLogin.Policy {
case constant.DefalutNotKick:
// 默认策略:不踢任何连接
case constant.PCAndOther:
// PC和其他策略:PC端不踢,其他端按终端处理
if constant.PlatformIDToClass(newClient.PlatformID) == constant.TerminalPC {
return
}
fallthrough
case constant.AllLoginButSameTermKick:
// 同终端踢下线策略
if !clientOK {
return
}
// 删除旧连接
ws.clients.DeleteClients(newClient.UserID, oldClients)
for _, c := range oldClients {
err := c.KickOnlineMessage()
if err != nil {
log.ZWarn(c.ctx, "KickOnlineMessage", err)
}
}
// 失效旧令牌,保留新令牌
ctx := mcontext.WithMustInfoCtx(
[]string{newClient.ctx.GetOperationID(), newClient.ctx.GetUserID(),
constant.PlatformIDToName(newClient.PlatformID), newClient.ctx.GetConnID()},
)
req := &pbAuth.InvalidateTokenReq{
PreservedToken: newClient.token,
UserID: newClient.UserID,
PlatformID: int32(newClient.PlatformID),
}
if err := ws.authClient.InvalidateToken(ctx, req); err != nil {
log.ZWarn(newClient.ctx, "InvalidateToken err", err, "userID", newClient.UserID,
"platformID", newClient.PlatformID)
}
case constant.AllLoginButSameClassKick:
// 同类别踢下线策略
clients, ok := ws.clients.GetAll(newClient.UserID)
if !ok {
return
}
var kickClients []*Client
// 查找同类别的连接
for _, client := range clients {
if constant.PlatformIDToClass(client.PlatformID) == constant.PlatformIDToClass(newClient.PlatformID) {
kickClients = append(kickClients, client)
}
}
kickTokenFunc(kickClients)
}
}
5.9.4 多端登录剔除机制深度解析
剔除策略详细说明:
-
DefalutNotKick(默认不踢):
- 允许所有平台同时在线
- 适用于宽松的多端使用场景
- 不进行任何连接冲突处理
-
PCAndOther(PC和其他):
- PC端享有特殊权限,不被踢下线
- 其他平台按照同终端踢下线策略处理
- 体现PC端的重要性和稳定性需求
-
AllLoginButSameTermKick(同终端踢下线):
go// 核心踢下线流程 func handleSameTerminalKick(oldClients []*Client, newClient *Client) { // 1. 从用户映射中删除旧连接 ws.clients.DeleteClients(newClient.UserID, oldClients) // 2. 向旧连接发送踢下线消息 for _, c := range oldClients { err := c.KickOnlineMessage() if err != nil { log.ZWarn(c.ctx, "KickOnlineMessage", err) } } // 3. 调用认证服务失效旧Token,保留新Token ctx := mcontext.WithMustInfoCtx(...) req := &pbAuth.InvalidateTokenReq{ PreservedToken: newClient.token, // 保留新连接的Token UserID: newClient.UserID, PlatformID: int32(newClient.PlatformID), } ws.authClient.InvalidateToken(ctx, req) }
5.10. OpenIM登录流程完整时序图
以下时序图展示了OpenIM设备登录的完整流程,包括Chat系统认证、Token生成、SDK初始化和WebSocket连接建立:
LoginActivity participant SDK as open-im-sdk-android
LoginVM participant ChatAPI as Chat System
HTTP API participant ChatRPC as Chat System
RPC Service participant Redis as Redis Cache
Token/Session存储 participant ChatDB as Chat Database
MongoDB participant OpenIMAPI as OpenIM Server
RPC API participant OpenIMDB as OpenIM Database
MongoDB participant IMSDK as openim-sdk-core
UserContext participant WSGateway as OpenIM Gateway
WebSocket Server participant UserMap as Connection Map
用户连接管理 Note over Client,UserMap: === 阶段1: 用户登录界面交互 === Client->>SDK: 1.1 用户输入登录信息
账号/手机/邮箱 + 密码/验证码 SDK->>SDK: 1.2 客户端参数验证
格式检查、必填项验证 SDK->>SDK: 1.3 构建登录参数
密码MD5加密、平台标识等 Note over Client,UserMap: === 阶段2: Chat系统认证 === SDK->>ChatAPI: 2.1 POST /account/login
{"phoneNumber":"...", "password":"...", "platform":2} ChatAPI->>ChatAPI: 2.2 获取客户端IP
用于安全检查和登录记录 ChatAPI->>ChatRPC: 2.3 Login RPC调用
传递完整登录信息 ChatRPC->>ChatRPC: 2.4 参数验证
密码和验证码至少一个 ChatRPC->>ChatRPC: 2.5 构建账号凭证
手机号: +86 13800000000 ChatRPC->>ChatDB: 2.6 查询用户凭证
根据账号查找用户信息 ChatDB-->>ChatRPC: 2.7 返回用户凭证信息 ChatRPC->>ChatRPC: 2.8 IP和用户登录频率检查
防止暴力破解 alt 密码登录 ChatRPC->>ChatDB: 2.9a 查询账户密码 ChatDB-->>ChatRPC: 2.10a 返回密码哈希 ChatRPC->>ChatRPC: 2.11a 密码MD5比对验证 else 验证码登录 ChatRPC->>Redis: 2.9b 验证验证码
Key: verify_code:{account}:3 Redis-->>ChatRPC: 2.10b 返回存储的验证码 ChatRPC->>ChatRPC: 2.11b 验证码比对验证 ChatRPC->>Redis: 2.12b 删除已使用验证码 end ChatRPC->>ChatRPC: 2.13 生成ChatToken
JWT: {userID, userType:1, platformID:0} ChatRPC->>Redis: 2.14 缓存ChatToken状态
Key: CHAT_UID_TOKEN_STATUS:{userID} ChatRPC->>ChatDB: 2.15 记录登录日志
IP、设备ID、平台信息 ChatRPC-->>ChatAPI: 2.16 返回认证结果
{userID, chatToken} Note over Client,UserMap: === 阶段3: 获取OpenIM管理员Token === ChatAPI->>ChatAPI: 3.1 获取管理员Token
ImAdminTokenWithDefaultAdmin() ChatAPI->>OpenIMAPI: 3.2 GetAdminToken RPC
{secret, adminUserID} OpenIMAPI->>OpenIMAPI: 3.3 验证系统密钥
config.Share.Secret OpenIMAPI->>OpenIMAPI: 3.4 验证管理员身份
IMAdminUserID列表检查 OpenIMAPI->>OpenIMDB: 3.5 检查管理员用户存在性 OpenIMDB-->>OpenIMAPI: 3.6 确认用户存在 OpenIMAPI->>OpenIMAPI: 3.7 生成管理员Token
JWT: {adminUserID, AdminPlatformID} OpenIMAPI->>Redis: 3.8 缓存管理员Token
Key: CHAT:IM_TOKEN:{adminUserID} OpenIMAPI-->>ChatAPI: 3.9 返回管理员Token Note over Client,UserMap: === 阶段4: 生成用户ImToken === ChatAPI->>OpenIMAPI: 4.1 GetUserToken RPC
{userID, platformID} + AdminToken OpenIMAPI->>OpenIMAPI: 4.2 验证管理员权限
CheckAdmin(adminUserID) OpenIMAPI->>OpenIMAPI: 4.3 平台ID合法性检查
不能为AdminPlatformID OpenIMAPI->>OpenIMDB: 4.4 获取用户信息验证
检查用户存在和权限 OpenIMDB-->>OpenIMAPI: 4.5 返回用户信息 OpenIMAPI->>OpenIMAPI: 4.6 生成用户ImToken
JWT: {userID, platformID} OpenIMAPI->>Redis: 4.7 缓存ImToken状态
Key: UID_PID_TOKEN_STATUS:{userID}:Android OpenIMAPI-->>ChatAPI: 4.8 返回用户ImToken ChatAPI-->>SDK: 4.9 返回登录结果
{userID, chatToken, imToken} Note over Client,UserMap: === 阶段5: Android SDK初始化 === SDK->>SDK: 5.1 缓存登录凭证
LoginCertificate.cache() SDK->>IMSDK: 5.2 调用OpenIM SDK登录
OpenIMClient.getInstance().login() IMSDK->>IMSDK: 5.3 检查登录状态
避免重复登录 IMSDK->>IMSDK: 5.4 设置登录状态为Logging
保存userID和token IMSDK->>IMSDK: 5.5 初始化各种管理器
消息、会话、好友、群组等 IMSDK->>IMSDK: 5.6 启动后台服务
长连接、消息同步、事件处理 Note over Client,UserMap: === 阶段6: WebSocket连接建立 === IMSDK->>IMSDK: 6.1 启动长连接管理器
readPump, writePump, heartbeat IMSDK->>IMSDK: 6.2 构建连接URL
携带token等参数 IMSDK->>WSGateway: 6.3 WebSocket连接请求
ws://server?sendID={userID}&token={imToken}&platformID=2 WSGateway->>WSGateway: 6.4 检查连接数限制
防止过载 WSGateway->>WSGateway: 6.5 解析连接参数
userID, token, platformID等 WSGateway->>OpenIMAPI: 6.6 ParseToken RPC调用
验证ImToken OpenIMAPI->>OpenIMAPI: 6.7 JWT Token解析
验证签名和格式 alt 管理员Token OpenIMAPI->>OpenIMAPI: 6.8a 管理员特权处理
跳过Redis状态检查 else 普通用户Token OpenIMAPI->>Redis: 6.8b 查询Token状态
Key: UID_PID_TOKEN_STATUS:{userID}:Android Redis-->>OpenIMAPI: 6.9b 返回Token状态 OpenIMAPI->>OpenIMAPI: 6.10b 检查Token状态
Normal/Kicked/Unknown end OpenIMAPI-->>WSGateway: 6.11 返回解析结果
{userID, platformID, expireTime} WSGateway->>WSGateway: 6.12 验证请求匹配性
URL参数与Token一致性 WSGateway->>WSGateway: 6.13 建立WebSocket连接
协议升级 WSGateway->>WSGateway: 6.14 创建Client对象
从对象pool获取并重置 Note over Client,UserMap: === 阶段7: 多端登录处理 === WSGateway->>UserMap: 7.1 检查用户连接状态
Get(userID, platformID) UserMap-->>WSGateway: 7.2 返回连接状态
userOK, clientOK, oldClients alt 用户首次登录 WSGateway->>UserMap: 7.3a 注册新用户连接
Set(userID, client) WSGateway->>WSGateway: 7.4a 更新监控指标
onlineUserNum++ else 用户已存在连接 WSGateway->>WSGateway: 7.3b 多端登录策略检查
根据配置处理冲突 alt 同终端踢下线策略 WSGateway->>UserMap: 7.4b1 删除旧连接
DeleteClients(userID, oldClients) WSGateway->>WSGateway: 7.5b1 发送踢下线消息
KickOnlineMessage() WSGateway->>OpenIMAPI: 7.6b1 失效旧Token
InvalidateToken RPC OpenIMAPI->>Redis: 7.7b1 更新Token状态
设置为KickedToken else 允许多端登录 WSGateway->>UserMap: 7.4b2 正常添加连接
Set(userID, client) end end Note over Client,UserMap: === 阶段8: 连接成功与服务启动 === WSGateway->>WSGateway: 8.1 启动消息处理协程
client.readMessage() WSGateway->>IMSDK: 8.2 连接成功通知
OnConnectSuccess回调 IMSDK->>IMSDK: 8.3 设置登录状态为Logged
初始化完成 IMSDK->>IMSDK: 8.4 启动消息同步服务
msgSyncer.DoListener() IMSDK->>IMSDK: 8.5 启动会话事件处理
ConsumeConversationEventLoop() IMSDK->>SDK: 8.6 SDK登录成功回调
onSuccess(data) SDK->>SDK: 8.7 设置全局登录状态
BaseApp.loginCertificate SDK->>Client: 8.8 通知UI登录成功
jump()跳转主界面 Note over Client,UserMap: === 数据存储结构说明 === Note over Redis: ChatToken: CHAT_UID_TOKEN_STATUS:{userID}
AdminToken: CHAT:IM_TOKEN:{adminUserID}
ImToken: UID_PID_TOKEN_STATUS:{userID}:{platform}
验证码: verify_code:{account}:{usedFor} Note over UserMap: 用户连接映射: {userID -> UserPlatform}
连接状态通知: UserState Channel
多端登录冲突处理: 实时剔除机制