Skip to content

🔐 认证系统

Ape-Volo-Admin 采用 JWT (JSON Web Token) 作为主要的身份认证机制,提供无状态、安全、高效的用户认证解决方案。

🎯 认证机制概述

JWT 认证流程(说明)

  1. 用户登录

    • 客户端向服务器发送登录请求,包含用户名和密码。
  2. 凭据校验

    • 服务器接收到请求后,将用户名和密码发送至数据库进行校验。
    • 数据库校验后返回对应的用户信息(如校验失败则返回错误信息)。
  3. 生成 JWT Token

    • 服务器根据用户信息创建 JWT(JSON Web Token),通常包含用户 ID、角色、过期时间等数据,并使用服务器密钥签名。
  4. Token 返回与存储

    • 服务器将生成的 Token 返回给客户端。
    • 客户端将 Token 存储在本地(如 LocalStorage、SessionStorage 或 Cookie),用于后续的接口调用。
  5. 携带 Token 的 API 请求

    • 客户端在后续的 API 请求中,将 Token 放入 HTTP 请求的 Authorization Header(格式为 Bearer <token>)。
  6. Token 校验

    • 服务器收到 API 请求后,首先从 Header 中获取 Token。
    • 服务器验证 Token 的合法性(签名、是否过期等)。
    • 若 Token 校验通过,则进行后续业务处理;否则拒绝访问并返回错误(如 401 未授权)。
  7. 返回数据或拒绝访问

    • 如果校验成功,服务器返回请求数据。
    • 如果校验失败,服务器返回拒绝访问提示(如 Token 失效、签名不正确等)。

🔧 JWT 配置

配置文件设置

appsettings.json 中配置 JWT 相关参数:

yaml
JwtAuth:
  Audience: "http://localhost" # JWT 接收方
  Issuer: "http://localhost" # JWT 签发方
  SecurityKey: 5ixKD0BkJxYYroZTvdPs3w9NWRoiUacN # JWT 密钥
  Expires: "12" # Token 有效期(小时)
  RefreshTokenExpires: "168" # 刷新 Token 缓冲有效期(小时)
  LoginPath: "/auth/login" # 登录 API 路径

🛠️ 核心组件

JWT 认证与授权中间件注册

位置:Ape.Volo.Infrastructure/Extensions/AuthorizationSetup.cs

csharp
public static class AuthorizationSetup
{
    public static void AddAuthorizationSetup(this IServiceCollection services)
    {
        if (services.IsNull()) throw new ArgumentNullException(nameof(services));


        services.AddScoped<IHttpUser, HttpUser>();
        services.AddScoped<ITokenService, TokenService>();

        var jwtAuthOptions = App.GetOptions<JwtAuthOptions>();

        var permissionRequirement = new PermissionRequirement();
        // 自定义策略授权
        services.AddAuthorization(options =>
        {
            options.AddPolicy(AuthConstants.AuthPolicyName,
                policy => policy.Requirements.Add(permissionRequirement));
        });

        // 开启Bearer认证
        services.AddAuthentication(o =>
            {
                o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
                o.DefaultChallengeScheme = nameof(ApiResponseHandler);
                o.DefaultForbidScheme = nameof(ApiResponseHandler);
            })
            // 添加JwtBearer服务
            .AddJwtBearer(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {

                    ValidateIssuer = true,
                    ValidIssuer = jwtAuthOptions.Issuer,

                    ValidateAudience = true,
                    ValidAudience = jwtAuthOptions.Audience,

                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtAuthOptions.SecurityKey)),
                    LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken,
                        TokenValidationParameters validationParameters) =>
                    {
                        if (expires == null)
                        {
                            return true;
                        }

                        return expires.Value > DateTime.UtcNow;
                    },
                    ValidateLifetime = true
                };
                options.Events = new JwtBearerEvents
                {
                    OnAuthenticationFailed = context =>
                    {
                        // 如果过期,把过期信息添加到头部
                        if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                        {
                            context.Response.Headers.Append("Token-Expired", "true");
                        }

                        return Task.CompletedTask;
                    }
                };
            })
            .AddScheme<AuthenticationSchemeOptions, ApiResponseHandler>(nameof(ApiResponseHandler), _ => { });


        services.AddScoped<IAuthorizationHandler, PermissionHandler>();
        services.AddSingleton(permissionRequirement);
    }
}

