ASP.NET OWIN OAuth:refresh token的持久化


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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM