這篇文章拖過久了,由於最近實在太忙了,加上這篇文章也很是長,因此花了很多時間,給你們說句抱歉。好,進入正題。目前的項目基本都是先後端分離了,前端分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部分