Abp vNext单点登录

Abp vNext单点登录

使用Abp vNext 6.0

分析

Abp vNext说OpenIddict是支持单点登录的,不过我找不到相关内容

OpenIddict module provides an integration with the OpenIddict which provides advanced authentication features like single sign-on, single log-out, and API access control. This module persists applications, scopes, and other OpenIddict-related objects to the database.

而且以abp之前IdentityServer4的神奇操作来说

  • /connect/revocation这个接口只能把refresh_token过期
  • /connect/token刷新refresh_token,原先的access_token并没有失效
  • 未使用refresh_token就重新登录去获取access_tokenrefresh_token,那么原先的refresh_token仍然有效

而abp在OpenIddict里,删掉了之前的/connect/revocation,虽然有/connect/revocat这个路由,但是好像没实现
/connect/token也默认不给refresh_token,需要scope:offline_access这个参数才能获取到,毕竟这个刷新其实跟重新登录没啥区别,不过能减少密码输入次数,稍微安全点

所以还是自己实现比较靠谱
方法其实挺简单的,用中间件和Redis就够了

  • 登录时,生成完token,在中间件的响应处理中把token添加到redis
  • 请求时,判断redis里没有对应的token就返回
  • 刷新token时,替换掉redis里的token就可以了

说实话,我是看不懂abp这个ICurrentUser怎么来的,源码都翻不到,但是看起来是解析jwt的,header和payload是不需要密钥的,而且用abp的demo确实可以把access_token拿去解析出用户数据

实现

既然写了中间件,那就先加个配置SinglePointLoginMiddlewareOptions

public class SinglePointLoginMiddlewareOptions
{
    /// <summary>
    /// 获取Token路由
    /// </summary>
    public string TokenUrl { get; set; }

    /// <summary>
    /// 是否全局验证
    /// </summary>
    public bool IsGlobalAuthorize { get; set; }

    /// <summary>
    /// redis中key的前缀
    /// </summary>
    public string RedisKeyPrefix { get; set; }

    public SinglePointLoginMiddlewareOptions()
    {
        this.TokenUrl = "/connect/token";
        this.IsGlobalAuthorize = false;
        this.RedisKeyPrefix = "Token_UserId_";
    }
}

加个扩展SinglePointLoginMiddlewareExtensions

public static class SinglePointLoginMiddlewareExtensions
{
    /// <summary>
    /// 使用单点登录
    /// </summary>
    /// <param name="builder"></param>
    /// <returns></returns>
    public static IApplicationBuilder UseSinglePointLogin(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<SinglePointLoginMiddleware>();
    }
}

再然后是中间件