JWT 自定义策略

csharp
// 自定义策略授权
services.AddAuthorization(options =>
{
    options.AddPolicy(AuthConstants.AuthPolicyName,
    policy => policy.Requirements.Add(permissionRequirement));
});

JWT 自定义策略

csharp
// 自定义策略授权
services.AddAuthorization(options =>
{
    options.AddPolicy(AuthConstants.AuthPolicyName,
    policy => policy.Requirements.Add(permissionRequirement));
});

JWT 自定义授权处理器

位置:Ape.Volo.Infrastructure/Authentication/PermissionHandler.cs

csharp
// 自定义授权处理器
public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
    /// <summary>
    /// 验证方案提供对象
    /// </summary>
    public IAuthenticationSchemeProvider Schemes { get; set; }

    private readonly IPermissionService _permissionService;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IUserService _userService;
    private readonly ISettingService _settingService;
    private readonly IBrowserDetector _browserDetector;
    private readonly ISearcher _ipSearcher;
    private readonly ITokenBlacklistService _tokenBlacklistService;
    private readonly ILogger<PermissionHandler> _logger;

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="schemes"></param>
    /// <param name="httpContextAccessor"></param>
    /// <param name="permissionService"></param>
    /// <param name="userService"></param>
    /// <param name="settingService"></param>
    /// <param name="browserDetector"></param>
    /// <param name="searcher"></param>
    /// <param name="tokenBlacklistService"></param>
    /// <param name="logger"></param>
    public PermissionHandler(IAuthenticationSchemeProvider schemes, IHttpContextAccessor httpContextAccessor,
        IPermissionService permissionService, IUserService userService, ISettingService settingService,
        IBrowserDetector browserDetector, ISearcher searcher, ITokenBlacklistService tokenBlacklistService,
        ILogger<PermissionHandler> logger)
    {
        _httpContextAccessor = httpContextAccessor;
        Schemes = schemes;
        _permissionService = permissionService;
        _settingService = settingService;
        _userService = userService;
        _browserDetector = browserDetector;
        _ipSearcher = searcher;
        _tokenBlacklistService = tokenBlacklistService;
        _logger = logger;
    }

    // 重写异步处理程序
    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
        PermissionRequirement requirement)
    {
        var isMatchRole = false;
        var httpContext = _httpContextAccessor?.HttpContext;
        //请求Url
        if (httpContext != null)
        {
            var requestPath = httpContext.Request.Path.Value?.ToLower();
            var requestMethod = httpContext.Request.Method.ToLower();
            if (requestPath == "/api/test/SearchOrder")
            {
                context.Succeed(requirement);
                return;
            }


            //判断请求是否停止
            var handlers = httpContext.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
            foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
            {
                if (await handlers.GetHandlerAsync(httpContext, scheme.Name) is IAuthenticationRequestHandler
                        handler && await handler.HandleRequestAsync())
                {
                    context.Fail();
                    return;
                }
            }

            //判断请求是否拥有凭据,即有没有登录
            var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
            if (defaultAuthenticate != null)
            {
                var result = await httpContext.AuthenticateAsync(defaultAuthenticate.Name);
                //result.Principal不为空代表http状态存在
                if (result is { Principal: not null })
                {
                    httpContext.User = result.Principal;

                    #region 判断jwt令牌是否已过期

                    //判断jwt令牌是否已过期
                    var expirationClaim =
                        httpContext.User.Claims.FirstOrDefault(s => s.Type == AuthConstants.JwtClaimTypes.Exp);
                    if (expirationClaim != null)
                    {
                        var expTime = Convert.ToInt64(expirationClaim.Value).TicksToDateTime();
                        var nowTime = DateTime.Now.ToLocalTime();
                        if (expTime < nowTime)
                        {
                            await App.Cache.RemoveAsync(GlobalConstants.CachePrefix.OnlineKey +
                                                        App.HttpUser.JwtToken.ToMd5String16());
                            context.Fail();
                            return;
                        }
                    }

                    #endregion

                    #region 用户缓存信息是否已过期

                    var loginUserInfo = await App.Cache.GetAsync<LoginUserInfo>(
                        GlobalConstants.CachePrefix.OnlineKey +
                        App.HttpUser.JwtToken.ToMd5String16());
                    if (loginUserInfo == null)
                    {
                        var tokenMd5 = App.HttpUser.JwtToken.ToMd5String16();
                        var tokenBlacklist = await _tokenBlacklistService.TableWhere(x => x.AccessToken == tokenMd5)
                            .FirstAsync();
                        if (tokenBlacklist.IsNotNull())
                        {
                            context.Fail();
                            return;
                        }

                        var netUser = await _userService.QueryByIdAsync(App.HttpUser.Id);
                        if (netUser.IsNull())
                        {
                            context.Fail();
                            return;
                        }

                        var remoteIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
                        var onlineUser = new LoginUserInfo
                        {
                            UserId = netUser.Id,
                            Account = netUser.UserName,
                            NickName = netUser.NickName,
                            DeptId = netUser.DeptId,
                            DeptName = netUser.Dept.Name,
                            Ip = remoteIp,
                            Address = _ipSearcher.Search(remoteIp),
                            OperatingSystem = _browserDetector.Browser?.OS,
                            DeviceType = _browserDetector.Browser?.DeviceType,
                            BrowserName = _browserDetector.Browser?.Name,
                            Version = _browserDetector.Browser?.Version,
                            LoginTime = DateTime.Now,
                            AccessToken = App.HttpUser.JwtToken
                        };
                        var onlineKey = onlineUser.AccessToken.ToMd5String16();
                        var isTrue = await App.Cache.SetAsync(
                            GlobalConstants.CachePrefix.OnlineKey + onlineKey, onlineUser, TimeSpan.FromHours(2),
                            null);
                        if (!isTrue)
                        {
                            context.Fail();
                            return;
                        }
                    }

                    #endregion

                    #region 开发环境下免鉴权

                    var noAuthenticationRequired =
                        await _settingService.GetSettingValue<bool>("NoAuthenticationRequired");
                    if (App.GetOptions<SystemOptions>().IsQuickDebug && noAuthenticationRequired)
                    {
                        context.Succeed(requirement);
                        return;
                    }

                    #endregion

                    #region 验证IP是否发生变化

                    var ipClaim =
                        httpContext.User.Claims.FirstOrDefault(s => s.Type == AuthConstants.JwtClaimTypes.Ip);
                    if (ipClaim != null)
                    {
                        var remoteIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
                        if (!remoteIp.Equals(ipClaim.Value))
                        {
                            //IP已发生变化,执行系统处理逻辑
                        }
                    }

                    #endregion

                    #region 验证用户Url访问权限

                    var urlAccessControlList = await _permissionService.GetUrlAccessControlAsync(App.HttpUser.Id);

                    if (urlAccessControlList.Any() && !requestPath.IsNullOrEmpty())
                    {
                        isMatchRole = urlAccessControlList.Any(x =>
                            x.Url.Equals(requestPath, StringComparison.CurrentCultureIgnoreCase) &&
                            x.Method.Equals(requestMethod, StringComparison.CurrentCultureIgnoreCase));
                    }

                    #endregion

                    #region 验证角色权限

                    if (!isMatchRole)
                    {
                        try
                        {
                            var netUser = await _userService.QueryByIdAsync(App.HttpUser.Id);
                            var roleCodes = netUser.Roles.Select(x => x.AuthCode).ToList();
                            if (context.Resource.IsNotNull())
                            {
                                var endpointFeature = (IEndpointFeature)((DefaultHttpContext)context.Resource)
                                    .Features.FirstOrDefault(x =>
                                        x.Key.FullName == typeof(IEndpointFeature).FullName).Value;
                                if (endpointFeature.Endpoint?.Metadata.FirstOrDefault(x =>
                                            x.GetType() == typeof(HasRoleAttribute)) is
                                        HasRoleAttribute
                                        apeVoloAuthorize && apeVoloAuthorize.AuthCodes.Any(roleCodes.Contains))
                                {
                                    isMatchRole = true;
                                }
                            }
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "验证角色权限时发生错误");
                        }
                    }

                    #endregion

                    #region 验证按钮权限

                    if (!isMatchRole)
                    {
                        try
                        {
                            var authCodes =
                                await _permissionService.GetAuthCodeAsync(App.HttpUser.Id);
                            if (context.Resource.IsNotNull())
                            {
                                var endpointFeature = (IEndpointFeature)((DefaultHttpContext)context.Resource)
                                    .Features.FirstOrDefault(x =>
                                        x.Key.FullName == typeof(IEndpointFeature).FullName).Value;
                                if (endpointFeature.Endpoint?.Metadata.FirstOrDefault(x =>
                                            x.GetType() == typeof(HasPermAttribute)) is
                                        HasPermAttribute
                                        apeVoloAuthorize && apeVoloAuthorize.AuthCodes.Any(authCodes.Contains))
                                {
                                    isMatchRole = true;
                                }
                            }
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "验证按钮权限时发生错误");
                        }
                    }

                    #endregion

                    #region 用户在线则放行

                    if (!isMatchRole)
                    {
                        try
                        {
                            //在线特性,接口拥有ApeVoloOnlineAttribute 直接放行
                            if (context.Resource.IsNotNull())
                            {
                                var endpointFeature = (IEndpointFeature)((DefaultHttpContext)context.Resource)
                                    ?.Features.FirstOrDefault(x =>
                                        x.Key.FullName == typeof(IEndpointFeature).FullName).Value;
                                if (endpointFeature != null)
                                {
                                    var apeVoloOnline =
                                        endpointFeature.Endpoint?.Metadata.FirstOrDefault(x =>
                                                x.GetType() == typeof(ApeVoloOnlineAttribute)) as
                                            ApeVoloOnlineAttribute;
                                    if (apeVoloOnline.IsNotNull())
                                    {
                                        context.Succeed(requirement);
                                        return;
                                    }
                                }
                            }
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "验证在线标识权限时发生错误");
                        }
                    }

                    #endregion

                    if (!isMatchRole)
                    {
                        context.Fail();
                        return;
                    }


                    context.Succeed(requirement);
                    return;
                }
            }

            context.Fail();
            return;
        }

        context.Fail();
    }
}

