如何簡單的在 ASP.NET Core 中集成 JWT 認證?

前情提要:ASP.NET Core 使用 JWT 搭建分佈式無狀態身份驗證系統html

文章超長預警(1萬字以上),不想看所有實現過程的同窗能夠直接跳轉到末尾查當作果或者一鍵安裝相關的 nuget 包git

自上一篇介紹如何在 ASP.NET Core 中集成 JWT 的博文發佈接近一年後,我又想來分享一些使用 JWT 的經驗了。過去的一年中,我每次遇到一些小的,垃圾的項目,就會按照去年的那片文章來進行配置,雖然代碼很少,可是每次寫這麼一些模板代碼,又感受很枯燥、冗餘,並且稍不注意就有可能配置的有問題,致使驗證不成功。前幾天,我繼續寫本身的垃圾畢設,寫到集成 JWT 的時候,我終於忍受不了這種重複的配置工做了,因而便着手封裝一個簡單易用的 JWT 插件。github

以前集成 JWT 的方法在 ConfigureServices 方法裏面添加了太多細節上的東西,因此在新的實現裏面,添加服務依賴的 API 必定要足夠簡單,其次,以前的實現裏面,簽發一個 Token 步驟太多且比較複雜,因此簽發 Token 的步驟也要簡化。最後,以前在 Cookie 中添加 JWT 支持也比較 hack,跟 ASP.NET Core 的集成也不是很好。帶着這些痛點,我在網上經歷了一番搜索,最終找到了這個倉庫 ,原本都想直接用他的實現了,不過他的配置看起來仍是有些麻煩的,因此沒辦法,只好本身手寫一個了。算法

從設計配置 API 開始

其實不論是我以前寫的實現仍是 GitHub 上找到的那個倉庫的實現,最讓我不滿意的地方就是配置,不少時候,我就只想快速地搭建一個項目,根本不想去研究「怎樣配置」,因此個人第一步的目標就是設計一個簡單的配置接口:shell

public abstract class EasyJwtOption
{
    public string Audience { get; set; }
    public string Issuer { get; set; }
    public bool EnableCookie { get; set; }
    /// <summary>
    /// 自定義 Cookie 選項,可空
    /// </summary>
    public Action<CookieAuthenticationOptions> CookieOptions { get; set; }
    /// <summary>
    /// 自定義 jwt 選項,可空
    /// </summary>
    public Action<JwtBearerOptions> JwtOptions { get; set; }
    public abstract SecurityKey GenerateKey();
    public abstract SigningCredentials GenerateCredentials();
}

EasyJwtOption 是用來進行描述 EasyJwt 配置的類型,它的每一個屬性都是咱們能夠進行配置的地方,同時爲了不把 ASP.NET Core 自帶的對 JWT 跟 Cookie 的配置項目重寫一遍,我就定義了 CookieOptionsJwtOptions 這兩個屬性,用來向微軟的 AuthenticationBuilder 傳遞配置。json

GenerateKey() GenerateCredentials() 這兩個抽象方法則跟加密算法相關,在 JWT 中,咱們可使用兩類算法進行加密:對稱加密與非對稱加密,在我以前寫的文章中,我使用的是非對稱加密的 RSA 算法,將原先的配置寫成新的 EasyJwtOption 就是:api

public class EasyRSAOptions : EasyJwtOption
{
    public EasyRSAOptions(string path)
    {
        if (string.IsNullOrEmpty(path))
        {
            throw new ArgumentException("Path can not be null", nameof(path));
        }

        Path = path;
    }

    public string Path { get; set; }
    public override SecurityKey GenerateKey()
    {
        if (RsaUtils.TryGetKeyParameters(Path, true, out var rsaParams) == false)
        {
            rsaParams = RsaUtils.GenerateAndSaveKey(Path);
        }

        return new RsaSecurityKey(rsaParams);
    }

    public override SigningCredentials GenerateCredentials()
    {
        return new SigningCredentials(GenerateKey(), SecurityAlgorithms.RsaSha256);
    }
}

因爲 RSA 算法的私鑰與密鑰只能機器生成,因此我仍是延續了之前的作法,把算法參數導出成 json 保存在本地,故 EasyRSAOptions 的構造函數接受一個存儲位置做爲必須參數。可是這種作法普適性不太好,更好的作法是把 RSA 私鑰與公鑰導出成標準格式的文本,這樣其餘的應用也能夠導入,不過我比較懶,先這麼湊活吧。安全