public class SinglePointLoginMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IDistributedCache _distributedCache;
    private readonly SinglePointLoginMiddlewareOptions _option;

    public SinglePointLoginMiddleware(RequestDelegate next, IDistributedCache distributedCache, IOptions<SinglePointLoginMiddlewareOptions> options)
    {
        this._next = next;
        this._distributedCache = distributedCache;
        this._option = options.Value;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        //需要读取响应,所以需要替换
        var originBodyStream = httpContext.Response.Body;
        using (var responseBodyStream = new MemoryStream())
        {
            try
            {
                httpContext.Response.Body = responseBodyStream;

                bool isAllowAnonymous = false;
                if (true == this._option.IsGlobalAuthorize)
                {
                    //全局验证,AllowAnonymous不需要验证
                    var allowAnonymousAttribute = HttpContextHelper.GetAttribute<AllowAnonymousAttribute>(httpContext);

                    if (null != allowAnonymousAttribute)
                    {
                        isAllowAnonymous = true;
                    }
                }
                else
                {
                    //非全局验证,默认不需要验证
                    var authorizeAttribute = HttpContextHelper.GetAttribute<AuthorizeAttribute>(httpContext);

                    if (null == authorizeAttribute)
                    {
                        isAllowAnonymous = true;
                    }
                }

                if (false == isAllowAnonymous)
                {
                    //目标控制器函数没有匿名属性
                    string userId = string.Empty;
                    string jwtToken = string.Empty;

                    //获取token
                    jwtToken = HttpContextHelper.GetJwtToken(httpContext);
                    if (true == string.IsNullOrWhiteSpace(jwtToken))
                    {
                        //没有传递token
                        var response_401 = httpContext.Response;
                        response_401.StatusCode = 401;

                        return;
                    }

                    //获取用户Id
                    JwtPayload payload = JwtHelper.GetPayload(jwtToken);
                    if (null != payload)
                    {
                        if (true == payload.TryGetValue("sub", out object userIdObj) && null != userIdObj)
                        {
                            string userIdStr = userIdObj.ToString();
                            if (false == string.IsNullOrWhiteSpace(userIdStr))
                            {
                                userId = userIdStr;
                            }
                        }
                    }
                    if (true == string.IsNullOrWhiteSpace(userId))
                    {
                        //payload中没有userId数据
                        var response_401 = httpContext.Response;
                        response_401.StatusCode = 401;

                        return;
                    }

                    //从redis获取token
                    string redisTokenKey = $"{this._option.RedisKeyPrefix}{userId}";
                    string redisToken = this._distributedCache.GetString(redisTokenKey);
                    if (true == string.IsNullOrWhiteSpace(redisToken) || redisToken != jwtToken)
                    {
                        //单点登录,本地token必须与redis里的相同
                        var response_401 = httpContext.Response;
                        response_401.StatusCode = 401;

                        return;
                    }
                }
                await this._next(httpContext);
                await this.ResponseHandler(httpContext);
            }
            finally
            {
                //重置响应
                responseBodyStream.Seek(0, SeekOrigin.Begin);
                await responseBodyStream.CopyToAsync(originBodyStream);
                httpContext.Response.Body = originBodyStream;//这一步要不要都可以
            }
        }
    }

    /// <summary>
    /// 响应处理
    /// 将token放到redis
    /// </summary>
    /// <param name="httpContext"></param>
    /// <returns></returns>
    private async Task<bool> ResponseHandler(HttpContext httpContext)
    {
        var request = httpContext.Request;
        var response = httpContext.Response;
        string path = request.Path;
        int statusCode = response.StatusCode;

        //从响应中获取access_token
        string token = string.Empty;
        if (this._option.TokenUrl == path && 200 == statusCode)
        {
            string bodyStr = await HttpContextHelper.GetResponseBodyStr(httpContext);
            if (false == string.IsNullOrWhiteSpace(bodyStr))
            {
                var bodyDic = JsonConvert.DeserializeObject<Dictionary<string, object>>(bodyStr);
                if (bodyDic != null)
                {
                    if (true == bodyDic.TryGetValue("access_token", out object accessTokenObj) && null != accessTokenObj)
                    {
                        string accessTokenStr = accessTokenObj.ToString();
                        if (false == string.IsNullOrWhiteSpace(accessTokenStr))
                        {
                            token = accessTokenStr;
                        }
                    }
                }
            }
        }

        if (false == string.IsNullOrWhiteSpace(token))
        {
            //获取userId
            JwtPayload payload = JwtHelper.GetPayload(token);
            string userId = string.Empty;
            long exp = 0;
            if (null != payload)
            {
                if (true == payload.TryGetValue("sub", out object userIdObj) && null != userIdObj)
                {
                    string userIdStr = userIdObj.ToString();
                    if (false == string.IsNullOrWhiteSpace(userIdStr))
                    {
                        userId = userIdStr;
                    }
                }

                if (true == payload.TryGetValue("exp", out object expObj) && null != expObj)
                {
                    string expStr = expObj.ToString();
                    if (false == string.IsNullOrWhiteSpace(expStr))
                    {
                        exp = long.Parse(expStr);
                    }
                }

            }

            //保存到redis
            if (false == string.IsNullOrWhiteSpace(userId) && 0 != exp)
            {
                userId = $"{this._option.RedisKeyPrefix}{userId}";
                DistributedCacheEntryOptions options = new DistributedCacheEntryOptions();
                options.AbsoluteExpiration = DateTimeOffset.FromUnixTimeSeconds(exp);
                this._distributedCache.SetString(userId, token, options);
                return true;
            }
        }

        return false;
    }
}

因为abp登录和刷新token都是同一个路由,所以步骤还更简单点,就是判断目标函数是不是匿名的,abp的源码默认是全部匿名,Authorize属性才验证token

  • 非匿名函数,我们从token中获取userId,再去redis中取出来
  • 匿名函数,我们就继续执行,到响应的时候再判断目标函数的路由属性,路由正确再把token存到redis中