JWT 自定义认证响应处理

位置:Ape.Volo.Infrastructure/Authentication/PermissionHandler.cs

csharp
// 自定义认证响应处理
public class ApiResponseHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public ApiResponseHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger,
        UrlEncoder encoder) :
        base(options, logger, encoder)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        throw new NotImplementedException();
    }

    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.ContentType = "application/json";
        Response.StatusCode = StatusCodes.Status401Unauthorized;
        await Response.WriteAsync(new ActionResultVm
        {
            Status = StatusCodes.Status401Unauthorized,
            ActionError = new ActionError(),
            Message = App.L.R("Sys.HttpUnauthorized"),
            Path = App.HttpContext?.Request.Path.Value?.ToLower()
        }.ToJson());
    }

    protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
    {
        var loginUserInfo = await App.Cache.GetAsync<LoginUserInfo>(
            GlobalConstants.CachePrefix.OnlineKey +
            App.HttpUser.JwtToken.ToMd5String16());
        if (loginUserInfo.IsNull())
        {
            Response.ContentType = "application/json";
            Response.StatusCode = StatusCodes.Status401Unauthorized;
            await Response.WriteAsync(new ActionResultVm
            {
                Status = StatusCodes.Status401Unauthorized,
                ActionError = new ActionError(),
                Message = App.L.R("Sys.HttpUnauthorized"),
                Path = App.HttpContext?.Request.Path.Value?.ToLower()
            }.ToJson());
        }
        else
        {
            Response.ContentType = "application/json";
            Response.StatusCode = StatusCodes.Status403Forbidden;
            await Response.WriteAsync(new ActionResultVm
            {
                Status = StatusCodes.Status403Forbidden,
                ActionError = new ActionError(),
                Message = App.L.R("Sys.HttpForbidden"),
                Path = App.HttpContext?.Request.Path.Value?.ToLower()
            }.ToJson());
        }
    }
}

