这篇文章拖过久了,由于最近实在太忙了,加上这篇文章也很是长,因此花了很多时间,给你们说句抱歉。好,进入正题。目前的项目基本都是先后端分离了,前端分Web,Ios,Android。。。,后端也基本是Java,.NET的天下,后端渲染页面的时代已经一去不复返,固然这是时代的进步。前端调用后端服务目前大多数基于JSON的HTTP服务,那么就引入的咱们今天的内容。客户端访问服务的时候怎么保证安全呢?不少同窗都据说过OAuth2.0,都知道这个是用来作第三方登陆的,实际上它也能够用来作Api的认证受权。不懂OAuth的同窗能够先去看看阮一峰的OAuth的讲解,若是你看不懂的话,那就对了,笔者当初也看了好久,结合实际项目才明白。这章我会结合具体的例子帮助你们理解。同时也也会结合前几章的内容作一个整合,让你们对微服务架构以及API受权有一个更清晰的认识。html
Api的认证受权,在微服务体系里面它也是一个服务,咱们叫作认证受权中心。同时咱们再提供一个用户中心和订单中心,构建咱们的业务场景。咱们模拟一个用户(客户端)是怎么一步一步获取咱们的订单数据的,同时也结合前几张的内容搭建一个相对完整的微服务架构的demo。前端
订单中心java
用户中心和认证受权中心有耦合的状况,访问认证受权的时候要去验证用户的帐号密码是否合法git
下图是一个简单的架构草图
服务中心和API网关你们看以前的文章来搭建,也能够直接看github上的源代码,没有什么变化。github
一直在说Ids4(IdentityServer4)这个框架,它其实是一个实现了OAuth+OIDC(OpenId Connect)这两个功能的解决方案。那么OAuth和OIDC又究竟是什么东西呢?简单来讲OAuth就是帮助咱们作受权获取token的,而OIDC就是帮助咱们作认证这个token合法性的。一个完整的受权认证系统应该包含这两个功能。那么咱们再谈一谈token,Ids4提供2种彻底不同的token加密方式,一种是JWT另外一种叫Reference。那么这两种加密方式有何不一样呢?JWT就是对这个字符串的一个加密算法,这个字符串包含了用户信息,客户端能够直接解析token,拿到用户信息,不须要和认证服受权务器去交互(程序首次加载的时候交互一次)。Reference更像Session,须要和认证服务器交互,由认证受权服务器去验证是否合法,每一次访问都须要和认证服务器进行交互,而且用户信息也是经过认证成功之后返回的。这两种方式各有优缺点。
JWT是一种加密方式,那么认证服务器不须要对token进行存储,而客户端也不须要找服务端验证,那么对于程序的性能是有很大的提高的,也不用考虑分布式和存储的问题,可是对于生成的token没办法控制,只能经过时效性来过时。
Reference的方式,token须要考虑分布式的存储,并且客户端须要一直和服务端认证,有必定的性能损耗,可是服务端能够对token进行控制,好比登出用户,修改密码均可以做废掉已经生成的token,这个时候再拿这个token是没办法使用的。然而不论是APP仍是WEB让用户主动登出操做这是一个很是伪的需求,实际上即便是Reference方式token依然靠时效性来控制。
那么问题来了,当你的上级不懂技术的时候,问你万一个人token泄露了怎么办?你能够这样回答他。若是是在传输过程当中的泄露,那么咱们能够经过HTTPS的方式加密。程序代码里面用户相关的操做,都应该对传递的UserId参数和token里面解析出来UserId进行比较,若是出现不一致,那么这必定是一个非法请求。例如张三拿着李四的token去修改密码,确定是修改不成功的。若是是在用户的客户端(WEB,APP)就把token泄露了,那么这个实际上这个客户端已经不止token泄露这么简单了,包括他全部的用户信息都泄露了,这个时候token已经没有了意义。就比如腾讯QQ加密算法作的如何如何牛逼,可是你泄露了你的QQ号和密码...
咱们能够在过时时间上尽可能短一点,客户端经过刷新token的方式不断获取新的token,而达到用户不用重复的登陆,就能一直访问API接口。
至于两种方式的安全性我以为都同样,微服务中我更倾向JWT这种方式,简单,高效。下面的代码我会模拟这两种模式,至于具体选择哪一种方式你们根据实际的业务需求来。redis
小插曲:和几位技术大牛通过激烈的讨论,你们一致认为服务与服务之间的通讯也是须要认证的,这样虽然增长了必定的性能损耗可是却更加的安全。我以为有句话说的很是好,
原则上内部其它系统都是不可信的。
因此微服务之间的访问也得认证。算法
Reference方式的token,Ids4默认采用的内存作存储,也提供了EF for MS SQL 作分布式存储,而咱们这里并不采用这种方式,咱们采用redis来做为token的存储。spring
<PackageReference Include="Foundatio.Redis" Version="5.1.1478" /> <PackageReference Include="IdentityServer4" Version="2.0.2" /> <PackageReference Include="Pivotal.Discovery.Client" Version="1.1.0" />
配置Client信息,咱们建立2个Client,一个采用JWT,一个采用Reference方式json
new Client { ClientId = "client.jwt", ClientSecrets = { new Secret("AB2DC090-0125-4FB8-902A-34AFB64B7D9B".Sha256()) }, AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, AllowOfflineAccess = true, AccessTokenLifetime = accessTokenLifetime, AllowedScopes = { "api1" }, AccessTokenType =AccessTokenType.Jwt } new Client { ClientId = "client.reference", ClientSecrets = { new Secret("A30E6E57-086C-43BE-AF79-67ADECDA0A5B".Sha256()) }, AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, AllowOfflineAccess = true, AccessTokenLifetime = accessTokenLifetime, AllowedScopes = { "api1" }, AccessTokenType =AccessTokenType.Reference },
实现IPersistedGrantStore接口来支持redis后端
public class RedisPersistedGrantStore : IPersistedGrantStore { private readonly ICacheClient _cacheClient; private readonly IConfiguration _configuration; public RedisPersistedGrantStore(ICacheClient cacheClient, IConfiguration configuration) { _cacheClient = cacheClient; _configuration = configuration; } public Task StoreAsync(PersistedGrant grant) { var accessTokenLifetime = double.Parse(_configuration.GetConnectionString("accessTokenLifetime")); var timeSpan = TimeSpan.FromSeconds(accessTokenLifetime); _cacheClient?.SetAsync(grant.Key, grant, timeSpan); return Task.CompletedTask; } public Task<PersistedGrant> GetAsync(string key) { if (_cacheClient.ExistsAsync(key).Result) { var ss = _cacheClient.GetAsync<PersistedGrant>(key).Result; return Task.FromResult<PersistedGrant>(_cacheClient.GetAsync<PersistedGrant>(key).Result.Value); } return Task.FromResult<PersistedGrant>((PersistedGrant)null); } public Task<IEnumerable<PersistedGrant>> GetAllAsync(string subjectId) { var persistedGrants = _cacheClient.GetAllAsync<PersistedGrant>().Result.Values; return Task.FromResult<IEnumerable<PersistedGrant>>(persistedGrants .Where(x => x.Value.SubjectId == subjectId).Select(x => x.Value)); } public Task RemoveAsync(string key) { _cacheClient?.RemoveAsync(key); return Task.CompletedTask; } public Task RemoveAllAsync(string subjectId, string clientId) { _cacheClient.RemoveAllAsync(); return Task.CompletedTask; } public Task RemoveAllAsync(string subjectId, string clientId, string type) { var persistedGrants = _cacheClient.GetAllAsync<PersistedGrant>().Result.Values .Where(x => x.Value.SubjectId == subjectId && x.Value.ClientId == clientId && x.Value.Type == type).Select(x => x.Value); foreach (var item in persistedGrants) { _cacheClient?.RemoveAsync(item.Key); } return Task.CompletedTask; } }
实现IResourceOwnerPasswordValidator接口实现自定义的用户验证逻辑
public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator { private readonly DiscoveryHttpClientHandler _handler; private const string UserApplicationName = "user"; public ResourceOwnerPasswordValidator(IDiscoveryClient client) { _handler = new DiscoveryHttpClientHandler(client); } public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { //调用用户中心的验证用户名密码接口 var client = new HttpClient(_handler); var url = $"http://{UserApplicationName}/search?name={context.UserName}&password={context.Password}"; var result = await client.GetAsync(url); if (result.IsSuccessStatusCode) { var user = await result.Content.ReadAsObjectAsync<dynamic>(); var claims = new List<Claim>() { new Claim("role", user.role.ToString()) }; context.Result = new GrantValidationResult(user.id.ToString(), OidcConstants.AuthenticationMethods.Password, claims); } else { context.Result = new GrantValidationResult(null); } } }
var claims = new List<Claim>() { new Claim("key", "value") }; 这里能够传递自定义的用户信息,在客户端经过User.Claims.FirstOrDefault(x => x.Type == "key")来获取
这里须要注意一下,由于这里走的是http因此,受权服务中心和用户中心存在耦合,我我的建议若是走JWT的方式,用户中心和认证受权中心能够合并成一个服务,若是采用Reference的方式,建议仍是拆分。
public void ConfigureServices(IServiceCollection services) { services.AddDiscoveryClient(Configuration); var redisconnectionString = Configuration.GetConnectionString("RedisConnectionString"); var config = new Config(Configuration); services.AddMvc(); services.AddIdentityServer({ x.IssuerUri = "http://identity"; x.PublicOrigin = "http://identity"; }) .AddDeveloperSigningCredential() .AddInMemoryPersistedGrants() .AddInMemoryApiResources(config.GetApiResources()) .AddInMemoryClients(config.GetClients()); services.AddSingleton(ConnectionMultiplexer.Connect(redisconnectionString)); services.AddTransient<ICacheClient, RedisCacheClient>();//注入redis services.AddSingleton<IPersistedGrantStore, RedisPersistedGrantStore>(); services.AddTransient<IResourceOwnerPasswordValidator, ResourceOwnerPasswordValidator>(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseMvc(); app.UseDiscoveryClient(); app.UseIdentityServer(); }
由于是采用服务发现的方式,因此咱们这里要修改IssuerUri和PublicOrigin。不要让发现服务暴露本身的具体URL地址,不然这里就负载不均衡了。
"ConnectionStrings": { "RedisConnectionString": "localhost", "AccessTokenLifetime": 3600 //token过时时间 单位秒 }, "spring": { "application": { "name": "identity" } }, "eureka": { "client": { "serviceUrl": "http://localhost:5000/eureka/" }, "instance": { "port": 8010 } }
用户中心主要实现2个接口,一个给受权中心验证用户使用,还有一个是给客户端登陆的时候返回token使用
<PackageReference Include="IdentityModel" Version="2.14.0" /> <PackageReference Include="Pivotal.Discovery.Client" Version="1.1.0" />
{ "spring": { "application": { "name": "user" } }, "eureka": { "client": { "serviceUrl": "http://localhost:5000/eureka/" }, "instance": { "port": 8040, "hostName": "localhost" } }, "IdentityServer": { //jwt "ClientId": "client.jwt", "ClientSecrets": "AB2DC090-0125-4FB8-902A-34AFB64B7D9B" //reference //"ClientId": "client.reference", //"ClientSecrets": "A30E6E57-086C-43BE-AF79-67ADECDA0A5B" } }
[Route("/")] public class ValuesController : Controller { private const string IdentityApplicationName = "identity"; private readonly DiscoveryHttpClientHandler _handler; private readonly IConfiguration _configuration; public ValuesController(IDiscoveryClient client, IConfiguration configuration) { _configuration = configuration; _handler = new DiscoveryHttpClientHandler(client); } [HttpGet("search")] public IActionResult Get(string name, string password) { var account = Account.GetAll().FirstOrDefault(x => x.Name == name && x.Password == password); if (account != null) { return Ok(account); } else { return NotFound(); } } [HttpPost("Login")] public async Task<IActionResult> Login([FromBody] LoginRequest input) { var discoveryClient = new DiscoveryClient($"http://{IdentityApplicationName}", _handler) { Policy = new DiscoveryPolicy { RequireHttps = false } }; var disco = await discoveryClient.GetAsync(); if (disco.IsError) throw new Exception(disco.Error); var clientId = _configuration.GetSection("IdentityServer:ClientId").Value; if (string.IsNullOrEmpty(clientId)) throw new Exception("clientId is not value."); var clientSecrets = _configuration.GetSection("IdentityServer:ClientSecrets").Value; if (string.IsNullOrEmpty(clientSecrets)) throw new Exception("clientSecrets is not value."); var tokenClient = new TokenClient(disco.TokenEndpoint, clientId, clientSecrets, _handler); var response = await tokenClient.RequestResourceOwnerPasswordAsync(input.Name, input.Password, "api1 offline_access");//若是须要刷新token那么这里要多传递一个offline_access参数,不传的话RefreshToken为null var response = await tokenClient.RequestResourceOwnerPasswordAsync(input.Name, input.Password, "api1"); if (response.IsError) throw new Exception(response.Error); return Ok(new LoginResponse() { AccessToken = response.AccessToken, ExpireIn = response.ExpiresIn, RefreshToken = response.RefreshToken }); } }
这里offline_access这个参数很重要,若是你须要刷新token必须传这个参数,传递了这个参数之后redis服务器会记录,经过refreshToken来获取一个新的accessToken,这里就不作演示了,Ids4的东西太多了,更细节的东西你们去关注Ids4的内容
提供2个用户,各有不一样的角色
public class Account { public string Name { get; set; } public string Password { get; set; } public int Id { get; set; } public string Role { get; set; } public static List<Account> GetAll() { return new List<Account>() { new Account() { Id = 87654, Name = "leo", Password = "123456", Role = "admin" }, new Account() { Id = 45678, Name = "mickey", Password = "123456", Role = "normal" } }; } }
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="2.1.0" /> <PackageReference Include="Pivotal.Discovery.Client" Version="1.1.0" />
public void ConfigureServices(IServiceCollection services) { services.AddDiscoveryClient(Configuration); var discoveryClient = services.BuildServiceProvider().GetService<IDiscoveryClient>(); var handler = new DiscoveryHttpClientHandler(discoveryClient); services.AddAuthorization(); services.AddAuthentication(x => { x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddIdentityServerAuthentication(x => { x.ApiName = "api1"; x.ApiSecret = "secret"; x.Authority = "http://identity"; x.RequireHttpsMetadata = false; x.JwtBackChannelHandler = handler; x.IntrospectionDiscoveryHandler = handler; x.IntrospectionBackChannelHandler = handler; }); services.AddMvc(); }
这里须要注意的一点是handler,Ids4居然在参数里面有handler的参数,这样咱们接入微服务里面的服务发现简直太easy了。同时这里也给你们一个启发,咱们再作第三方接口的时候,必定要参数齐全,哪怕这个参数并不会被大多数状况下使用,若是Ids4没提供这个参数,那么我就须要重写一套验证逻辑了。
添加4个接口,针对不一样的角色用户
[Route("/")] public class ValuesController : Controller { // admin role [HttpGet("admin")] [Authorize(Roles = "admin")] public IActionResult Get1() { var userId = User.Claims.FirstOrDefault(x => x.Type == "sub")?.Value; var role = User.Claims.FirstOrDefault(x => x.Type == "role")?.Value; return Ok(new { userId, role }); } // normal role [HttpGet("normal")] [Authorize(Roles = "normal")] public IActionResult Get2() { var userId = User.Claims.FirstOrDefault(x => x.Type == "sub")?.Value; return Ok(new { role = "normal", userId = userId }); } // any role [HttpGet("any")] [Authorize] public IActionResult Get3() { var userId = User.Claims.FirstOrDefault(x => x.Type == "sub")?.Value; return Ok(new { role = "any", userId = userId }); } // Anonymous [HttpGet] [AllowAnonymous] public IActionResult Get() { return Ok(new { role = "allowAnonymous" }); } }
分别运行这个5个应用程序,访问http://localhost:5000
如图表示,所有运行成功。
经过postman模拟用户登陆,经过api网关地址访问。
url:http://localhost:5555/user/login
method:post
requestBody:
{
"name":"leo",
"password":"123456"
}
拿到token后,咱们再访问订单中心的地址。
url:http://locahost:5555/order/admin
mothod:get
header: Authorization:bearer token(bearer和token中间有一个空格)
成功返回userId和role信息
咱们随意修改一下token的字符串再访问,会返回401,认证不会经过。
这里须要注意的是zuul默认不支持header的传递,须要在网关服务里面增长一个配置
zuul.sensitive-headers=true
这个时候咱们修改url地址http://locahost:5555/order/normal
返回了403表示这个接口没有权限
再修改地址访问http://locahost:5555/order/any
这个接口只要受权用户均可以访问。
最后这个接口http://locahost:5555/order就比较容易理解是一个匿名用户均可以访问的接口不用作身份验证,咱们去掉header信息
咱们能够再试试另外一个用户mickey/123456试试,篇幅有限,这里就再也不作描述了,mickey这个用户拥有http://locahost:5555/order/normal这个接口的访问权限。
切换一下配置文件,来支持reference,修改User项目的appsettings.json文件
"IdentityServer": { //"ClientId": "client.jwt", //"ClientSecrets": "AB2DC090-0125-4FB8-902A-34AFB64B7D9B", "ClientId": "client.reference", "ClientSecrets": "A30E6E57-086C-43BE-AF79-67ADECDA0A5B" }
从新运行程序
经过postman模拟用户登陆,经过api网关地址访问。
url:http://localhost:5555/user/login
method:post
requestBody:
{
"name":"leo",
"password":"123456"
}
咱们能够看到accessToken和JWT的彻底不同,很短的一个字符串,这个时候咱们打开redis客户端能够找个这个信息
用户信息是保存在了redis里面。这里的key是经过加密的方式生成的。
拿到token后,咱们再访问订单中心的地址。
url:http://locahost:5555/order/admin
mothod:get
header: Authorization:bearer token
验证成功,后面的几个接口和上面同样,同窗们本身来演示。
经过上面的例子,咱们把整个受权认证流程都走了一遍(JWT和Reference),经过Postman来模拟客户端的请求,Ids4的东西实在是太多,我没办法在这里写的太全,你们能够参考一下园子里面关于Ids4的文章。这篇文章例子比较多,强烈建议你们先下载代码,跟着博客的流程走一次,而后本身再按照步骤写一遍,这样才能加深理解。顺便给本身打个广告,笔者目前正在考虑新的工做机会,若是贵公司须要使用.NET core来搭建微服务平台,我想我很是合适。个人邮箱240226543@qq.com。
关于受权认证部分你们能够看看园子里面雨夜朦胧的博客,他经过源代码分析写的很是透彻。
全部代码均上传github。代码按照章节的顺序上传,例如第一章demo1,第二章demo2以此类推。
求推荐,大家的支持是我写做最大的动力,个人QQ群:328438252,交流微服务。
java部分
.net部分