因为刷新token的路由和获取token的路由是同一个,所以刷新token也会重置redis里的token
思路不算复杂,有个步骤是比较麻烦的,在中间件里的响应数据是不能读取的,需要走点弯路

最后在UseConfiguredEndpoints()前调用UseSinglePointLogin()就可以了
还有这个配置的依赖注入

//配置单点登录
Configure<SinglePointLoginMiddlewareOptions>(options =>
{
    options.TokenUrl = "/connect/token";
    options.IsGlobalAuthorize = false;
    options.RedisKeyPrefix = "Token_UserId_";
});

还有两个工具类

JwtHelper操作token

public class JwtHelper
{
    /// <summary>
    /// 从jwtToken中获取Header
    /// </summary>
    /// <param name="jwtToken"></param>
    /// <returns></returns>
    public static JwtHeader GetHeader(string jwtToken)
    {
        var handler = new JwtSecurityTokenHandler();
        var jwt = handler.ReadJwtToken(jwtToken);
        var header = jwt.Header;

        return header;
    }

    /// <summary>
    /// 从jwtToken中获取Payload
    /// </summary>
    /// <param name="jwtToken"></param>
    /// <returns></returns>
    public static JwtPayload GetPayload(string jwtToken)
    {
        var handler = new JwtSecurityTokenHandler();
        var jwt = handler.ReadJwtToken(jwtToken);
        var payload = jwt.Payload;

        return payload;
    }
}

HttpContextHelper操作中间件的HttpContext

public class HttpContextHelper
{
    /// <summary>
    /// 从HttpContext中获取jwtToken
    /// </summary>
    /// <param name="httpContext"></param>
    /// <returns></returns>
    public static string GetJwtToken(HttpContext httpContext)
    {
        string requestToken = string.Empty;
        string jwtToken = string.Empty;

        var request = httpContext.Request;
        var header = request.Headers;
        if (null != header)
        {
            if (true == header.TryGetValue("Authorization", out StringValues authorizationStr))
            {
                requestToken = authorizationStr;
            }
        }

        if (false == string.IsNullOrWhiteSpace(requestToken))
        {
            jwtToken = requestToken.Replace("Bearer ", string.Empty);
        }

        return jwtToken;
    }

    /// <summary>
    /// 从请求的Controller中获取Attribute
    /// </summary>
    /// <typeparam name="TAttribute"></typeparam>
    /// <param name="httpContext"></param>
    /// <returns></returns>
    public static TAttribute GetAttribute<TAttribute>(HttpContext httpContext) where TAttribute : class
    {
        var endpoint = httpContext.Features.Get<IEndpointFeature>()?.Endpoint;
        var attribute = endpoint?.Metadata.GetMetadata<TAttribute>();

        return attribute;
    }

    /// <summary>
    /// 获取响应数据,需要先将响应数据转换成MemoryStream
    /// </summary>
    /// <param name="httpContext"></param>
    /// <returns></returns>
    public static async Task<string> GetResponseBodyStr(HttpContext httpContext)
    {
        var response = httpContext.Response;
        var body = response.Body;

        body.Seek(0, SeekOrigin.Begin);
        var streamReader = new StreamReader(body);
        var bodyStr = await streamReader.ReadToEndAsync();
        body.Seek(0, SeekOrigin.Begin);

        return bodyStr;
    }
}

刷新token

上面的代码虽然也适用于刷新token,但是刷新token本身就有缺陷
因为刷新的一段时间内可能还有多个同样token的请求,如果此时替换掉redis的token会出大问题,需要给旧的token一点时间和新的token同时存在
虽然思路是这样,但是操作起来问题不少,可以明确的是,肯定是替换token前的请求有问题,即旧token

  • 如果用新旧两个key保存token的方案,新token覆盖时,此时单点登录验证没取到旧token而取到新token,那就401了;如果不覆盖,只设置过期时间,似乎就没有这个问题,但是这个操作逻辑在两个token同时存在时执行有问题,即短时间内重复调用登录和刷新token,因为这样就执行了覆盖操作,大概率不是一般用户,可以不管;如果要处理这个问题,可以动态生成key,因为我们是验证本地token,所以这个key可以加个时间戳之类的,这样就没问题了
  • 如果用List来保存token,并不能给List的元素单独设置过期时间,这就需要在代码中设置延时操作来删除旧token了,这样其实也有隐患,操作起来反而比两个key麻烦

