Compare commits

...

2 Commits

Author SHA1 Message Date
glz
fa1acec8af Merge branch 'master' of http://8.137.159.94:3000/glz/QYZH.InteractiveMagazine 2026-06-30 09:16:09 +08:00
glz
c342f543f7 fix 登录授权的问题 2026-06-30 09:15:52 +08:00
5 changed files with 94 additions and 70 deletions

View File

@ -11,6 +11,9 @@ namespace QYZH.InteractiveMagazine.Infrastructure.Auth;
/// </summary> /// </summary>
public static class JwtHelper public static class JwtHelper
{ {
public const string AdminTokenKeyPrefix = "InteractiveMagazine:AdminAuth:Token";
public const string WeChatTokenKeyPrefix = "InteractiveMagazine:WeChatAuth:Token";
/// <summary> /// <summary>
/// 生成JWT令牌管理端 / 单用户场景) /// 生成JWT令牌管理端 / 单用户场景)
/// </summary> /// </summary>
@ -60,6 +63,26 @@ public static class JwtHelper
/// </summary> /// </summary>
public const string WxUserIdClaimType = "WxUserId"; public const string WxUserIdClaimType = "WxUserId";
public static string BuildAdminTokenKey(string userId)
{
return $"{AdminTokenKeyPrefix}:{userId}";
}
public static string BuildAdminTokenKey(long userId)
{
return BuildAdminTokenKey(userId.ToString());
}
public static string BuildWeChatTokenKey(string wxUserId, string userId)
{
return $"{WeChatTokenKeyPrefix}:{wxUserId}:{userId}";
}
public static string BuildWeChatTokenKey(long wxUserId, long userId)
{
return BuildWeChatTokenKey(wxUserId.ToString(), userId.ToString());
}
private static string BuildToken(Claim[] claims, JwtSettings settings) private static string BuildToken(Claim[] claims, JwtSettings settings)
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecretKey!)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecretKey!));

View File

