diff --git a/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs b/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs index b952fc362644e766dcc0abadc1669efbd8af623f..dbd230f468e334eeb34cd5a2f33f288f21a1e9d5 100644 --- a/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs +++ b/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs @@ -10,13 +10,37 @@ namespace EOM.TSHotelManagement.WebApi.Authorization public class CustomAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler { + private const string AuthFailureReasonItemKey = JwtAuthConstants.AuthFailureReasonItemKey; + private const string AuthFailureReasonTokenRevoked = JwtAuthConstants.AuthFailureReasonTokenRevoked; + private const string AuthFailureReasonTokenExpired = JwtAuthConstants.AuthFailureReasonTokenExpired; + private readonly AuthorizationMiddlewareResultHandler _defaultHandler = new AuthorizationMiddlewareResultHandler(); public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult) { - if (authorizeResult.Challenged || authorizeResult.Forbidden) + if (authorizeResult.Challenged) { - var response = new BaseResponse(BusinessStatusCode.PermissionDenied, + var response = new BaseResponse( + BusinessStatusCode.Unauthorized, + ResolveUnauthorizedMessage(context)); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json; charset=utf-8"; + + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null + }); + + await context.Response.WriteAsync(json); + return; + } + + if (authorizeResult.Forbidden) + { + var response = new BaseResponse( + BusinessStatusCode.PermissionDenied, LocalizationHelper.GetLocalizedString("PermissionDenied", "该账户缺少权限,请联系管理员添加")); context.Response.StatusCode = StatusCodes.Status200OK; @@ -34,5 +58,30 @@ namespace EOM.TSHotelManagement.WebApi.Authorization await _defaultHandler.HandleAsync(next, context, policy, authorizeResult); } + + private static string ResolveUnauthorizedMessage(HttpContext context) + { + if (context.Items.TryGetValue(AuthFailureReasonItemKey, out var reasonObj)) + { + var reason = reasonObj?.ToString(); + if (string.Equals(reason, AuthFailureReasonTokenRevoked, System.StringComparison.OrdinalIgnoreCase)) + { + return LocalizationHelper.GetLocalizedString( + "Token has been revoked. Please log in again.", + "该Token已失效,请重新登录"); + } + + if (string.Equals(reason, AuthFailureReasonTokenExpired, System.StringComparison.OrdinalIgnoreCase)) + { + return LocalizationHelper.GetLocalizedString( + "Token has expired. Please log in again.", + "登录已过期,请重新登录"); + } + } + + return LocalizationHelper.GetLocalizedString( + "Unauthorized. Please log in again.", + "未授权或登录已失效,请重新登录"); + } } } diff --git a/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs b/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs index b76c30ad42431ff41341fd94371f68e25e3ccbf7..e37fa554ac097e2c92e756db28bb331f8281a261 100644 --- a/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs +++ b/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs @@ -100,6 +100,13 @@ namespace EOM.TSHotelManagement.WebApi.Authorization var httpContext = _httpContextAccessor.HttpContext; var user = httpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + // Unauthenticated requests should be handled by authentication challenge, + // not by permission checks. + return; + } + var userNumber = user?.FindFirst(ClaimTypes.SerialNumber)?.Value ?? user?.FindFirst("serialnumber")?.Value; @@ -198,4 +205,4 @@ namespace EOM.TSHotelManagement.WebApi.Authorization return null; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/LoginController.cs b/EOM.TSHotelManagement.API/Controllers/LoginController.cs index 10c94a3fa30e7eebae3125d37a7a793472523f61..07baeac01cfc8cb36d368dd80c9ea93de15c0576 100644 --- a/EOM.TSHotelManagement.API/Controllers/LoginController.cs +++ b/EOM.TSHotelManagement.API/Controllers/LoginController.cs @@ -1,24 +1,34 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Infrastructure; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; namespace EOM.TSHotelManagement.WebApi { public class LoginController : ControllerBase { + private const string JwtTokenUserIdItemKey = JwtAuthConstants.JwtTokenUserIdItemKey; + private readonly IAntiforgery _antiforgery; private readonly CsrfTokenConfig _csrfConfig; + private readonly JwtTokenRevocationService _tokenRevocationService; public LoginController( IAntiforgery antiforgery, - IOptions csrfConfig) + IOptions csrfConfig, + JwtTokenRevocationService tokenRevocationService) { _antiforgery = antiforgery; _csrfConfig = csrfConfig.Value; + _tokenRevocationService = tokenRevocationService; } [HttpGet] @@ -65,5 +75,87 @@ namespace EOM.TSHotelManagement.WebApi { return GetCSRFToken(); } + + [HttpPost] + public async Task Logout() + { + var authorizationHeader = Request.Headers["Authorization"].ToString(); + if (!JwtTokenRevocationService.TryGetBearerToken(authorizationHeader, out var token)) + { + return new BaseResponse( + BusinessStatusCode.BadRequest, + LocalizationHelper.GetLocalizedString( + "Missing or invalid Authorization header.", + "缺少或无效的 Authorization 请求头。")); + } + + var currentUserId = GetCurrentUserId(); + if (!TryGetTokenUserId(token, out var tokenUserId)) + { + return new BaseResponse( + BusinessStatusCode.BadRequest, + LocalizationHelper.GetLocalizedString( + "Invalid token.", + "Invalid token.")); + } + + if (!string.Equals(currentUserId, tokenUserId, StringComparison.Ordinal)) + { + return new BaseResponse( + BusinessStatusCode.PermissionDenied, + LocalizationHelper.GetLocalizedString( + "Permission denied.", + "Permission denied.")); + } + + await _tokenRevocationService.RevokeTokenAsync(token); + return new BaseResponse( + BusinessStatusCode.Success, + LocalizationHelper.GetLocalizedString("Logout success.", "登出成功。")); + } + + private string GetCurrentUserId() + { + if (HttpContext.Items.TryGetValue(JwtTokenUserIdItemKey, out var userIdObj)) + { + var userId = userIdObj?.ToString(); + if (!string.IsNullOrWhiteSpace(userId)) + { + return userId; + } + } + + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value + ?? string.Empty; + } + + private static bool TryGetTokenUserId(string token, out string tokenUserId) + { + tokenUserId = string.Empty; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + try + { + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); + tokenUserId = jwtToken.Claims.FirstOrDefault(c => + c.Type == ClaimTypes.SerialNumber || + c.Type == "serialnumber" || + c.Type == ClaimTypes.NameIdentifier || + c.Type == JwtRegisteredClaimNames.Sub)?.Value + ?? string.Empty; + + return !string.IsNullOrWhiteSpace(tokenUserId); + } + catch + { + return false; + } + } } } diff --git a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs index 84ce7c60f661ee206e2d9435ab7d3e8cdfd624ad..8d902cd684b2aa1fe419307aa287ec6b0310a439 100644 --- a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs @@ -31,6 +31,7 @@ namespace EOM.TSHotelManagement.WebApi .InstancePerDependency(); builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); diff --git a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs index f5239ed9280e3fc87d6b215de4be00008c8784c0..14b066ad61bcb5e91ec920a3407d194bbbb28cd6 100644 --- a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs @@ -20,8 +20,10 @@ using NSwag; using NSwag.Generation.Processors.Security; using Quartz; using System; +using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; +using System.Security.Claims; using System.Text; using System.Text.Json.Serialization; using System.Threading; @@ -31,6 +33,13 @@ namespace EOM.TSHotelManagement.WebApi { public static class ServiceExtensions { + private const string AuthFailureReasonItemKey = JwtAuthConstants.AuthFailureReasonItemKey; + private const string AuthFailureReasonTokenRevoked = JwtAuthConstants.AuthFailureReasonTokenRevoked; + private const string AuthFailureReasonTokenExpired = JwtAuthConstants.AuthFailureReasonTokenExpired; + private const string AuthFailureReasonTokenInvalid = JwtAuthConstants.AuthFailureReasonTokenInvalid; + private const string JwtTokenUserIdItemKey = JwtAuthConstants.JwtTokenUserIdItemKey; + private const string JwtTokenJtiItemKey = JwtAuthConstants.JwtTokenJtiItemKey; + public static void ConfigureDataProtection(this IServiceCollection services, IConfiguration configuration) { if (Environment.GetEnvironmentVariable(SystemConstant.Env.Code) == SystemConstant.Docker.Code) @@ -165,6 +174,48 @@ namespace EOM.TSHotelManagement.WebApi IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is not configured"))) }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var authorizationHeader = context.HttpContext.Request.Headers["Authorization"].ToString(); + if (!JwtTokenRevocationService.TryGetBearerToken(authorizationHeader, out var token)) + { + context.Fail("Missing token."); + return; + } + + var userId = context.Principal?.FindFirst(ClaimTypes.SerialNumber)?.Value + ?? context.Principal?.FindFirst("serialnumber")?.Value + ?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? context.Principal?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + if (!string.IsNullOrWhiteSpace(userId)) + { + context.HttpContext.Items[JwtTokenUserIdItemKey] = userId; + } + + var jti = context.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value; + if (!string.IsNullOrWhiteSpace(jti)) + { + context.HttpContext.Items[JwtTokenJtiItemKey] = jti; + } + + var tokenRevocationService = context.HttpContext.RequestServices + .GetRequiredService(); + + if (await tokenRevocationService.IsTokenRevokedAsync(token)) + { + context.HttpContext.Items[AuthFailureReasonItemKey] = AuthFailureReasonTokenRevoked; + context.Fail("Token has been revoked."); + } + }, + OnAuthenticationFailed = context => + { + context.HttpContext.Items[AuthFailureReasonItemKey] = ResolveAuthFailureReason(context.Exception); + return Task.CompletedTask; + } + }; }); services.AddAuthorization(options => @@ -273,6 +324,19 @@ namespace EOM.TSHotelManagement.WebApi }); }); } + + private static string ResolveAuthFailureReason(Exception exception) + { + return exception switch + { + SecurityTokenExpiredException => AuthFailureReasonTokenExpired, + SecurityTokenInvalidSignatureException => AuthFailureReasonTokenInvalid, + SecurityTokenInvalidAudienceException => AuthFailureReasonTokenInvalid, + SecurityTokenInvalidIssuerException => AuthFailureReasonTokenInvalid, + SecurityTokenNoExpirationException => AuthFailureReasonTokenInvalid, + _ => AuthFailureReasonTokenInvalid + }; + } } internal sealed class DeleteConcurrencyHelperWarmupService : IHostedService { diff --git a/EOM.TSHotelManagement.Common/Constant/JwtAuthConstants.cs b/EOM.TSHotelManagement.Common/Constant/JwtAuthConstants.cs new file mode 100644 index 0000000000000000000000000000000000000000..264c47fa82cc4cd18f25b26a60d7bc7fdacf2f0d --- /dev/null +++ b/EOM.TSHotelManagement.Common/Constant/JwtAuthConstants.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace EOM.TSHotelManagement.Common +{ + public static class JwtAuthConstants + { + public const string AuthFailureReasonItemKey = "AuthFailureReason"; + public const string AuthFailureReasonTokenRevoked = "token_revoked"; + public const string AuthFailureReasonTokenExpired = "token_expired"; + public const string AuthFailureReasonTokenInvalid = "token_invalid"; + public const string JwtTokenUserIdItemKey = "JwtTokenUserId"; + public const string JwtTokenJtiItemKey = "JwtTokenJti"; + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/JwtTokenRevocationService.cs b/EOM.TSHotelManagement.Common/Helper/JwtTokenRevocationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..dbeddc406b6ee74496b97488851ccab9cb9b1142 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Helper/JwtTokenRevocationService.cs @@ -0,0 +1,200 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace EOM.TSHotelManagement.Common +{ + public class JwtTokenRevocationService + { + private const string RevokedTokenKeyPrefix = "auth:revoked:"; + private readonly ConcurrentDictionary _memoryStore = new(); + private long _memoryProbeCount; + + private readonly RedisHelper _redisHelper; + private readonly ILogger _logger; + private readonly bool _useRedis; + private readonly TimeSpan _fallbackTtl = TimeSpan.FromMinutes(30); + + + public JwtTokenRevocationService( + IConfiguration configuration, + RedisHelper redisHelper, + ILogger logger) + { + _redisHelper = redisHelper; + _logger = logger; + _useRedis = ResolveRedisEnabled(configuration); + } + + public async Task RevokeTokenAsync(string token) + { + var normalizedToken = NormalizeToken(token); + if (string.IsNullOrWhiteSpace(normalizedToken)) + { + return; + } + + var key = BuildRevokedTokenKey(normalizedToken); + var ttl = CalculateRevokedTtl(normalizedToken); + + if (_useRedis) + { + try + { + var db = _redisHelper.GetDatabase(); + await db.StringSetAsync(key, "1", ttl); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Redis token revoke failed, fallback to memory store."); + } + } + + var memoryExpiresAt = DateTimeOffset.UtcNow.Add(ttl); + _memoryStore.AddOrUpdate(key, _ => memoryExpiresAt, (_, _) => memoryExpiresAt); + } + + public async Task IsTokenRevokedAsync(string token) + { + var normalizedToken = NormalizeToken(token); + if (string.IsNullOrWhiteSpace(normalizedToken)) + { + return false; + } + + var key = BuildRevokedTokenKey(normalizedToken); + + if (_useRedis) + { + try + { + var db = _redisHelper.GetDatabase(); + return await db.KeyExistsAsync(key); + } + catch (Exception ex) + { + _logger.LogError(ex, "Redis token revoke-check failed, fallback to memory store."); + } + } + + PruneMemoryStoreIfNeeded(); + if (!_memoryStore.TryGetValue(key, out var expiresAt)) + { + return false; + } + + if (expiresAt <= DateTimeOffset.UtcNow) + { + _memoryStore.TryRemove(key, out _); + return false; + } + + return true; + } + + public static bool TryGetBearerToken(string authorizationHeader, out string token) + { + token = string.Empty; + if (string.IsNullOrWhiteSpace(authorizationHeader)) + { + return false; + } + + token = NormalizeToken(authorizationHeader); + return !string.IsNullOrWhiteSpace(token); + } + + private static string BuildRevokedTokenKey(string token) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return $"{RevokedTokenKeyPrefix}{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string NormalizeToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return string.Empty; + } + + var normalized = token.Trim(); + if (normalized.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring(7).Trim(); + } + + return normalized; + } + + private DateTimeOffset? GetTokenExpiresAtUtc(string token) + { + try + { + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); + if (jwtToken.ValidTo == DateTime.MinValue) + { + _logger.LogWarning("Failed to parse JWT token expiration"); + return null; + } + + return new DateTimeOffset(DateTime.SpecifyKind(jwtToken.ValidTo, DateTimeKind.Utc)); + } + catch + { + _logger.LogWarning("Failed to parse JWT token expiration"); + return null; + } + } + + private TimeSpan CalculateRevokedTtl(string token) + { + var now = DateTimeOffset.UtcNow; + var expiresAt = GetTokenExpiresAtUtc(token) ?? now.Add(_fallbackTtl); + var remaining = expiresAt - now; + + if (remaining <= TimeSpan.Zero) + { + return TimeSpan.FromMinutes(1); + } + + var ttlMinutes = Math.Ceiling(remaining.TotalMinutes) + 1; + return TimeSpan.FromMinutes(ttlMinutes); + } + + private void PruneMemoryStoreIfNeeded() + { + if (Interlocked.Increment(ref _memoryProbeCount) % 200 != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + foreach (var kvp in _memoryStore.ToArray()) + { + if (kvp.Value <= now) + { + _memoryStore.TryRemove(kvp.Key, out _); + } + } + } + + private static bool ResolveRedisEnabled(IConfiguration configuration) + { + var redisSection = configuration.GetSection("Redis"); + var enable = redisSection.GetValue("Enabled"); + if (enable.HasValue) + { + return enable.Value; + } + + return redisSection.GetValue("Enabled"); + } + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs b/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs index 98deaa6b06f4c33b63df2968e3107f69653d2ca7..1238879225c1d203bef93855a89e92f8d4b764b1 100644 --- a/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Infrastructure; +using EOM.TSHotelManagement.Infrastructure; using Microsoft.Extensions.Logging; using StackExchange.Redis; using System; @@ -34,6 +34,8 @@ namespace EOM.TSHotelManagement.Common return; } + int defaultDatabase = redisConfig.DefaultDatabase ?? -1; + if (string.IsNullOrWhiteSpace(redisConfig?.ConnectionString)) throw new ArgumentException("Redis连接字符串不能为空"); @@ -43,7 +45,7 @@ namespace EOM.TSHotelManagement.Common options.ReconnectRetryPolicy = new ExponentialRetry(3000); _connection = ConnectionMultiplexer.Connect(options); - _connection.GetDatabase().Ping(); + _connection.GetDatabase(defaultDatabase).Ping(); } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs b/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs index c4e27a12f44f66eac9c3ceb439ca798ec55ff0cd..967ad81bb3c4c85e77aa4eca7dfa7ef5ab8fe41f 100644 --- a/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs +++ b/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs @@ -1,8 +1,9 @@ -namespace EOM.TSHotelManagement.Infrastructure +namespace EOM.TSHotelManagement.Infrastructure { public class RedisConfig { public string ConnectionString { get; set; } public bool Enable { get; set; } + public int? DefaultDatabase { get; set; } } } diff --git a/README.en.md b/README.en.md index 8620ff004e74da1de2fb9bd7c35e65155756132f..424be9cad3678ff58938caa3da145e12584776a5 100644 --- a/README.en.md +++ b/README.en.md @@ -208,6 +208,10 @@ docker run -d \ > ⚠️ **Security Advisory**: In production environments, do not directly pass password-like parameters in plaintext via the `-e` flag. It is recommended to utilise Docker Secrets or environment variable injection tools (such as HashiCorp Vault) for protection. +## Development Pace + +![development_pace](https://picrepo.oscode.top/i/2026/02/18/Development_pace.png) + ## Acknowledgements We extend our gratitude to the following outstanding open-source projects: diff --git a/README.md b/README.md index d05c6e047ebcbdd9744c2a29ac891ea13d1a8e78..79cae06b4a42029a9cf0b21e94cd9dea5308875d 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,10 @@ docker run -d \ > ⚠️ **安全提醒**:生产环境中请勿直接通过 `-e` 明文传入密码类参数,推荐使用 Docker Secrets 或环境变量注入工具(如 HashiCorp Vault)进行保护。 +## 开发节奏 + +![development_pace](https://picrepo.oscode.top/i/2026/02/18/Development_pace.png) + ## 鸣谢 感谢以下优秀的开源项目: