ABP入门系列(16)——通过webapi与系统进行交互
1. 引言
上一節(jié)我們講解了如何創(chuàng)建微信公眾號(hào)模塊,這一節(jié)我們就繼續(xù)跟進(jìn),來講一講公眾號(hào)模塊如何與系統(tǒng)進(jìn)行交互。
微信公眾號(hào)模塊作為一個(gè)獨(dú)立的web模塊部署,要想與現(xiàn)有的【任務(wù)清單】進(jìn)行交互,我們要想明白以下幾個(gè)問題:
ABP模板項(xiàng)目中默認(rèn)創(chuàng)建了webapi項(xiàng)目,其動(dòng)態(tài)webapi技術(shù)允許我們直接訪問appservice作為webapi而不用在webapi層編寫額外的代碼。所以,自然而然我們要通過webapi與系統(tǒng)進(jìn)行交互。
我們知道暴露的webapi如果不加以授權(quán)控制,就如同在大街上裸奔。所以在訪問webapi時(shí),我們需要通過身份認(rèn)證來確保安全訪問。
第一種就是大家熟知的cookie認(rèn)證方式;
第二種就是token認(rèn)證方式:在訪問webapi之前,先要向目標(biāo)系統(tǒng)申請(qǐng)令牌(token),申請(qǐng)到令牌后,再使用令牌訪問webapi。Abp默認(rèn)提供了這種方式;
第三種是基于OAuth2.0的token認(rèn)證方式:OAuth2.0是什么玩意?建議先看看OAuth2.0 知多少以便我們后續(xù)內(nèi)容的展開。OAuth2.0認(rèn)證方式彌補(bǔ)了Abp自帶token認(rèn)證的短板,即無法進(jìn)行token刷新。
基于這一節(jié),我完善了一個(gè)demo,大家可以直接訪問http://shengjietest.azurewebsites.net/進(jìn)行體驗(yàn)。
demo
下面我們就以【通過webapi請(qǐng)求用戶列表】為例看一看三種認(rèn)證方式的具體實(shí)現(xiàn)。
2. Cookie認(rèn)證方式
Cookie認(rèn)證方式的原理就是:在訪問webapi之前,通過登錄目標(biāo)系統(tǒng)建立連接,將cookie寫入本地。下一次訪問webapi的時(shí)候攜帶cookie信息就可以完成認(rèn)證。
2.1. 登錄目標(biāo)系統(tǒng)
這一步簡(jiǎn)單,我們僅需提供用戶名密碼,Post一個(gè)登錄請(qǐng)求即可。
我們?cè)谖⑿拍K中創(chuàng)建一個(gè)WeixinController:
?
public class WeixinController : Controller {private readonly IAbpWebApiClient _abpWebApiClient;private string baseUrl = "http://shengjie.azurewebsites.net/";private string loginUrl = "/account/login";private string webapiUrl = "/api/services/app/User/GetUsers";private string abpTokenUrl = "/api/Account/Authenticate";private string oAuthTokenUrl = "/oauth/token";private string user = "admin";private string pwd = "123qwe";public WeixinController(){_abpWebApiClient = new AbpWebApiClient();} }其中IAbpWebApiClient是對(duì)HttpClient的封裝,用于發(fā)送 HTTP 請(qǐng)求和接收HTTP 響應(yīng)。
下面添加CookieBasedAuth方法,來完成登錄認(rèn)證,代碼如下:
?
public async Task CookieBasedAuth() {Uri uri = new Uri(baseUrl + loginUrl);var handler = new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.None, UseCookies = true };using (var client = new HttpClient(handler)){client.BaseAddress = uri;client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));var content = new FormUrlEncodedContent(new Dictionary<string, string>(){{"TenancyName", "Default"},{"UsernameOrEmailAddress", user},{"Password", pwd }});var result = await client.PostAsync(uri, content);string loginResult = await result.Content.ReadAsStringAsync();var getCookies = handler.CookieContainer.GetCookies(uri);foreach (Cookie cookie in getCookies){_abpWebApiClient.Cookies.Add(cookie);}} }這段代碼中有幾個(gè)點(diǎn)需要注意:
2.2. 攜帶cookie訪問webapi
服務(wù)器返回的cookie信息在登錄成功后已經(jīng)填充到_abpWebApiClient.Cookies中,我們只需post一個(gè)請(qǐng)求到目標(biāo)api即可。
?
public async Task<PartialViewResult> SendRequestBasedCookie() {await CookieBasedAuth();return await GetUserList(baseUrl + webapiUrl); }private async Task<PartialViewResult> GetUserList(string url) {try{var users = await _abpWebApiClient.PostAsync<ListResultDto<UserListDto>>(url);return PartialView("_UserListPartial", users.Items);}catch (Exception e){ViewBag.ErrorMessage = e.Message;}return null; }3. Token認(rèn)證方式
Abp默認(rèn)提供的token認(rèn)證方式,很簡(jiǎn)單,我們僅需要post一個(gè)請(qǐng)求到/api/Account/Authenticate即可請(qǐng)求到token。然后使用token即可請(qǐng)求目標(biāo)webapi。
但這其中有一個(gè)問題就是,如果token過期,就必須使用用戶名密碼重寫申請(qǐng)token,體驗(yàn)不好。
3.1. 請(qǐng)求token
?
public async Task<string> GetAbpToken() {var tokenResult = await _abpWebApiClient.PostAsync<string>(baseUrl + abpTokenUrl, new{TenancyName = "Default",UsernameOrEmailAddress = user,Password = pwd});this.Response.SetCookie(new HttpCookie("access_token", tokenResult));return tokenResult; }這段代碼中我們將請(qǐng)求到token直接寫入到cookie中。以便我們下次直接從cookie中取回token直接訪問webapi。
3.2. 使用token訪問webapi
從cookie中取回token,在請(qǐng)求頭中添加Authorization = Bearer token,即可。
?
public async Task<PartialViewResult> SendRequest() {var token = Request.Cookies["access_token"]?.Value;//將token添加到請(qǐng)求頭_abpWebApiClient.RequestHeaders.Add(new NameValue("Authorization", "Bearer " + token));return await GetUserList(baseUrl + webapiUrl); }這里面需要注意的是,abp中配置app.UseOAuthBearerAuthentication(AccountController.OAuthBearerOptions);使用的是Bearer token,所以我們?cè)谡?qǐng)求weiapi時(shí),要在請(qǐng)求頭中假如Authorization信息時(shí),使用Bearer token的格式傳輸token信息(Bearer后有一個(gè)空格!)。
4. OAuth2.0 Token認(rèn)證方式
OAuth2.0提供了token刷新機(jī)制,當(dāng)服務(wù)器頒發(fā)的token過期后,我們可以直接通過refresh_token來申請(qǐng)token即可,不需要用戶再錄入用戶憑證申請(qǐng)token。
4.1. Abp集成OAuth2.0
在WebApi項(xiàng)目中的Api路徑下創(chuàng)建Providers文件夾,添加SimpleAuthorizationServerProvider和SimpleRefreshTokenProvider類。
其中SimpleAuthorizationServerProvider用來驗(yàn)證客戶端的用戶名和密碼來頒發(fā)token;SimpleRefreshTokenProvider用來刷新token。
?
public class SimpleAuthorizationServerProvider : OAuthAuthorizationServerProvider, ITransientDependency {private readonly LogInManager _logInManager;public SimpleAuthorizationServerProvider(LogInManager logInManager){_logInManager = logInManager;}public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context){string clientId;string clientSecret;if (!context.TryGetBasicCredentials(out clientId, out clientSecret)){context.TryGetFormCredentials(out clientId, out clientSecret);}var isValidClient = string.CompareOrdinal(clientId, "app") == 0 &&string.CompareOrdinal(clientSecret, "app") == 0;if (isValidClient){context.OwinContext.Set("as:client_id", clientId);context.Validated(clientId);}else{context.SetError("invalid client");}return Task.FromResult<object>(null);}public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context){var tenantId = context.Request.Query["tenantId"];var result = await GetLoginResultAsync(context, context.UserName, context.Password, tenantId);if (result.Result == AbpLoginResultType.Success){//var claimsIdentity = result.Identity; var claimsIdentity = new ClaimsIdentity(result.Identity);claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));var ticket = new AuthenticationTicket(claimsIdentity, new AuthenticationProperties());context.Validated(ticket);}}public override Task GrantRefreshToken(OAuthGrantRefreshTokenContext context){var originalClient = context.OwinContext.Get<string>("as:client_id");var currentClient = context.ClientId;// enforce client binding of refresh tokenif (originalClient != currentClient){context.Rejected();return Task.FromResult<object>(null);}// chance to change authentication ticket for refresh token requestsvar newId = new ClaimsIdentity(context.Ticket.Identity);newId.AddClaim(new Claim("newClaim", "refreshToken"));var newTicket = new AuthenticationTicket(newId, context.Ticket.Properties);context.Validated(newTicket);return Task.FromResult<object>(null);}private async Task<AbpLoginResult<Tenant, User>> GetLoginResultAsync(OAuthGrantResourceOwnerCredentialsContext context,string usernameOrEmailAddress, string password, string tenancyName){var loginResult = await _logInManager.LoginAsync(usernameOrEmailAddress, password, tenancyName);switch (loginResult.Result){case AbpLoginResultType.Success:return loginResult;default:CreateExceptionForFailedLoginAttempt(context, loginResult.Result, usernameOrEmailAddress, tenancyName);//throw CreateExceptionForFailedLoginAttempt(context,loginResult.Result, usernameOrEmailAddress, tenancyName);return loginResult;}}private void CreateExceptionForFailedLoginAttempt(OAuthGrantResourceOwnerCredentialsContext context, AbpLoginResultType result, string usernameOrEmailAddress, string tenancyName){switch (result){case AbpLoginResultType.Success:throw new ApplicationException("Don't call this method with a success result!");case AbpLoginResultType.InvalidUserNameOrEmailAddress:case AbpLoginResultType.InvalidPassword:context.SetError(L("LoginFailed"), L("InvalidUserNameOrPassword"));break;// return new UserFriendlyException(("LoginFailed"), ("InvalidUserNameOrPassword"));case AbpLoginResultType.InvalidTenancyName:context.SetError(L("LoginFailed"), L("ThereIsNoTenantDefinedWithName", tenancyName));break;// return new UserFriendlyException(("LoginFailed"), string.Format("ThereIsNoTenantDefinedWithName{0}", tenancyName));case AbpLoginResultType.TenantIsNotActive:context.SetError(L("LoginFailed"), L("TenantIsNotActive", tenancyName));break;// return new UserFriendlyException(("LoginFailed"), string.Format("TenantIsNotActive {0}", tenancyName));case AbpLoginResultType.UserIsNotActive:context.SetError(L("LoginFailed"), L("UserIsNotActiveAndCanNotLogin", usernameOrEmailAddress));break;// return new UserFriendlyException(("LoginFailed"), string.Format("UserIsNotActiveAndCanNotLogin {0}", usernameOrEmailAddress));case AbpLoginResultType.UserEmailIsNotConfirmed:context.SetError(L("LoginFailed"), L("UserEmailIsNotConfirmedAndCanNotLogin"));break;// return new UserFriendlyException(("LoginFailed"), ("UserEmailIsNotConfirmedAndCanNotLogin"));//default: //Can not fall to default actually. But other result types can be added in the future and we may forget to handle it// //Logger.Warn("Unhandled login fail reason: " + result);// return new UserFriendlyException(("LoginFailed"));}}private static string L(string name, params object[] args){//return new LocalizedString(name);return IocManager.Instance.Resolve<ILocalizationService>().L(name, args);} }?
public class SimpleRefreshTokenProvider : IAuthenticationTokenProvider, ITransientDependency {private static ConcurrentDictionary<string, AuthenticationTicket> _refreshTokens = new ConcurrentDictionary<string, AuthenticationTicket>();public Task CreateAsync(AuthenticationTokenCreateContext context){var guid = Guid.NewGuid().ToString("N");// maybe only create a handle the first time, then re-use for same client// copy properties and set the desired lifetime of refresh tokenvar refreshTokenProperties = new AuthenticationProperties(context.Ticket.Properties.Dictionary){IssuedUtc = context.Ticket.Properties.IssuedUtc,ExpiresUtc = DateTime.UtcNow.AddYears(1)};var refreshTokenTicket = new AuthenticationTicket(context.Ticket.Identity, refreshTokenProperties);//_refreshTokens.TryAdd(guid, context.Ticket);_refreshTokens.TryAdd(guid, refreshTokenTicket);// consider storing only the hash of the handlecontext.SetToken(guid);return Task.FromResult<object>(null);}public Task ReceiveAsync(AuthenticationTokenReceiveContext context){AuthenticationTicket ticket;if (_refreshTokens.TryRemove(context.Token, out ticket)){context.SetTicket(ticket);}return Task.FromResult<object>(null);}public void Create(AuthenticationTokenCreateContext context){throw new NotImplementedException();}public void Receive(AuthenticationTokenReceiveContext context){throw new NotImplementedException();} }以上兩段代碼我就不做過多解釋,請(qǐng)自行走讀。
緊接著我們?cè)贏pi目錄下創(chuàng)建OAuthOptions類用來配置OAuth認(rèn)證。
?
public class OAuthOptions {/// <summary>/// Gets or sets the server options./// </summary>/// <value>The server options.</value>private static OAuthAuthorizationServerOptions _serverOptions;/// <summary>/// Creates the server options./// </summary>/// <returns>OAuthAuthorizationServerOptions.</returns>public static OAuthAuthorizationServerOptions CreateServerOptions(){if (_serverOptions == null){var provider = IocManager.Instance.Resolve<SimpleAuthorizationServerProvider>();var refreshTokenProvider = IocManager.Instance.Resolve<SimpleRefreshTokenProvider>();_serverOptions = new OAuthAuthorizationServerOptions{TokenEndpointPath = new PathString("/oauth/token"),Provider = provider,RefreshTokenProvider = refreshTokenProvider,AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(30),AllowInsecureHttp = true};}return _serverOptions;} }從中我們可以看出,主要配置了以下幾個(gè)屬性:
- TokenEndpointPath :用來指定請(qǐng)求token的路由;
- Provider:用來指定創(chuàng)建token的Provider;
- RefreshTokenProvider:用來指定刷新token的Provider;
- AccessTokenExpireTimeSpan :用來指定token過期時(shí)間,這里我們指定了30s,是為了demo 如何刷新token。
- AllowInsecureHttp:用來指定是否允許http連接。
創(chuàng)建上面三個(gè)類之后,我們需要回到Web項(xiàng)目的Startup類中,配置使用集成的OAuth2.0,代碼如下:
?
public void Configuration(IAppBuilder app) {//第一步:配置跨域訪問app.UseCors(CorsOptions.AllowAll);app.UseOAuthBearerAuthentication(AccountController.OAuthBearerOptions);//第二步:使用OAuth密碼認(rèn)證模式app.UseOAuthAuthorizationServer(OAuthOptions.CreateServerOptions());//第三步:使用Abpapp.UseAbp();//省略其他代碼 }其中配置跨越訪問時(shí),我們需要安裝Microsoft.Owin.CorsNuget包。
至此,Abp集成OAuth的工作完成了。
4.2. 申請(qǐng)OAuth token
我們?cè)贏bp集成OAuth配置的申請(qǐng)token的路由是/oauth/token,所以我們將用戶憑證post到這個(gè)路由即可申請(qǐng)token:
?
public async Task<string> GetOAuth2Token() {Uri uri = new Uri(baseUrl + oAuthTokenUrl);var handler = new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.None };using (var client = new HttpClient(handler)){client.BaseAddress = uri;client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));var content = new FormUrlEncodedContent(new Dictionary<string, string>(){{"grant_type", "password"},{"username", user },{"password", pwd },{"client_id", "app" },{"client_secret", "app"},});//獲取token保存到cookie,并設(shè)置token的過期日期 var result = await client.PostAsync(uri, content);string tokenResult = await result.Content.ReadAsStringAsync();var tokenObj = (JObject)JsonConvert.DeserializeObject(tokenResult);string token = tokenObj["access_token"].ToString();string refreshToken = tokenObj["refresh_token"].ToString();long expires = Convert.ToInt64(tokenObj["expires_in"]);this.Response.SetCookie(new HttpCookie("access_token", token));this.Response.SetCookie(new HttpCookie("refresh_token", refreshToken));this.Response.Cookies["access_token"].Expires = Clock.Now.AddSeconds(expires);return tokenResult;} }在這段代碼中我們指定的grant_type = password,這說明我們使用的是OAuth提供的密碼認(rèn)證模式。其中{"client_id", "app" }, {"client_secret", "app"}(搞過微信公眾號(hào)開發(fā)的應(yīng)該對(duì)這個(gè)很熟悉)用來指定客戶端的身份和密鑰,這邊我們直接寫死。
通過OAuth的請(qǐng)求的token主要包含四部分:
- token:令牌
- refreshtoken:刷新令牌
- expires_in:token有效期
- token_type:令牌類型,我們這里是Bearer
為了演示方便,我們直接把token信息直接寫入到cookie中,實(shí)際項(xiàng)目中建議寫入數(shù)據(jù)庫。
4.3. 刷新token
如果我們的token過期了怎么辦,咱們可以用refresh_token來重新獲取token。
?
public async Task<string> GetOAuth2TokenByRefreshToken(string refreshToken) {Uri uri = new Uri(baseUrl + oAuthTokenUrl);var handler = new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.None, UseCookies = true };using (var client = new HttpClient(handler)){client.BaseAddress = uri;client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));var content = new FormUrlEncodedContent(new Dictionary<string, string>(){{"grant_type", "refresh_token"},{"refresh_token", refreshToken},{"client_id", "app" },{"client_secret", "app"},});//獲取token保存到cookie,并設(shè)置token的過期日期 var result = await client.PostAsync(uri, content);string tokenResult = await result.Content.ReadAsStringAsync();var tokenObj = (JObject)JsonConvert.DeserializeObject(tokenResult);string token = tokenObj["access_token"].ToString();string newRefreshToken = tokenObj["refresh_token"].ToString();long expires = Convert.ToInt64(tokenObj["expires_in"]);this.Response.SetCookie(new HttpCookie("access_token", token));this.Response.SetCookie(new HttpCookie("refresh_token", newRefreshToken));this.Response.Cookies["access_token"].Expires = Clock.Now.AddSeconds(expires);return tokenResult;} }這段代碼較直接使用用戶名密碼申請(qǐng)token的差別主要在參數(shù)上,{"grant_type", "refresh_token"},{"refresh_token", refreshToken}。
4.4. 使用token訪問webapi
有了token,訪問webapi就很簡(jiǎn)單了。
?
public async Task<ActionResult> SendRequestWithOAuth2Token() {var token = Request.Cookies["access_token"]?.Value;if (token == null){//throw new Exception("token已過期");string refreshToken = Request.Cookies["refresh_token"].Value;var tokenResult = await GetOAuth2TokenByRefreshToken(refreshToken);var tokenObj = (JObject)JsonConvert.DeserializeObject(tokenResult);token = tokenObj["access_token"].ToString();}_abpWebApiClient.RequestHeaders.Add(new NameValue("Authorization", "Bearer " + token));return await GetUserList(baseUrl + webapiUrl); }這段代碼中,我們首先從cookie中取回access_token,若access_token為空說明token過期,我們就從cookie中取回refresh_token重新申請(qǐng)token。然后構(gòu)造一個(gè)Authorization將token信息添加到請(qǐng)求頭即可訪問目標(biāo)webapi。
5. 總結(jié)
本文介紹了三種不同的認(rèn)證方式進(jìn)行訪問webapi,并舉例說明。文章不可能面面俱到,省略了部分代碼,請(qǐng)直接參考源碼。若有紕漏之處也歡迎大家留言指正。
本文主要參考自以下文章:
使用OAuth打造webapi認(rèn)證服務(wù)供自己的客戶端使用
ABP中使用OAuth2(Resource Owner Password Credentials Grant模式)
Token Based Authentication using ASP.NET Web API 2, Owin, and Identity
作者:圣杰
鏈接:https://www.jianshu.com/p/d14733432dc2
來源:簡(jiǎn)書
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。
總結(jié)
以上是生活随笔為你收集整理的ABP入门系列(16)——通过webapi与系统进行交互的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 618买手机最佳入手时机来了!京东狂促2
- 下一篇: 董宇辉“吐槽”俞敏洪:之前说热度过去就能