作者:聖傑
鏈接:https://www.jianshu.com/p/d14733432dc2
來源:簡書 著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
1. 引言
上一節我們講解了如何創建微信公眾號模塊,這一節我們就繼續跟進,來講一講公眾號模塊如何與系統進行交互。
微信公眾號模塊作為一個獨立的web模塊部署,要想與現有的【任務清單】進行交互,我們要想明白以下幾個問題:
- 如何進行交互?
ABP模板項目中默認創建了webapi項目,其動態webapi技術允許我們直接訪問appservice作為webapi而不用在webapi層編寫額外的代碼。所以,自然而然我們要通過webapi與系統進行交互。 - 通過webapi與系統進行交互,如何確保安全?
我們知道暴露的webapi如果不加以授權控制,就如同在大街上裸奔。所以在訪問webapi時,我們需要通過身份認證來確保安全訪問。 - 都有哪幾種身份認證方式?
第一種就是大家熟知的cookie認證方式;
第二種就是token認證方式:在訪問webapi之前,先要向目標系統申請令牌(token),申請到令牌后,再使用令牌訪問webapi。Abp默認提供了這種方式;
第三種是基於OAuth2.0的token認證方式:OAuth2.0是什么玩意?建議先看看OAuth2.0 知多少以便我們后續內容的展開。OAuth2.0認證方式彌補了Abp自帶token認證的短板,即無法進行token刷新。
基於這一節,我完善了一個demo,大家可以直接訪問http://shengjietest.azurewebsites.net/進行體驗。

下面我們就以【通過webapi請求用戶列表】為例看一看三種認證方式的具體實現。
2. Cookie認證方式
Cookie認證方式的原理就是:在訪問webapi之前,通過登錄目標系統建立連接,將cookie寫入本地。下一次訪問webapi的時候攜帶cookie信息就可以完成認證。
2.1. 登錄目標系統
這一步簡單,我們僅需提供用戶名密碼,Post一個登錄請求即可。
我們在微信模塊中創建一個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
是對HttpClient
的封裝,用於發送 HTTP 請求和接收HTTP 響應。
下面添加CookieBasedAuth
方法,來完成登錄認證,代碼如下:
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); } } }
這段代碼中有幾個點需要注意:
- 指定
HttpClientHandler
屬性UseCookie = true
,使用Cookie; client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
用來指定接受的返回值;- 使用
FormUrlEncodedContent
進行傳參; - 使用
var getCookies = handler.CookieContainer.GetCookies(uri);
獲取返回的Cookie,並添加到_abpWebApiClient.Cookies
的集合中,以便下次直接攜帶cookie信息訪問webapi。
2.2. 攜帶cookie訪問webapi
服務器返回的cookie信息在登錄成功后已經填充到_abpWebApiClient.Cookies
中,我們只需post一個請求到目標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認證方式
Abp默認提供的token認證方式,很簡單,我們僅需要post一個請求到/api/Account/Authenticate
即可請求到token。然后使用token即可請求目標webapi。
但這其中有一個問題就是,如果token過期,就必須使用用戶名密碼重寫申請token,體驗不好。
3.1. 請求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; }
這段代碼中我們將請求到token直接寫入到cookie中。以便我們下次直接從cookie中取回token直接訪問webapi。
3.2. 使用token訪問webapi
從cookie中取回token,在請求頭中添加Authorization = Bearer token
,即可。
public async Task<PartialViewResult> SendRequest() { var token = Request.Cookies["access_token"]?.Value; //將token添加到請求頭 _abpWebApiClient.RequestHeaders.Add(new NameValue("Authorization", "Bearer " + token)); return await GetUserList(baseUrl + webapiUrl); }
這里面需要注意的是,abp中配置app.UseOAuthBearerAuthentication(AccountController.OAuthBearerOptions);
使用的是Bearer token
,所以我們在請求weiapi時,要在請求頭中假如Authorization
信息時,使用Bearer token
的格式傳輸token信息(Bearer后有一個空格!)。
4. OAuth2.0 Token認證方式
OAuth2.0提供了token刷新機制,當服務器頒發的token過期后,我們可以直接通過refresh_token
來申請token即可,不需要用戶再錄入用戶憑證申請token。
4.1. Abp集成OAuth2.0
在WebApi項目中的Api路徑下創建Providers
文件夾,添加SimpleAuthorizationServerProvider
和SimpleRefreshTokenProvider
類。
其中SimpleAuthorizationServerProvider
用來驗證客戶端的用戶名和密碼來頒發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 token if (originalClient != currentClient) { context.Rejected(); return Task.FromResult<object>(null); } // chance to change authentication ticket for refresh token requests var 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 token var 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 handle context.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(); } }
以上兩段代碼我就不做過多解釋,請自行走讀。
緊接着我們在Api目錄下創建OAuthOptions
類用來配置OAuth認證。
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; } }
從中我們可以看出,主要配置了以下幾個屬性:
- TokenEndpointPath :用來指定請求token的路由;
- Provider:用來指定創建token的Provider;
- RefreshTokenProvider:用來指定刷新token的Provider;
- AccessTokenExpireTimeSpan :用來指定token過期時間,這里我們指定了30s,是為了demo 如何刷新token。
- AllowInsecureHttp:用來指定是否允許http連接。
創建上面三個類之后,我們需要回到Web項目的Startup
類中,配置使用集成的OAuth2.0,代碼如下:
public void Configuration(IAppBuilder app) { //第一步:配置跨域訪問 app.UseCors(CorsOptions.AllowAll); app.UseOAuthBearerAuthentication(AccountController.OAuthBearerOptions); //第二步:使用OAuth密碼認證模式 app.UseOAuthAuthorizationServer(OAuthOptions.CreateServerOptions()); //第三步:使用Abp app.UseAbp(); //省略其他代碼 }
其中配置跨越訪問時,我們需要安裝Microsoft.Owin.Cors
Nuget包。
至此,Abp集成OAuth的工作完成了。
4.2. 申請OAuth token
我們在Abp集成OAuth配置的申請token的路由是/oauth/token
,所以我們將用戶憑證post到這個路由即可申請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,並設置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提供的密碼認證模式。其中{"client_id", "app" }, {"client_secret", "app"}
(搞過微信公眾號開發的應該對這個很熟悉)用來指定客戶端的身份和密鑰,這邊我們直接寫死。
通過OAuth的請求的token主要包含四部分:
- token:令牌
- refreshtoken:刷新令牌
- expires_in:token有效期
- token_type:令牌類型,我們這里是Bearer
為了演示方便,我們直接把token信息直接寫入到cookie中,實際項目中建議寫入數據庫。
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,並設置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; } }
這段代碼較直接使用用戶名密碼申請token的差別主要在參數上,{"grant_type", "refresh_token"},{"refresh_token", refreshToken}
。
4.4. 使用token訪問webapi
有了token,訪問webapi就很簡單了。
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
重新申請token。然后構造一個Authorization
將token信息添加到請求頭即可訪問目標webapi。
5. 總結
本文介紹了三種不同的認證方式進行訪問webapi,並舉例說明。文章不可能面面俱到,省略了部分代碼,請直接參考源碼。若有紕漏之處也歡迎大家留言指正。