文章出處

前一篇博文中,我們初步地了解了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 


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()