那么我们先确定使用两个token的方案,并且使用token生成的时间戳为标记,再来分析
因为校验的是本地token,而/connect/token并不需要token就能访问,所以如果前端不传token进来,那就没法操作旧token,单点登录也就失效了

那么我们操作旧token不从本地获取,再用一个key存储用户token,因为/connect/token的响应一定有access_token,所以可以在这里判断有没有旧token,这个key只用在响应处理时确认唯一token,而不是用于验证
不过这样会占掉双倍的内存,毕竟存了两个token,如果新旧token都只短时间保留,这内存就能省下来,不过代码就要多走几步,经典内存换性能

那么就开始实现吧

SinglePointLoginMiddlewareOptions配置里加一个旧token缓冲时间

public class SinglePointLoginMiddlewareOptions
{
    /// <summary>
    /// 获取Token路由
    /// </summary>
    public string TokenUrl { get; set; }

    /// <summary>
    /// 是否全局验证
    /// </summary>
    public bool IsGlobalAuthorize { get; set; }

    /// <summary>
    /// redis中key的前缀
    /// </summary>
    public string RedisKeyPrefix { get; set; }

    /// <summary>
    /// 旧Token保存时间,单位 秒
    /// </summary>
    public int OldTokenExpiresIn { get; set; }

    public SinglePointLoginMiddlewareOptions()
    {
        this.TokenUrl = "/connect/token";
        this.IsGlobalAuthorize = false;
        this.RedisKeyPrefix = "Token_UserId_";
        this.OldTokenExpiresIn = 30;
    }
}

Module里再加配置

//配置单点登录
Configure<SinglePointLoginMiddlewareOptions>(options =>
{
    options.TokenUrl = "/connect/token";
    options.IsGlobalAuthorize = false;
    options.RedisKeyPrefix = "Token_UserId_";
    options.OldTokenExpiresIn = 30;
});

最后就是中间件SinglePointLoginMiddleware