JWT Token 接口实现

位置:Ape.Volo.Infrastructure/Authentication/TokenService.cs

csharp
public class TokenService : ITokenService
{
    private readonly ILogger<TokenService> _logger;


    public TokenService(ILogger<TokenService> logger)
    {
        _logger = logger;
    }

    /// <summary>
    /// 颁发Token
    /// </summary>
    /// <param name="loginUserInfo"></param>
    /// <param name="refresh"></param>
    /// <param name="refreshTime"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentNullException"></exception>
    public async Task<TokenVo> IssueTokenAsync(LoginUserInfo loginUserInfo, bool refresh = false, long refreshTime = 0)
    {
        if (loginUserInfo == null)
            throw new ArgumentNullException(nameof(loginUserInfo));

        var jwtAuthOptions = App.GetOptions<JwtAuthOptions>();
        var signinCredentials =
            new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtAuthOptions.SecurityKey)),
                SecurityAlgorithms.HmacSha256);
        var nowTime = DateTime.Now;
        if (refreshTime == 0)
        {
            refreshTime = nowTime.AddMinutes(jwtAuthOptions.RefreshTokenExpires).ToUnixTimeStampMillisecond();
        }
        var cls = new List<Claim>
        {
            new(AuthConstants.JwtClaimTypes.Jti, loginUserInfo.UserId.ToString()),
            new(AuthConstants.JwtClaimTypes.Name, loginUserInfo.Account),
            new(AuthConstants.JwtClaimTypes.TenantId, loginUserInfo.TenantId.ToString()),
            new(AuthConstants.JwtClaimTypes.DeptId, loginUserInfo.DeptId.ToString()),
            new(AuthConstants.JwtClaimTypes.Iat, nowTime.ToUnixTimeStampMillisecond().ToString()),
            new(AuthConstants.JwtClaimTypes.Ip, loginUserInfo.Ip),
            new(AuthConstants.JwtClaimTypes.RefreshTime,refreshTime.toString())
        };
        var identity = new ClaimsIdentity(AuthConstants.JwtTokenType);
        identity.AddClaims(cls);


        var tokeOptions = new JwtSecurityToken(
            issuer: jwtAuthOptions.Issuer,
            audience: jwtAuthOptions.Audience,
            claims: cls,
            notBefore: nowTime,
            expires: nowTime.AddMinutes(jwtAuthOptions.Expires),
            signingCredentials: signinCredentials
        );

        var expires = nowTime.AddMinutes(jwtAuthOptions.Expires).ToUnixTimeStampMillisecond();
        var token = new JwtSecurityTokenHandler().WriteToken(tokeOptions);
        if (refresh)
        {
            return await Task.FromResult(new TokenVo
            {
                Expires = expires,
                TokenType = AuthConstants.JwtTokenType,
                RefreshToken = token,
                RefreshTokenExpires = refreshTime
            });
        }

        return await Task.FromResult(new TokenVo
        {
            AccessToken = token,
            Expires = expires,
            TokenType = AuthConstants.JwtTokenType,
            RefreshToken = "",
            RefreshTokenExpires = refreshTime
        });
    }


    /// <summary>
    /// 读取Token
    /// </summary>
    /// <param name="token"></param>
    /// <returns></returns>
    public async Task<JwtSecurityToken> ReadJwtToken(string token)
    {
        try
        {
            token = token.Replace(AuthConstants.JwtTokenType, "").Trim();
            var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
            if (jwtSecurityTokenHandler.CanReadToken(token))
            {
                var jwtAuthOptions = App.GetOptions<JwtAuthOptions>();
                var signinCredentials =
                    new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtAuthOptions.SecurityKey)),
                        SecurityAlgorithms.HmacSha256);
                JwtSecurityToken jwtSecurityToken = jwtSecurityTokenHandler.ReadJwtToken(token);
                var rawSignature = JwtTokenUtilities.CreateEncodedSignature(
                    jwtSecurityToken.RawHeader + "." + jwtSecurityToken.RawPayload,
                    signinCredentials);
                if (jwtSecurityToken.RawSignature == rawSignature)
                {
                    return await Task.FromResult(jwtSecurityToken);
                }
            }
        }
        catch (Exception e)
        {
            _logger.LogError("Error reading JWT token: {Message}", e.Message);
        }

        return null;
    }
}

