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_token
和refresh_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