@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using QYZH.InteractiveMagazine.Infrastructure.Auth;
using QYZH.InteractiveMagazine.Infrastructure.Middleware; using QYZH.InteractiveMagazine.Infrastructure.Middleware;
using QYZH.InteractiveMagazine.Models.Settings; using QYZH.InteractiveMagazine.Models.Settings;
using System.Security.Claims; using System.Security.Claims;
@ -16,34 +17,23 @@ using System.Text.Encodings.Web;
namespace QYZH.InteractiveMagazine.Infrastructure.Extensions; namespace QYZH.InteractiveMagazine.Infrastructure.Extensions;
/// <summary> /// <summary>
/// 统一服务注册扩展 /// Infrastructure service registration extensions.
/// </summary> /// </summary>
public static class DependencyInjectionExtensions public static class DependencyInjectionExtensions
{ {
/// <summary> /// <summary>
/// 注册基础设施服务 /// Registers infrastructure services.
/// </summary> /// </summary>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置</param>
/// <param name="environment">运行环境</param>
public static void AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment? environment = null) public static void AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment? environment = null)
{ {
AddJwtAuthentication(services, configuration, environment); AddJwtAuthentication(services, configuration, environment);
services.AddTransient<GlobalExceptionMiddleware>(); services.AddTransient<GlobalExceptionMiddleware>();
services.AddTransient<OperationLogMiddleware>(); services.AddTransient<OperationLogMiddleware>();
} }
/// <summary>
/// 配置JWT认证
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置</param>
/// <param name="environment">运行环境</param>
private static void AddJwtAuthentication(IServiceCollection services, IConfiguration configuration, IWebHostEnvironment? environment = null) private static void AddJwtAuthentication(IServiceCollection services, IConfiguration configuration, IWebHostEnvironment? environment = null)
{ {
// 开发环境下跳过 JWT 验证
if (environment?.IsDevelopment() == true) if (environment?.IsDevelopment() == true)
{ {
services.AddAuthentication("NoAuth") services.AddAuthentication("NoAuth")
@ -74,37 +64,56 @@ public static class DependencyInjectionExtensions
{ {
OnTokenValidated = async context => OnTokenValidated = async context =>
{ {
var currentToken = GetBearerToken(context);
if (string.IsNullOrEmpty(currentToken))
{
context.Fail("Invalid token");
return;
}
var userId = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; var userId = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
{ {
context.Fail("无效的 Token"); context.Fail("Invalid token");
return; return;
} }
// 检查管理后台 Token var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId));
var adminToken = await RedisHelper.GetAsync($"InteractiveMagazine:AdminAuth:Token:{userId}"); var wxUserId = context.Principal?.FindFirst(JwtHelper.WxUserIdClaimType)?.Value;
if (!string.IsNullOrEmpty(adminToken)) if (string.IsNullOrEmpty(wxUserId) && !string.IsNullOrEmpty(adminToken))
{ {
return; return;
} }
// 检查微信端 Token if (!string.IsNullOrEmpty(wxUserId))
var wechatToken = await RedisHelper.GetAsync($"InteractiveMagazine:WeChatAuth:Token:{userId}"); {
if (!string.IsNullOrEmpty(wechatToken)) var wechatToken = await RedisHelper.GetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId));
if (wechatToken == currentToken)
{ {
return; return;
} }
}
// Redis 中不存在任何 Token认证失败 context.Fail("Token expired, please login again");
context.Fail("Token 已失效,请重新登录");
} }
}; };
}); });
} }
private static string? GetBearerToken(TokenValidatedContext context)
{
var authHeader = context.HttpContext.Request.Headers.Authorization.FirstOrDefault();
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return authHeader.Substring("Bearer ".Length).Trim();
}
} }
/// <summary> /// <summary>
/// 开发环境免认证处理器 /// Authentication handler used only in development.
/// </summary> /// </summary>
public class NoAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions> public class NoAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{ {
@ -115,14 +124,13 @@ public class NoAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
protected override Task<AuthenticateResult> HandleAuthenticateAsync() protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// 开发环境下始终认证成功
var claims = new[] var claims = new[]
{ {
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "DevUser"), new Claim(ClaimTypes.Name, "DevUser"),
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "0") new Claim(ClaimTypes.NameIdentifier, "0")
}; };
var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name); var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new System.Security.Claims.ClaimsPrincipal(identity); var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name); var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket)); return Task.FromResult(AuthenticateResult.Success(ticket));

View File