🎯 鉴权方式

开发环境免鉴权

  • 配置文件 IsQuickDebug 为true并且全局设置 NoAuthenticationRequired 为true时,免鉴权。

API 路由权限验证

  • 获取 httpContext.Request.Path.Value?.ToLower()。
  • 与用户角色所拥有的 API 路由进行匹配。

用户角色权限验证

csharp
[AttributeUsage(AttributeTargets.Method)]
public class HasRoleAttribute : Attribute
{
    public HasRoleAttribute(string[] authCodes)
    {
        AuthCodes = authCodes;
    }

    /// <summary>
    /// 权限代码
    /// </summary>
    public string[] AuthCodes { get; }
}

[HasRole(["admin"])]
public Task<ActionResult> GetApiGroup()
{
    //略...
}
  • 获取 接口方法的 HasRoleAttribute 自定义特性。
  • 与用户角色权限标识符进行匹配。

按钮权限验证

csharp
[AttributeUsage(AttributeTargets.Method)]
public class HasPermAttribute : Attribute
{
    public HasPermAttribute(string[] authCodes)
    {
        AuthCodes = authCodes;
    }

    /// <summary>
    /// 权限代码
    /// </summary>
    public string[] AuthCodes { get; }
}

[HasPerm(["sys:api:add"])]
public Task<ActionResult> AddApiGroup()
{
        //略...
}
  • 获取 接口方法的 HasPermAttribute 自定义特性。
  • 与用户菜单按钮权限标识符进行匹配。

