在前一篇博文中,我們初步地了解了refresh token的用途——它是用于刷新access token的一種token,并且用簡單的示例代碼體驗了一下獲取refresh token并且用它刷新access token。在這篇博文中,我們來進一步探索refresh token。
之前只知道refresh token是用于刷新access token的,卻不知道refresh token憑什么可以刷新access token?知其然,卻不知其所以然。
這是由于之前沒有發現refresh token與access token有1個非常重要的區別——Refresh token只是一種標識,不包含任何信息;而access token是經過序列化并加密的授權信息,發送到服務器時,會被解密并從中讀取授權信息。正是因為access token包含的是信息,信息是易變的,所以它的過期時間很短;正是因為refresh token只是一種標識,不易變,所以生命周期可以很長。這才是既生access token,何生refresh token背后的真正原因。
在前一篇博文中,我們將refresh token存儲在ConcurrentDictionary類型的靜態變量中,只要程序重啟,refresh token及相關信息就會丟失。為了給refresh token的生命周期保駕護航,我們不得不干一件經常干的事情——持久化,這篇博文也是因此而生。
要持久化,首先想到的就是Entity Framework與數據庫,但我們目前的Web API只有2個客戶端,一個是iOS App,一個是單元測試代碼,用EF+數據庫有如殺雞用牛刀。何不換一種簡單的方式?直接序列化為josn格式,然后保存在文件中。這么想,也這么干了。
下面就來分享一下我們如何用文件存儲實現refresh token的持久化。
首先定義一個RefreshToken實體:
public class RefreshToken { public string Id { get; set; } public string UserName { get; set; } public Guid ClientId { get; set; } public DateTime IssuedUtc { get; set; } public DateTime ExpiresUtc { get; set; } public string ProtectedTicket { get; set; } }
這個RefreshToken實體不僅僅包含refresh token(對應于這里的Id屬性),而且包含refresh token所關聯的信息。因為refresh token是用于刷新accesss token的,如果沒有這些關聯信息,就無法生成access token。
接下來,我們在Application層定義一個與RefreshToken相關的服務接口IRefreshTokenService。雖然只是一個很簡單的程序,我們還是使用n層架構來做,不管多小的項目,分離關注、減少依賴總是有幫助的,最起碼可以增添寫代碼的樂趣。
namespace CNBlogs.OpenAPI.Application.Interfaces { public interface IRefreshTokenService { Task<RefreshToken> Get(string Id); Task<bool> Save(RefreshToken refreshToken); Task<bool> Remove(string Id); } }
IRefreshTokenService接口定義了3個方法:Get()用于在刷新access token時獲取RefreshToken,Save()與Remove()用于在生成refresh token時將新RefreshToken保存并將舊RefreshToken刪除。
定義好IRefreshTokenService接口之后,就可以專注OAuth部分的實現,持久化的實現部分暫且丟在一邊(分離關注[注意力]的好處在這里就體現啦)。
OAuth部分的實現主要在CNBlogsRefreshTokenProvider(繼承自AuthenticationTokenProvider),實現代碼如下:
public class CNBlogsRefreshTokenProvider : AuthenticationTokenProvider { private IRefreshTokenService _refreshTokenService; public CNBlogsRefreshTokenProvider(IRefreshTokenService refreshTokenService) { _refreshTokenService = refreshTokenService; } public override async Task CreateAsync(AuthenticationTokenCreateContext context) { var clietId = context.OwinContext.Get<string>("as:client_id"); if (string.IsNullOrEmpty(clietId)) return; var refreshTokenLifeTime = context.OwinContext.Get<string>("as:clientRefreshTokenLifeTime"); if (string.IsNullOrEmpty(refreshTokenLifeTime)) return; //generate access token RandomNumberGenerator cryptoRandomDataGenerator = new RNGCryptoServiceProvider(); byte[] buffer = new byte[50]; cryptoRandomDataGenerator.GetBytes(buffer); var refreshTokenId = Convert.ToBase64String(buffer).TrimEnd('=').Replace('+', '-').Replace('/', '_'); var refreshToken = new RefreshToken() { Id = refreshTokenId, ClientId = new Guid(clietId), UserName = context.Ticket.Identity.Name, IssuedUtc = DateTime.UtcNow, ExpiresUtc = DateTime.UtcNow.AddSeconds(Convert.ToDouble(refreshTokenLifeTime)), ProtectedTicket = context.SerializeTicket() }; context.Ticket.Properties.IssuedUtc = refreshToken.IssuedUtc; context.Ticket.Properties.ExpiresUtc = refreshToken.ExpiresUtc; if (await _refreshTokenService.Save(refreshToken)) { context.SetToken(refreshTokenId); } } public override async Task ReceiveAsync(AuthenticationTokenReceiveContext context) { var refreshToken = await _refreshTokenService.Get(context.Token); if (refreshToken != null) { context.DeserializeTicket(refreshToken.ProtectedTicket); var result = await _refreshTokenService.Remove(context.Token); } } }
代碼解讀:
- 為了調用IRefreshTokenService,我們將之通過CNBlogsRefreshTokenProvider的構造函數注入。
- CreateAsync() 中用RNGCryptoServiceProvider生成refresh token,并獲取相關信息(比如clientId, refreshTokenLifeTime, ProtectedTicket),創建RefreshToken,調用 IRefreshTokenService.Save() 進行持久化保存。
- ReceiveAsync() 中調用 IRefreshTokenService.Get() 獲取 RefreshToken,用它反序列出生成access token所需的ticket,從持久化中刪除舊的refresh token(刷新access token時,refresh token也會重新生成)。
由于在CNBlogsRefreshTokenProvider中需要獲取Client的clientId與refreshTokenLifeTime信息,所以我們需要在CNBlogsAuthorizationServerProvider中提供這個信息,在ValidateClientAuthentication重載方法中添加如下的代碼:
context.OwinContext.Set<string>("as:client_id", clientId); context.OwinContext.Set<string>("as:clientRefreshTokenLifeTime", client.RefreshTokenLifeTime.ToString());
以下是精簡過的CNBlogsAuthorizationServerProvider完整實現代碼(我們對client也用文件存儲進行了持久化):
public class CNBlogsAuthorizationServerProvider : OAuthAuthorizationServerProvider { private IClientService _clientService; public CNBlogsAuthorizationServerProvider(IClientService clientService) { _clientService = clientService; } public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context) { string clientId; string clientSecret; //省略了return之前context.SetError的代碼 if (!context.TryGetBasicCredentials(out clientId, out clientSecret)) { return; } var client = await _clientService.Get(clientId); if (client == null) { return; } if (client.Secret != clientSecret) { return;} context.OwinContext.Set<string>("as:client_id", clientId); context.OwinContext.Set<string>("as:clientRefreshTokenLifeTime", client.RefreshTokenLifeTime.ToString()); context.Validated(clientId); } public override async Task GrantClientCredentials(OAuthGrantClientCredentialsContext context) { var oAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType); context.Validated(oAuthIdentity); } public override async Task GrantResourceOwnerCredentials( OAuthGrantResourceOwnerCredentialsContext context) { //驗證context.UserName與context.Password var oAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType); oAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName)); context.Validated(oAuthIdentity); } public override async Task GrantRefreshToken(OAuthGrantRefreshTokenContext context) { var newId = new ClaimsIdentity(context.Ticket.Identity); newId.AddClaim(new Claim("newClaim", "refreshToken")); var newTicket = new AuthenticationTicket(newId, context.Ticket.Properties); context.Validated(newTicket); } }
OAuth部分的主要代碼完成后,接下來丟開OAuth,專心實現持久化部分的代碼(分層帶來的關注分離的好處再次體現)。
先實現Repository層的代碼(Application層的接口已完成),定義IRefreshTokenRepository接口:
namespace CNBlogs.OpenAPI.Repository.Interfaces { public interface IRefreshTokenRepository { Task<RefreshToken> FindById(string Id); Task<bool> Insert(RefreshToken refreshToken); Task<bool> Delete(string Id); } }
然后以RefreshTokenRepository實現IRefreshTokenRepository接口,用文件存儲進行持久化的實現代碼都在這里(就是json的序列化與反序列化):
namespace CNBlogs.OpenAPI.Repository.FileStorage { public class RefreshTokenRepository : IRefreshTokenRepository { private string _jsonFilePath; private List<RefreshToken> _refreshTokens; public RefreshTokenRepository() { _jsonFilePath = HostingEnvironment.MapPath("~/App_Data/RefreshToken.json"); if (File.Exists(_jsonFilePath)) { var json = File.ReadAllText(_jsonFilePath); _refreshTokens = JsonConvert.DeserializeObject<List<RefreshToken>>(json); } if(_refreshTokens == null) _refreshTokens = new List<RefreshToken>(); } public async Task<RefreshToken> FindById(string Id) { return _refreshTokens.Where(x => x.Id == Id).FirstOrDefault(); } public async Task<bool> Insert(RefreshToken refreshToken) { _refreshTokens.Add(refreshToken); await WriteJsonToFile(); return true; } public async Task<bool> Delete(string Id) { _refreshTokens.RemoveAll(x => x.Id == Id); await WriteJsonToFile(); return true; } private async Task WriteJsonToFile() { using (var tw = TextWriter.Synchronized(new StreamWriter(_jsonFilePath, false))) { await tw.WriteAsync(JsonConvert.SerializeObject(_refreshTokens, Formatting.Indented)); } } } }
接著就是Application層接口IRefreshTokenService的實現(調用Repository層的接口):
namespace CNBlogs.OpenAPI.Application.Services { public class RefreshTokenService : IRefreshTokenService { private IRefreshTokenRepository _refreshTokenRepository; public RefreshTokenService(IRefreshTokenRepository refreshTokenRepository) { _refreshTokenRepository = refreshTokenRepository; } public async Task<RefreshToken> Get(string Id) { return await _refreshTokenRepository.FindById(Id); } public async Task<bool> Save(RefreshToken refreshToken) { return await _refreshTokenRepository.Insert(refreshToken); } public async Task<bool> Remove(string Id) { return await _refreshTokenRepository.Delete(Id); } } }
好了,主要工作都已完成:
1)Web層的CNBlogsAuthorizationServerProvider與CNBlogsRefreshTokenProvider
2)Domain層的實體RefreshToken
3)Application層的IRefreshTokenService與RefreshTokenService.cs
4)Repository層的IRefreshTokenRepository與RefreshTokenRepository
麻雀雖小,五臟俱全。
最后就剩下一些收尾工作了。
由于調用的接口都是通過構造函數注入的,需要做一些依賴注入的工作,實現DependencyInjectionConfig:
namespace OpenAPI.App_Start { public static class DependencyInjectionConfig { public static void Register() { var containter = IocContainer.Default = new IocUnityContainer(); containter.RegisterType<IRefreshTokenService, RefreshTokenService>(); containter.RegisterType<IRefreshTokenRepository, RefreshTokenRepository>(); } } }
(注:IocContainer是我們內部用的組件,封裝了Unity)
然后在Application_Start中調用它。
到這里就萬事俱備,只欠東風了。
只要在Startup.Auth.cs中通過IOC容器解析出CNBlogsAuthorizationServerProvider與CNBlogsRefreshTokenProvider的實例,東風就來了。
public partial class Startup { public static OAuthAuthorizationServerOptions OAuthOptions { get; private set; } public void ConfigureAuth(IAppBuilder app) { OAuthOptions = new OAuthAuthorizationServerOptions { TokenEndpointPath = new PathString("/token"), Provider = IocContainer.Resolver.Resolve<CNBlogsAuthorizationServerProvider>(), AccessTokenExpireTimeSpan = TimeSpan.FromDays(1), AllowInsecureHttp = true, RefreshTokenProvider = IocContainer.Resolver.Resolve<CNBlogsRefreshTokenProvider>() }; app.UseOAuthBearerTokens(OAuthOptions); } }
至此,開發第一版給iOS App用的Web API所面臨的OAuth問題基本解決了。這些博文只是解決實際問題之后的一點記載,希望能讓想基于ASP.NET OWIN OAuth開發Web API的朋友少走一些彎路。
【參考資料】
Enable OAuth Refresh Tokens in AngularJS App using ASP .NET Web API 2, and Owin
文章列表