在 GitHub 找到的那個項目中,做者使用的是對稱加密算法,把這個算法改爲個人 EasyJwtOption 就是:cookie

public class EasySymmetricOptions : EasyJwtOption
{
    public EasySymmetricOptions(string secret)
    {
        Secret = secret ?? throw new ArgumentNullException(nameof(secret));
        Secret = Secret.GetMd5();
    }

    public string Secret { get; set; }
    public override SecurityKey GenerateKey()
    {
        return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Secret));
    }

    public override SigningCredentials GenerateCredentials()
    {
        return new SigningCredentials(GenerateKey(), SecurityAlgorithms.HmacSha256);
    }
}

在非對稱加密算法中,咱們須要提供一個密鑰供加密、解密使用,因此 EasySymmetricOptions 的構造函數接受一個任意的字符串做爲參數,又由於 SymmetricSecurityKey 對安全性的要求,密鑰的長度過短會報出異常,用戶的輸入的密鑰字符串進行了一些轉換,來知足密鑰長度條件。mvc

方便的簽發 Token

爲了可以讓網站的各個組件可以方便的隨時簽發 Token ,我設計了下面這個類,它的構造函數接受一個 EasyJwtOption 做爲參數:

public class EasyJwt
{
    private readonly EasyJwtOption _option;

    public EasyJwt(EasyJwtOption option)
    {
        _option = option;
    }

    public string GenerateToken(string userName, IEnumerable<Claim> claims, DateTime expiratoin)
    {
        ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(userName));
        identity.AddClaims(claims);
        var handler = new JwtSecurityTokenHandler();
        var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor
        {
            Issuer = _option.Issuer,
            Audience = _option.Audience,
            SigningCredentials = _option.GenerateCredentials(),
            Subject = identity,
            Expires = expiratoin
        });
        return token;
    }
}

只要咱們在 Starpup.ConfigureServices 方法中把這個類添加進 IoC 容器,任何依賴 EasyJwt 的對象均可以很是簡便的爲用戶生成 Token,調用方法大體以下:

var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, userName, ClaimValueTypes.String)
};
var token = _jwt.GenerateToken(userName, claims, DateTime.Now.AddDays(1));

claims 是 Identity 中的概念,表示用戶的信息,例如:用戶名、郵箱。簽發 token 須要指定用戶名、用戶相關的信息以及 token 過時時間。咱們 EasyJwt 獲得了簽發 token 所須要的參數後會建立一個 ClaimsIdentity 對象,這一樣也是 Identity 中的概念,用來表示用戶的一些身份信息的集合,咱們能夠把一個 Identity 對象想象成一張通行證,上面記錄着用戶的身份信息。一個用戶能夠有多張通行證,這些通行證既能夠由咱們本身的應用生成,也能夠由第三方受權的應用生成,不過具體的細節就涉及到了 Identity 的身份認證設計,在此就不拓展來說了。

爲應用添加 JWT 認證支持

上面說了這麼多還只是停留在簽發 Token 的階段,進行身份認證從這裏才真正開始。微軟早就已經提供了一個添加 JWT 認證支持的拓展,不過那個還不算特別的簡單易用,因此我就在微軟的 API 之上設計了一個新的拓展方法來結合以前的 EasyJwt 配置 JWT 認證:

public static IServiceCollection AddEasyJwt(this IServiceCollection services, EasyJwtOption option)
{
    var easyJwt = new EasyJwt(option);
    var jwtParams = easyJwt.ExportTokenParameters();
    services.AddSingleton(easyJwt);

    var authBuilder = services.AddAuthentication()
        .AddJwtBearer(jwtOptions =>
        {
            jwtOptions.Audience = option.Audience;
            jwtOptions.ClaimsIssuer = option.Issuer;
            jwtOptions.TokenValidationParameters = jwtParams;
            option.JwtOptions?.Invoke(jwtOptions);
        });

    return services;
}

這個拓展方法接受一個 EasyJwtOption 的子類實例做爲參數,並經過這個參數初始化一個 EasyJwt 對象,並將其添加進 IoC 容器中。接着就是簡單的調用微軟的拓展方法,爲應用程序添加 JwtBearer 認證。這裏的 jwtParams 是由 EasyJwt 對象導出的,具體的導出代碼實現能夠在個人 GitHub 上看到,並非很重要的代碼,因此就不在這裏貼出來了。

爲 Cookie 添加 Jwt 支持是最讓人頭疼的了,並且還要讓咱們的 API 跟 ASP.NET Core 本身的機制可以較完美的結合起來,這裏就須要比較多的代碼了。