在线放行验证

csharp
/// <summary>
/// 自定义鉴权特性,在线则可通行
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class ApeVoloOnlineAttribute : Attribute
{
}

[ApeVoloOnline]
public Task<ActionResult> QueryByName()
{
        //略...
}
  • 获取 接口方法的 ApeVoloOnlineAttribute 自定义特性。
  • 能到这一步说明 Token 还在有效期,所以存在则放行。

🔑 登录实现

登录 API

位置:Ape.Volo.Api/Controllers/Auth/AuthorizationController.cs

csharp
    /// <summary>
    /// 用户登录
    /// </summary>
    /// <param name="authUser"></param>
    /// <returns></returns>
    [HttpPost]
    [Route("login")]
    [Description("Action.UserLogin")]
    [AllowAnonymous]
    [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LoginResultVo))]
    public async Task<ActionResult> Login([FromBody] LoginAuthUser authUser)
    {
        if (!ModelState.IsValid)
        {
            var actionError = ModelState.GetErrors();
            return Error(actionError);
        }


        var loginFailedLimitOptions = App.GetOptions<LoginFailedLimitOptions>();
        var attempsCacheKey = GlobalConstants.CachePrefix.Attempts + App.HttpContext.Connection.RemoteIpAddress +
                              authUser.UserName;
        LoginAttempt loginAttempt = null;
        if (loginFailedLimitOptions.Enabled)
        {
            loginAttempt = await App.Cache.GetAsync<LoginAttempt>(attempsCacheKey);
            if (loginAttempt.IsNull())
            {
                loginAttempt = new LoginAttempt { Count = 0, IsLocked = false, LockUntil = DateTime.MinValue };
                await App.Cache.SetAsync(attempsCacheKey, loginAttempt,
                    TimeSpan.FromSeconds(loginFailedLimitOptions.Lockout), null);
            }

            if (loginAttempt.IsLocked && DateTime.Now < loginAttempt.LockUntil)
            {
                // 可以实施账户锁定时,通过邮件或短信通知用户。
                // 可以实施账户锁定后要求管理员手动解锁
                return Error(App.L.R("Error.AccountLockedWithUnlockTime{0}",
                    loginAttempt.LockUntil.ToString("yyyy-MM-dd HH:mm:ss")));
            }
        }


        var captchaOptions = App.GetOptions<CaptchaOptions>();
        var showCaptcha = true; //是否显示验证码
        var thresholdCacheKey = GlobalConstants.CachePrefix.Threshold + App.HttpContext.Connection.RemoteIpAddress;
        var failedThreshold = 0;
        if (captchaOptions.Threshold > 0)
        {
            failedThreshold = await App.Cache.GetAsync<int>(thresholdCacheKey);
            if (failedThreshold <= 0)
            {
                failedThreshold = 1;
                await App.Cache.SetAsync(thresholdCacheKey, failedThreshold,
                    TimeSpan.FromSeconds(captchaOptions.TimeOut), null);
            }

            showCaptcha = failedThreshold > captchaOptions.Threshold;
        }


        if (!App.GetOptions<SystemOptions>().IsQuickDebug && showCaptcha)
        {
            if (authUser.Captcha.IsNullOrEmpty())
            {
                return Error(ValidationError.Required(authUser, nameof(authUser.Captcha)));
            }

            if (authUser.CaptchaId.IsNullOrEmpty())
            {
                return Error(ValidationError.Required(authUser, nameof(authUser.CaptchaId)));
            }


            var code = await App.Cache.GetAsync<string>(authUser.CaptchaId);
            if (code.IsNullOrEmpty())
            {
                return Error(App.L.R("Error.VerificationCodeExpired"));
            }

            if (!code.Equals(authUser.Captcha))
            {
                if (captchaOptions.Threshold > 0)
                {
                    failedThreshold++;
                    await App.Cache.SetAsync(thresholdCacheKey, failedThreshold,
                        TimeSpan.FromSeconds(captchaOptions.TimeOut),
                        null);
                }

                return Error(App.L.R("Error.InvalidVerificationCode"));
            }
        }

        var userDto = await _userService.QueryByNameAsync(authUser.UserName);
        if (userDto == null)
        {
            if (captchaOptions.Threshold > 0)
            {
                failedThreshold++;
                await App.Cache.SetAsync(thresholdCacheKey, failedThreshold,
                    TimeSpan.FromSeconds(captchaOptions.TimeOut),
                    null);
            }

            return Error(App.L.R("Error.UserNotFound"));
        }

        var rsaOptions = App.GetOptions<RsaOptions>();
        var password = new RsaHelper(rsaOptions.PrivateKey, rsaOptions.PublicKey).Decrypt(authUser.Password);
        if (!BCryptHelper.Verify(password, userDto.Password))
        {
            if (captchaOptions.Threshold > 0)
            {
                failedThreshold++;
                await App.Cache.SetAsync(thresholdCacheKey, failedThreshold,
                    TimeSpan.FromSeconds(captchaOptions.TimeOut),
                    null);
            }

            if (loginFailedLimitOptions.Enabled && loginAttempt != null)
            {
                loginAttempt.Count++;
                if (loginAttempt.Count >= loginFailedLimitOptions.MaxAttempts)
                {
                    loginAttempt.IsLocked = true;
                    loginAttempt.LockUntil = DateTime.Now.AddSeconds(loginFailedLimitOptions.Lockout);
                }


                await App.Cache.SetAsync(attempsCacheKey, loginAttempt,
                    TimeSpan.FromSeconds(loginFailedLimitOptions.Lockout), null);
            }

            return loginFailedLimitOptions.Enabled
                ? Error(App.L.R("Error.InvalidPasswordWithLockWarning"))
                : Error(App.L.R("Error.InvalidPassword"));
        }

        if (!userDto.Enabled)
        {
            if (captchaOptions.Threshold > 0)
            {
                failedThreshold++;
                await App.Cache.SetAsync(thresholdCacheKey, failedThreshold,
                    TimeSpan.FromSeconds(captchaOptions.TimeOut),
                    null);
            }

            return Error(App.L.R("Error.UserNotActivated"));
        }

        await App.Cache.RemoveAsync(authUser.CaptchaId);
        await App.Cache.RemoveAsync(thresholdCacheKey);
        await App.Cache.RemoveAsync(attempsCacheKey);
        var netUser = await _userService.QueryByIdAsync(userDto.Id);
        return await LoginResult(netUser, "login");
    }

