在ASP.NET Web API 2中使用Owin OAuth 刷新令牌(示例代碼)

上篇文章介紹了Web Api中使用令牌進行受權的後端實現方法,基於WebApi2和OWIN OAuth實現了獲取access token,使用token訪問需受權的資源信息。本文將介紹在Web Api中啓用刷新令牌的後端實現。html

本文要用到上篇文章所使用的代碼,代碼編寫環境爲VS 201七、.Net Framework 4.7.2,數據庫爲MS SQL 2008 R2.git

OAuth 刷新令牌

上文已經搞了一套Token受權訪問,這裏有多出來一個刷新令牌(Refresh Token),平白添加程序的複雜度,意義何在呢angularjs

  • 刷新令牌在設置時,有幾個選項,其中有一個AccessTokenExpireTimeSpan,即過時時間,針對不一樣的客戶端過時時間該設置爲多久呢
  • 並且令牌一旦生成便可在過時時間內一直使用,若是修改了用戶的權限信息,如何通知到客戶端其權限的變動
  • 還有就是訪問令牌過時後,客戶端調用須要從新驗證用戶名密碼進行交互,這樣是否是有點麻煩了?

使用刷新令牌

刷新令牌(Refresh Token) 是用來從身份認證服務器交換得到新的訪問令牌,容許在沒有用戶交互的狀況下請求新的訪問令牌。刷新令牌有幾個好處:github

  • 能夠無需用戶交互狀況下更新訪問令牌:能夠設置一個較長時間的刷新令牌有效期,客戶端一次身份驗證後除非管理員撤銷刷新令牌,不然在刷新令牌有效期內均不用再次身份驗證
  • 能夠撤銷已經過身份驗證的用戶的受權: 只要用戶獲取到訪問令牌,除非本身編寫單獨的處理邏輯,不然是無法撤銷掉訪問令牌,可是刷新令牌相對簡單的多

這個在沒有特定的業務場景比較難理解,下面仍是一步一步操做一遍,動動手後會有更多收穫。本文須要使用進行客戶端和刷新令牌的持久化,須要用到EF和數據庫客戶端。web

 

步驟一:設計客戶端表、刷新令牌表,啓用持久化操做

 

  1. 啓用EntityFramework,安裝 Nuget包


    install-package Entityframework
  2. 添加數據實體,項目右鍵,新建文件夾命名爲Entities,而後文件夾右鍵,新增類命名爲OAuthContext,代碼以下:

    using System.Data.Entity;
    
    namespace OAuthExample.Entities
    {
        public class OAuthContext : DbContext
        {
            public OAuthContext() : base("OAuthConnection") { }
    
            public DbSet<Client> Clients { get; set; }
            public DbSet<RefreshToken> RefreshTokens { get; set; }
        }
    }
  3. 添加客戶端、刷新令牌實體 ,在文件夾右鍵,分別新增Client類、RefreshToken類,代碼以下:

    Client 實體:算法

    using System.ComponentModel.DataAnnotations;
    
    namespace OAuthExample.Entities
    {
        public class Client
        {
            [Key]
            public string Id { get; set; }
    
            [Required]
            public string Secret { get; set; }
            [Required]
            [MaxLength(100)]
            public string Name { get; set; }
    
            public ApplicationTypes ApplicationType { get; set; }
    
            public bool Active { get; set; }
    
            public int RefreshTokenLifeTime { get; set; }
    
            [MaxLength(100)]
            public string AllowedOrigin { get; set; }
        }
    
        public enum ApplicationTypes
        {
            JavaScript = 0,
            NativeConfidential = 1
        };
    }

    RefreshToken 實體:數據庫

    using System;
    using System.ComponentModel.DataAnnotations;
    
    namespace OAuthExample.Entities
    {
        public class RefreshToken
        {
            [Key]
            public string Id { get; set; }
    
            [Required]
            [MaxLength(50)]
            public string Subject { get; set; }
    
            [Required]
            [MaxLength(50)]
            public string ClientId { get; set; }
    
            public DateTime IssuedUtc { get; set; }
    
            public DateTime ExpiresUtc { get; set; }
    
            [Required]
            public string ProtectedTicket { get; set; }
        }
    }
  4. 進行數據庫遷移 ,在程序包管理器控制檯分別運行以下命令:

    PM> enable-migrations
    
    PM> add-migration initDatabase
    
    PM> update-database
  5. 實現倉儲類,在項目中添加文件夾,命名爲Infrastructure,而後添加類,命名爲 AuthRepository ,代碼以下:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using OAuthExample.Entities;
    
    namespace OAuthExample.Infrastructure
    {
        public class AuthRepository : IDisposable
        {
            private OAuthContext context;
    
            public AuthRepository()
            {
                context = new OAuthContext();
            }
    
            public Client FindClient(string clientId)
            {
                var client = context.Clients.Find(clientId);
    
                return client;
            }
    
            public async Task<bool> AddRefreshToken(RefreshToken token)
            {
                var existingToken = context.RefreshTokens.Where(r => r.Subject == token.Subject && r.ClientId == token.ClientId).SingleOrDefault();
    
                if (existingToken != null)
                {
                    var result = await RemoveRefreshToken(existingToken);
                }
    
                context.RefreshTokens.Add(token);
    
                return await context.SaveChangesAsync() > 0;
            }
    
            public async Task<bool> RemoveRefreshToken(string refreshTokenId)
            {
                var refreshToken = await context.RefreshTokens.FindAsync(refreshTokenId);
    
                if (refreshToken != null)
                {
                    context.RefreshTokens.Remove(refreshToken);
                    return await context.SaveChangesAsync() > 0;
                }
    
                return false;
            }
    
            public async Task<bool> RemoveRefreshToken(RefreshToken refreshToken)
            {
                context.RefreshTokens.Remove(refreshToken);
                return await context.SaveChangesAsync() > 0;
            }
    
            public async Task<RefreshToken> FindRefreshToken(string refreshTokenId)
            {
                var refreshToken = await context.RefreshTokens.FindAsync(refreshTokenId);
    
                return refreshToken;
            }
    
            public List<RefreshToken> GetAllRefreshTokens()
            {
                return context.RefreshTokens.ToList();
            }
    
            public void Dispose()
            {
                context.Dispose();
            }
        }
    }

