权限框架这样设计太绝啦!!!

1,830 阅读9分钟

背景

随着项目中业务和功能的逐渐增多,我们发现许多基础架构的设计出现了漏洞。今天我想分享的是我们公司内部权限系统的改造经验

目前,权限系统缺乏统一的规范,权限与业务代码混杂在一起,导致每次都需要重新查询数据库,这不仅浪费了性能,还经常引发线上问题,给开发团队带来了极大的不便。近期,我们经过深入的调研和各部门的讨论,整理出了一版新的权限框架,并最终达成了一致意见,以期优化系统性能、提升开发效率,并增强权限管理的灵活性和可维护性

框架设计

image.png

新版权限框架内置了身份校验和权限校验,用户登录后返回两个关键信息:权限和(令牌)Token,业务访问路由前会经过系统的身份权限双重校验,提供了双Token,权限加密等一系列的安全保障,无感更新Token让用户有更好的使用体验

登录

用户首次访问系统或在长时间未登录、身份校验失效或异常的情况下,需要重新登录

用户填写账号、密码(验证码)后,向后端接口发送请求。后端获取用户信息,处理权限,并生成令牌(Token),随后将其返回给前端并存储在本地缓存中。

业务

在业务处理中,用户携带权限和令牌(Token)请求业务接口。在请求之前,系统会先进行身份校验和权限校验,只有通过这两项检查,用户才能访问相关接口

如果身份校验的 Token 过期,系统将自动调用刷新 Token 接口进行更新,刷新完成后将继续发送业务请求,实现无感刷新。如果身份校验的 Token 无效、出现异常,或刷新 Token 失败,则用户需要重新登录

如果是没有相关权限或权限格式不正确,则禁止访问路由

身份校验

Token

Token采用的是双Token的模式有两个Token,AccessTokenRefreshToken

  • 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);
    }
}

总结

以上就是新权限框架的核心部分,身份校验和权限过滤为此次重构的重点

建立了统一的团队规范,使用起来也非常简单,在一定程度上提高了团队的开发效率