CodeMaker

人生不止眼前的 Bug,还有无尽的版本迭代

Web系统License授权分享与思考

Web系统License授权分享与思考

AMI2.0的重构在WebAPI服务最先要放给前端的接口是鉴权,这是在前后端分离的同时重构侧重在后端服务和性能优化的目标下不得不照旧的。可以分享一遍这种License鉴权的方案然后聊聊可以改进之处。

方案原理

鉴权流程图
WebAPI控制器提供两个接口DoRegister和CheckRegister,当Web端调DoRegister接口时,后端利用传入的注册码code与约定的systemName字段进行MD5运算得到一个key,然后用key和Web端传入的license进行AES解密,解密成功且有效,则作为合法注册将license持久化到Redis或者Oracle中,另外一个接口CheckRegister会在系统运行中每间隔1分钟接受Web端轮询,如果没有发现持久化的license或解开license中的日期失效,则认为Web系统未授权,拒绝提供页面服务。

实现方式

后端两个接口核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/// <summary>
/// 系统注册
/// </summary>
[HttpPost]
public APIResult DoRegister(RegisterInput input)
{
string key = Md5Crypto.MD5Encrypt(input.code + SystemName);
var regResult = AesCrypto.AESDecrypt(input.licence, key);

if (!regResult.IsOk)
{
return APIResult.Fail(regResult.Content);
}

var exist = _context.HesSysParams.FirstOrDefault(x => x.ParamName == "WebUIAuth");
if (exist != null)
{
exist.ParamVal = input.ToJson();
}
else
{
_context.Add(new HesSysParam
{
ValCat = "DataCache",
ParamName = "WebUIAuth",
ParamDesc = "WebUIAuth",
ParamType = "string",
ParamVal = input.ToJson()
});
}

var dbResult = _context.SaveChanges();
if (dbResult < 0)
{
return APIResult.Fail("save error");
}

var registerModel = regResult.Content.FromJson<RegisterOutput>();

if (registerModel.experienceDaysFlag == "True"
&& !string.IsNullOrWhiteSpace(registerModel.experienceDays)
&& Convert.ToDateTime(registerModel.cDate).AddDays(Convert.ToInt32(registerModel.experienceDays)) < DateTime.Now)
{
return APIResult.Fail("auth expired");
}

return APIResult.Success("SUCCESS", registerModel);
}

/// <summary>
/// 系统授权检测
/// </summary>
[HttpPost]
public APIResult CheckRegister()
{
var licenceModel = _context.HesSysParams.FirstOrDefault(x => x.ParamName == "WebUIAuth");
if (licenceModel == null)
{
return APIResult.Fail("check fail: not registered");
}

var data = licenceModel.ParamVal.FromJson<RegisterInput>();
var key = Md5Crypto.MD5Encrypt(data?.code + SystemName);
var regResult = AesCrypto.AESDecrypt(data.licence, key);

if (!regResult.IsOk)
{
return APIResult.Fail("check fail: licence error or expired");
}

var registerModel = regResult.Content.FromJson<RegisterOutput>();

if (registerModel.experienceDaysFlag == "True"
&& !string.IsNullOrWhiteSpace(registerModel.experienceDays)
&& Convert.ToDateTime(registerModel.cDate).AddDays(Convert.ToInt32(registerModel.experienceDays)) < DateTime.Now)
{
return APIResult.Fail("auth expired");
}

return APIResult.Success("SUCCESS", registerModel);
}

license解密核心方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/// <summary>
/// AES 解密
/// </summary>
/// <param name="source">密文(Base64)</param>
/// <param name="key">密钥</param>
/// <param name="iv">向量(可选,ECB模式下可为空)</param>
/// <param name="padding">填充模式,默认 PKCS7</param>
/// <param name="mode">加密模式,默认 ECB</param>
/// <returns>(IsOk, Content) 解密结果</returns>
public static (bool IsOk, string Content) AESDecrypt(
string source,
string key,
string iv = "",
PaddingMode padding = PaddingMode.PKCS7,
CipherMode mode = CipherMode.ECB)
{
try
{
byte[] keyBytes = Encoding.UTF8.GetBytes(key);
byte[] textBytes = Convert.FromBase64String(source);
byte[] ivBytes = Encoding.UTF8.GetBytes(iv);

var useKeyBytes = NormalizeBytes(keyBytes, 16);
var useIvBytes = NormalizeBytes(ivBytes, 16);

using var aes = Aes.Create();
aes.KeySize = 256;
aes.BlockSize = 128;
aes.Padding = padding;
aes.Mode = mode;
aes.Key = useKeyBytes;
aes.IV = useIvBytes;

using var decryptor = aes.CreateDecryptor();
byte[] resultBytes = decryptor.TransformFinalBlock(textBytes, 0, textBytes.Length);

return (true, Encoding.UTF8.GetString(resultBytes));
}
catch (Exception ex)
{
return (false, string.Empty);
}
}

/// <summary>
/// 规范化密钥/IV 到指定长度
/// </summary>
private static byte[] NormalizeBytes(byte[] input, int length)
{
var result = new byte[length];
Array.Copy(input, result, Math.Min(input.Length, length));
return result;
}

存在问题

  • 一致性问题:双方除了约定systemName和算法外,在互认关联上做的远远不够,且授权只考虑到了Web站点,对后端毫无约束,尽管后端也可以借用这种方案自检;
  • 安全问题:没有跟服务器或者PC机硬件的强绑定,持久化到DB的license可以随便滥用;在检测环节也不严谨,可以劫持请求伪造响应或者纯前端修改校验函数;
  • 性能问题:Web端通过不断轮询依赖后端去检测系统是否在有效期的做法看似时效性高实则很浪费资源;而且缺乏前后端接口校验还会导致被恶意重放;
  • 异常错误:前端这种持久依赖后端的做法,在前后端分离的设计中,如果后端服务宕机会导致前端非鉴权强关联业务不能使用跟随瘫痪,这不是必须或友好的;

改进方向

这种鉴权方案在外行看来是能工作的,但是非常粗超,改进方向之一就是可以用JWT鉴权替换掉:

  • 注册:仍然可以由服务端或者额外的注册机生成管控,生成license和验证起合法性都放在服务端(可以充分关联服务器设备信息)一个源头;
  • 验证:license校验隐含到JWT认证的每一次请求中,不用额外开销资源;
  • 安全:借用JWT的认证机制和加密算法,同时对Body内容签名验证(能上证书最好),复用JWT的定时刷新机制;