首先咱們須要自定義一個 Cookie 中存儲 Jwt Token 的格式,也就是下面這個 EasyJwtAuthTicketFormat

/// <summary>
/// user info |> jwt |> store in ticket |> serialize |> data protection |> base64 encode
/// https://amanagrawal.blog/2017/09/18/jwt-token-authentication-with-cookies-in-asp-net-core/
/// </summary>
public class EasyJwtAuthTicketFormat : ISecureDataFormat<AuthenticationTicket>
{
    private readonly TokenValidationParameters _validationParameters;
    private readonly IDataSerializer<AuthenticationTicket> _ticketSerializer;
    private readonly IDataProtector _dataProtector;

    /// <summary>
    /// Create a new instance of the <see cref="EasyJwtAuthTicketFormat"/>
    /// </summary>
    /// <param name="validationParameters">
    /// instance of <see cref="TokenValidationParameters"/> containing the parameters you
    /// configured for your application
    /// </param>
    /// <param name="ticketSerializer">
    /// an implementation of <see cref="IDataSerializer{TModel}"/>. The default implemenation can
    /// also be passed in"/&gt;
    /// </param>
    /// <param name="dataProtector">
    /// an implementation of <see cref="IDataProtector"/> used to securely encrypt and decrypt
    /// the authentication ticket.
    /// </param>
    public EasyJwtAuthTicketFormat(TokenValidationParameters validationParameters,
        IDataSerializer<AuthenticationTicket> ticketSerializer,
        IDataProtector dataProtector)
    {
        _validationParameters = validationParameters ??
                                    throw new ArgumentNullException($"{nameof(validationParameters)} cannot be null");
        _ticketSerializer = ticketSerializer ??
                                throw new ArgumentNullException($"{nameof(ticketSerializer)} cannot be null"); ;
        _dataProtector = dataProtector ??
                             throw new ArgumentNullException($"{nameof(dataProtector)} cannot be null");
    }

    /// <summary>
    /// Does the exact opposite of the Protect methods i.e. converts an encrypted string back to
    /// the original <see cref="AuthenticationTicket"/> instance containing the JWT and claims.
    /// </summary>
    /// <param name="protectedText"></param>
    /// <returns></returns>
    public AuthenticationTicket Unprotect(string protectedText)
        => Unprotect(protectedText, null);

    /// <summary>
    /// Does the exact opposite of the Protect methods i.e. converts an encrypted string back to
    /// the original <see cref="AuthenticationTicket"/> instance containing the JWT and claims.
    /// Additionally, optionally pass in a purpose string.
    /// </summary>
    /// <param name="protectedText"></param>
    /// <param name="purpose"></param>
    /// <returns></returns>
    public AuthenticationTicket Unprotect(string protectedText, string purpose)
    {
        var authTicket = _ticketSerializer.Deserialize(
            _dataProtector.Unprotect(
                Base64UrlTextEncoder.Decode(protectedText)));

        var embeddedJwt = authTicket
            .Properties?
            .GetTokenValue(JwtBearerDefaults.AuthenticationScheme);

        try
        {
            // 校驗並讀取 jwt 中的用戶信息(Claims)
            var principal = new JwtSecurityTokenHandler()
                .ValidateToken(embeddedJwt, _validationParameters, out var token);

            if (!(token is JwtSecurityToken))
            {
                throw new SecurityTokenValidationException("JWT token was found to be invalid");
            }
            // todo: 此處還能夠校驗 token 是否被吊銷
            // 將 jwt 中的用戶信息與 Cookie 中的包含的用戶信息合併起來
            authTicket.Principal.AddIdentities(principal.Identities);
            return authTicket;
        }
        catch (Exception)
        {
            return null;
        }
    }

    /// <summary>
    /// Protect the authentication ticket and convert it to an encrypted string before sending
    /// out to the users.
    /// </summary>
    /// <param name="data">an instance of <see cref="AuthenticationTicket"/></param>
    /// <returns>encrypted string representing the <see cref="AuthenticationTicket"/></returns>
    public string Protect(AuthenticationTicket data) => Protect(data, null);

    /// <summary>
    /// Protect the authentication ticket and convert it to an encrypted string before sending
    /// out to the users. Additionally, specify the purpose of encryption, default is null.
    /// </summary>
    /// <param name="data">an instance of <see cref="AuthenticationTicket"/></param>
    /// <param name="purpose">a purpose string</param>
    /// <returns>encrypted string representing the <see cref="AuthenticationTicket"/></returns>
    public string Protect(AuthenticationTicket data, string purpose)
    {
        var array = _ticketSerializer.Serialize(data);

        return Base64UrlTextEncoder.Encode(_dataProtector.Protect(array));
    }
}