到這裏終於完成了Client與RefreshToken兩個實體表的管理,這裏主要是實現一下Client和RefreshToken這兩個實體的一些增刪改查操做,在後面會用到。具體實現方式不限於以上代碼。json

這裏有個小插曲,執行數據遷移的時候出現錯誤 「沒法將參數綁定到參數「Path」,由於該參數是空值。」,卡了半個小時也沒解決,最後卸載掉當前EntityFramework,換了個低版本的,一切正常了。後端

 

步驟二:實現Client驗證

 

如今,咱們須要實現負責驗證發送給應用程序請求訪問令牌或使用刷新令牌的客戶端信息的客戶端信息的邏輯,所以打開文件「 CustomAuthorizationServerProvider」修改方法ValidateClientAuthentication,代碼以下:api

public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
        {

            string clientId = string.Empty;
            string clientSecret = string.Empty;
            Client client = null;

            if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
            {
                context.TryGetFormCredentials(out clientId, out clientSecret);
            }

            if (context.ClientId == null)
            {
                //Remove the comments from the below line context.SetError, and invalidate context 
                //if you want to force sending clientId/secrects once obtain access tokens. 
                context.Validated();
                //context.SetError("invalid_clientId", "ClientId should be sent.");
                return Task.FromResult<object>(null);
            }

            using (AuthRepository _repo = new AuthRepository())
            {
                client = _repo.FindClient(context.ClientId);
            }

            if (client == null)
            {
                context.SetError("invalid_clientId", string.Format("Client '{0}' is not registered in the system.", context.ClientId));
                return Task.FromResult<object>(null);
            }

            if (client.ApplicationType == ApplicationTypes.NativeConfidential)
            {
                if (string.IsNullOrWhiteSpace(clientSecret))
                {
                    context.SetError("invalid_clientId", "Client secret should be sent.");
                    return Task.FromResult<object>(null);
                }
                else
                {
                    if (client.Secret != Helper.GetHash(clientSecret))
                    {
                        context.SetError("invalid_clientId", "Client secret is invalid.");
                        return Task.FromResult<object>(null);
                    }
                }
            }

            if (!client.Active)
            {
                context.SetError("invalid_clientId", "Client is inactive.");
                return Task.FromResult<object>(null);
            }

            context.OwinContext.Set<string>("as:clientAllowedOrigin", client.AllowedOrigin);
            context.OwinContext.Set<string>("as:clientRefreshTokenLifeTime", client.RefreshTokenLifeTime.ToString());

            context.Validated();
            return Task.FromResult<object>(null);
        }