@ -3,21 +3,17 @@ using Microsoft.Extensions.Configuration;
using QYZH.InteractiveMagazine.Infrastructure.Auth; using QYZH.InteractiveMagazine.Infrastructure.Auth;
using QYZH.InteractiveMagazine.Models.Settings; using QYZH.InteractiveMagazine.Models.Settings;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace QYZH.InteractiveMagazine.Infrastructure.Middleware; namespace QYZH.InteractiveMagazine.Infrastructure.Middleware;
/// <summary> /// <summary>
/// JWT 自动刷新中间件 /// Refreshes the Redis session TTL for the current JWT when it is still the active token.
/// 功能:
/// 每次请求都刷新 Redis 中 Token 的过期时间(保持会话活跃)
/// 前端永远使用同一个 Token无需处理 Token 刷新
/// </summary> /// </summary>
public class JwtAutoRefreshMiddleware public class JwtAutoRefreshMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private const string AdminTokenKeyPrefix = "InteractiveMagazine:AdminAuth:Token";
private const string WeChatTokenKeyPrefix = "InteractiveMagazine:WeChatAuth:Token";
public JwtAutoRefreshMiddleware(RequestDelegate next, IConfiguration configuration) public JwtAutoRefreshMiddleware(RequestDelegate next, IConfiguration configuration)
{ {
@ -31,13 +27,13 @@ public class JwtAutoRefreshMiddleware
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ")) if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
{ {
var token = authHeader.Substring("Bearer ".Length).Trim(); var token = authHeader.Substring("Bearer ".Length).Trim();
await TryRefreshRedisTokenExpiryAsync(context, token); await TryRefreshRedisTokenExpiryAsync(token);
} }
await _next(context); await _next(context);
} }
private async Task TryRefreshRedisTokenExpiryAsync(HttpContext context, string token) private async Task TryRefreshRedisTokenExpiryAsync(string token)
{ {
try try
{ {
@ -47,60 +43,57 @@ public class JwtAutoRefreshMiddleware
return; return;
} }
var expiryTime = jwtToken.ValidTo; if (jwtToken.ValidTo <= DateTime.UtcNow)
var remainingTime = expiryTime - DateTime.Now;
// JWT 已过期,不处理
if (remainingTime <= TimeSpan.Zero)
{ {
return; return;
} }
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userId = GetClaimValue(jwtToken, ClaimTypes.NameIdentifier, JwtRegisteredClaimNames.NameId);
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
{ {
return; return;
} }
var wxUserId = jwtToken.Claims.FirstOrDefault(c => c.Type == JwtHelper.WxUserIdClaimType)?.Value;
if (string.IsNullOrEmpty(wxUserId))
{
return;
}
var jwtSettings = _configuration.GetSection("JwtSettings").Get<JwtSettings>(); var jwtSettings = _configuration.GetSection("JwtSettings").Get<JwtSettings>();
if (jwtSettings == null || jwtSettings.ExpiryMinutes <= 0) if (jwtSettings == null || jwtSettings.ExpiryMinutes <= 0)
{ {
return; return;
} }
// 每次请求都刷新 Redis 中 Token 的过期时间 var wxUserId = GetClaimValue(jwtToken, JwtHelper.WxUserIdClaimType);
await RefreshRedisTokenExpiryAsync(wxUserId,userId, token, jwtSettings.ExpiryMinutes); await RefreshRedisTokenExpiryAsync(wxUserId, userId, token, jwtSettings.ExpiryMinutes);
} }
catch catch
{ {
// 忽略自动刷新异常,由后续认证中间件处理 // Ignore refresh failures. Authentication middleware will validate the request later.
} }
} }
/// <summary> private static async Task RefreshRedisTokenExpiryAsync(string? wxUserId, string userId, string currentToken, int expiryMinutes)
/// 刷新 Redis 中已存在 Token 的过期时间(保持会话活跃)
/// </summary>
private async Task RefreshRedisTokenExpiryAsync(string wxUserId,string userId, string currentToken, int expiryMinutes)
{ {
// 检查 Admin Token var adminTokenKey = JwtHelper.BuildAdminTokenKey(userId);
var adminTokenKey = $"{AdminTokenKeyPrefix}:{userId}";
var adminToken = await RedisHelper.GetAsync(adminTokenKey); var adminToken = await RedisHelper.GetAsync(adminTokenKey);
if (!string.IsNullOrEmpty(adminToken)) if (string.IsNullOrEmpty(wxUserId) && !string.IsNullOrEmpty(adminToken))
{ {
await RedisHelper.SetAsync(adminTokenKey, currentToken, TimeSpan.FromMinutes(expiryMinutes)); await RedisHelper.SetAsync(adminTokenKey, adminToken, TimeSpan.FromMinutes(expiryMinutes));
return; return;
} }
// 检查 WeChat Token if (string.IsNullOrEmpty(wxUserId))
var wechatTokenKey = $"{WeChatTokenKeyPrefix}:{wxUserId}{userId}"; {
return;
}
var wechatTokenKey = JwtHelper.BuildWeChatTokenKey(wxUserId, userId);
var wechatToken = await RedisHelper.GetAsync(wechatTokenKey); var wechatToken = await RedisHelper.GetAsync(wechatTokenKey);
if (!string.IsNullOrEmpty(wechatToken)) if (wechatToken == currentToken)
{ {
await RedisHelper.SetAsync(wechatTokenKey, currentToken, TimeSpan.FromMinutes(expiryMinutes)); await RedisHelper.SetAsync(wechatTokenKey, currentToken, TimeSpan.FromMinutes(expiryMinutes));
} }
} }
private static string? GetClaimValue(JwtSecurityToken jwtToken, params string[] claimTypes)
{
return jwtToken.Claims.FirstOrDefault(c => claimTypes.Contains(c.Type))?.Value;
}
} }