public class SinglePointLoginMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IDistributedCache _distributedCache;
    private readonly SinglePointLoginMiddlewareOptions _option;

    public SinglePointLoginMiddleware(RequestDelegate next, IDistributedCache distributedCache, IOptions<SinglePointLoginMiddlewareOptions> options)
    {
        this._next = next;
        this._distributedCache = distributedCache;
        this._option = options.Value;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        //需要读取响应,所以需要替换
        var originBodyStream = httpContext.Response.Body;
        using (var responseBodyStream = new MemoryStream())
        {
            try
            {
                string issuedAtTime = string.Empty;
                httpContext.Response.Body = responseBodyStream;

                bool isAllowAnonymous = false;
                if (true == this._option.IsGlobalAuthorize)
                {
                    //全局验证,AllowAnonymous不需要验证
                    var allowAnonymousAttribute = HttpContextHelper.GetAttribute<AllowAnonymousAttribute>(httpContext);

                    if (null != allowAnonymousAttribute)
                    {
                        isAllowAnonymous = true;
                    }
                }
                else
                {
                    //非全局验证,默认不需要验证
                    var authorizeAttribute = HttpContextHelper.GetAttribute<AuthorizeAttribute>(httpContext);

                    if (null == authorizeAttribute)
                    {
                        isAllowAnonymous = true;
                    }
                }

                if (false == isAllowAnonymous)
                {
                    //目标控制器函数没有匿名属性
                    string userId = string.Empty;
                    string jwtToken = string.Empty;

                    //获取token
                    jwtToken = HttpContextHelper.GetJwtToken(httpContext);
                    if (true == string.IsNullOrWhiteSpace(jwtToken))
                    {
                        //没有传递token
                        var response_401 = httpContext.Response;
                        response_401.StatusCode = 401;

                        return;
                    }

                    //获取用户Id
                    JwtPayload payload = JwtHelper.GetPayload(jwtToken);
                    if (null != payload)
                    {
                        if (true == payload.TryGetValue("sub", out object userIdObj) && null != userIdObj)
                        {
                            string userIdStr = userIdObj.ToString();
                            if (false == string.IsNullOrWhiteSpace(userIdStr))
                            {
                                userId = userIdStr;
                            }
                        }

                        if (true == payload.TryGetValue("iat", out object iatObj) && null != iatObj)
                        {
                            string iatStr = iatObj.ToString();
                            if (false == string.IsNullOrWhiteSpace(iatStr))
                            {
                                issuedAtTime = iatStr;
                            }
                        }
                    }
                    if (true == string.IsNullOrWhiteSpace(userId))
                    {
                        //payload中没有userId数据
                        var response_401 = httpContext.Response;
                        response_401.StatusCode = 401;

                        return;
                    }

                    //从redis获取token
                    //单点登录,本地token必须能在redis中找到
                    bool isTrueToken = false;

                    string redisTokenKey = $"{this._option.RedisKeyPrefix}{userId}";
                    string redisToken = await this._distributedCache.GetStringAsync(redisTokenKey);
                    if (false == string.IsNullOrWhiteSpace(redisToken) && redisToken == jwtToken)
                    {
                        //本地token与唯一token相等
                        isTrueToken = true;
                    }
                    else if (false == string.IsNullOrWhiteSpace(issuedAtTime))
                    {
                        //从redis中获取对应时间戳token
                        string tempRedisTokenKey = $"{this._option.RedisKeyPrefix}{userId}_{issuedAtTime}";
                        string tempRedisToken = await this._distributedCache.GetStringAsync(tempRedisTokenKey);

                        if (false == string.IsNullOrWhiteSpace(tempRedisToken) && tempRedisToken == jwtToken)
                        {
                            //本地token与对应时间戳token相等
                            isTrueToken = true;
                        }

                    }

                    if (false == isTrueToken)
                    {
                        var response_401 = httpContext.Response;
                        response_401.StatusCode = 401;

                        return;
                    }
                }
                await this._next(httpContext);
                await this.ResponseHandler(httpContext);
            }
            finally
            {
                //重置响应
                responseBodyStream.Seek(0, SeekOrigin.Begin);
                await responseBodyStream.CopyToAsync(originBodyStream);
                httpContext.Response.Body = originBodyStream;//这一步要不要都可以
            }
        }
    }

    /// <summary>
    /// 响应处理
    /// 将token放到redis
    /// </summary>
    /// <param name="httpContext"></param>
    /// <returns></returns>
    private async Task<bool> ResponseHandler(HttpContext httpContext)
    {
        var request = httpContext.Request;
        var response = httpContext.Response;
        string path = request.Path;
        int statusCode = response.StatusCode;

        //从响应中获取access_token
        string newToken = string.Empty;
        if (this._option.TokenUrl == path && 200 == statusCode)
        {
            string bodyStr = await HttpContextHelper.GetResponseBodyStr(httpContext);
            if (false == string.IsNullOrWhiteSpace(bodyStr))
            {
                var bodyDic = JsonConvert.DeserializeObject<Dictionary<string, object>>(bodyStr);
                if (bodyDic != null)
                {
                    if (true == bodyDic.TryGetValue("access_token", out object accessTokenObj) && null != accessTokenObj)
                    {
                        string accessTokenStr = accessTokenObj.ToString();
                        if (false == string.IsNullOrWhiteSpace(accessTokenStr))
                        {
                            newToken = accessTokenStr;
                        }
                    }
                }
            }
        }

        if (false == string.IsNullOrWhiteSpace(newToken))
        {
            //获取userId
            JwtPayload payload = JwtHelper.GetPayload(newToken);
            string userId = string.Empty;
            long exp = 0;
            string newTokenIssuedAtTime = string.Empty;
            if (null != payload)
            {
                if (true == payload.TryGetValue("sub", out object userIdObj) && null != userIdObj)
                {
                    string userIdStr = userIdObj.ToString();
                    if (false == string.IsNullOrWhiteSpace(userIdStr))
                    {
                        userId = userIdStr;
                    }
                }
                if (true == payload.TryGetValue("exp", out object expObj) && null != expObj)
                {
                    string expStr = expObj.ToString();
                    if (false == string.IsNullOrWhiteSpace(expStr))
                    {
                        exp = long.Parse(expStr);
                    }
                }
                if (true == payload.TryGetValue("iat", out object iatObj) && null != iatObj)
                {
                    string iatStr = iatObj.ToString();
                    if (false == string.IsNullOrWhiteSpace(iatStr))
                    {
                        newTokenIssuedAtTime = iatStr;
                    }
                }
            }

            //保存到redis
            if (false == string.IsNullOrWhiteSpace(userId) && false == string.IsNullOrWhiteSpace(newTokenIssuedAtTime) && 0 != exp)
            {
                //确认唯一token
                string identityTokenKey = $"{this._option.RedisKeyPrefix}{userId}";
                string identityToken = await this._distributedCache.GetStringAsync(identityTokenKey);
                if (false == string.IsNullOrWhiteSpace(identityToken))
                {
                    var identityTokenPayload = JwtHelper.GetPayload(identityToken);

                    string oldToken = identityToken;
                    string oldTokenIssuedAtTime = string.Empty;
                    string oldTokenExpirationTime = string.Empty;
                    if (null != identityTokenPayload)
                    {
                        if (true == identityTokenPayload.TryGetValue("exp", out object identityExpObj) && null != identityExpObj)
                        {
                            string identityExpStr = identityExpObj.ToString();
                            if (false == string.IsNullOrWhiteSpace(identityExpStr))
                            {
                                oldTokenExpirationTime = identityExpStr;
                            }
                        }
                        if (true == identityTokenPayload.TryGetValue("iat", out object identityIatObj) && null != identityIatObj)
                        {
                            string identityIatStr = identityIatObj.ToString();
                            if (false == string.IsNullOrWhiteSpace(identityIatStr))
                            {
                                oldTokenIssuedAtTime = identityIatStr;
                            }
                        }
                    }

                    //旧token设置到期时间
                    if (false == string.IsNullOrWhiteSpace(oldTokenIssuedAtTime) && false == string.IsNullOrWhiteSpace(oldTokenExpirationTime))
                    {
                        //判断旧Token剩余时间是否大于将要设置的缓冲时间
                        //其实这步判断可以不要,可以直接设置缓冲时间,因为之前的中间件会验证token是否有效
                        var tempOldTokenExpirationTime = DateTimeOffset.UtcNow.AddSeconds(this._option.OldTokenExpiresIn).ToUnixTimeSeconds();
                        long oldTokenExpirationTimeLong = long.Parse(oldTokenExpirationTime);
                        if (tempOldTokenExpirationTime < oldTokenExpirationTimeLong)
                        {
                            string oldRedisTokenKey = $"{this._option.RedisKeyPrefix}{userId}_{oldTokenIssuedAtTime}";
                            DistributedCacheEntryOptions oldTokenOptions = new DistributedCacheEntryOptions();
                            oldTokenOptions.AbsoluteExpiration = DateTimeOffset.FromUnixTimeSeconds(tempOldTokenExpirationTime);
                            await this._distributedCache.SetStringAsync(oldRedisTokenKey, oldToken, oldTokenOptions);
                        }
                    }
                }

                //添加新token
                var newTokenExpirationTime = DateTimeOffset.UtcNow.AddSeconds(this._option.OldTokenExpiresIn).ToUnixTimeSeconds();
                DistributedCacheEntryOptions newTokenOptions = new DistributedCacheEntryOptions();
                newTokenOptions.AbsoluteExpiration = DateTimeOffset.FromUnixTimeSeconds(newTokenExpirationTime);
                string newRedisTokenKey = $"{this._option.RedisKeyPrefix}{userId}_{newTokenIssuedAtTime}";
                await this._distributedCache.SetStringAsync(newRedisTokenKey, newToken, newTokenOptions);

                //设置唯一token为新token
                DistributedCacheEntryOptions identityTokenOptions = new DistributedCacheEntryOptions();
                identityTokenOptions.AbsoluteExpiration = DateTimeOffset.FromUnixTimeSeconds(exp);
                await this._distributedCache.SetStringAsync(identityTokenKey, newToken, identityTokenOptions);

                return true;
            }
        }

        return false;
    }
}

每次请求/connect/token时,缓冲时间内会存在两个或三个token,缓冲时间外则只有一个token

Abp vNext单点登录 结束

posted @ 2023-08-09 15:50  .NET好耶  阅读(1249)  评论(0编辑  收藏  举报