上述操做,咱們主要執行了一下驗證步驟

  • 在請求標頭中獲取clientId 和Client Secret
  • 檢查Client Id是否爲空
  • 檢查Client是否註冊
  • 檢查Client是否須要進行Client Secret驗證的客戶端類型,在此上下文中,NativeConfidential時須要驗證Javascript不須要驗證
  • 檢查Client是否處於活動狀態
  • 以上全部均爲有效時,將上下文標記爲有效,驗證經過

 

步驟三:驗證資源全部者憑證

如今,咱們須要修改方法「 GrantResourceOwnerCredentials」以驗證資源全部者的用戶名/密碼是否正確,並將客戶端ID綁定到生成的訪問令牌,所以打開文件「 CustomAuthorizationServerProvider」並修改GrantResourceOwnerCredentials方法和添加TokenEndpoint實現代碼:

/// <summary>
        /// Called when a request to the Token endpoint arrives with a "grant_type" of "password". This occurs when the user has provided name and password
        /// credentials directly into the client application's user interface, and the client application is using those to acquire an "access_token" and
        /// optional "refresh_token". If the web application supports the
        /// resource owner credentials grant type it must validate the context.Username and context.Password as appropriate. To issue an
        /// access token the context.Validated must be called with a new ticket containing the claims about the resource owner which should be associated
        /// with the access token. The application should take appropriate measures to ensure that the endpoint isn’t abused by malicious callers.
        /// The default behavior is to reject this grant type.
        /// See also http://tools.ietf.org/html/rfc6749#section-4.3.2
        /// </summary>
        /// <param name="context">The context of the event carries information in and results out.</param>
        public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
        {
            var allowedOrigin = context.OwinContext.Get<string>("as:clientAllowedOrigin");
            if (allowedOrigin == null)
            {
                allowedOrigin = "*";
            }
            context.OwinContext.Response.Headers.Add("Access-Control-Allow-Origin", new[] { allowedOrigin });
            //這裏是驗證用戶名和密碼,能夠根據項目狀況本身實現
            if (!(context.UserName == "zhangsan" && context.Password == "123456"))
            {
                context.SetError("invalid_grant", "The user name or password is incorrect.");
                return;
            }
            var identity = new ClaimsIdentity(context.Options.AuthenticationType);
            identity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
            identity.AddClaim(new Claim("sub", context.UserName));
            identity.AddClaim(new Claim("role", "user"));
            var props = new AuthenticationProperties(new Dictionary<string, string>
                {
                    {
                        "as:client_id", (context.ClientId == null) ? string.Empty : context.ClientId
                    },
                    {
                        "userName", context.UserName
                    }
                });
            var ticket = new AuthenticationTicket(identity, props);
            context.Validated(ticket);

        }

        /// <summary>
        /// Called at the final stage of a successful Token endpoint request. An application may implement this call in order to do any final
        /// modification of the claims being used to issue access or refresh tokens. This call may also be used in order to add additional
        /// response parameters to the Token endpoint's json response body.
        /// </summary>
        /// <param name="context">The context of the event carries information in and results out.</param>
        /// <returns>
        /// Task to enable asynchronous execution
        /// </returns>
        public override Task TokenEndpoint(OAuthTokenEndpointContext context)
        {
            foreach (var item in context.Properties.Dictionary)
            {
                context.AdditionalResponseParameters.Add(item.Key, item.Value);
            }
            return Task.FromResult<object>(null);

        }

 

經過上面代碼,咱們完成了以下操做:

  • 從OWIN中獲取到「Access-Control-Allow-Origin」並添加到OWIN上下文響應中
  • 驗證資源全部者的用戶名/密碼,這裏是寫死的,實際應用中能夠本身擴展一下,驗證完成後調用context.Validated(ticket),將生成token

步驟四:生成Refresh Token,並持久化