View File

@ -24,7 +24,6 @@ public class WeChatAuthService(
IPetService petService) IPetService petService)
: BaseRepository<WxUser>, IWeChatAuthService : BaseRepository<WxUser>, IWeChatAuthService
{ {
private const string TokenKeyPrefix = "InteractiveMagazine:WeChatAuth:Token";
private const string AccessTokenCacheKey = "InteractiveMagazine:WeChat:AccessToken"; private const string AccessTokenCacheKey = "InteractiveMagazine:WeChat:AccessToken";
private const string Code2SessionUrl = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code"; private const string Code2SessionUrl = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code";
private const string GetAccessTokenUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}"; private const string GetAccessTokenUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}";
@ -85,10 +84,10 @@ public class WeChatAuthService(
UpdatedAt = DateTime.Now UpdatedAt = DateTime.Now
}; };
var wxUserId = await wxUserRepository.Insertable(wxUser).ExecuteReturnIdentityAsync(); await wxUserRepository.InsertAsync(wxUser);
wxUser.Id = wxUserId;
logger.LogInformation("WxUser 创建成功WxUserId: {WxUserId}, OpenId: {OpenId}", wxUserId, wxResponse.OpenId);
logger.LogInformation("WxUser 创建成功WxUserId: {WxUserId}, OpenId: {OpenId}", wxUser.Id, wxResponse.OpenId);
} }
else else
{ {
@ -201,8 +200,8 @@ public class WeChatAuthService(
var token = JwtHelper.GenerateToken(wxUserId, refreshedUser.Id, refreshedUser.Name ?? string.Empty, jwtSettings); var token = JwtHelper.GenerateToken(wxUserId, refreshedUser.Id, refreshedUser.Name ?? string.Empty, jwtSettings);
// 清除旧 Redis Token写入新 Token // 清除旧 Redis Token写入新 Token
await RedisHelper.DelAsync($"{TokenKeyPrefix}:{currentUserId}"); await RedisHelper.DelAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, currentUserId));
await RedisHelper.SetAsync($"{TokenKeyPrefix}:{refreshedUser.Id}", token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes)); await RedisHelper.SetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, refreshedUser.Id), token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
return new WeChatSwitchUserOutput return new WeChatSwitchUserOutput
{ {
@ -327,7 +326,7 @@ public class WeChatAuthService(
var jwtSettings = GetJwtSettings(); var jwtSettings = GetJwtSettings();
var token = JwtHelper.GenerateToken(wxUser.Id, userId, userName, jwtSettings); var token = JwtHelper.GenerateToken(wxUser.Id, userId, userName, jwtSettings);
await RedisHelper.SetAsync($"{TokenKeyPrefix}:{wxUser.Id}{userId}", token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes)); await RedisHelper.SetAsync(JwtHelper.BuildWeChatTokenKey(wxUser.Id, userId), token, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
return new WeChatLoginOutput return new WeChatLoginOutput
{ {

View File

@ -6,7 +6,8 @@
"Issuer": "QYZH.InteractiveMagazine", "Issuer": "QYZH.InteractiveMagazine",
"Audience": "QYZH.InteractiveMagazine", "Audience": "QYZH.InteractiveMagazine",
"SecretKey": "zG7pLqR9xVw2bN8fYtHk3mPc5sA1dF6eUjW4gXhC7vB", "SecretKey": "zG7pLqR9xVw2bN8fYtHk3mPc5sA1dF6eUjW4gXhC7vB",
"ExpiryMinutes": 120 "ExpiryMinutes": 120,
"JwtTokenExpiryDays": 30
}, },
"RedisSettings": { "RedisSettings": {
"ConnectionString": "192.168.20.150:16379,defaultDatabase=5", "ConnectionString": "192.168.20.150:16379,defaultDatabase=5",