這個類我借鑑了前面提到的 Github 上面的那個項目的實現,並花了一些功夫對它作了一些改動。你能夠看到,這個真的是很是大的一坨代碼,不過咱們仍是先克服困難,從構造函數來閱讀吧。

EasyJwtAuthTicketFormat 的構造函數接受三個參數,第一個咱們已經見過了,是 EasyJwt 導出的 TokenValidationParameters,用來對 Jwt token 進行驗證、解密。另外兩個參數與 ASP.NET Core 的安全機制有關,IDataSerializer<AuthenticationTicket> ticketSerializer 用來將要存入 Cookie 中的數據序列化或者從 Cookie 中反序列化咱們須要讀出來的數據。IDataProtector dataProtector 則是用來對 Cookie 進行加密、解密的工具。

據 Github 上那個項目的做者說,他的代碼是從微軟的默認實現裏面魔改出來的,因此我我的認爲其中有些東西對於 Jwt 來講其實不是必須的,理由我會在下面詳細解釋。

首先一塊兒來看看 Unprotect 方法,他的 protectedText 參數就是存儲在 Cookie 中的字符串,首先咱們須要對他用 Base64 進行解碼,而後接着要用以前的 dataProtector 進行解密,最後再用 ticketSerializer 反序列化出 AuthenticationTicket 對象,這個 AuthenticationTicket 中存儲的就是一些跟身份認證相關的數據,在咱們這裏,主要就是存儲着 jwt Token。當咱們把 token 中的用戶數據解密並提取出來以後,再跟 Cookie 中可能含有的其餘的身份信息合併起來(雖然可能並不會有什麼其餘信息。。。),最終就把結果返回出去。

Protect 方法就很簡單了,基本上就是 Unprotect 開頭一部分的逆序,先把 AuthenticationTicket 序列化,而後使用 dataProtector 加密,最終 Base64 編碼成字符串返回出去。

那麼頗有意思的事情就出現了,jwt 自己的設計就是能夠直接在 HTTP 協議中直接傳遞的,通常來講,並不須要咱們從新對其進行 Base64 編碼,並且 JWT 自己的內容就是有加密校驗的,也就是說信息可讀可是不可被修改,那麼使用 dataProtector 對其加密的過程也應該是能夠省略的。不過因爲我比較懶,並且對這裏不太確定,因此就沒有移除這部分的代碼。

你可能以爲這有啥意思,不就是直接 Cookies.Add() 就行了?然而這樣作是無法讓認證中間件正確的提取出 Token 的,咱們須要用到 HttpContext.SignInAsync 這個方法。這個方法的一個重載是接受一個 ClaimsPrincipal 跟一個 AuthentifactionProperties 做爲參數,而這兩個東西就是上一節提到的 AuthenticationTicket 的重要組成。

因此,咱們除了要讓 EasyJwt 簽發 token 以外,還要它可以生成 AuthTicket,方便咱們跟自帶的認證中間件結合使用,相關的實現代碼以下:

// EasyJwt.cs

public (ClaimsPrincipal, AuthenticationProperties) GenerateAuthTicket(string userName, IEnumerable<Claim> claims, DateTime expiratoin)
    {
        ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(userName));
        var principal = new ClaimsPrincipal(identity);
        var authProps = new AuthenticationProperties();
        var token = GenerateToken(userName, claims, expiratoin);
        authProps.StoreTokens(new[]
        {
            new AuthenticationToken
                {Name = JwtBearerDefaults.AuthenticationScheme ,Value = token}
        });
        return (principal, authProps);
    }

這個方法跟簽發 Token 的方法長得一個樣,接受一個 Claims 集合,而後用這些 claims 構建出一張通行證(ClaimsIdentity),而後把這個 identity 對象扔進一個 ClaimsPrincipal 裏面。同時,咱們還須要把 token 塞進一個 AuthentifactionProperties 對象裏面。最後,把這兩個建立出來的東西返回出去。

爲了可以簡化這部分的調用,我又寫了一個拓展方法把 SignInAsync 從新包裝了一下:

public static async Task SignInAsync(this HttpContext context, string userName, IEnumerable<Claim> claims, DateTime expiratoin)
    {
        var jwt = context.RequestServices.GetService<EasyJwt>();
        var (principal, authProps) = jwt.GenerateAuthTicket(userName, claims, expiratoin);
        // 調用自帶的 SignInAsync
        await context.SignInAsync(principal, authProps);
    }