如今咱們須要生成Refresh Token並實現持久化到數據庫中,咱們須要添加一個新的實現類。項目中找到Providers文件夾,右鍵添加類,命名爲」CustomRefreshTokenProvider」,該類繼承於」IAuthenticationTokenProvider」,實現代碼以下:

using System;
using System.Threading.Tasks;
using log4net;
using Microsoft.Owin.Security.Infrastructure;
using OAuthExample.Entities;
using OAuthExample.Infrastructure;

namespace OAuthExample.Providers
{
    public class CustomRefreshTokenProvider : IAuthenticationTokenProvider
    {
        private ILog logger = LogManager.GetLogger(typeof(CustomRefreshTokenProvider));

        public void Create(AuthenticationTokenCreateContext context)
        {
            throw new NotImplementedException();
        }
        /// <summary>
        /// Creates the asynchronous.
        /// </summary>
        /// <param name="context">The context.</param>
        public async Task CreateAsync(AuthenticationTokenCreateContext context)
        {
            var clientid = context.Ticket.Properties.Dictionary["as:client_id"];

            if (string.IsNullOrEmpty(clientid))
            {
                return;
            }
            //爲刷新令牌生成一個惟一的標識符,這裏咱們使用Guid,也能夠本身單獨寫一個字符串生成的算法
            var refreshTokenId = Guid.NewGuid().ToString("n");

            using (AuthRepository _repo = new AuthRepository())
            {
                //從Owin上下文中讀取令牌生存時間,並將生存時間設置到刷新令牌
                var refreshTokenLifeTime = context.OwinContext.Get<string>("as:clientRefreshTokenLifeTime");

                var token = new RefreshToken()
                {
                    Id = Helper.GetHash(refreshTokenId),
                    ClientId = clientid,
                    Subject = context.Ticket.Identity.Name,
                    IssuedUtc = DateTime.UtcNow,
                    ExpiresUtc = DateTime.UtcNow.AddMinutes(Convert.ToDouble(refreshTokenLifeTime))
                };
                //爲刷新令牌設置有效期
                context.Ticket.Properties.IssuedUtc = token.IssuedUtc;
                context.Ticket.Properties.ExpiresUtc = token.ExpiresUtc;
                //負責對票證內容進行序列化,稍後咱們將次序列化字符串持久化到數據
                token.ProtectedTicket = context.SerializeTicket();
                var result = await _repo.AddRefreshToken(token);

                //在響應中文中發送刷新令牌Id
                if (result)
                {
                    context.SetToken(refreshTokenId);
                }
            }
        }

        public void Receive(AuthenticationTokenReceiveContext context)
        {
            throw new NotImplementedException();
        }
        /// <summary>
        /// Receives the asynchronous.
        /// </summary>
        /// <param name="context">The context.</param>
        /// <returns></returns>
        /// <exception cref="System.NotImplementedException"></exception>
        public async Task ReceiveAsync(AuthenticationTokenReceiveContext context)
        {
            //設置跨域訪問
            var allowedOrigin = context.OwinContext.Get<string>("as:clientAllowedOrigin");
            context.OwinContext.Response.Headers.Add("Access-Control-Allow-Origin", new[] { allowedOrigin });

            //獲取到刷新令牌,hash後在數據庫查找是否已經存在
            string hashedTokenId = Helper.GetHash(context.Token);
            using (AuthRepository _repo = new AuthRepository())
            {
                var refreshToken = await _repo.FindRefreshToken(hashedTokenId);


                if (refreshToken != null)
                {
                    //Get protectedTicket from refreshToken class
                    context.DeserializeTicket(refreshToken.ProtectedTicket);
                    //刪除當前刷新令牌,而後再次生成新令牌保存到數據庫
                    var result = await _repo.RemoveRefreshToken(hashedTokenId);
                }
            }
        }
    }
}

 

經過上面代碼,咱們完成了以下操做:

  • CreateAsync 方法中

    咱們在此方法實現了刷新令牌的設置和生成,並持久化到數據。添加此方法後,在獲取access token的過程當中,須要將client Id添加到Authorization中,驗證經過後,在響應報文中生成了refresh_token

  • ReceiveAsync 方法中
  • 咱們在此方法實現了經過刷新令牌獲取訪問令牌的一部分,詳見代碼註釋

 

步驟五:使用刷新令牌生成訪問令牌

 