🔄 Token 刷新

自动刷新

当 Token 过期时(在 RefreshTokenExpires 时间内),系统实现 Token 以旧换新返回客户端:

csharp
/// <summary>
    /// 刷新Token
    /// </summary>
    /// <param name="refreshToken"></param>
    /// <returns></returns>
    [HttpPost]
    [Route("refreshToken")]
    [Description("Action.RefreshToken")]
    [AllowAnonymous]
    [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TokenVo))]
    public async Task<ActionResult> RefreshToken([FromBody] RefreshToken refreshToken)
    {
        if (!ModelState.IsValid)
        {
            var actionError = ModelState.GetErrors();
            return Error(actionError);
        }

        var token = refreshToken.Token;
        var tokenMd5 = token.ToMd5String16();
        var tokenBlacklist = await _tokenBlacklistService
            .TableWhere(x => x.AccessToken == tokenMd5, null, null, null, true)
            .FirstAsync();
        if (!tokenBlacklist.IsNull())
        {
            return Error(App.L.R("Error.TokenRevoked"));
        }

        var jwtSecurityToken = await _tokenService.ReadJwtToken(token);
        if (jwtSecurityToken != null)
        {
            var userId = Convert.ToInt64(jwtSecurityToken.Claims
                .FirstOrDefault(s => s.Type == AuthConstants.JwtClaimTypes.Jti)?.Value);
            var loginTime = Convert.ToInt64(jwtSecurityToken.Claims
                .FirstOrDefault(s => s.Type == AuthConstants.JwtClaimTypes.Iat)?.Value).TimeMillisecondToDateTime();
            var nowTime = DateTime.Now.ToLocalTime();
            var refreshTimeClaims = Convert.ToInt64(jwtSecurityToken.Claims
                .FirstOrDefault(s => s.Type == AuthConstants.JwtClaimTypes.RefreshTime)?.Value);
            var refreshTime = refreshTimeClaims.TimeMillisecondToDateTime();
            // 允许token刷新时间内
            if (nowTime < refreshTime)
            {
                var netUser = await _userService.QueryByIdAsync(userId);
                if (netUser.IsNotNull())
                    if (netUser.UpdateTime == null || netUser.UpdateTime < loginTime)
                        return await LoginResult(netUser, "refresh", refreshTimeClaims);
            }

            return Error(App.L.R("Error.TokenExpired"));
        }

        return Error(App.L.R("Error.TokenParseFailed"));
    }

