说实话,做了这么多年C#开发,自定义鉴权这个话题我私下里被问过太多次了。不管是做ASP.NET Core Web API、WPF上位机,还是内部工具系统,总逃不过“怎么控制谁能访问、谁能操作”这一关。很多朋友一开始直接上框架自带的方案,结果发现跟自己的老系统对不上、跟第三方客户端对接又卡壳,最后兜兜转转还是得自己写一套。
这篇文章就围绕“自定义鉴权”这个核心,系统梳理一下我实际项目中用过的几种方案:从最简单的Token签名,到完整的JWT鉴权中间件,再到适合上位机/内部服务调用的轻量级签名校验。会给出可以直接落到代码里的实现思路和踩坑记录,不管你是刚接触C#的初级开发,还是被乱七八糟的认证需求折磨过的老手,应该都能在里面找到点有用的东西。
1. 先把概念理清楚:鉴权与授权到底在干什么
一上来就写代码肯定不行,得先把“自定义鉴权”这几个字拆开。很多人把鉴权和授权混在一起说,其实这是两个完全不同的环节。
1.1 鉴权与授权的边界
- 鉴权(Authentication):回答“你是谁”。系统拿到请求后,需要确认调用方的身份。常见手段是用户名密码、Token、证书、签名等。
- 授权(Authorization):回答“你能做什么”。身份确认之后,系统再判断这个身份有没有权限执行某个操作。常见手段是角色、策略、权限码。
说的直白一点:鉴权是进门时验身份证,授权是进了门之后你能去哪个房间、能动哪些东西。
很多自定义鉴权场景核心都是在“鉴权”这一层做文章:因为框架自带的方案不一定认识你手里的Token格式,或者你的客户端根本不按浏览器Cookie那套来。我们要做的,就是让系统用自己的规则去识别“来访者是谁”,识别完之后再交给授权系统去控制权限。
1.2 为什么需要自定义鉴权:三个典型场景
市面上现成的鉴权方案其实不少,但在实际项目中,我遇到需要自定义的情况主要是这三类:
- 老系统对接:公司内部可能有一套多年前用C++、Java或者PHP写的旧系统,Token的生成规则是内部约定,前端、后端、桌面端都得按这个规则来。新系统要复用同一套身份体系,就只能照着旧规则自己实现一遍校验逻辑。
- 非浏览器客户端:如果你做的是C#上位机(比如通过串口、TCP、HTTP调用本机或局域网服务的程序),或者第三方系统通过API对接你的服务,对方根本没有“登录页”的概念,你没法让它们走Cookie+Session那一套,只能靠Token或者签名。
- 安全策略定制:某些场景下,系统要求每次请求都带签名、带时间戳防重放,或者要根据设备指纹、IP白名单做额外校验。这些需求框架默认不支持,只能自己动手写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义鉴权的技术选型:从算法到载体
搞清楚“为什么要自定义”之后,就得开始设计具体的方案。这里牵扯几个核心选择:用什么算法生成凭证、凭证放在哪里、怎么传递、服务端怎么校验。
2.1 对称加密与非对称加密的选择
签名的核心算法无非两类:
| 类型 | 典型算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 对称加密 | HMACSHA256、AES | 计算快,实现简单,密钥只要一个 | 密钥要保管好,泄露就全完 | 内部系统、上位机、前后端一体的系统 |
| 非对称加密 | RSA、ECDSA | 私钥签发,公钥验证,私钥不怕泄露给调用方 | 计算稍慢,密钥管理复杂 | 开放API、第三方接入、前后端完全分离的大系统 |
对称加密的理解成本低,HMACSHA256是我用得最多的。它的意思就是用同一个密钥对内容做哈希运算,生成一段固定长度的签名。服务端生成Token时用这个密钥,服务端校验Token时也用这个密钥,少了私钥分发的麻烦。
如果是给几十上百家第三方公司开放API,那就得考虑RSA了:你只把公钥给对方,或者对方把公钥给你,各自拿着对应的私钥做签名和验签,私钥永远不会离开各自服务器。两种方式没有绝对的好坏,关键看你的业务边界。
2.2 Token的载体:JWT还是自定义格式
Token本质上就是一小段字符串,服务端一看就能认出身份。目前主流的Token格式有两种思路:
- JWT(JSON Web Token):三段式结构(Header.Payload.Signature),自带JSON信息,能被标准库解析,跨语言支持极好。
- 自定义Token:按项目的内部约定拼接一段字符串,比如userId + "." + 过期时间戳 + "." + 签名。格式自由,但是要自己写解析逻辑。
我的建议是:能用JWT就用JWT。虽然JWT因为标准统一、信息可读,在网上被讨论得多,但它在跨语言场景下确实方便。你用C#签发Token,客户端用Java、Python甚至Node都能轻松验签,生态成熟,省得自己造轮子。反过来,如果Token只在你自己的几个系统之间流转,而且格式越简单越好,那自定义格式也完全够用。
2.3 凭证传递:Authorization Header 还是请求参数
Token生成了,怎么带到服务端?
最简单的思路是放在请求的Header里,HTTP标准规定了一个专门字段叫Authorization。一般的习惯是:
code复制Authorization: Bearer <token>
自定义鉴权时,也有人直接写成:
code复制Authorization: <token>
还有人为了偷懒把Token放在URL的Query参数里,比如?token=xxx。我强烈不建议这么做:URL参数会出现在访问日志、浏览器历史记录和各种代理日志里,Token非常容易泄密。非浏览器环境虽然没这些顾虑,但既然有Header可以用,为什么还要给自己埋雷?
另一个常见的传参位置是自定义Header,比如X-Auth-Token、X-Signature。如果你要同时传多个信息(比如AppId、时间戳、随机数、签名值),用自定义Header是常见做法。
3. JWT自定义鉴权实操:从登录到校验全流程
下面用一个实际项目里的例子来演示。需求背景如下:有一个ASP.NET Core Web API,需要给前端Web应用和第三方客户端提供接口。身份认证希望自己控制:不采用Identity Server那套重型框架,而是自己签发Token、自己写鉴权中间件。
3.1 生成JWT:登录接口的核心逻辑
登录接口的任务很简单:接收用户名密码,校验通过之后生成Token返回给客户端。我们用一个自定义的TokenService来封装生成逻辑。
csharp复制using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateToken(string userId, string userName, string role)
{
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.UniqueName, userName),
new Claim(ClaimTypes.Role, role),
new Claim("token_type", "access_token")
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(2),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
这里有几个细节需要解释一下:
- Claim就是Token里携带的“身份信息”。需要什么就往里塞什么,但别塞敏感数据进去,比如身份证号、手机号,因为JWT的Payload默认只是Base64编码,不是加密的,抓到Token的人可以轻松解出来看。
- expires一定要设置。很多人图省事签发“永久Token”,一旦泄密就要连锅端。我一般习惯2小时,需要更长的再单独发RefreshToken。
- Audience和Issuer按需设置,如果只是内部系统调用,不设置也能跑。但设置了的话,校验时也能多一道保障。
3.2 自定义鉴权中间件:拦截每个请求
框架自带UseAuthentication中间件,走的是JWT Bearer方案。既然我们做“自定义鉴权”,这里有两种做法:
做法一:配置框架的JWT Bearer,只换校验参数
csharp复制services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = _config["Jwt:Issuer"],
ValidAudience = _config["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"]))
};
});
这种做法本质上还是“标准JWT Bearer”,不算完全自定义。如果Token格式、校验规则完全符合JWT标准,那这一步最稳,不用自己处理很多边界情况。但如果你有额外的校验逻辑,比如要检查用户是否被禁用、请求IP是否在白名单内,就得接着往下看。
做法二:完全自定义的鉴权中间件
很多老系统、上位机场景下,Token根本不是JWT,或者校验规则非常特殊,这时候就需要自己写中间件。下面的代码展示了自定义中间件的核心骨架:
csharp复制public class CustomAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly TokenValidator _validator;
public CustomAuthMiddleware(RequestDelegate next, TokenValidator validator)
{
_next = next;
_validator = validator;
}
public async Task InvokeAsync(HttpContext context)
{
var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", "");
if (string.IsNullOrEmpty(token))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("缺少访问令牌");
return;
}
var principal = _validator.ValidateToken(token);
if (principal == null)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("令牌无效或已过期");
return;
}
context.User = principal;
await _next(context);
}
}
注册中间件时注意顺序:
csharp复制app.UseMiddleware<CustomAuthMiddleware>();
app.UseAuthorization();
中间件把解析出来的ClaimsPrincipal赋给context.User之后,后面的授权中间件才能正常工作,所以自定义鉴权中间件一定要在UseAuthorization之前注册。
如果用的是ASP.NET Core,注册中间件不需要什么特殊技巧;如果用的是老式Owin、经典ASP.NET或者自建TCP服务,思路完全一样:在处理请求的最前面加上一道“验Token”的门。
3.3 授权联动:让[Authorize]和角色生效
自定义中间件还原了用户身份之后,控制器里的[Authorize]特性就可以正常使用了。比如:
csharp复制[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
[HttpGet]
[Authorize(Roles = "admin")]
public IActionResult GetAll()
{
var userId = User.FindFirst("sub")?.Value;
// 业务逻辑...
return Ok();
}
[HttpPost]
[Authorize] // 只要登录就能访问
public IActionResult Create(OrderDto order)
{
// 业务逻辑...
return Ok();
}
}
这里有个经验:如果你想完全自己在中间件里做授权(不依赖框架特性),可以在context.User不满足条件时直接返回403。但既然框架自带[Authorize(Roles = "xxx")]能用,就别重复造轮子了。
3.4 与Swagger配合:调试接口不再手忙脚乱
自定义鉴权之后,调试接口时最烦的就是每一个请求都要手动加Header。如果你项目里有Swagger,配一下JWT的授权按钮敲几个接口——点一下“Authorize”,填上Token,后续所有请求自动带Header,调试体验直接起飞。
csharp复制services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "请输入Token,格式:Bearer {token}",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new List<string>()
}
});
});
Swagger配置好之后,你的接口文档里每个方法都会带一个锁形图标,点开就能填Token,团队联调效率能高不少。
4. 更轻量的签名方案:适合上位机与内部服务
如果你开发的不是Web API,而是C#上位机程序——比如本机有一个Windows服务,上位机客户端通过HTTP或TCP访问它——那JWT有时候显得“重”了。因为连个登录页都没有,谁来都直接带Token也不现实。
这种场景我常用的是AppId + Timestamp + Nonce + Signature的签名方案。说到底就是:调用方用约定好的密钥对请求参数签名,服务端用同样的方式验签,签名通过就认为请求合法。
4.1 签名流程设计
假设要调用一个“获取设备状态”的接口:
- 调用方拥有一个
AppId和对应的AppSecret; - 调用方生成请求参数:
appId=10001×tamp=1713000000&nonce=abc123random&deviceId=DEV001; - 把参数按字典序排列,拼成一个字符串,用
HMACSHA256和AppSecret算出sign; - 请求头带上
X-AppId、X-Timestamp、X-Nonce、X-Sign四个字段。
服务端校验时,先查一下AppId对应的AppSecret,然后用同样的规则重新计算签名,比对是否一致。这个方案有几个天然优点:
- 无状态:服务端不用保存任何会话,验签通过就算认证通过;
- 防重放:校验
Timestamp与当前时间差(比如5分钟内有效),再配合Nonce(随机字符串)记录使用过的随机数,能有效防止抓包重放; - 轻量:不需要登录接口,不需要刷新Token,特别适合机器与机器之间的通信。
4.2 服务端验签中间件核心代码
csharp复制public class SignatureAuthMiddleware
{
private readonly RequestDelegate _next;
private readonly IAppSecretStore _secretStore;
public SignatureAuthMiddleware(RequestDelegate next, IAppSecretStore secretStore)
{
_next = next;
_secretStore = secretStore;
}
public async Task InvokeAsync(HttpContext context)
{
// 1. 取参数
var appId = context.Request.Headers["X-AppId"].FirstOrDefault();
var timestamp = context.Request.Headers["X-Timestamp"].FirstOrDefault();
var nonce = context.Request.Headers["X-Nonce"].FirstOrDefault();
var sign = context.Request.Headers["X-Sign"].FirstOrDefault();
// 2. 基本非空校验
if (string.IsNullOrEmpty(appId) ||
string.IsNullOrEmpty(timestamp) ||
string.IsNullOrEmpty(nonce) ||
string.IsNullOrEmpty(sign))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("缺少签名参数");
return;
}
// 3. 防时间差攻击(允许5分钟误差,注意服务器时间与客户端时间差)
var requestTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp));
if (Math.Abs((DateTimeOffset.UtcNow - requestTime).TotalMinutes) > 5)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("请求时间戳已过期");
return;
}
// 4. 防重放(用缓存记录已使用过的nonce,这里简化示例)
// if (await _nonceCache.ExistsAsync(nonce))
// {
// context.Response.StatusCode = StatusCodes.Status401Unauthorized;
// await context.Response.WriteAsync("重复请求");
// return;
// }
// 5. 根据appId取密钥
var secret = await _secretStore.GetSecretByAppIdAsync(appId);
if (string.IsNullOrEmpty(secret))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("无效的AppId");
return;
}
// 6. 计算签名
var dataToSign = $"appId={appId}&nonce={nonce}×tamp={timestamp}{secret}";
var computedSign = SignHelper.HmacSha256(dataToSign, secret);
// 7. 恒定时间比较,避免时序攻击(这里用CryptographicOperations即可)
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSign),
Encoding.UTF8.GetBytes(sign)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("签名校验失败");
return;
}
context.Items["AppId"] = appId;
await _next(context);
}
}
对应的签名计算工具类:
csharp复制public static class SignHelper
{
public static string HmacSha256(string data, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
return Convert.ToHexString(hash).ToLower();
}
}
这里注意两个细节:
- 签名拼接字符串的格式必须是约定死的,调用方和服务端用一模一样的规则,多一个空格、少一个参数都会验签失败;
- 用
Convert.ToHexString得到的小写十六进制字符串,大小写一定要统一,不然两边都是对的却互相验不过。
5. 自定义鉴权的安全细节与常见坑
写完功能只是第一步,鉴权这东西,安全细节不到位就跟没写差不多。我把自己踩过的坑和常见的雷区整理一下。
5.1 密钥管理:别硬编码在代码里
很多教程为了演示方便,直接SecretKey = "abc123"写在代码里。小项目自己玩玩没事,正式环境这么干就是灾难。密钥一旦泄露,任何人都能签发Token,你的整个鉴权体系形同虚设。
我常用的做法:
- 开发环境用
appsettings.Development.json,正式环境用环境变量或配置中心(比如K8s的Secret、云厂商的密钥管理服务); - 定期轮换密钥,特别是人员离职、代码仓库泄密时;
- 如果用了JWT,换密钥的过渡期要注意:老密钥签发的Token还没过期,校验时得保留一个密钥列表,而不是只认当前密钥。
5.2 过期时间与时钟偏移
JWT的exp校验依赖服务器时间。如果服务器时钟不准,Token可能提前失效,也可能过期很久还能用。部署时一定确保服务器开启了NTP时间同步。自定义中间件里如果做了“时间差5分钟”的判断,同样要留意调用方机器和服务端机器的时间偏差,央求所有客户端都对时不太现实,校验时给个宽容窗口是很常见的工程取舍。
5.3 日志与审计:出了事才能查
鉴权失败的请求一定要打日志,但不能把Token本身打到日志里。Token属于敏感信息,日志里应该记录的是AppId、UserId、IP、时间、失败原因。不然排查问题的时候日志倒是全了,Token也全泄了。
我见过一个同事排查线上问题,把整个请求头打到日志,结果一天下来几百个用户的Token全部出现在日志文件里,还得全部通知用户重新登录。教训非常深刻。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 客户端拿到的Token用不了,服务端报401 | 签发时和校验时的密钥不一致 | 检查配置中心的密钥是否同步 |
| Token明明没过期,却被判定过期 | 服务器时间不准或时钟偏移 | 开启NTP同步,检查两个环境时区 |
| 换了一台机器部署,所有Token失效 | 密钥是部署时随机生成的,重启变新 | 密钥要固化到配置文件或环境变量 |
| 签名接口总是验签失败 | 拼接参数字典序不对、大小写不一致 | 统一拼接规则和Hex大小写风格 |
| 上位机访问服务提示“缺少访问令牌” | 客户端没有正确设置Header字段名 | 核对X-AppId/Authorization字段名是否一致 |
| Swagger里调试接口带不上Token | Swagger配置缺少SecurityDefinition | 按上文配好AddSecurityDefinition并引用 |
5.5 一个绕不开的话题:防爆破与限流
鉴权接口本身特别容易被盯上。有人闲着没事会对你的登录接口暴力猜密码,也会拿着抓到的Token不停重放。自定义鉴权时,至少要做的两件事:
- 登录接口限流:同一IP、同一账户短时间内的失败次数超过阈值就锁定一段时间;
- 签名接口做Nonce去重:前面提到的时间戳+随机数方案,如果随机数不加缓存去重,攻击者拿到一个合法请求可以反复重放。用
IDistributedCache或MemoryCache简单存一下Nonce,设置一个跟时间窗口一致的过期时间,开销很小,但能挡住绝大多数简单重放。
6. 自定义鉴权模板:一个可以直接复用的思路框架
最后,我把自己在多个项目里沉淀下来的自定义鉴权“套路”分享出来。无论你用JWT还是自定义签名,整体流程都可以照抄这个模板来思考:
6.1 五步设计法
- 确定认证方式:是用户名密码换Token,还是AppId+Secret直接签名?
- 确定Token格式:用JWT标准格式,还是自定义拼接字符串?里面放哪些字段?
- 确定传递方式:Authorization Header?X-App-Key?还是多个自定义Header?
- 确定校验逻辑:服务端先做什么后做什么?验签名/验时间/验用户状态?
- 确定授权联动:校验完怎么把用户信息传给业务层?用ClaimsPrincipal还是塞进HttpContext.Items?
6.2 关键代码骨架:自定义鉴权中间件通用结构
csharp复制public class AuthMiddleware
{
private readonly RequestDelegate _next;
public AuthMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// 1. 提取凭证
// 2. 校验格式 / 签名 / 过期时间
// 3. 构建用户身份(ClaimsPrincipal)
// 4. 写入 context.User
// 5. 放行或拦截
await _next(context);
}
}
这套骨架就像一张画纸,你可以按业务需要往里面填任何校验逻辑:数据库查用户、Redis验黑名单、内存缓存验Nonce,甚至对接LDAP、AD域。
6.3 什么时候别自己造轮子
虽然这篇文章一直在讲“自定义”,但我也要泼盆冷水:如果项目是面向公众互联网、需要多端登录、第三方授权、OpenID Connect等完整能力,直接用现成框架可能更合适。比如:
- 大型SaaS应用:考虑Identity Server 4/5、OpenIddict;
- 企业内部系统:考虑Azure AD、OAUTH2.0;
- 极其简单的单机工具:干脆不做鉴权,只做局域网限制;
自定义的意义在于“可控”和“贴合”,但代价是“自己维护安全”。如果你对密码学、安全攻防没有太多经验,我的建议是先照着成熟方案的思路做简化版,不要发明新算法、新协议。千万不要自己发明加密算法,这是铁律。
我自己早期在这个问题上栽过跟头:为了图省事,把Token设计成Base64(userId + "|" + 过期时间),连签名都没加,想着内部系统无所谓。结果有一次网络抓包被人看到,人家把userId从1改到100,一个个试过去,直接把整个系统的数据拉了一遍。那次之后我明白了,鉴权方案可以不复杂,但该有的签名、过期、防重放,一个都不能省。
7. 写在最后的一段经验
做自定义鉴权,本质上是在回答两个问题:服务端怎么确定你真的是你?你来了之后能碰哪些东西?
我之前在一个WPF上位机项目里就犯过一个低级错误:自定义鉴权中间件写好后,没有在Startup里把它放到正确的位置,结果请求一路穿过静态文件中间件、路由中间件,最后才走到鉴权逻辑。表面上看着也能拦截,实际上静态文件和部分路由根本拦不住。后来把app.UseMiddleware<AuthMiddleware>()调到最前面,问题才解决。这不是什么高深技术,纯粹是“中间件顺序”这个细节,但我相信不少人都在这上面耗过时间。
另一个经验是:自定义鉴权一定要在一开始就把日志、密钥管理、过期策略考虑进去,不要等功能跑通了再补。 否则上线之后再来加日志、换密钥体系,牵一发而动全身,改一次怕一次。
如果你正被某个鉴权需求卡住,不妨先按上面这套思路理一理:你的Token里需要带什么?你的服务端能拿到什么做校验?如果这两个问题能清晰回答,代码只是时间问题。希望这篇分享能给你节省一点摸索的时间,少踩几个我踩过的坑。