打開CustomRefreshTokenProvider類,添加實現接口方法ReceiveAsync 。代碼見上

打開CustomAuthorizationServerProvider類,添加GrantRefreshToken方法的實現,代碼以下:

/// <summary>
        /// Called when a request to the Token endpoint arrives with a "grant_type" of "refresh_token". This occurs if your application has issued a "refresh_token"
        /// along with the "access_token", and the client is attempting to use the "refresh_token" to acquire a new "access_token", and possibly a new "refresh_token".
        /// To issue a refresh token the an Options.RefreshTokenProvider must be assigned to create the value which is returned. The claims and properties
        /// associated with the refresh token are present in the context.Ticket. The application must call context.Validated to instruct the
        /// Authorization Server middleware to issue an access token based on those claims and properties. The call to context.Validated may
        /// be given a different AuthenticationTicket or ClaimsIdentity in order to control which information flows from the refresh token to
        /// the access token. The default behavior when using the OAuthAuthorizationServerProvider is to flow information from the refresh token to
        /// the access token unmodified.
        /// See also http://tools.ietf.org/html/rfc6749#section-6
        /// </summary>
        /// <param name="context">The context of the event carries information in and results out.</param>
        /// <returns>
        /// Task to enable asynchronous execution
        /// </returns>
        public override Task GrantRefreshToken(OAuthGrantRefreshTokenContext context)
        {
            //從原始票證中讀取Client Id與當前的Client Id進行比較,若是不一樣,將拒絕次操做請求,以保證刷新令牌生成後綁定到同一個Client
            var originalClient = context.Ticket.Properties.Dictionary["as:client_id"];
            var currentClient = context.ClientId;

            if (originalClient != currentClient)
            {
                context.SetError("invalid_clientId", "Refresh token is issued to a different clientId.");
                return Task.FromResult<object>(null);
            }

            // Change auth ticket for refresh token requests
            var newIdentity = new ClaimsIdentity(context.Ticket.Identity);

            var newClaim = newIdentity.Claims.Where(c => c.Type == "newClaim").FirstOrDefault();
            if (newClaim != null)
            {
                newIdentity.RemoveClaim(newClaim);
            }
            newIdentity.AddClaim(new Claim("newClaim", "newValue"));


            var newTicket = new AuthenticationTicket(newIdentity, context.Ticket.Properties);
            //方法執行後,代碼將回到CustomRefreshTokenProvider中的"CreateAsync"方法,生成新的刷新令牌,並將其和新的訪問令牌一塊兒返回到響應中
            context.Validated(newTicket);

            return Task.FromResult<object>(null);
        }

代碼測試

 

使用PostMan進行模擬測試

 

在未受權時,訪問 http://localhost:56638/api/Orders,提示「已拒絕爲此請求受權」

image

獲取受權,訪問 http://localhost:56638/oauth/token,得到的報文中包含有refresh_token

image

使用Refresh token獲取新的Access token

image

 

使用新的Access Token 附加到Order請求,再次嘗試訪問:

image

監視請求上下文中的信息以下,注意newClaim是刷新令牌訪問時才設置的:

image

總結

dddd,終於把這兩個總結搞完了,回頭把以前webapi參數加密的合到一塊兒,代碼整理一下放到文末。

 

本文參照了不少文章以及代碼,文章主要架構與下面連接基本一致,其實也沒多少原創,可是在整理總結的過程當中梳理了一邊Access Token 和Refresh Token的知識,當你在組織語言解釋代碼的時候,才無情的揭露了本身的無知與淺薄,受益不淺~

Token Based Authentication using ASP.NET Web API 2, Owin, and Identity

Enable OAuth Refresh Tokens in AngularJS App using ASP .NET Web API 2, and Owin

 

其餘參考資料是在較多,有的看一點就關掉了。有的做爲參考,列舉一二

Web API與OAuth:既生access token,何生refresh token

在ASP.NET中基於Owin OAuth使用Client Credentials Grant受權發放Token

使用OAuth打造webapi認證服務供本身的客戶端使用

ASP.NET Web API與Owin OAuth:調用與用戶相關的Web API

C#進階系列——WebApi 身份認證解決方案:Basic基礎認證

 

最後本文示例代碼地址:Github

相關文章
相關標籤/搜索