三,安全与签名、黑名单和权限管理、玩转聊天室和临时对话
本章导读
在前一篇消息收发的更多方式,离线推送与消息同步,多设备登录中,我们演示了与消息相关的更多特殊需求的实现方法,现在,我们会更进一步,从系统安全和成员权限管理的角度,给大家详细说明:
- 如何通过第三方鉴权来控制客户端登录与操作
- 如何对成员权限进行限制,以保证聊天流程能被运营人员很好管理起来
- 如何实现一个不限人数的直播聊天室
- 如何对大型群聊中的消息进行实时内容过滤
- 如何使用临时对话
安全与签名
即时通讯服务有一大特色就是让应用账户系统和聊天服务解耦,终端用户只需要登录应用账户系统就可以直接使用即时通讯服务,同时从系统安全角度出发,我们还提供了第三方操作签名的机制来保证聊天通道的安全性。
该机制的工作架构是,在客户端和即时通讯云端之间,增加应用自己的鉴权服务器(也就是即时通讯服务之外的「第三方」),在客户端开始一些有安全风险的操作命令(如登录聊天服务、建立对话、加入群组、邀请他人等)之前,先通过鉴权服务器获取签名,之后即时通讯云端会依据它和第三方鉴权服务之间的协议来验证该签名,只有附带有效签名的请求才会被执行,非法请求全部会被阻止下来。
使用操作签名可以保证聊天通道的安全,这一功能默认是关闭的,可以在 云服务控制台 > 即时通讯 > 设置 > 即时通讯选项 中进行开启:
- 登录启用签名认证,用于控制所有的用户登录
- 对话操作启用签名认证,用于控制新建或加入对话、邀请/踢出对话成员等操作
- 聊天记录查询启用签名认证,用于控制聊天记录查询操作
- 黑名单操作启用签名认证,用于控制修改对话的黑名单用户列表操作(关于黑名单,请参考下一节)
开发者可根据实际需要进行选择。一般来说,登录认证 是最基本的安全机制,我们强烈建议开发者开启登录认证。
- 客户端进行登录或新建对话等操作,SDK 会调用
SignatureFactory
的实现,并携带用户信息和用户行为(登录、新建对话或群组操作)请求签名; - 应用自有的权限系统,或应用在云引擎上的签名程序收到请求,进行权限验证,如果通过则利用下文所述的 签名算法 生成时间戳、随机字符串和签名返回给客户端;
- 客户端获得签名后,编码到请求中,发给即时通讯服务器;
- 即时通讯服务器对请求的内容和签名做一遍验证,确认这个操作是被应用服务器允许的,进而执行后续的实际操作。
签名采用 HMAC-SHA1 算法,输出字节流的十六进制字符串(hex dump)。针对不同的请求,开发者需要拼装不同组合的字符串,加上 UTC timestamp 以及随机字符串作为签名的消息(参见后续格式说明)。总体上,签名就是使用特定的密钥(在这里我们使用应用的 Master Key
),对输入的消息(即「签名的消息」)进行哈希计算,得到一串十六进制的字符串,这就是最终的「签名」。
对于使用 LCUser
的应用,可使用 REST API 获取登录签名进行登录认证。
签名格式说明
下面我们详细说明一下不同操作的签名消息格式。
用户登录签名
签名的消息格式如下,注意 clientid
与 timestamp
之间是两个冒号:
appid:clientid::timestamp:nonce
参数 | 说明 |
---|---|
appid | 应用的 ID。 |
clientid | 登录时使用的 clientId 。 |
timestamp | 当前的 UTC 时间距离 Unix epoch 的 毫秒数。 |
nonce | 随机字符串。 |
注意:签名的 key 必须 是应用的
Master Key
,你可以在 云服务控制台 > 设置 > 应用凭证 里找到。请保护好 Master Key,不要泄露给任何无关人员。
开发者可以实现自己的 SignatureFactory
,调用远程服务器的签名接口获得签名。如果你没有自己的服务器,可以直接在云引擎上通过 网站托管 来实现自己的签名接口。在移动应用中直接进行签名的做法 非常危险,它可能导致你的 Master Key 泄漏。
签名的有效期是 6 个小时,强制下线后签名立即失效。 签名失效不影响当前在线的 client。
开启对话签名
新建一个对话的时候,签名的消息格式为:
appid:clientid:sorted_member_ids:timestamp:nonce
appid
、clientid
、timestamp
和nonce
的含义 同上。sorted_member_ids
是以半角冒号(:
)分隔、升序排序 的clientId
,即邀请参与该对话的成员列表。
群组功能的签名
在群组功能中,我们对 加群、邀请 和 踢出群 这三个动作也允许加入签名,签名的消息格式是:
appid:clientid:convid:sorted_member_ids:timestamp:nonce:action
appid
、clientid
、sorted_member_ids
、timestamp
和nonce
的含义同上。对创建群的情况,这里sorted_member_ids
是空字符串。convid
是此次行为关联的对话 ID。action
是此次行为的动作,invite
表示加群和邀请,kick
表示踢出群。
查询聊天记录的签名
appid:client_id:convid:nonce:timestamp
各参数的含义同上。
注意,此签名仅用于通过 REST API 查询历史消息,客户端 SDK 不适用。
黑名单的签名
由于黑名单有两种情况,所以签名的消息格式也有两种:
client
对conversation
appid:clientid:convid::timestamp:nonce:action
action
是此次行为的动作,client-block-conversations
表示添加黑名单,client-unblock-conversations
表示取消黑名单。
conversation
对client
appid:clientid:convid:sorted_member_ids:timestamp:nonce:action
action
是此次行为的动作,conversation-block-clients
表示添加黑名单,conversation-unblock-clients
表示取消黑名单。sorted_member_ids
同上。
云引擎签名范例
为了帮助开发者理解云端签名的算法,我们开源了一个用「Node.js + 云引擎」实现签名的云端,供开发者学习和使用:即时通讯云引擎签名 Demo。
客户端如何支持操作签名
上面的签名算法,都是对第三方鉴权服务器如何进行签名的协议说明,在开启了操作签名的前提下,客户端这边的使用流程需要进行相应的改变,增加请求签名的环节,才能让整套机制顺利运行起来。
即时通讯 SDK 为每一个 AVIMClient
实例都预留了一个 Signature
工厂接口,这个接口默认不设置就表示不使用签名,启动签名的时候,只需要在客户端实现这一接口,调用远程服务器的签名接口获得签名,并把它绑定到 AVIMClient
实例上即可:
- Unity
- Android
- iOS
public class LocalSignatureFactory : ILCIMSignatureFactory {
const string MasterKey = "pyvbNSh5jXsuFQ3C8EgnIdhw";
public Task<LCIMSignature> CreateConnectSignature(string clientId) {
long timestamp = DateTimeOffset.Now.ToUnixTimeSeconds();
string nonce = NewNonce();
string signature = GenerateSignature(LCApplication.AppId, clientId, string.Empty, timestamp.ToString(), nonce);
return Task.FromResult(new LCIMSignature {
Signature = signature,
Timestamp = timestamp,
Nonce = nonce
});
}
public Task<LCIMSignature> CreateStartConversationSignature(string clientId, IEnumerable<string> memberIds) {
string sortedMemberIds = string.Empty;
if (memberIds != null) {
List<string> sortedMemberList = memberIds.ToList();
sortedMemberList.Sort();
sortedMemberIds = string.Join(":", sortedMemberList);
}
long timestamp = DateTimeOffset.Now.ToUnixTimeSeconds();
string nonce = NewNonce();
string signature = GenerateSignature(LCApplication.AppId, clientId, sortedMemberIds, timestamp.ToString(), nonce);
return Task.FromResult(new LCIMSignature {
Signature = signature,
Timestamp = timestamp,
Nonce = nonce
});
}
public Task<LCIMSignature> CreateConversationSignature(string conversationId, string clientId, IEnumerable<string> memberIds, string action) {
string sortedMemberIds = string.Empty;
if (memberIds != null) {
List<string> sortedMemberList = memberIds.ToList();
sortedMemberList.Sort();
sortedMemberIds = string.Join(":", sortedMemberList);
}
long timestamp = DateTimeOffset.Now.ToUnixTimeSeconds();
string nonce = NewNonce();
string signature = GenerateSignature(LCApplication.AppId, clientId, conversationId, sortedMemberIds, timestamp.ToString(), nonce, action);
return Task.FromResult(new LCIMSignature {
Signature = signature,
Timestamp = timestamp,
Nonce = nonce
});
}
public Task<LCIMSignature> CreateBlacklistSignature(string conversationId, string clientId, IEnumerable<string> memberIds, string action) {
string sortedMemberIds = string.Empty;
if (memberIds != null) {
List<string> sortedMemberList = memberIds.ToList();
sortedMemberList.Sort();
sortedMemberIds = string.Join(":", sortedMemberList);
}
long timestamp = DateTimeOffset.Now.ToUnixTimeSeconds();
string nonce = NewNonce();
string signature = GenerateSignature(LCApplication.AppId, clientId, conversationId, sortedMemberIds, timestamp.ToString(), nonce, action);
return Task.FromResult(new LCIMSignature {
Signature = signature,
Timestamp = timestamp,
Nonce = nonce
});
}
private static string SignSHA1(string key, string text) {
HMACSHA1 hmac = new HMACSHA1(Encoding.UTF8.GetBytes(key));
byte[] bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(text));
string signature = BitConverter.ToString(bytes).Replace("-", string.Empty);
return signature;
}
private static string NewNonce() {
byte[] bytes = new byte[10];
using (RandomNumberGenerator generator = RandomNumberGenerator.Create()) {
generator.GetBytes(bytes);
}
return Convert.ToBase64String(bytes);
}
private static string GenerateSignature(params string[] args) {
string text = string.Join(":", args);
string signature = SignSHA1(MasterKey, text);
return signature;
}
}
// 设置签名工程
LCIMClient tom = new LCIMClient("tom", signatureFactory: new LocalSignatureFactory());
// 这是一个依赖云引擎完成签名的示例
public class KeepAliveSignatureFactory implements SignatureFactory {
@Override
public Signature createSignature(String peerId, List<String> watchIds) throws SignatureException {
Map<String,Object> params = new HashMap<String,Object>();
params.put("self_id",peerId);
params.put("watch_ids",watchIds);
try{
Object result = LCCloud.callFunction("sign",params);
if(result instanceof Map){
Map<String,Object> serverSignature = (Map<String,Object>) result;
Signature signature = new Signature();
signature.setSignature((String)serverSignature.get("signature"));
signature.setTimestamp((Long)serverSignature.get("timestamp"));
signature.setNonce((String)serverSignature.get("nonce"));
return signature;
}
}catch(LCException e){
throw (SignatureFactory.SignatureException) e;
}
return null;
}
@Override
public Signature createConversationSignature(String convId, String peerId,
List<String> targetPeerIds,String action) throws SignatureException{
Map<String,Object> params = new HashMap<String,Object>();
params.put("client_id",peerId);
params.put("conv_id",convId);
params.put("members",targetPeerIds);
params.put("action",action);
try{
Object result = LCCloud.callFunction("sign2",params);
if(result instanceof Map){
Map<String,Object> serverSignature = (Map<String,Object>) result;
Signature signature = new Signature();
signature.setSignature((String)serverSignature.get("signature"));
signature.setTimestamp((Long)serverSignature.get("timestamp"));
signature.setNonce((String)serverSignature.get("nonce"));
return signature;
}
}catch(LCException e){
throw (SignatureFactory.SignatureException) e;
}
return null;
}
@Override
public Signature createBlacklistSignature(String clientId, String conversationId, List<String> memberIds,
String action) throws SignatureException {
Map<String,Object> params = new HashMap<String,Object>();
params.put("client_id",clientId);
params.put("conv_id",conversationId);
params.put("members",memberIds);
params.put("action",action);
try{
Object result = LCCloud.callFunction("sign3",params);
if(result instanceof Map){
Map<String,Object> serverSignature = (Map<String,Object>) result;
Signature signature = new Signature();
signature.setSignature((String)serverSignature.get("signature"));
signature.setTimestamp((Long)serverSignature.get("timestamp"));
signature.setNonce((String)serverSignature.get("nonce"));
return signature;
}
}catch(LCException e){
throw (SignatureFactory.SignatureException) e;
}
return null;
}
}
// 将签名工厂类的实例绑定到 LCIMClient 上
LCIMOptions.getGlobalOptions().setSignatureFactory(new KeepAliveSignatureFactory());
// 实现 LCIMSignatureDataSource 协议
- (void)client:(LCIMClient *)client
action:(LCIMSignatureAction)action
conversation:(LCIMConversation * _Nullable)conversation
clientIds:(NSArray<NSString *> * _Nullable)clientIds
signatureHandler:(void (^)(LCIMSignature * _Nullable))handler
{
if ([action isEqualToString:LCIMSignatureActionOpen]) {
// 开启了签名认证的模块,需返回对应的签名
LCIMSignature *signature;
/*
...
...
具体实现可以参考章节「云引擎签名范例」
*/
handler(signature);
} else {
// 没有开启签名认证的模块,需返回 nil
handler(nil);
}
}
// 设置协议代理者
NSError *error;
LCIMClient *imClient = [[LCIMClient alloc] initWithClientId:@"Tom" error:&error];
if (!error) {
imClient.signatureDataSource = signatureDelegator;
}
需要强调的是:开发者切勿在客户端直接使用 Master Key
进行签名操作,因为 Master Key
一旦泄露,会造成应用的数据处于高危状态,后果不容小视。因此,强烈建议开发者将签名的具体代码托管在安全性高稳定性好的云端服务器上(例如云引擎)。"
内建账户系统(User)的签名机制
User
是存储服务提供的默认账户系统,对于使用了它来完成用户注册、登录的产品来说,终端用户通过 User
账户系统的登录认证之后,转到即时通讯服务上,是无需再进行登录签名操作的。
使用 User
账号系统登录即时通讯服务的示例如下:
- Unity
- Android
- iOS
LCUser user = await LCUser.Login("username", "password");
CIMClient client = new LCIMClient(user);
await client.Open();
// 以 LCUser 的用户名和密码登录到内建账户系统
LCUser.logInInBackground("username", "password", new LogInCallback<LCUser>() {
@Override
public void done(LCUser user, LCException e) {
if (null != e) {
return;
}
// 以 LCUser 实例创建了一个 client
LCIMClient client = LCIMClient.getInstance(user);
// 登录即 时通讯云端
client.open(new LCIMClientCallback() {
@Override
public void done(final LCIMClient avimClient, LCIMException e) {
// 执行其他逻辑
}
});
}
});
// 以 LCUser 的用户名和密码登录到内建账户系统
[LCUser logInWithUsernameInBackground:username password:password block:^(LCUser * _Nullable user, NSError * _Nullable error) {
// 以 LCUser 实例创建了一个 client
NSError *err;
LCIMClient *client = [[LCIMClient alloc] initWithUser:user error:&err];
if (!err) {
// 登录即时通讯云端
[client openWithCallback:^(BOOL succeeded, NSError * _Nullable error) {
// 执行其他逻辑
}];
}
}];
内置账户系统与即时通讯服务可以共享登录签名信息,这里我们直接用 logIn
成功之后的 LCUser
实例来创建 IMClient
,在即时通讯服务的用户登录环节,云端会自动关联账户系统来确认用户身份的合法性,这样可以省掉 SDK 向第三方申请登录签名的操作,进一步简化开发流程。
IMClient
完成即时通讯系统登录之后,其他功能的使用就和之前的介绍没有任何区别了。
权限管理与黑名单
第三方鉴权是一种服务端对全局进行控制的机制,具体到单个对话的群组,例如开放聊天室,出于产品运营的需求,我们还需要对成员权限进行区分,以及允许管理员来限时/永久屏蔽部分用户。下面我们详细说明一下这样的需求该如何实现。
设置成员权限
「成员权限」是指将对话内成员划分成不同角色,实现类似 QQ 群管理员的效果。使用这个功能需要在 云服务控制台 > 即时通讯 > 设置 > 即时通讯选项 中开启「对话成员属性功能(成员角色管理功能)」。
目前系统内的角色与管理功能的对应关系:
角色 | 功能列表 |
---|---|
Owner | 永久性禁言、踢人、加人、拉黑、更新他人权限 |
Manager | 永久性禁言、踢人、加 人、拉黑、更新他人权限 |
Member | 加人 |
角色的操作权限大小是按照 Owner
-> Manager
-> Member
的顺序逐级递减的,高级别的角色可以修改低级别角色的权限,但反过来的修改是不允许的。同时,对于加人和踢人的操作,在前面文档中我们可以看到,是所有成员都可以执行的操作,在成员角色管理功能开启之后,就变成 Owner
和 Manager
专属的功能的,普通成员发起这两种请求都会报错。
一个对话的 Owner
是不可变更的,我们 SDK 提供了 Conversation#updateMemberRole
方法,支持把一个终端用户在 Manager
和 Member
之间切换角色:
- Unity
- Android
- iOS
/// <summary>
/// Updates the role of a member of this conversation.
/// </summary>
/// <param name="memberId">The member to update.</param>
/// <param name="role">The new role of the member.</param>
/// <returns></returns>
public async Task UpdateMemberRole(string memberId, string role);