基于.net core 3.1微服务架构的SSO单点登录实战
本文所涵盖的几个比较重要的知识点如下:
- 资源请求管道模型
- 依赖注入
- 自定义中间件
- 静态扩展
- AOP
先看两幅图大概了解一下
Oauth2
-------------------------------------分割线-----------------------------------------
JWT
由于篇幅所限,对与OAuth2和JWT的相关概念将不再赘述
核心代码分以下几个模块
数据仓储层,存放数据库实体以及数据库上下文
//当前登录用户实体
public class LoginUser
{
public string UserId { get; set; }
public string UserName { get; set; }
public string Mobile { get; set; }
/// <summary>
/// 租户ID
/// </summary>
public string TenantId { get; set; }
}
//用户数据
public class AuthSysUser
{
public string UserId { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string Mobile { get; set; }
public string TenantId { get; set; }
}
//用户token
public class AuthSysUserToken
{
public string UserId { get; set; }
public string Token { get; set; }
public DateTime? ExpireTime { get; set; }
public DateTime? UpdateTime { get; set; }
}
//数据库上下文,这里博主用的是sqlserver,当然你也可以用其他数据库实现
//右键依赖项管理NuGet程序包 搜索并添加 Microsoft.EntityFrameworkCore.SqlServer;
using Microsoft.EntityFrameworkCore;
public class AuthDbContext : DbContext
{
public AuthDbContext() { }
public AuthDbContext(DbContextOptions<AuthDbContext> options)
: base(options) { }
public DbSet<AuthSysUserToken> SysUserTokens { get; set; }
public DbSet<AuthSysUser> Users { get; set; }
}
服务层,对于JWT和Oauth2提供接口约束和实现
//定义接口
public interface IAuthService
{
string ServiceName { get; }
/// 判断权限
Task<bool> PermissionAsync(string token, string path);
/// 获取用户
Task SetUserAsync(string token);
}
//JWT实现
public class JwtAuthServiceImpl : IAuthService
{
private readonly GlobalSettings _globalSettings;
private readonly LoginUser _currentUser;
public JwtAuthServiceImpl(IOptions<GlobalSettings> options, LoginUser currentUser)
{
_globalSettings = options.Value;
_currentUser = currentUser;
}
public string ServiceName => nameof(JwtAuthServiceImpl);
public Task<bool> PermissionAsync(string token, string path)
{
return Task.FromResult(JwtUtils.CheckToken(token, _globalSettings.Jwt.Secret));
}
public Task SetUserAsync(string token)
{
var payload = JwtUtils.GetPayload(token);
_currentUser.UserId = payload.sub;
return Task.CompletedTask;
}
}
//Oauth2实现
public class OauthAuthServiceImpl : IAuthService
{
private readonly AuthDbContext _passportDbContext;
private readonly GlobalSettings _globalSettings;
private readonly LoginUser _currentUser;
public OauthAuthServiceImpl(AuthDbContext passportDbContext, IOptions<GlobalSettings> options, LoginUser currentUser)
{
_passportDbContext = passportDbContext;
_globalSettings = options.Value;
_currentUser = currentUser;
}
public string ServiceName => nameof(OauthAuthServiceImpl);
/// <summary>
/// 测试TOKEN
/// </summary>
public static string TEST_TOKEN = "73c8aa709d744848b3f4b697b48905ca";
/// <summary>
/// 测试用户
/// </summary>
public static string TEST_ADMIN = "a437a67c2de345a9a5c56ade745c0ecd";
public async Task<bool> PermissionAsync(string token, string path)
{
if (token == null) throw new ArgumentNullException(nameof(token));
if (path == null) throw new ArgumentNullException(nameof(path));
if (token == TEST_TOKEN)
{
await SetUserAsync(token);
return true;
}
//Token过期
var tokenEntity = await _passportDbContext.SysUserTokens.Where(s => s.Token == token).FirstOrDefaultAsync();
if (tokenEntity == null) return false;
if (tokenEntity.ExpireTime != null && tokenEntity.ExpireTime <= DateTime.Now) return false;
return true;
}
public async Task SetUserAsync(string token)
{
if (string.IsNullOrEmpty(_currentUser.UserId))
{
//TODO: 固定一个测试用户Token
if (token == TEST_TOKEN)
{
var user = new LoginUser
{
UserId = Guid.NewGuid().ToString("N"),
Mobile = "13012345678",
UserName = "Roy",
TenantId = DateTime.Now.ToString()
};
user.CopyTo(_currentUser);
}
else
{
var user = await (from t in _passportDbContext.SysUserTokens.AsNoTracking()
join u in _passportDbContext.Users.AsNoTracking()
on t.UserId equals u.UserId
where t.Token == token
select new LoginUser
{
UserId = u.UserId,
Mobile = u.Mobile,
UserName = u.Username,
TenantId = u.TenantId
}).FirstOrDefaultAsync();
user?.CopyTo(_currentUser);
}
}
}
}
中间件层,用于服务注册在管道模型中
public sealed class LoginMiddlerware
{
private readonly RequestDelegate _next;
public LoginMiddlerware(RequestDelegate next)
{
_next = next;
}
/// <summary>
/// 设置登录用户
/// </summary>
/// <param name="context"></param>
/// <param name="_authService"></param>
/// <returns></returns>
public async Task InvokeAsync(HttpContext context, IEnumerable<IAuthService> _authService)
{
string token = GetToken();
if (!string.IsNullOrEmpty(token))
{
if (token.StartsWith("Bearer") || token.Contains('.'))
{
await _authService.First(a => a.ServiceName == nameof(JwtAuthServiceImpl)).SetUserAsync(token);
}
else
{
await _authService.First(a => a.ServiceName == nameof(OauthAuthServiceImpl)).SetUserAsync(token);
}
}
await _next.Invoke(context);
string GetToken()
{
string token = context.Request.Headers["token"];
if (string.IsNullOrEmpty(token))
{
token = context.Request.Query["token"];
}
return token;
}
}
}
//过滤器依赖注入(AOP)
public class ClaimRequirementAttribute
{
/// <summary>
/// 自定义授权验证特性
/// </summary>
public class RequiresPermissionsAttribute : TypeFilterAttribute
{
public RequiresPermissionsAttribute(ClaimType claimType, string claimValue = "") : base(typeof(ClaimRequirementFilter))
{
Arguments = new object[] { new Claim(claimType.ToString(), claimValue) };
}
}
/// <summary>
/// 自定义授权验证过滤器
/// </summary>
public class ClaimRequirementFilter : IAuthorizationFilter
{
//授权声明
readonly Claim _claim;
//授权接口
readonly IEnumerable<IAuthService> _authService;
//全局配置类
private readonly GlobalSettings _globalSettings;
//构造函数注入
public ClaimRequirementFilter(Claim claim, IEnumerable<IAuthService> authService, IOptions<GlobalSettings> _options)
{
_claim = claim;
_authService = authService;
_globalSettings = _options.Value;
}
public void OnAuthorization(AuthorizationFilterContext context)
{
//获取控制器描述符
ControllerActionDescriptor controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
if (controllerActionDescriptor != null)
{
var skipAuthorization = controllerActionDescriptor.MethodInfo.GetCustomAttributes(inherit: true)
.Any(a => a.GetType().Equals(typeof(AllowAnonymousAttribute)));
//如果存在描述符则跳过验证 比如【AllowAnonymous】
if (skipAuthorization)
{
return;
}
}
//检查白名单
if (_globalSettings.WhiteList != null && _globalSettings.WhiteList.Any(path => path == context.HttpContext.Request.Path.Value))
{
return;
}
//声明类型
ClaimType claimType = Enum.Parse<ClaimType>(_claim.Type);
bool permission = false;
//获取token
string token = GetToken();
if (string.IsNullOrEmpty(token))
{
//返回401
context.Result = new UnauthorizedResult();
return;
}
if (claimType == ClaimType.Multiple)
{
//根据Token类型选择认证方式
if (token.Any(t => t == '.'))
{
claimType = ClaimType.JWT;
}
else
{
claimType = ClaimType.Oauth2;
}
}
switch (claimType)
{
case ClaimType.Oauth2:
permission = _authService.First(a => a.ServiceName == nameof(OauthAuthServiceImpl)).PermissionAsync(token, _claim.Value).Result;
break;
case ClaimType.JWT:
permission = _authService.First(a => a.ServiceName == nameof(JwtAuthServiceImpl)).PermissionAsync(token, _claim.Value).Result;
break;
default:
throw new Exception($"没有指定的授权方式:{claimType}");
}
if (!permission)
{
//返回401
context.Result = new UnauthorizedResult();
return;
}
string GetToken()
{
string token = context.HttpContext.Request.Headers["token"];
if (string.IsNullOrEmpty(token))
{
token = context.HttpContext.Request.Query["token"];
}
if (string.IsNullOrEmpty(token))
{
context.HttpContext.Request.Cookies.TryGetValue("token", out token);
}
return token;
}
}
}
}
public enum ClaimType
{
Oauth2,
JWT,
Multiple
}
附上JWT帮助类
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace MyTest.Utils
{
public static class JwtUtils
{
/// <summary>
/// 生成token
/// </summary>
/// <param name="claims"></param>
/// <returns></returns>
public static string CreateToken(IEnumerable<Claim> claims, string securityKey)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512);
var securityToken = new JwtSecurityToken(
issuer: null,
audience: null,
claims: claims,
//expires: DateTime.Now.AddMinutes(settings.ExpMinutes),
signingCredentials: creds);
var token = new JwtSecurityTokenHandler().WriteToken(securityToken);
return token;
}
/// <summary>
/// 生成Jwt
/// </summary>
/// <param name="userName"></param>
/// <param name="roleName"></param>
/// <param name="userId"></param>
/// <returns></returns>
public static string GenerateToken(string userId, string securityKey)
{
//声明claim
var claims = new Claim[] {
new Claim(JwtRegisteredClaimNames.Typ,"JWT"),
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Iat,DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddDays(2).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), //过期时间
new Claim("username","roy")
};
return CreateToken(claims, securityKey);
}
/// <summary>
/// 从token中获取用户身份
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public static IEnumerable<Claim> GetClaims(string token)
{
var handler = new JwtSecurityTokenHandler();
var securityToken = handler.ReadJwtToken(token);
return securityToken?.Claims;
}
/// <summary>
/// 从Token中获取用户身份
/// </summary>
/// <param name="token"></param>
/// <param name="securityKey">securityKey明文,Java加密使用的是Base64</param>
/// <returns></returns>
public static ClaimsPrincipal GetPrincipal(string token, string securityKey)
{
try
{
var handler = new JwtSecurityTokenHandler();
TokenValidationParameters tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey)),
ValidateLifetime = false
};
return handler.ValidateToken(token, tokenValidationParameters, out SecurityToken validatedToken);
}
catch (Exception ex)
{
return null;
}
}
/// <summary>
/// 校验Token
/// </summary>
/// <param name="token">token</param>
/// <returns></returns>
public static bool CheckToken(string token, string securityKey)
{
var principal = GetPrincipal(token, securityKey);
if (principal is null)
{
return false;
}
return true;
}
/// <summary>
/// 获取Token中的载荷数据
/// </summary>
/// <param name="token">token</param>
/// <returns></returns>
public static JwtPayload GetPayload(string token)
{
var jwtHandler = new JwtSecurityTokenHandler();
JwtSecurityToken securityToken = jwtHandler.ReadJwtToken(token);
return new JwtPayload
{
sub = securityToken.Payload[JwtRegisteredClaimNames.Sub]?.ToString(),
exp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(securityToken.Payload[JwtRegisteredClaimNames.Exp].ToString())).ToLocalTime().DateTime,
iat = securityToken.Payload[JwtRegisteredClaimNames.Iat]?.ToString()
};
}
/// <summary>
/// 获取Token中的载荷数据
/// </summary>
/// <typeparam name="T">泛型</typeparam>
/// <param name="token">token</param>
/// <returns></returns>
public static T GetPayload<T>(string token)
{
var jwtHandler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = jwtHandler.ReadJwtToken(token);
return JsonConvert.DeserializeObject<T>(jwtToken.Payload.SerializeToJson());
}
}
/// <summary>
/// Jwt载荷信息
/// </summary>
public class JwtPayload
{
public string sub { get; set; }
public string iat { get; set; }
public DateTime exp { get; set; }
}
}
噢对了,还有静态扩展方法,通过泛型参数和反射用来实现cope两个对象的属性值
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Web;
namespace MyTest.Extension
{
public static class ModelExtension
{
/// <summary>
/// 反射实现两个类的对象之间相同属性的值的复制
/// </summary>
/// <typeparam name="Tag">返回的实体</typeparam>
/// <typeparam name="Source">数据源实体</typeparam>
/// <param name="tag">目标实体</param>
/// <param name="sourct">数据源实体</param>
/// <param name="ignoreId">忽略ID字段</param>
/// <param name="ignoreEmpty">忽略空字段</param>
/// <returns></returns>
public static void CopyTo<Source, Tag>(this Source sourct, Tag tag, bool ignoreId = true, bool ignoreEmpty = false)
{
var Types = sourct.GetType();
var TypeTag = typeof(Tag);
foreach (PropertyInfo source in Types.GetProperties())
{
foreach (PropertyInfo tagProp in TypeTag.GetProperties())
{
if (!(ignoreId && tagProp.Name.Equals("id", StringComparison.OrdinalIgnoreCase)) && tagProp.Name.Equals(source.Name, StringComparison.OrdinalIgnoreCase) && tagProp.PropertyType == source.PropertyType)
{
if (tagProp.CanWrite && source.GetValue(sourct) != null)
{
tagProp.SetValue(tag, source.GetValue(sourct, null), null);
}
}
}
}
}
}
}
OK核心代码上完,接下来就是重头戏了,我们都知道Startup启动类是整个项目的灵魂,里面包含了管道处理模型,中间件,以及各种服务的注入,于是乎对两个主要配置函数Configure和ConfigureServices进行扩展
Configure扩展
public static class GoAppExtensions
{
public static IApplicationBuilder UseGo(this IApplicationBuilder app, IConfiguration configuration)
{
//允许body重用
app.Use(next => context =>
{
context.Request.EnableBuffering();
return next(context);
});
//设置基础路由ContextPath
var config = configuration.GetValue<string>("GlobalSettings:ContextPath");
if (config != null)
{
app.UsePathBase(new Microsoft.AspNetCore.Http.PathString($"/{config}"));
}
//路由匹配中间件,找到匹配的终结者路由Endpoint
app.UseRouting();
//启用 跨域 中间件
app.UseCors();
//登录中间件
app.UseMiddleware(typeof(LoginMiddlerware));
//针对 UseRouting 中间件中匹配到的路由进行拦截 做授权验证操作
app.UseAuthorization();
//终结者路由,针对 UseRouting 中间件匹配到的路由进行 委托方法的执行等操作
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
return app;
}
}
ConfigureServices扩展
public static class GoServicesExtensions
{
public static IServiceCollection AddGoServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<KestrelServerOptions>(x => x.AllowSynchronousIO = true);
//添加对控制器以及与 API 相关的功能
services.AddControllers();
//跨域
services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
builder
.SetIsOriginAllowed(t => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
services.AddDbContext<AuthDbContext>(optionsBuilder =>
{
//数据库连接字符串,取配置文件,可自行更改
optionsBuilder.UseSqlServer(configuration.GetConnectionString("passport"));
});
//当前登录用户实体
services.AddScoped<LoginUser>();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
//注入配置类
services.Configure<GlobalSettings>(configuration.GetSection("GlobalSettings"));
//鉴权服务
services.AddScoped<IAuthService, OauthAuthServiceImpl>();
services.AddScoped<IAuthService, JwtAuthServiceImpl>();
//ServiceLocator.SetLocatorProvider(services.BuildServiceProvider());
return services;
}
}
Startup.cs
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddGoServices(Configuration);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.Use(next => context =>
{
context.Request.EnableBuffering();
return next(context);
});
app.UseGo(Configuration);
}
}
大功告成,整个框架就搭建完毕了,要想做单点登录的话可以在登录成功后把生成的token存储在数据库,设置一个有效期,然后把token返回到客户端存储,每次请求接口在请求头部带上token即可。
服务端如果有接口需要登录才能允许访问的,可以直接在Controller或者Action上面加上特性 [RequiresPermissions(ClaimType.Oauth2)]
不需要身份效验的Action上加上 [AllowAnonymous] (注意命名空间:using Microsoft.AspNetCore.Authorization)就OK了
最后附上项目源码Git地址