spring cloud+.net core搭建微服务架构:Api授权认证(六)
前言
這篇文章拖太久了,因為最近實在太忙了,加上這篇文章也非常長,所以花了不少時間,給大家說句抱歉。好,進入正題。目前的項目基本都是前后端分離了,前端分Web,Ios,Android。。。,后端也基本是Java,.NET的天下,后端渲染頁面的時代已經一去不復返,當然這是時代的進步。前端調用后端服務目前大多數基于JSON的HTTP服務,那么就引入的我們今天的內容。客戶端訪問服務的時候怎么保證安全呢?很多同學都聽說過OAuth2.0,都知道這個是用來做第三方登錄的,實際上它也可以用來做Api的認證授權。不懂OAuth的同學可以先去看看阮一峰的OAuth的講解,如果你看不懂的話,那就對了,筆者當初也看了很久,結合實際項目才明白。這章我會結合具體的例子幫助大家理解。同時也也會結合前幾章的內容做一個整合,讓大家對微服務架構以及API授權有一個更清晰的認識。
業務場景
Api的認證授權,在微服務體系里面它也是一個服務,我們叫做認證授權中心。同時我們再提供一個用戶中心和訂單中心,構建我們的業務場景。我們模擬一個用戶(客戶端)是怎么一步一步獲取我們的訂單數據的,同時也結合前幾張的內容搭建一個相對完整的微服務架構的demo。
程序清單列表
-
服務中心
-
API網關
-
認證授權中心
-
用戶中心
-
訂單中心
用戶中心和認證授權中心有耦合的情況,訪問認證授權的時候要去驗證用戶的賬號密碼是否合法
下圖是一個簡單的架構草圖
服務中心和API網關大家看之前的文章來搭建,也可以直接看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這種方式,簡單,高效。下面的代碼我會模擬這兩種模式,至于具體選擇哪種方式大家根據實際的業務需求來。
小插曲:和幾位技術大牛經過激烈的討論,大家一致認為服務與服務之間的通信也是需要認證的,這樣雖然增加了一定的性能損耗但是卻更加的安全。我覺得有句話說的非常好,原則上內部其它系統都是不可信的。所以微服務之間的訪問也得認證。
Reference方式的token,Ids4默認采用的內存做存儲,也提供了EF for MS SQL 做分布式存儲,而我們這里并不采用這種方式,我們采用redis來作為token的存儲。
添加nuget引用
<PackageReference Include="Foundatio.Redis" Version="5.1.1478" /> <PackageReference Include="IdentityServer4" Version="2.0.2" /> <PackageReference Include="Pivotal.Discovery.Client" Version="1.1.0" />Config.cs
配置Client信息,我們創建2個Client,一個采用JWT,一個采用Reference方式
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 },RedisPersistedGrantStore.cs
實現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;} }
ResourceOwnerPasswordValidator.cs
實現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的方式,建議還是拆分。
Startup.cs
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>();//注入redisservices.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地址,否則這里就負載不均衡了。
appsettings.json
"ConnectionStrings": { ? ? ??"RedisConnectionString": "localhost", ? ? ? ?"AccessTokenLifetime": 3600 //token過期時間 單位秒}, ? ?"spring": { ? ? ? ?"application": { ? ? ? ? ? ?"name": "identity"}}, ? ?"eureka": { ? ? ? ?"client": { ? ? ? ? ? ?"serviceUrl": "http://localhost:5000/eureka/"}, ? ? ? ?"instance": { ? ? ? ? ?"port": 8010}}
用戶中心
用戶中心主要實現2個接口,一個給授權中心驗證用戶使用,還有一個是給客戶端登錄的時候返回token使用
nuget引用
<PackageReference Include="IdentityModel" Version="2.14.0" /> <PackageReference Include="Pivotal.Discovery.Client" Version="1.1.0" />appsettings.json
{ ?"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"} } ?
ValuesController.cs
[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為nullvar 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的內容
Account.cs
提供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"}};}}
訂單中心
nuget引用
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="2.1.0" /> <PackageReference Include="Pivotal.Discovery.Client" Version="1.1.0" />Startup.cs
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沒提供這個參數,那么我就需要重寫一套驗證邏輯了。
ValuesController.cs
添加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" });} }
演示部分
JWT
分別運行這個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
切換一下配置文件,來支持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,交流微服務。
相關文章:
-
手把手教你使用spring cloud+dotnet core搭建微服務架構:服務治理(-)
-
spring cloud+dotnet core搭建微服務架構:服務發現(二)
-
spring cloud+dotnet core搭建微服務架構:Api網關(三)
-
微服務~Eureka實現的服務注冊與發現及服務之間的調用
-
spring cloud+dotnet core搭建微服務架構:配置中心(四)
原文地址:http://www.cnblogs.com/longxianghui/p/7800316.html
.NET社區新聞,深度好文,歡迎訪問公眾號文章匯總 http://www.csharpkit.com
總結
以上是生活随笔為你收集整理的spring cloud+.net core搭建微服务架构:Api授权认证(六)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 使用Identity Server 4建
- 下一篇: 活动:北京Xamarin分享会第8期(2