背景
随着项目中业务和功能的逐渐增多,我们发现许多基础架构的设计出现了漏洞。今天我想分享的是我们公司内部权限系统的改造经验
目前,权限系统缺乏统一的规范,权限与业务代码混杂在一起,导致每次都需要重新查询数据库,这不仅浪费了性能,还经常引发线上问题,给开发团队带来了极大的不便。近期,我们经过深入的调研和各部门的讨论,整理出了一版新的权限框架,并最终达成了一致意见,以期优化系统性能、提升开发效率,并增强权限管理的灵活性和可维护性
框架设计
新版权限框架内置了身份校验和权限校验,用户登录后返回两个关键信息:权限和(令牌)Token,业务访问路由前会经过系统的身份权限双重校验,提供了双Token,权限加密等一系列的安全保障,无感更新Token让用户有更好的使用体验
登录
用户首次访问系统或在长时间未登录、身份校验失效或异常的情况下,需要重新登录
用户填写账号、密码(验证码)后,向后端接口发送请求。后端获取用户信息,处理权限,并生成令牌(Token),随后将其返回给前端并存储在本地缓存中。
业务
在业务处理中,用户携带权限和令牌(Token)请求业务接口。在请求之前,系统会先进行身份校验和权限校验,只有通过这两项检查,用户才能访问相关接口
如果身份校验的 Token 过期,系统将自动调用刷新 Token 接口进行更新,刷新完成后将继续发送业务请求,实现无感刷新。如果身份校验的 Token 无效、出现异常,或刷新 Token 失败,则用户需要重新登录
如果是没有相关权限或权限格式不正确,则禁止访问路由
身份校验
Token
Token采用的是双Token的模式有两个Token,AccessToken和 RefreshToken
AccessToken是携带用户信息的,用于身份校验,有效时间较短RefreshToken是随机生成的guid,用于刷新AccessToken时提供一层安全校验,有效时间较长
public class TokenGenerator
{
private static string secretKey = "12345678901234567890123456789012"; // 确保这个是安全的
public static string refreshToken = ""; // 临时存储
private static readonly TimeSpan AccessTokenExpiration = TimeSpan.FromMinutes(15);
private static readonly TimeSpan RefreshTokenExpiration = TimeSpan.FromDays(7);
public static (string accessToken, string refreshToken) GenerateTokens(string username)
{
// 使用一个符合256位要求的密钥
var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); // 32个字符
var credentials = new SigningCredentials(symmetricKey, SecurityAlgorithms.HmacSha256);
// 生成 Access Token
var accessToken = new JwtSecurityToken(
issuer: "test",
audience: "test",
claims: new[] { new Claim(ClaimTypes.Name, username) },
expires: DateTime.UtcNow.AddMinutes(1), // 设置有效期
signingCredentials: credentials);
// 生成 Refresh Token
var refreshToken = Guid.NewGuid().ToString(); // 使用 GUID 作为 Refresh Token
// 使用 JwtSecurityTokenHandler 生成 Token 字符串
var tokenHandler = new JwtSecurityTokenHandler();
var tokenString = tokenHandler.WriteToken(accessToken);
return (tokenString, refreshToken);
}
// 刷新token
public static (string accessToken, string refreshToken) RefreshAccessToken(string refreshToken, string username)
{
// 验证 Refresh Token(例如从数据库中查找)
// var isValid = ValidateRefreshToken(refreshToken);
// if (!isValid) throw new SecurityTokenException("Invalid refresh token");
// 假设验证通过,获取用户名
//string username = "exampleUser"; // 这里应该从数据库或存储中获取
// 生成新的 Access Token
var (newAccessToken, newRefreshToken) = GenerateTokens(username);
return (newAccessToken, newRefreshToken);
}
// 校验token
public static returnReq ValidateToken(string token)
{
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
ValidateIssuer = false, // 可根据需求验证
ValidateAudience = false, // 可根据需求验证
// 允许的时钟偏差
ClockSkew = TimeSpan.Zero // 可选
};
try
{
// 验证 token, 如果有效,它会返回 ClaimsPrincipal
var claimsPrincipal = tokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken);
return new returnReq
{
r = true,
mess = "验证成功"
}; // 验证成功
}
catch (SecurityTokenExpiredException)
{
Console.WriteLine("Token 已过期");
return new returnReq
{
r = false,
mess = "Token 已过期"
}; // 过期
}
catch (SecurityTokenInvalidSignatureException)
{
Console.WriteLine("Token 签名无效");
return new returnReq
{
r = false,
mess = "Token 签名无效"
}; // 签名无效
}
catch (Exception ex)
{
Console.WriteLine($"Token 验证失败: {ex.Message}");
return new returnReq
{
r = false,
mess = $"Token 验证失败: {ex.Message}"
}; // 其他异常
}
}
}
TokenGenerator类提供了生成Token,刷新Token,校验Token
身份校验
身份校验采用中间件实现
访问后端路由第一层验证,根据请求头携带 AccessToken 验证是否符合要求,绝大部分路由都需要验证,排除白名单(登录,刷新Token等)
没有验证通过会改变返回状态401和对应的提示信息
public class TokenValidationMiddleware
{
private readonly RequestDelegate _next;
private readonly TokenValidationParameters _tokenValidationParameters;
private readonly string[] whiteList = { "/api/WeatherForecast/login", "/api/WeatherForecast/refreshToken" }; // 白名单
public TokenValidationMiddleware(RequestDelegate next, IOptions<TokenValidationParameters> tokenValidationParameters)
{
_next = next;
_tokenValidationParameters = tokenValidationParameters.Value;
}
public async Task InvokeAsync(HttpContext context)
{
// 获取请求路径
string path = context.Request.Path;
string method = context.Request.Method;
// 检查请求路径是否为登录接口,预检请求
if (path.StartsWith("/api") && !hasWhiteList(path) && method != "OPTIONS")
{
// 从请求头中获取 token
var token = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
if (!string.IsNullOrWhiteSpace(token))
{
returnReq flag = TokenGenerator.ValidateToken(token);
if(!flag.r)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
context.Response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
context.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
await context.Response.WriteAsync(flag.mess);
return;
}
}
else
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
context.Response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
context.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
await context.Response.WriteAsync("Authorization header is missing.");
return;
}
}
await _next(context); // 调用下一个中间件
}
public bool hasWhiteList (string router)
{
return Array.Exists(whiteList, item => item == router);
}
}
前端封装了响应拦截,如果状态是401,Token过期会调用刷新Token的接口并重新调用业务接口,从而实现无感刷新
let refreshing = false;
const queue = [];
const http = axios.create({
timeout: 10000, // 请求超时时间
});
http.interceptors.request.use(function (config) {
const accessToken = localStorage.getItem("Authorization");
const refreshToken = localStorage.getItem("RefreshToken");
const wfid = localStorage.getItem("wfid");
if(accessToken) {
config.headers.Authorization = 'Bearer ' + accessToken;
}
if(wfid) {
config.headers.wfid = wfid;
}
if(refreshToken) {
config.headers.refreshToken = refreshToken;
}
return config;
})
http.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { data, status, config } = error.response;
if(refreshing) {
return new Promise((resolve) => {
queue.push({
config,
resolve
});
});
}
if (status === 401 && data === "Token 已过期") {
refreshing = true;
const res = await refreshToken();
refreshing = false;
if(res.status === 200) {
localStorage.setItem("Authorization", res.data.accessToken);
localStorage.setItem("RefreshToken", res.data.refreshToken);
queue.forEach(({config, resolve}) => {
resolve(http(config))
})
return http(config);
} else {
alert(data || '登录过期,请重新登录');
}
} else {
return error.response;
}
}
)
function refreshToken() {
return http.get("https://localhost:7172/api/WeatherForecast/refreshToken")
}
权限过滤
权限生成
权限管理平台生成的权限是4位十进制数字,从0001到9999依次排列,在数据库中存储用户的权限用十进制很浪费存储空间,所以在后端写了一个 PermissionManager类来处理权限
假设有1024个权限,用64位 ulong数组存储需要16个元素,数组中每个元素换算成二进制能用来表示64个权限的状态
字符串总长度=320 (元素总长度)+30 (逗号和空格)+2 (方括号)=352
public class PermissionManager
{
// 用一个 ulong 数组存储 1024 位的权限(根据总权限调整长度)
private const int MaxPermissions = 1024;
private const int BitsPerLong = 64; // 每个 ulong 可以存储 64 位
private ulong[] permissions;
public PermissionManager()
{
// 计算需要多少个 ulong 数组来存储 1024 位
permissions = new ulong[(MaxPermissions + BitsPerLong - 1) / BitsPerLong];
}
// 获取权限 十六进制
public ulong[] GetPermission()
{
return permissions;
}
// 获取权限 二进制
public static string ConvertToBitmap(ulong[] permissions)
{
// 计算需要的字节数
int byteCount = permissions.Length * sizeof(ulong);
byte[] bitmap = new byte[byteCount];
// 将每个 ulong 转换为对应的 byte[]
for (int i = 0; i < permissions.Length; i++)
{
BitConverter.GetBytes(permissions[i]).CopyTo(bitmap, i * sizeof(ulong));
}
string bitmapStr = "";
// 输出位图 二进制
foreach (var b in bitmap)
{
bitmapStr = Convert.ToString(b, 2).PadLeft(8, '0') + bitmapStr;
}
return bitmapStr;
}
// 获取权限 十进制
public static List<string> GetEnabledPermissions(ulong[] permissions)
{
List<string> enabledPermissions = new List<string>();
for (int i = 0; i < permissions.Length; i++)
{
for (int j = 0; j < BitsPerLong; j++)
{
if ((permissions[i] & (1UL << j)) != 0) // 如果指定的位是 1
{
int permissionCode = (i * BitsPerLong) + j + 1; // 计算权限代码
if (permissionCode <= MaxPermissions)
{
enabledPermissions.Add(permissionCode.ToString("D4"));
}
}
}
}
return enabledPermissions;
}
// 设置权限
public void SetPermission(int code)
{
if (code < 1 || code > MaxPermissions)
{
throw new ArgumentOutOfRangeException(nameof(code), "Permission code must be between 1 and 1024.");
}
int arrayIndex = (code - 1) / BitsPerLong; // 查找要设置的 ulong 元素
int bitIndex = (code - 1) % BitsPerLong; // 查找位索引
permissions[arrayIndex] |= (1UL << bitIndex); // 将指定的位设置为 1
}
// 检查权限
public static bool HasPermission(ulong[] permissions, int code)
{
if (code < 1 || code > MaxPermissions)
{
throw new ArgumentOutOfRangeException(nameof(code), "Permission code must be between 1 and 1024.");
}
int arrayIndex = (code - 1) / BitsPerLong; // 查找要检查的 ulong 元素
int bitIndex = (code - 1) % BitsPerLong; // 查找位索引
return (permissions[arrayIndex] & (1UL << bitIndex)) != 0; // 判断对应位是否为 1
}
}
PermissionManager类提供了获取权限(十进制,二进制),设置权限,检查权限
权限加密
处理完权限后的权限是一个类似于 [513,2,0,0,0,0,0,0,0,0,0,0,0,0,0,9223372311732682752]的数组,在前后端传输还是有一定的安全隐患的,为了防止被恶意篡改,我们考虑使用加密传输
在接口返回数据前加密数据(AES),校验时解密校验,加密解密的方法封装在了 AesEncryption类
加密后的效果 dMNW6mCGybX1gRAk/BBfDb1pJuBsrQBGYnaIfwkoMlM7v1F2A1/0DhXObCO2Ma2WMuMl564fSpkEcGwngfrXLQ==
public class AesEncryption
{
static byte[] _key;
static byte[] _iv;
// 加密方法
public static byte[] Encrypt(string plainText, byte[] key, byte[] iv)
{
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.IV = iv;
using (var encryptor = aes.CreateEncryptor(aes.Key, aes.IV))
using (var msEncrypt = new MemoryStream())
{
using (var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
using (var writer = new StreamWriter(csEncrypt))
{
writer.Write(plainText);
}
return msEncrypt.ToArray(); // 返回加密后的字节数组
}
}
}
// 解密方法
public static string Decrypt(byte[] cipherText, byte[] key, byte[] iv)
{
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.IV = iv;
using (var decryptor = aes.CreateDecryptor(aes.Key, aes.IV))
using (var msDecrypt = new MemoryStream(cipherText))
using (var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
using (var reader = new StreamReader(csDecrypt))
{
return reader.ReadToEnd(); // 返回解密后的明文
}
}
}
// 生成密钥和初始化向量
public static (byte[] key, byte[] iv) GenerateKeyAndIV()
{
using (Aes aes = Aes.Create())
{
aes.GenerateKey();
aes.GenerateIV();
_key = aes.Key;
_iv = aes.IV;
return (aes.Key, aes.IV);
}
}
// 获取密钥和初始化向量
public static (byte[] key, byte[] iv) GetKeyAndIV()
{
return (_key, _iv);
}
}
权限过滤
在业务代码中,用户访问某个方法或控制器,考虑到不是所有的方法和控制器都需要权限校验,采用过滤器更合理
通过在方法或控制器上加入 [Auth("0001")](0001代表十进制权限编码),在访问路由前会先调用 Auth方法
[Auth("0001")]
[HttpPost("GetList")]
public List<Info> GetList()
{
var req = Request;
string wfid = req.Headers["wfid"].ToString() ?? "";
List<Info> list = new List<Info> { };
list.Add(new Info
{
Name = "博客1",
Id = "1"
});
return list;
}
Auth 的实现
public class AuthAttribute : Attribute, IAsyncActionFilter
{
private readonly string _requiredPermission;
public AuthAttribute(string requiredPermission)
{
_requiredPermission = requiredPermission;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// 获取请求头信息
var headers = context.HttpContext.Request.Headers;
string wfid = headers["wfid"].ToString() ?? "";
string decrypted = "";
// 解密
try
{
var (key, iv) = AesEncryption.GetKeyAndIV();
byte[] decryptedBytes = Convert.FromBase64String(wfid);
decrypted = AesEncryption.Decrypt(decryptedBytes, key, iv);
}
catch (Exception ex)
{
context.Result = new ObjectResult(new
{
Message = "未授权访问,权限不符合规范。",
})
{
StatusCode = StatusCodes.Status403Forbidden // 设置状态码为403 Forbidden
};
return;
}
ulong[] restoredPermissions = JsonConvert.DeserializeObject<ulong[]>(decrypted);
bool flag = PermissionManager.HasPermission(restoredPermissions, int.Parse(_requiredPermission));
if (!flag)
{
// 如果未包含所需权限,设置返回信息
context.Result = new ObjectResult(new
{
Message = "未授权访问,缺少必要的权限。",
RequiredPermission = _requiredPermission // 缺少的权限信息
})
{
StatusCode = StatusCodes.Status403Forbidden // 设置状态码为403 Forbidden
};
return;
}
// 继续执行请求
await next();
}
public bool CheckUserAuthorization(string[] wfidArr)
{
return Array.Exists(wfidArr, item => item == _requiredPermission);
}
}
总结
以上就是新权限框架的核心部分,身份校验和权限过滤为此次重构的重点
建立了统一的团队规范,使用起来也非常简单,在一定程度上提高了团队的开发效率