
简介面向C#上位机开发者的权限管理示例项目AuthDemo围绕用户身份验证、角色授权与访问控制等核心问题给出了一套可参考的完整实现思路。资源共127个文件以C#源代码、dll依赖库、json配置文件、pdb调试符号、资源文件及工程项目文件为主7z压缩包仅376KB整体结构紧凑便于按模块查看权限管理各层实现。已有411人学习下载非常适合正在设计上位机登录与权限模块的研发人员参考借鉴。AuthDemo覆盖了登录界面与多因素认证、角色与权限分配、操作权限校验、密码哈希存储、日志审计、设备访问控制等环节并演示了如何利用C#安全框架、缓存与异步编程来兼顾安全性和界面响应性。对照示例代码可快速掌握上位机权限管理从界面设计、数据加密到后端校验的完整路径并迁移到实际项目中。1. C#上位机权限管理登录只是第一步按钮级控制才是试金石做过三年以上上位机开发的工程师大多有这种体会单机版Demo跑得再顺一旦接进车间局域网权限问题就会变成一块硬骨头。登录窗口谁都会写但真正要落地的是Token怎么存、过期了怎么续、菜单怎么按角色过滤、操作按钮怎么在服务端拦一道。这份AuthDemo.7z打包的是一套可直接二次开发的上位机权限管理框架核心是用户-角色-权限三层模型覆盖登录认证、Token过期刷新、菜单级与按钮级权限控制适合正在做设备上位机、MES客户端或产线数据采集系统的开发者。我拆完这套Demo最深的感受是权限管理的难点不在数据库建几张表而在“控制链路上少一环”导致的功能越权。2. 为什么按“用户-角色-权限”拆模型设计先于代码2.1 三张表的结构与关联逻辑AuthDemo采用经典RBAC模型数据库里核心是三张基础表和两张关联表。基础表是User、Role、Permission关联表是UserRole和RolePermission。初次打开项目时先看这五张表的建表语句顺序比表结构本身更重要——必须先建Role和Permission再建User最后建两张关联表否则外键约束会直接报错。-- 角色表 CREATE TABLE Role ( RoleId INT PRIMARY KEY IDENTITY(1,1), RoleName NVARCHAR(50) NOT NULL UNIQUE, Description NVARCHAR(200) NULL ); -- 权限表Type字段区分菜单权限(0)和按钮权限(1) CREATE TABLE Permission ( PermissionId INT PRIMARY KEY IDENTITY(1,1), PermissionName NVARCHAR(50) NOT NULL, PermissionCode NVARCHAR(100) NOT NULL UNIQUE, Type INT NOT NULL DEFAULT 0, ParentId INT NULL, SortOrder INT NOT NULL DEFAULT 0 );这里要注意PermissionCode的命名规范例如System.User.Add、System.User.Delete这类点分格式。命名时把“模块.实体.操作”三段写清楚后面在代码里做权限校验就能直接按Code匹配不需要额外查数据库解释含义。ParentId字段用于构建树形权限结构比如“系统管理”这个菜单权限的ParentId为NULL它下面的“用户管理”菜单ParentId指向系统管理的PermissionId。按钮权限比如“新增用户”的ParentId指向用户管理菜单的PermissionId。2.2 身份标识与Token载体的选择这套Demo里登录成功后服务端会返回一个Token客户端在内存中维护一个当前登录用户对象。Token采用JWT格式包含UserId、UserName、RoleId、ExpireTime四个核心声明。// JWT Token生成核心代码 var claims new ListClaim { new Claim(UserId, user.UserId.ToString()), new Claim(UserName, user.UserName), new Claim(RoleId, user.RoleId.ToString()), new Claim(ExpireTime, DateTime.Now.AddHours(12).Ticks.ToString()) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config[Jwt:SecretKey])); var credentials new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.Now.AddHours(12), signingCredentials: credentials );为什么把Token过期时间设成12小时而不是8小时或24小时这是个折中方案。上位机软件通常跟着车间排班走一个班次时长大约8到12小时12小时过期意味着白班人员基本不用中途重新登录夜班交接时正好触发一次重新认证顺带清理掉上一班次的缓存数据。SecretKey必须放在appsettings.json里并且上线前替换长度至少32个字符否则HmacSha256会报密钥长度不足。2.3 WinForms与WPF下的实现差异套餐里同时包含WinForms和WPF两个版本的客户端代码。WinForms版本用了一个静态类AuthContext来存放当前登录用户和Token界面切换窗口时直接从AuthContext取用户信息。WPF版本则用了依赖注入把IUserSession注册成单例服务在ViewModel构造函数里注入使用。// WinForms版本全局静态登录上下文 public static class AuthContext { public static UserDto CurrentUser { get; set; } public static string Token { get; set; } public static bool HasPermission(string permissionCode) { if (CurrentUser null) return false; // 管理员角色直接放行 if (CurrentUser.RoleCode Admin) return true; return CurrentUser.Permissions.Contains(permissionCode); } }WinForms版本直接访问AuthContext.CurrentUser写起来很顺手但有个隐患——所有窗体都依赖这个静态类单元测试时很难替换。WPF版用IUserSession接口注入就没有这个问题。如果你打算在上位机上跑单元测试或者后续迁移到其他UI框架建议直接用WPF版本作为基线。接口注入还有个好处模拟登录时只需Mock一个IUserSession实现不需要真的走一遍登录接口。3. 登录认证与Token过期刷新AuthDemo的完整闭环3.1 登录流程与密码处理登录窗口的核心逻辑是用户输入账号密码客户端用RSA公钥加密密码后发给服务端服务端用私钥解密得到明文再与数据库中存储的BCrypt哈希做比对。整套流程不直接传输明文密码也不在数据库存明文。// 服务端登录接口核心逻辑 [HttpPost(login)] public async TaskIActionResult Login(LoginRequest request) { // request.Password是RSA加密后的Base64字符串 var rsa new RSACryptoServiceProvider(); rsa.FromXmlString(_config[Rsa:PrivateKey]); var decryptedBytes rsa.Decrypt( Convert.FromBase64String(request.Password), false); var plainPassword Encoding.UTF8.GetString(decryptedBytes); var user _db.Users.FirstOrDefault(u u.UserName request.UserName); if (user null || !BCrypt.Net.BCrypt.Verify(plainPassword, user.PasswordHash)) { // 失败日志记录UserIP、LoginTime不记录完整密码 return Unauthorized(new { message 用户名或密码错误 }); } var token GenerateJwtToken(user); var permissions GetUserPermissions(user.RoleId); return Ok(new { Token token, UserInfo new UserDto { UserId user.UserId, UserName user.UserName, RoleId user.RoleId, Permissions permissions } }); }登录接口返回的是Token加完整权限集合。权限集合直接放在登录响应里有两个考虑一是避免客户端每次校验权限都请求服务端界面响应更快二是上位机运行在车间局域网内网络相对稳定一次性拉取权限开销不大。但在权限集合较大时一次返回几百条数据会有明显延迟AuthDemo的做法是只返回PermissionCode列表界面端用一个HashSet存储查询时间复杂度降到O(1)。3.2 Token过期处理的两种策略AuthDemo里处理Token过期做了两层策略第一层是客户端拦截401响应后弹窗提示重新登录第二层是窗口关闭时清空Token。前者是最简单的兜底方案但用户正在操作的工序数据可能因此丢失——填了一半的参数表因为Token过期被强制退出谁都会恼火。更好的做法是滑动过期策略。AuthDemo服务端在JWT过期时间前30分钟支持RefreshToken机制客户端每次调用业务接口时服务端检查剩余有效期如果小于30分钟且RefreshToken有效就自动签发新的AccessToken返回同时在响应头或响应体中携带新Token。客户端HttpHelper封装类解析响应发现新Token就自动更新内存和本地配置。// 客户端请求封装自动处理Token刷新 public async TaskT PostAsyncT(string url, object data) { using (var client new HttpClient()) { client.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, _token); var response await client.PostAsJsonAsync(url, data); if (response.StatusCode HttpStatusCode.Unauthorized) { // 尝试刷新Token var newToken await RefreshTokenAsync(); if (string.IsNullOrEmpty(newToken)) { // 刷新失败跳转登录窗口 NavigateToLogin(); return default; } _token newToken; // 用新Token重发原请求 client.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, _token); response await client.PostAsJsonAsync(url, data); } // 从响应头读取新Token var renewToken response.Headers.GetValues(X-New-Token).FirstOrDefault(); if (!string.IsNullOrEmpty(renewToken)) { _token renewToken; } return await response.Content.ReadAsAsyncT(); } }这里有个细节很容易被忽略RefreshToken的存储位置。AuthDemo把RefreshToken存在内存变量里程序重启后丢失用户需要重新登录。如果你希望上位机重启后保持登录状态可以把RefreshToken加密后存在本地配置文件中但要注意安全性——车间电脑可能多人共用Token落地文件存在被拷贝的风险。我的建议是保持AuthDemo默认的内存存储让用户每次启动时重新登录多一次登录操作换来的是安全边界更清晰。3.3 界面按钮权限的渲染控制登录成功拿到权限列表后主窗体的加载过程会对所有菜单项和按钮做一次“可见性”控制。AuthDemo的做法是在窗体的Load事件里遍历所有控件通过Control.Tag属性标记权限Code然后统一控制Visible属性。// 主窗体权限渲染控制 private void ApplyPermissionToControls() { foreach (Control control in this.Controls) { if (control.Tag ! null) { var permissionCode control.Tag.ToString(); var hasPermission AuthContext.HasPermission(permissionCode); if (control is Button btn) { btn.Visible hasPermission; } else if (control is ToolStripMenuItem menu) { menu.Visible hasPermission; } // 处理一级菜单下的子菜单 if (control is MenuStrip menuStrip) { ApplyPermissionToMenu(menuStrip.Items); } } } } private void ApplyPermissionToMenu(ToolStripItemCollection items) { foreach (ToolStripMenuItem item in items) { if (item.Tag ! null !AuthContext.HasPermission(item.Tag.ToString())) { item.Visible false; continue; } if (item.HasDropDownItems) { ApplyPermissionToMenu(item.DropDownItems); } } }这段代码的核心思路是约定优于配置——在窗体设计器里把所有需要权限控制的按钮和菜单项的Tag属性填上权限Code运行时统一过滤。好处是新加一个按钮只需要在界面上设置Tag属性不用改任何代码坏处是如果开发者忘设Tag那这个按钮就默认免检出现权限漏洞。我一般会在ApplyPermissionToControls方法末尾加一段调试日志把可见控件列表打出来检查哪些该隐藏的没隐藏。4. 权限拦截的三个避坑点从数据库权限到界面控制的落地4.1 坑一界面按钮隐藏了但用户直接调接口仍能执行第一个常见坑是界面上把“删除产品记录”按钮隐藏了但懂技术的人在浏览器或Postman里直接调用删除接口照样能把数据库删了。这不是Bug而是界面控制和服务端校验根本是两码事。原因AuthDemo早期版本只在客户端做了按钮隐藏服务端接口没有加权限校验标签。解决在服务端每个接口上加上授权特性服务端才是权限控制的最后一道防线。// 服务端接口权限校验 [HttpDelete(product/{id})] [Authorize] [RequirePermission(Product.Delete)] public IActionResult DeleteProduct(int id) { var product _db.Products.Find(id); if (product null) { return NotFound(new { message 产品不存在 }); } _db.Products.Remove(product); _db.SaveChanges(); return Ok(new { message 删除成功 }); }RequirePermission是一个自定义AuthorizationFilter通过解析JWT里的RoleId查出该角色所有权限Code检查是否包含“Product.Delete”。只加[Authorize]是不够的它只能验证用户已登录不能验证你有没有操作权限。4.2 坑二Token在多个窗口间共享A窗口刷新后B窗口还在用旧Token上位机通常打开多个子窗口每个子窗体的业务操作都可能触发Token刷新。AuthDemo用静态AuthContext.Token持有Token主窗口刷新了Token值子窗口可能还持有刷新前的旧Token。如果子窗口没有经过封装好的HttpHelper而是直接用HttpClient发送请求就会命中401逻辑上表现为“用户明明在线却突然要重新登录”。解决思路是单一事实来源。AuthDemo里Token只存放在AuthContext.Token这一个静态属性中所有客户端请求一律走封装类获取Token不允许多处缓存Token副本。// 统一从AuthContext读取Token public class ApiClient { private HttpClient _client; public ApiClient() { _client new HttpClient(); // 超时时间设长一点车间网络环境波动大 _client.Timeout TimeSpan.FromSeconds(30); } private void AttachToken() { var token AuthContext.Token; if (!string.IsNullOrEmpty(token)) { _client.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, token); } } public async TaskHttpResponseMessage GetAsync(string url) { AttachToken(); return await _client.GetAsync(url); } }4.3 坑三刷新Token的接口本身也需要权限校验否则死循环第三个坑比较隐蔽。RefreshToken接口如果也加了[Authorize]特性而调用时用的正好是那个已经过期未刷新的旧Token服务端会直接返回401客户端收到401后调用RefreshTokenRefreshToken也返回401形成一个解不开的死循环。原因刷新接口不应该要求AccessToken有效而是要求RefreshToken有效。解决RefreshToken接口不需要[Authorize]只验证RefreshToken本身是否有效且存在数据库中。AuthDemo在服务端维护了一张UserToken表记录RefreshToken的哈希值和过期时间刷新接口拿客户端传来的RefreshToken查库比对比对通过才签发新AccessToken。4.4 坑四权限修改后用户不重新登录旧权限一直生效场景是这样的管理员在权限管理界面修改了某个用户的角色把“参数修改”权限移除掉了但那个用户正在使用的上位机界面上的按钮依然可见可点。这是因为客户端只登录时拉取了一次权限列表存在内存里服务端根本不知道客户端何时需要刷新权限。AuthDemo的解决办法是每次进入一级功能模块时重新校验权限。在窗体Activate事件里再次调用权限校验接口或者服务端在权限变更时主动推送消息。上位机通常用不到WebSocket这类实时推送轮询就够了。// 窗体激活时重新拉取权限 protected override void OnActivated(EventArgs e) { base.OnActivated(e); // 判断当前窗体状态避免频繁拉取拖慢性能 if (DateTime.Now - _lastPermissionUpdate TimeSpan.FromMinutes(5)) { RefreshPermissionsAsync(); _lastPermissionUpdate DateTime.Now; } } private async void RefreshPermissionsAsync() { var response await _apiClient.PostAsyncListstring( /api/auth/refresh-permissions, new { UserId AuthContext.CurrentUser.UserId }); AuthContext.CurrentUser.Permissions new HashSetstring(response); ApplyPermissionToControls(); }总在菜单切换时或固定时间间隔触发权限刷新是个合理的折中方案但要注意别把服务端打得过载。5分钟一次的刷新频率对车间系统足够如果是几十台客户端同时轮询需要给刷新接口加内存缓存减少数据库查询压力。5. 权限配置界面与常用技巧验证清单与细节收尾5.1 管理员授权界面角色与权限的关联操作AuthDemo附带了一个管理员权限配置窗体核心操作是选择角色、勾选权限、保存关联。这个界面本质上就是维护RolePermission表的数据。// 保存角色权限关联 [HttpPost(role/permissions)] public IActionResult UpdateRolePermissions(RolePermissionRequest request) { // request包含RoleId、PermissionId列表 var existingLinks _db.RolePermissions .Where(rp rp.RoleId request.RoleId).ToList(); // 策略先删后插简单且不易出错 _db.RolePermissions.RemoveRange(existingLinks); foreach (var permissionId in request.PermissionIds) { _db.RolePermissions.Add(new RolePermission { RoleId request.RoleId, PermissionId permissionId }); } // 清空缓存中该角色的权限缓存 _permissionCache.Remove($role_permissions_{request.RoleId}); _db.SaveChanges(); return Ok(); }先删后插的策略在权限关联不多时最省心但如果一个角色关联了几百个权限每次都全删全插会产生大量数据库操作日志。数据量大了之后可以改为增量更新先算出差异集合只执行删除和新增操作。AuthDemo默认用先删后插数据量上到几百条时你会明显感觉到保存按钮变慢。5.2 权限验证的自动化清单每次改完权限系统我习惯做以下验证避免上线后翻车。这套清单不是官方文档里的是实际调试中总结出来的。验证项操作步骤预期结果失败排查方向登录认证输入正确账号密码进入主界面Token生成RSA密钥长度、数据库连接串登录失败输入错误密码3次第3次后锁定30分钟锁定状态是否存库、计时是否可配置菜单过滤用普通角色登录菜单只显示有权限的项Tag属性是否漏标、权限Code是否匹配按钮隐藏用无删除权限角色登录删除按钮不可见控件Tag是否正确、ApplyPermissionToControls是否执行Token过期修改系统时间13小时弹窗提示重新登录JWT过期时间读取的时区、系统时间设置权限实时更新管理员修改角色权限5分钟内客户端更新界面轮询时间配置、客户端是否重新拉取权限服务端拦截Postman直接调用无权限接口返回403RequirePermission过滤器是否生效缓存与内存长时间运行后内存稳定无泄漏、权限刷新正常是否有定时器未释放、事件是否反复订阅验证Token过期这步有一个高效技巧在服务端控制器里加一个临时的调试Action强制返回一个伪造的过期Token客户端请求时就能稳定触发401流程不需要去改系统时间。测试完把调试Action删掉就可以。5.3 权限Code命名规范与维护习惯Code的命名决定了排查权限问题时是轻松还是抓瞎。AuthDemo的规范是模块.实体.操作但如果你负责的系统不止一个上位机建议统一整理一个权限字典表分发给前端、后端和测试各一份。这个表格本质上是可执行文档比单独写一份Word权限说明有用得多。// 权限Code常量定义统一放在静态类中避免硬编码字符串 public static class PermissionCodes { public const string UserView System.User.View; public const string UserAdd System.User.Add; public const string UserEdit System.User.Edit; public const string UserDelete System.User.Delete; public const string ProductView Product.Manage.View; public const string ProductEdit Product.Manage.Edit; public const string ProductExport Product.Manage.Export; public const string ParamModify Equipment.Param.Modify; public const string ParamReset Equipment.Param.Reset; }常量类的好处显而易见界面设置Tag属性时直接输入PermissionCodes.UserAdd编译期就能发现拼写错误。如果某些Button的事件处理逻辑里也要判断权限直接引用常量比裸字符串更可靠。5.4 一个影响上线效果的细节默认角色与初始密码策略AuthDemo初始数据里有一个Admin角色和一个Admin用户初始密码是123456。我拆完代码后第一件事就是建议任何人部署前必须改掉这个默认配置。历次项目评审中被问最多的问题就是“如果忘了改默认密码会怎样”——远的不说任何能接触到数据库连接串的人都能直接用Admin账号登进去权限树形同虚设。我的习惯是在种子数据初始化逻辑里加一个强制条件// 首次启动时检测默认密码是否被修改 if (defaultUser.PasswordHash BCrypt.HashPassword(123456)) { // 系统启动后跳转到修改密码页面不允许进入主界面 ShowChangePasswordDialog(defaultUser); }AuthDemo没有内置这个强改逻辑但它预留了修改入口。加这么一段代码的成本很低却能在系统上线时避免大量安全风险。走过这一整套拆解之后我养成了一个习惯每次拿到权限相关的项目先看数据库初始化脚本再看服务端过滤器最后才看客户端界面——因为服务端权限控制才是那个“真正起作用的门”客户端只是给人看的门面。希望这篇拆解能帮你快速看清楚一份权限管理Demo的骨架与关节少踩几个我已经踩过的坑。下载后按第2章的建表脚本开始跑你会发现整个链路比想象中要清楚得多祝顺利。本文还有配套的精品资源点击获取