這樣,在用戶登陸的時候就能夠很是的簡單的同時把 token 顯式的返回並設置在 Cookie 中了:

var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user, ClaimValueTypes.String)
};
var token = _jwt.GenerateToken(user, claims, DateTime.Now.AddDays(1));
await HttpContext.SignInAsync(user, claims, DateTime.Now.AddDays(1));
return Json(new {Token = token});

看起來咱們終於可以正確的簽發 token 了,然而事情並無結束,咱們尚未把 Cookie 認證及其相關依賴添加到 IoC 容器中,讓咱們直接修改一下前面的操做註冊服務的拓展方法好了:

public static IServiceCollection AddEasyJwt(this IServiceCollection services, EasyJwtOption option)
{
    // 略

    var authBuilder = services.AddAuthentication(authOptions =>
        {
            // 默認使用 Cookie 認證方式
            authOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(jwtOptions =>
        {
            // 略
        });
    // 啓用
    if (option.EnableCookie)
    {
        // 註冊 DataProtector 服務
        services.AddDataProtection(dpOptions =>
        {
            dpOptions.ApplicationDiscriminator = $"app-{option.Issuer}";
        });
        // 註冊 TicketSerializer 服務
        services.AddScoped<IDataSerializer<AuthenticationTicket>, TicketSerializer>();
        
        var tmpProvider = services.BuildServiceProvider();
        var protectionProvider = tmpProvider.GetService<IDataProtectionProvider>();
        var dataProtector = protectionProvider.CreateProtector("jwt-cookie");
        authBuilder.AddCookie(options =>
        {
            // 設置 Cookie 內容格式
            options.TicketDataFormat =
                new EasyJwtAuthTicketFormat(jwtParams,
                    tmpProvider.GetService<IDataSerializer<AuthenticationTicket>>(),
                    dataProtector);
            options.ClaimsIssuer = option.Issuer;
            options.LoginPath = "/Login";
            options.AccessDeniedPath = "/Login";
            options.Cookie.HttpOnly = true;
            options.Cookie.Name = "tk";
            option.CookieOptions?.Invoke(options);
        });
    }

    return services;
}

至此,咱們終於可以完整的讓 Jwt 的功能運行起來了。

成果展現

那麼如何在一個空白的項目中使用 EasyJwt 認證呢?

1. 註冊服務

// Startup.ConfigureServices

// 使用對稱加密算法
services.AddEasyJwt(new EasySymmetricOptions("test")
{
    Audience = "test",
    Issuer = "test",
    EnableCookie = true
});

// 或者你可使用非對稱加密算法
services.AddEasyJwt(new EasyRSAOptions(PlatformServices.Default.Application.ApplicationBasePath)
{
    Audience = "test",
    Issuer = "test",
    EnableCookie = true
});

2. 添加認證中間件

// Startup.Configure

app.UseAuthentication();

3. 編寫本身的登陸註冊控制器

/// <summary>
/// 演示性登陸 API,返回新的 token 並設置 Cookie
/// </summary>
/// <param name="user"></param>
[HttpPost]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
public async Task<IActionResult> Post([FromForm]string user)
{
    // 假的用戶信息
    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, user, ClaimValueTypes.String)
    };
    var token = _jwt.GenerateToken(user, claims, DateTime.Now.AddDays(1));
    await HttpContext.SignInAsync(user, claims, DateTime.Now.AddDays(1));
    return Json(new {Token = token});
}

4. 使用 EasyJwtAuthorize 認證過濾器保護你的 API 或者 MVC 控制器

// POST api/<controller>
[EasyJwtAuthorize]
[HttpPost]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
public string Post([FromForm]string value)
{
    var userName = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
    return userName;
}

終於,通過咱們一系列的魔改,咱們能夠很是快速的來構建一個使用 Jwt 來進行身份認證的網站了。

本文的所有代碼您均可以在個人這個項目中找到,或者,若是您想在您的項目中試試我寫的這個小拓展,能夠直接使用 dotnet cli 來安裝:

dotnet add package ZeekoUtilsPack.AspNetCore --source https://www.myget.org/F/zeekoget/api/v3/index.json

能夠改進的地方

  1. 加入吊銷 token 的功能
  2. 移除 EasyJwtAuthTicketFormat 中冗餘的代碼
相關文章
相關標籤/搜索