.NET Core Web API 中基于 JWT 的身份验证与安全防护策略
引言:构建安全的 API 访问机制
在现代 Web API 开发中,保障接口的安全性是核心任务之一。HTTP 请求本身是无状态且开放的,因此必须引入可靠的认证和授权机制来防止未授权访问、数据篡改及重放攻击。常见的做法是结合 JWT(JSON Web Token)进行身份识别,并通过签名机制确保请求完整性。
本文将介绍如何在 .NET Core 项目中实现完整的 API 安全体系,涵盖用户认证、JWT 发放与验证、刷新令牌管理、非对称加密签名以及 JWE 加密保护等关键技术点。完整示例代码可参考:Gitee 仓库链接。
用户系统基础:集成 ASP.NET Core Identity
安全控制的前提是具备完善的用户管理体系。ASP.NET Core 提供了内置的身份框架 Identity,支持用户注册、登录、密码哈希、角色管理等功能,为后续的 Token 颁发提供可信的数据源。
启用 Identity 组件后,可通过 Entity Framework 进行数据库迁移,快速搭建用户存储结构。登录成功后,系统可根据用户信息生成 JWT 并返回给客户端,用于后续请求的身份凭证。
JWT 的生成与验证流程
用户登录成功后,服务端应签发一个 JWT,通常包含用户 ID、角色、过期时间等声明(Claims)。客户端在后续请求中将该 Token 放入 HTTP 头部(如 Authorization: Bearer <token>),由中间件或过滤器完成解析和验证。
配置 JWT 验证服务
在 Program.cs 中注册 JWT 身份验证服务:
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = rsaSecurityPublicKey, // 使用公钥验签
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.Zero
};
});
解决 JWT 不可撤销问题:引入 Refresh Token
JWT 的主要缺陷在于其一旦签发,在有效期内无法主动失效。为降低风险,可采用短生命周期的访问令牌(Access Token)配合长周期的刷新令牌(Refresh Token)机制。
- Access Token 有效期设为 30 分钟,用于常规接口调用。
- Refresh Token 存储于数据库,有效期可达数天甚至数周,仅用于获取新的 Access Token。
- 当用户修改密码或主动登出时,可将数据库中的 Refresh Token 标记为已使用或删除,从而强制重新登录。
刷新 Token 示例逻辑
var storedToken = await _context.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == refreshToken && !t.Used);
if (storedToken == null)
{
return Unauthorized("无效的刷新令牌");
}
storedToken.Used = true;
await _context.SaveChangesAsync();
var newAccessToken = GenerateAccessToken(user);
return Ok(new { AccessToken = newAccessToken });
从 JWT 中提取用户信息
业务逻辑中常需获取当前用户标识,例如在修改密码时校验并使旧 Refresh Token 失效。为此,需要从请求头中提取并解码 JWT。
封装上下文工具类
public interface ICurrentUserAccessor
{
string? GetRawToken();
ClaimsPrincipal? GetClaims();
}
public class CurrentUserAccessor : ICurrentUserAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserAccessor(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string? GetRawToken()
{
var authHeader = _httpContextAccessor.HttpContext?.Request.Headers["Authorization"].ToString();
return authHeader?.StartsWith("Bearer ") is true
? authHeader.Substring(7)
: null;
}
public ClaimsPrincipal? GetClaims()
{
var token = GetRawToken();
if (string.IsNullOrEmpty(token)) return null;
var handler = new JwtSecurityTokenHandler();
try
{
return handler.ValidateToken(token, new TokenValidationParameters
{
ValidateLifetime = false,
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = _securityKey,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }
}, out _);
}
catch
{
return null;
}
}
}
控制器中使用
[HttpPost("change-password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordModel model)
{
var token = _userAccessor.GetRawToken();
var claims = _userAccessor.GetClaims();
if (claims == null) return Unauthorized();
var userIdClaim = claims.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim == null) return BadRequest();
var result = await _userService.ChangePasswordAsync(int.Parse(userIdClaim), model.OldPassword, model.NewPassword, token);
return result.Succeeded ? Ok() : BadRequest(result.Errors);
}
增强安全性:使用非对称加密签名
对称加密方式下,所有服务共享同一密钥,存在泄露风险。更优方案是使用 RSA 等非对称算法:
- 私钥(private key)仅保留在认证服务端,用于签名 JWT。
- 公钥(public key)可公开分发给各微服务,用于验证 Token 合法性。
生成并加载 RSA 密钥对
public class RsaKeyProvider
{
private readonly string _publicKeyPath;
private readonly string _privateKeyPath;
public RsaKeyProvider(IWebHostEnvironment env)
{
var dir = Path.Combine(env.ContentRootPath, "Keys");
Directory.CreateDirectory(dir);
_publicKeyPath = Path.Combine(dir, "jwt.pub.json");
_privateKeyPath = Path.Combine(dir, "jwt.pri.json");
if (!File.Exists(_publicKeyPath) || !File.Exists(_privateKeyPath))
{
GenerateKeys();
}
}
private void GenerateKeys()
{
using var rsa = RSA.Create(2048);
var priParams = rsa.ExportParameters(true);
var pubParams = rsa.ExportParameters(false);
File.WriteAllText(_privateKeyPath, JsonConvert.SerializeObject(priParams));
File.WriteAllText(_publicKeyPath, JsonConvert.SerializeObject(pubParams));
}
public RsaSecurityKey GetPrivateKey() =>
new(JsonConvert.DeserializeObject<RSAParameters>(File.ReadAllText(_privateKeyPath)));
public RsaSecurityKey GetPublicKey() =>
new(JsonConvert.DeserializeObject<RSAParameters>(File.ReadAllText(_publicKeyPath)));
}
注册签名凭据
var keyProvider = new RsaKeyProvider(builder.Environment);
var signingKey = keyProvider.GetPrivateKey();
builder.Services.AddSingleton<SigningCredentials>(
new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256)
);
builder.Services.AddSingleton(keyProvider.GetPublicKey());
进一步加密:使用 JWE 实现 JWT 内容加密
虽然 JWT 可防篡改,但 Payload 是 Base64 编码明文。若需隐藏敏感信息(如用户权限细节),可启用 JSON Web Encryption(JWE)。
创建加密 Token
var encryptingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("abc111111111111111111111111111111cba"));
var encryptingCredentials = new EncryptingCredentials(
encryptingKey,
JwtConstants.DirectKeyUseAlg,
SecurityAlgorithms.Aes128CbcHmacSha256
);
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Role, "Admin")
}),
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = signingCredentials,
EncryptingCredentials = encryptingCredentials
};
var handler = new JwtSecurityTokenHandler();
var tokenString = handler.CreateEncodedJwt(descriptor);
配置解密支持
在 TokenValidationParameters 中添加解密密钥:
options.TokenValidationParameters.TokenDecryptionKey =
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("abc111111111111111111111111111111cba"));
注意:JWE 会增加处理开销,建议仅对包含敏感信息的 Token 启用。
前端协同控制:即时登出实践
当用户修改密码后,已发放的 JWT 在过期前仍有效。理想解决方案包括:
- 黑名单机制:使用 Redis 缓存已注销 Token,每次请求校验是否在黑名单中。
- 短时效 + 强制刷新:设置极短有效期(如 5 分钟),依赖 Refresh Token 自动续签。
- 前端清理:通知前端清除本地存储的 Token 和 Refresh Token,实现"软登出"。
综合来看,最轻量的方式是由后端标记 Refresh Token 失效,同时前端主动清空凭证,避免继续使用旧 Token。