OAuth2/OpenID Connect 认证
大约 9 分钟约 2586 字
OAuth2/OpenID Connect 认证
简介
OAuth2 是授权框架,允许用户授权第三方应用访问其资源。OpenID Connect(OIDC)在 OAuth2 之上增加了身份认证层。ASP.NET Core 通过 Microsoft.AspNetCore.Authentication.OpenIdConnect 包提供完整的 OAuth2/OIDC 支持。
特点
OAuth2 基本概念
授权码模式流程
用户 → 浏览器 → 客户端应用 → 授权服务器(登录)
↓
授权码(Authorization Code)
↓
客户端应用 → 授权服务器(换取 Token)
↓
Access Token + Refresh Token
↓
客户端应用 → 资源服务器(带 Token 访问)JWT Token 结构
// Header
{
"alg": "RS256",
"typ": "JWT"
}
// Payload
{
"sub": "user-123",
"name": "张三",
"email": "zhang@example.com",
"role": "admin",
"iss": "https://auth.example.com",
"aud": "my-api",
"exp": 1704067200,
"iat": 1704063600
}JWT Bearer 认证
配置 JWT
// NuGet: Microsoft.AspNetCore.Authentication.JwtBearer
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://auth.example.com";
options.Audience = "my-api";
options.RequireHttpsMetadata = true;
// 自定义 Token 验证
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://auth.example.com",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1), // 时钟偏移
ValidateIssuerSigningKey = true
};
// 从 Query 读取 Token(WebSocket 场景)
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token))
{
context.Token = token;
}
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();生成 JWT Token
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config) => _config = config;
public string GenerateToken(User user)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Name, user.Name),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
{
return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
}
}登录接口
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly TokenService _tokenService;
private readonly IUserService _userService;
public AuthController(TokenService tokenService, IUserService userService)
{
_tokenService = tokenService;
_userService = userService;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var user = await _userService.ValidateAsync(request.Username, request.Password);
if (user == null)
return Unauthorized(new { message = "用户名或密码错误" });
var accessToken = _tokenService.GenerateToken(user);
var refreshToken = _tokenService.GenerateRefreshToken();
// 保存 Refresh Token
await _userService.SaveRefreshTokenAsync(user.Id, refreshToken);
return Ok(new
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = 900 // 15 分钟
});
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
var principal = GetPrincipalFromExpiredToken(request.AccessToken);
var userId = int.Parse(principal.FindFirst(ClaimTypes.NameIdentifier)!.Value);
var user = await _userService.GetByIdAsync(userId);
if (user?.RefreshToken != request.RefreshToken)
return Unauthorized("无效的 Refresh Token");
var newAccessToken = _tokenService.GenerateToken(user);
var newRefreshToken = _tokenService.GenerateRefreshToken();
await _userService.SaveRefreshTokenAsync(user.Id, newRefreshToken);
return Ok(new
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken
});
}
}OIDC 集成
OpenID Connect 配置
Token 安全与生命周期管理
Token 黑名单与吊销
/// <summary>
/// Token 吊销机制
/// </summary>
public class TokenBlacklistService
{
private readonly IDistributedCache _cache;
private readonly ILogger<TokenBlacklistService> _logger;
public TokenBlacklistService(
IDistributedCache cache,
ILogger<TokenBlacklistService> logger)
{
_cache = cache;
_logger = logger;
}
// 吊销 Token
public async Task RevokeTokenAsync(string jti, TimeSpan? expires = null)
{
var key = $"token:blacklist:{jti}";
var ttl = expires ?? TimeSpan.FromHours(24);
await _cache.SetStringAsync(key, "1", new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
});
_logger.LogInformation("Token 已吊销: {Jti}", jti);
}
// 检查 Token 是否被吊销
public async Task<bool> IsRevokedAsync(string jti)
{
var key = $"token:blacklist:{jti}";
var result = await _cache.GetStringAsync(key);
return result == "1";
}
// 吊销用户所有 Token(用于密码修改/强制下线)
public async Task RevokeAllUserTokensAsync(long userId, TimeSpan? expires = null)
{
var key = $"user:token_version:{userId}";
var newVersion = Guid.NewGuid().ToString("N");
await _cache.SetStringAsync(key, newVersion, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expires ?? TimeSpan.FromDays(30)
});
}
// 检查用户 Token 版本
public async Task<bool> IsUserTokenValidAsync(long userId, string tokenVersion)
{
var key = $"user:token_version:{userId}";
var currentVersion = await _cache.GetStringAsync(key);
return currentVersion == tokenVersion;
}
}
// JWT 事件中检查黑名单
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var blacklistService = context.HttpContext.RequestServices
.GetRequiredService<TokenBlacklistService>();
var jti = context.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value;
if (!string.IsNullOrEmpty(jti) && await blacklistService.IsRevokedAsync(jti))
{
context.Fail("Token 已被吊销");
}
}
};
});Refresh Token 安全
/// <summary>
/// 安全的 Refresh Token 管理
/// </summary>
public class RefreshTokenManager
{
private readonly AppDbContext _db;
private readonly TokenBlacklistService _blacklist;
public RefreshTokenManager(AppDbContext db, TokenBlacklistService blacklist)
{
_db = db;
_blacklist = blacklist;
}
// 生成 Refresh Token(使用密码学安全随机数)
public string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
// 保存 Refresh Token
public async Task SaveRefreshTokenAsync(
long userId, string refreshToken, string jti, TimeSpan lifetime)
{
var token = new RefreshToken
{
UserId = userId,
Token = refreshToken,
JwtJti = jti, // 关联 Access Token 的 JTI
ExpiresAt = DateTime.UtcNow.Add(lifetime),
CreatedAt = DateTime.UtcNow,
IsRevoked = false
};
_db.RefreshTokens.Add(token);
await _db.SaveChangesAsync();
}
// 刷新 Token(Rotation 模式)
public async Task<TokenPair> RefreshTokenAsync(string refreshToken)
{
var existing = await _db.RefreshTokens
.Include(r => r.User)
.FirstOrDefaultAsync(r => r.Token == refreshToken);
if (existing == null || existing.IsRevoked || existing.ExpiresAt < DateTime.UtcNow)
throw new UnauthorizedAccessException("无效的 Refresh Token");
// 吊销旧 Token(Token Rotation)
existing.IsRevoked = true;
_db.RefreshTokens.Update(existing);
// 吊销关联的 Access Token
if (!string.IsNullOrEmpty(existing.JwtJti))
{
await _blacklist.RevokeTokenAsync(existing.JwtJti);
}
// 检测 Token 重用(安全告警)
if (existing.IsRevoked)
{
_logger.LogWarning("检测到 Refresh Token 重用,可能存在安全风险。用户: {UserId}", existing.UserId);
// 可以选择吊销该用户所有 Token
await _blacklist.RevokeAllUserTokensAsync(existing.UserId);
}
// 生成新的 Token 对
var newAccessToken = _tokenService.GenerateToken(existing.User);
var newRefreshToken = GenerateRefreshToken();
var newRecord = new RefreshToken
{
UserId = existing.UserId,
Token = newRefreshToken,
ExpiresAt = DateTime.UtcNow.Add(TimeSpan.FromDays(7)),
CreatedAt = DateTime.UtcNow
};
_db.RefreshTokens.Add(newRecord);
await _db.SaveChangesAsync();
return new TokenPair(newAccessToken, newRefreshToken);
}
}多租户认证
多租户 Token 管理
/// <summary>
/// 多租户 OAuth2 配置
/// </summary>
// 每个租户使用不同的 OIDC 配置
public class MultiTenantOAuthHandler
{
private readonly ITenantService _tenantService;
private readonly ConcurrentDictionary<string, JwtBearerOptions> _optionsCache = new();
public async Task<JwtBearerOptions> GetOptionsAsync(string tenantId)
{
if (_optionsCache.TryGetValue(tenantId, out var cached))
return cached;
var tenant = await _tenantService.GetTenantAsync(tenantId);
var options = new JwtBearerOptions
{
Authority = tenant.OidcAuthority,
Audience = tenant.OidcAudience,
TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = tenant.OidcAuthority,
ValidAudience = tenant.OidcAudience,
ValidateLifetime = true
}
};
_optionsCache[tenantId] = options;
return options;
}
}
// 动态租户认证中间件
public class MultiTenantAuthMiddleware
{
private readonly RequestDelegate _next;
public MultiTenantAuthMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (!string.IsNullOrEmpty(tenantId))
{
// 设置当前租户
context.Items["TenantId"] = tenantId;
}
await _next(context);
}
}PKCE 增强
Proof Key for Code Exchange
/// <summary>
/// PKCE 流程(防止授权码拦截攻击)
/// </summary>
public class PkceClient
{
private readonly HttpClient _httpClient;
public PkceClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
// 1. 生成 Code Verifier(43-128 字符的随机字符串)
public string GenerateCodeVerifier()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Base64UrlEncode(bytes);
}
// 2. 生成 Code Challenge(Code Verifier 的 SHA256 哈希)
public string GenerateCodeChallenge(string codeVerifier)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
return Base64UrlEncode(bytes);
}
// 3. 授权请求(带 code_challenge)
public async Task<Uri> BuildAuthorizationUrlAsync(string clientId, string redirectUri, string codeChallenge)
{
var url = $"{_authority}/authorize?" +
$"client_id={Uri.EscapeDataString(clientId)}&" +
$"redirect_uri={Uri.EscapeDataString(redirectUri)}&" +
$"response_type=code&" +
$"code_challenge={codeChallenge}&" +
$"code_challenge_method=S256&" +
$"scope=openid profile email";
return new Uri(url);
}
// 4. 用授权码换取 Token(带 code_verifier)
public async Task<TokenResponse> ExchangeCodeAsync(
string code, string codeVerifier, string redirectUri)
{
var response = await _httpClient.PostAsync($"{_authority}/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["code_verifier"] = codeVerifier,
["redirect_uri"] = redirectUri,
["client_id"] = _clientId,
["client_secret"] = _clientSecret
}));
return await response.Content.ReadFromJsonAsync<TokenResponse>();
}
private static string Base64UrlEncode(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
// PKCE 适用场景:SPA、移动应用、原生应用
// 不需要 client_secret,降低密钥泄露风险// NuGet: Microsoft.AspNetCore.Authentication.OpenIdConnect
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://login.microsoftonline.com/{tenant-id}/v2.0";
options.ClientId = "your-client-id";
options.ClientSecret = "your-client-secret";
options.ResponseType = "code";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true; // 保存 Token 到 Cookie
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = context =>
{
// Token 验证后自定义处理
var claims = context.Principal?.Claims;
// 可以从 Token 中提取额外信息
return Task.CompletedTask;
}
};
});授权策略
基于策略的授权
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("admin"));
options.AddPolicy("CanEdit", policy =>
policy.RequireClaim("permission", "edit"));
options.AddPolicy("SeniorUser", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
// 自定义需求
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var ageClaim = context.User.FindFirst("age");
if (ageClaim != null && int.Parse(ageClaim.Value) >= requirement.MinimumAge)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// 注册 Handler
builder.Services.AddScoped<IAuthorizationHandler, MinimumAgeHandler>();
// 使用
[Authorize(Policy = "AdminOnly")]
[ApiController]
public class AdminController : ControllerBase { }优点
缺点
总结
OAuth2/OIDC 核心:JWT 用于 API 认证,OIDC 用于 Web 应用 SSO。JWT 配置使用 AddJwtBearer,设置 Authority/Audience/验证参数。Token 刷新用 Refresh Token 模式。OIDC 集成使用 AddOpenIdConnect 对接 IdentityServer/Azure AD。授权策略用 AddPolicy 定义规则。密钥管理建议使用 Azure Key Vault 或配置加密。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
- 安全类主题的关键不只在认证成功,而在于权限边界、证书信任链和审计链路是否完整。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
- 明确令牌生命周期、刷新策略、作用域、Claims 和失败返回模型。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
- 只验证登录成功,不验证权限收敛和令牌失效场景。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
- 继续深入零信任、细粒度授权、证书自动化和密钥轮换。
适用场景
- 当你准备把《OAuth2/OpenID Connect 认证》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《OAuth2/OpenID Connect 认证》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《OAuth2/OpenID Connect 认证》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《OAuth2/OpenID Connect 认证》最大的收益和代价分别是什么?