🚫 Token 黑名单

黑名单机制

为了支持用户登出和 Token 撤销,系统实现了 JWT 黑名单机制:

csharp
    /// <summary>
    /// Token黑名单
    /// </summary>
    [SugarTable("sys_token_blacklist")]
    public class TokenBlacklist : BaseEntity
    {
        /// <summary>
        /// 令牌 登录token的MD5值
        /// </summary>
        public string AccessToken { get; set; } = string.Empty;
    }

    var token = refreshToken.Token;
    var tokenMd5 = token.ToMd5String16();
    var tokenBlacklist = await _tokenBlacklistService
            .TableWhere(x => x.AccessToken == tokenMd5, null, null, null, true)
            .FirstAsync();
    if (!tokenBlacklist.IsNull())
    {
        return Error(App.L.R("Error.TokenRevoked"));
    }

🔒 安全最佳实践

1. 密钥管理

  • 使用强随机密钥作为签名密钥
  • 定期轮换签名密钥
  • 将密钥存储在安全的配置文件中

2. Token 生命周期

  • 设置合理的过期时间(建议不超过 24 小时)
  • 实现 Token 刷新机制
  • 支持主动撤销 Token

3. 传输安全

  • 始终使用 HTTPS 传输 Token
  • 在请求头中传递 Token,避免在 URL 中暴露
  • 客户端安全存储 Token

版权所有 © 2021-2026 ApeVolo-Team