ASP.NET Web API 的 JWT 认证

351

我正在尝试在我的Web API应用程序中支持JWT承载令牌(JSON Web Token),但我迷失了方向。

我看到.NET Core和OWIN应用程序都有支持。
我目前将我的应用程序托管在IIS中。

我该如何在我的应用程序中实现此身份验证模块? 有没有办法我可以使用<authentication>配置类似于我使用表单/Windows身份验证的方式?

7个回答

797
我在4年前回答了这个问题:如何保护ASP.NET Web API ,使用了HMAC。
目前,安全领域发生了很多变化,特别是JWT变得越来越流行。在这个答案中,我将尝试以最简单和基础的方式解释如何使用JWT,以免被OWIN、Oauth2、ASP.NET Identity等内容所迷惑。
如果您不了解JWT令牌,您需要查看以下内容:

https://www.rfc-editor.org/rfc/rfc7519

基本上,JWT令牌看起来像这样:

<base64-encoded header>.<base64-encoded claims>.<base64-encoded signature>

例子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6ImN1b25nIiwibmJmIjoxNDc3NTY1NzI0LCJleHAiOjE0Nzc1NjY5MjQsImlhdCI6MTQ3NzU2NTcyNH0.6MzD1VwA5AcOcajkFyKhLYybr3h13iZjDyHm9zysDFQ

一个JWT令牌有三个部分:
  1. 头部:以Base64编码的JSON格式
  2. 负载:以Base64编码的JSON格式。
  3. 签名:根据头部和负载创建并签名,以Base64编码。

如果您使用上面的令牌在jwt.io网站上,您可以解码该令牌并查看如下:

A screenshot of jwt.io with the raw jwt source and the decoded JSON it represents

技术上,JWT使用一个签名,该签名是从头部和声明中使用指定的安全算法(例如:HMACSHA256)签署的。因此,如果在其声明中存储任何敏感信息,则必须通过HTTPs传输JWT。

现在,为了使用JWT身份验证,如果您有一个遗留的Web Api系统,您实际上不需要OWIN中间件。简单的概念是如何提供JWT令牌以及在请求到达时如何验证令牌。就这样。

在我创建的演示(github)中,为了使JWT令牌轻量化,我只存储用户名过期时间。但是这种方式,如果您想进行角色授权等操作,则必须重新构建新的本地身份(principal)以添加更多信息。但是,如果您想将更多信息添加到JWT中,那么就由您决定:它非常灵活。

您可以使用控制器动作简单地提供JWT令牌终结点,而无需使用OWIN中间件:

public class TokenController : ApiController
{
    // This is naive endpoint for demo, it should use Basic authentication
    // to provide token or POST request
    [AllowAnonymous]
    public string Get(string username, string password)
    {
        if (CheckUser(username, password))
        {
            return JwtManager.GenerateToken(username);
        }

        throw new HttpResponseException(HttpStatusCode.Unauthorized);
    }

    public bool CheckUser(string username, string password)
    {
        // should check in the database
        return true;
    }
}

这是一种天真的做法;在生产环境中,应该使用POST请求或基本身份验证端点来提供JWT令牌。

如何基于用户名生成令牌?

您可以使用来自Microsoft的NuGet软件包System.IdentityModel.Tokens.Jwt生成令牌,或者您喜欢的其他软件包。 在演示中,我使用了HMACSHA256SymmetricKey

/// <summary>
/// Use the below code to generate symmetric Secret Key
///     var hmac = new HMACSHA256();
///     var key = Convert.ToBase64String(hmac.Key);
/// </summary>
private const string Secret = "db3OIsj+BXE9NZDy0t8W3TcNekrF+2d/1sFnWG4HnV8TZY30iTOdtVWJG8abWvB1GlOgJuQZdcF2Luqm/hccMw==";

public static string GenerateToken(string username, int expireMinutes = 20)
{
    var symmetricKey = Convert.FromBase64String(Secret);
    var tokenHandler = new JwtSecurityTokenHandler();

    var now = DateTime.UtcNow;
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.Name, username)
        }),

        Expires = now.AddMinutes(Convert.ToInt32(expireMinutes)),
        
        SigningCredentials = new SigningCredentials(
            new SymmetricSecurityKey(symmetricKey), 
            SecurityAlgorithms.HmacSha256Signature)
    };

    var stoken = tokenHandler.CreateToken(tokenDescriptor);
    var token = tokenHandler.WriteToken(stoken);

    return token;
}

提供JWT令牌的终点已完成。

请求到来时如何验证JWT?

演示中,我构建了JwtAuthenticationAttribute,它继承自IAuthenticationFilter(有关身份验证过滤器的更多详细信息,请参见此处)。

使用此属性,您可以对任何操作进行身份验证:只需在该操作上放置此属性即可。

public class ValueController : ApiController
{
    [JwtAuthentication]
    public string Get()
    {
        return "value";
    }
}

你可以使用OWIN中间件或DelegateHandler来验证所有传入的WebAPI请求(不特定于Controller或action)。
以下是身份验证过滤器的核心方法:
private static bool ValidateToken(string token, out string username)
{
    username = null;

    var simplePrinciple = JwtManager.GetPrincipal(token);
    var identity = simplePrinciple.Identity as ClaimsIdentity;

    if (identity == null || !identity.IsAuthenticated)
        return false;

    var usernameClaim = identity.FindFirst(ClaimTypes.Name);
    username = usernameClaim?.Value;

    if (string.IsNullOrEmpty(username))
       return false;

    // More validate to check whether username exists in system

    return true;
}

protected Task<IPrincipal> AuthenticateJwtToken(string token)
{
    string username;

    if (ValidateToken(token, out username))
    {
        // based on username to get more information from database 
        // in order to build local identity
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, username)
            // Add more claims if needed: Roles, ...
        };

        var identity = new ClaimsIdentity(claims, "Jwt");
        IPrincipal user = new ClaimsPrincipal(identity);

        return Task.FromResult(user);
    }

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

工作流程是使用JWT库(上面的NuGet包)验证JWT令牌,然后返回ClaimsPrincipal。您可以执行更多验证,例如检查用户是否存在于系统中,并在需要时添加其他自定义验证。

验证JWT令牌并获取主体的代码:

public static ClaimsPrincipal GetPrincipal(string token)
{
    try
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var jwtToken = tokenHandler.ReadToken(token) as JwtSecurityToken;

        if (jwtToken == null)
            return null;

        var symmetricKey = Convert.FromBase64String(Secret);

        var validationParameters = new TokenValidationParameters()
        {
            RequireExpirationTime = true,
            ValidateIssuer = false,
            ValidateAudience = false,
            IssuerSigningKey = new SymmetricSecurityKey(symmetricKey)
        };

        SecurityToken securityToken;
        var principal = tokenHandler.ValidateToken(token, validationParameters, out securityToken);

        return principal;
    }
    catch (Exception)
    {
        //should write log
        return null;
    }
}

如果JWT令牌已验证并返回主体,则应构建新的本地标识并添加更多信息以检查角色授权。请记得在全局范围内添加config.Filters.Add(new AuthorizeAttribute());(默认授权)以防止任何匿名请求访问您的资源。您可以使用Postman测试演示。请求令牌(如上所述,仅用于演示)。
GET http://localhost:{port}/api/token?username=cuong&password=1

在授权请求的头部中放置JWT令牌,例如:
GET http://localhost:{port}/api/value

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6ImN1b25nIiwibmJmIjoxNDc3NTY1MjU4LCJleHAiOjE0Nzc1NjY0NTgsImlhdCI6MTQ3NzU2NTI1OH0.dSwwufd4-gztkLpttZsZ1255oEzpWCJkayR_4yvNL1s

这个演示可以在这里找到:https://github.com/cuongle/WebApi.Jwt


8
@Cuong Le已经讲解得很清楚了,但我想再补充一点:如果你正在使用OWIN,请检查Microsoft.Owin.Security.Jwt中提供的UseJwtBearerAuthentication方法。你可以在WebAPI上使用此OWIN中间件自动验证每个传入请求。使用OWIN启动类来注册这个中间件。 - Jek
6
您无需设置响应的 token,token 应该存储在客户端的其他位置上。对于 web 应用,您可以将其放置在本地存储中,在每次发送 HTTP 请求时将 token 放置在请求头中即可。请注意不要改变原意。 - cuongle
17
哇,这是我很长时间以来见过的最简单的解释。如果可以的话,+100分。 - gyozo kudor
5
@Homam:很抱歉回复晚了,生成的最佳方法是:var hmac = new HMACSHA256();var key = Convert.ToBase64String(hmac.Key); - cuongle
8
使用CuongLe代码库中的演示代码的人会注意到一个bug,即未带授权头的请求不被处理,这意味着任何没有授权头的查询都可以通过(端点安全性不高!)。@magicleon提出了一个拉取请求来解决这个问题,链接在这里:https://github.com/cuongle/WebApi.Jwt/pull/4 - Chucky
显示剩余57条评论

21

我只需最小的努力就能做到这一点(与ASP.NET Core同样简单)。

为此,我使用OWIN Startup.cs文件和Microsoft.Owin.Security.Jwt库。

为了让应用程序命中Startup.cs,我们需要修改Web.config

<configuration>
  <appSettings>
    <add key="owin:AutomaticAppStartup" value="true" />
    ...

以下是 Startup.cs 文件的样例:

using MyApp.Helpers;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Jwt;
using Owin;

[assembly: OwinStartup(typeof(MyApp.App_Start.Startup))]

namespace MyApp.App_Start
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            app.UseJwtBearerAuthentication(
                new JwtBearerAuthenticationOptions
                {
                    AuthenticationMode = AuthenticationMode.Active,
                    TokenValidationParameters = new TokenValidationParameters()
                    {
                        ValidAudience = ConfigHelper.GetAudience(),
                        ValidIssuer = ConfigHelper.GetIssuer(),
                        IssuerSigningKey = ConfigHelper.GetSymmetricSecurityKey(),
                        ValidateLifetime = true,
                        ValidateIssuerSigningKey = true
                    }
                });
        }
    }
}

现在很多人都在使用ASP.NET Core,所以你可以看到它与我们之前用的差别不大。

一开始我真的被搞糊涂了,我试图实现自定义提供程序等等。但我没有想到这会是如此简单。OWIN真是太棒了!

只有一件事要提醒大家——启用OWIN Startup后,NSWag库就对我不起作用了(例如,有些人可能想为Angular应用程序自动生成TypeScript HTTP代理)。

解决方案也很简单——我用Swashbuckle替换了NSWag,之后就没有遇到任何问题了。


好的,现在分享ConfigHelper代码:

public class ConfigHelper
{
    public static string GetIssuer()
    {
        string result = System.Configuration.ConfigurationManager.AppSettings["Issuer"];
        return result;
    }

    public static string GetAudience()
    {
        string result = System.Configuration.ConfigurationManager.AppSettings["Audience"];
        return result;
    }

    public static SigningCredentials GetSigningCredentials()
    {
        var result = new SigningCredentials(GetSymmetricSecurityKey(), SecurityAlgorithms.HmacSha256);
        return result;
    }

    public static string GetSecurityKey()
    {
        string result = System.Configuration.ConfigurationManager.AppSettings["SecurityKey"];
        return result;
    }

    public static byte[] GetSymmetricSecurityKeyAsBytes()
    {
        var issuerSigningKey = GetSecurityKey();
        byte[] data = Encoding.UTF8.GetBytes(issuerSigningKey);
        return data;
    }

    public static SymmetricSecurityKey GetSymmetricSecurityKey()
    {
        byte[] data = GetSymmetricSecurityKeyAsBytes();
        var result = new SymmetricSecurityKey(data);
        return result;
    }

    public static string GetCorsOrigins()
    {
        string result = System.Configuration.ConfigurationManager.AppSettings["CorsOrigins"];
        return result;
    }
}

另一个重要的方面是 - 我通过Authorization头发送JWT令牌,因此我的TypeScript代码如下所示:

(下面的代码由NSWag生成)

@Injectable()
export class TeamsServiceProxy {
    private http: HttpClient;
    private baseUrl: string;
    protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;

    constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) {
        this.http = http;
        this.baseUrl = baseUrl ? baseUrl : "https://localhost:44384";
    }

    add(input: TeamDto | null): Observable<boolean> {
        let url_ = this.baseUrl + "/api/Teams/Add";
        url_ = url_.replace(/[?&]$/, "");

        const content_ = JSON.stringify(input);

        let options_ : any = {
            body: content_,
            observe: "response",
            responseType: "blob",
            headers: new HttpHeaders({
                "Content-Type": "application/json", 
                "Accept": "application/json",
                "Authorization": "Bearer " + localStorage.getItem('token')
            })
        };

请查看头部部分 - "Authorization": "Bearer " + localStorage.getItem('token')


我用Swashbuckle替换了NSWag,之后没有遇到任何问题。Swashbuckle是否具有生成TypeScript文件的功能,还是您自己添加了该功能? - crush
@crush swashbucle是一个后端库,提供类似于NuGet NSwag库的JSON功能,但更好。为了生成TypeScript文件,您仍应该使用来自npm的NSwag包。 - Alex Herman
没错,我已经在我的项目中使用了一段时间的Swashbuckle,听起来你是在建议它可以生成TypeScript模型而不是nswag。我不喜欢nswag...它很重。我创建了自己的C#->TypeScript转换,并将其连接到Swashbuckle - 作为后构建过程生成文件,并将它们发布到我们项目的npm feed中。我只是想确保我没有忽略一个已经做同样事情的Swashbuckle项目。 - crush

14

这是一个使用JWT令牌在ASP.NET Core Web API中实现Claims based身份验证的极简且安全的方法。

首先,您需要公开一个端点,该端点返回一个分配给用户的JWT令牌与Claim。


 /// <summary>
        /// Login provides API to verify user and returns authentication token.
        /// API Path:  api/account/login
        /// </summary>
        /// <param name="paramUser">Username and Password</param>
        /// <returns>{Token: [Token] }</returns>
        [HttpPost("login")]
        [AllowAnonymous]
        public async Task<IActionResult> Login([FromBody] UserRequestVM paramUser, CancellationToken ct)
        {

            var result = await UserApplication.PasswordSignInAsync(paramUser.Email, paramUser.Password, false, lockoutOnFailure: false);

            if (result.Succeeded)
            {
                UserRequestVM request = new UserRequestVM();
                request.Email = paramUser.Email;


                ApplicationUser UserDetails = await this.GetUserByEmail(request);
                List<ApplicationClaim> UserClaims = await this.ClaimApplication.GetListByUser(UserDetails);

                var Claims = new ClaimsIdentity(new Claim[]
                                {
                                    new Claim(JwtRegisteredClaimNames.Sub, paramUser.Email.ToString()),
                                    new Claim(UserId, UserDetails.UserId.ToString())
                                });


                //Adding UserClaims to JWT claims
                foreach (var item in UserClaims)
                {
                    Claims.AddClaim(new Claim(item.ClaimCode, string.Empty));
                }

                var tokenHandler = new JwtSecurityTokenHandler();
                  // this information will be retrived from you Configuration
                //I have injected Configuration provider service into my controller
                var encryptionkey = Configuration["Jwt:Encryptionkey"];
                var key = Encoding.ASCII.GetBytes(encryptionkey);
                var tokenDescriptor = new SecurityTokenDescriptor
                {
                    Issuer = Configuration["Jwt:Issuer"],
                    Subject = Claims,

                // this information will be retrived from you Configuration
                //I have injected Configuration provider service into my controller
                    Expires = DateTime.UtcNow.AddMinutes(Convert.ToDouble(Configuration["Jwt:ExpiryTimeInMinutes"])),

                    //algorithm to sign the token
                    SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)

                };

                var token = tokenHandler.CreateToken(tokenDescriptor);
                var tokenString = tokenHandler.WriteToken(token);

                return Ok(new
                {
                    token = tokenString
                });
            }

            return BadRequest("Wrong Username or password");
        }

现在您需要在ConfigureServices中添加身份验证到您的服务,在您的startup.cs中添加JWT身份验证作为默认身份验证服务,如下所示:

services.AddAuthentication(x =>
            {
                x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            })
             .AddJwtBearer(cfg =>
             {
                 cfg.RequireHttpsMetadata = false;
                 cfg.SaveToken = true;
                 cfg.TokenValidationParameters = new TokenValidationParameters()
                 {
                     //ValidateIssuerSigningKey = true,
                     IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Encryptionkey"])),
                     ValidateAudience = false,
                     ValidateLifetime = true,
                     ValidIssuer = configuration["Jwt:Issuer"],
                     //ValidAudience = Configuration["Jwt:Audience"],
                     //IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JWT:Key"])),
                 };
             });

现在,您可以像这样向授权服务添加策略:

services.AddAuthorization(options =>
            {
                options.AddPolicy("YourPolicyNameHere",
                                policy => policy.RequireClaim("YourClaimNameHere"));
            });

或者,您也可以(非必需)从数据库中填充所有声明,因为这只会在应用程序启动时运行一次,并将它们添加到策略中,如下所示:

  services.AddAuthorization(async options =>
            {
                var ClaimList = await claimApplication.GetList(applicationClaim);
                foreach (var item in ClaimList)
                {                        
                    options.AddPolicy(item.ClaimCode, policy => policy.RequireClaim(item.ClaimCode));                       
                }
            });

现在,您可以像这样在任何想要获得授权的方法上放置策略过滤器:

 [HttpPost("update")]
        [Authorize(Policy = "ACC_UP")]
        public async Task<IActionResult> Update([FromBody] UserRequestVM requestVm, CancellationToken ct)
        {
//your logic goes here
}

希望这能帮到您。

7
在我的情况下,JWT由单独的API创建,因此ASP.NET只需要解码和验证它。与被接受的答案相反,我们使用的是非对称算法RSA,因此上面提到的SymmetricSecurityKey类不起作用。
以下是结果。
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Threading;
using System.Threading.Tasks;

    public static async Task<JwtSecurityToken> VerifyAndDecodeJwt(string accessToken)
    {
        try
        {
            var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>($"{securityApiOrigin}/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever());
            var openIdConfig = await configurationManager.GetConfigurationAsync(CancellationToken.None);
            var validationParameters = new TokenValidationParameters()
            {
                ValidateLifetime = true,
                ValidateAudience = false,
                ValidateIssuer = false,
                RequireSignedTokens = true,
                IssuerSigningKeys = openIdConfig.SigningKeys,
            };
            new JwtSecurityTokenHandler().ValidateToken(accessToken, validationParameters, out var validToken);
            // threw on invalid, so...
            return validToken as JwtSecurityToken;
        }
        catch (Exception ex)
        {
            logger.Info(ex.Message);
            return null;
        }
    }

5
我认为你应该使用第三方服务器来支持JWT令牌,而WEB API 2没有开箱即用的JWT支持。
但是,有一个OWIN项目可以支持某些格式的签名令牌(不是JWT)。它作为简化的OAuth协议工作,为网站提供了一种简单的身份验证形式。
你可以在这里阅读更多信息:这里
虽然内容比较长,但大部分都是关于控制器和ASP.NET Identity的细节,您可能根本不需要。最重要的是:

步骤9:添加对OAuth Bearer Tokens生成的支持

步骤12:测试后端API

在那里,您可以了解如何设置端点(例如“/token”),以便从前端访问(以及请求格式的详细信息)。
其他步骤提供了有关如何将该端点连接到数据库等的详细信息,您可以选择所需的部分。

1

您无需使用奇怪的JwtSecurityTokenHandler API

使用简单的API的JwtUtils Nuget包

var claims =  new Dictionary<string, object>
{
   { "exp", 1639942616 },
   { "uname", "i.a.ivanov" },
   { "claim1", "claim1_value" },   
   { "claims_array", new [] {"claim_item1", "claim_item2"}}
};
       
string token = JWT.HS256.Create(claims, "{TOKEN_SECRET}");

不支持最新的框架版本。 - Frederik Gysel
它支持NetStandard2.1。你在使用哪个框架? - ZOXEXIVO
.NET Framework 4.7.2 - Frederik Gysel
无法将其移植到4.7.2,因为最新的.NET Framework是4.8,现在已经转移到.NET 6。 - ZOXEXIVO

0

您可以按照此代码来使用令牌控制器,或者更多详情请访问此处:如何使用JWT令牌保护API。使用ASP.NET Core和Entity Framework Core以及Swagger构建CRUD API使用JWT令牌

从这里您可以学习到非常简单的使用JWT令牌的方法

using JWTToken.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace JWTToken.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TokenController : ControllerBase
    {
        public IConfiguration _configuration;
        private readonly InventoryContext _context;

        public TokenController(IConfiguration config, InventoryContext context)
        {
            _configuration = config;
            _context = context;
        }

        [HttpPost]
        public async Task<IActionResult> Post(UserInfo _userData)
        {

            if (_userData != null && _userData.Email != null && _userData.Password != null)
            {
                var user = await GetUser(_userData.Email, _userData.Password);

                if (user != null)
                {
                    //create claims details based on the user information
                    var claims = new[] {
                    new Claim(JwtRegisteredClaimNames.Sub, _configuration["Jwt:Subject"]),
                    new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
                    new Claim(JwtRegisteredClaimNames.Iat, DateTime.UtcNow.ToString()),
                    new Claim("Id", user.UserId.ToString()),
                    new Claim("FirstName", user.FirstName),
                    new Claim("LastName", user.LastName),
                    new Claim("UserName", user.UserName),
                    new Claim("Email", user.Email)
                   };

                    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));

                    var signIn = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

                    var token = new JwtSecurityToken(_configuration["Jwt:Issuer"], _configuration["Jwt:Audience"], claims, expires: DateTime.UtcNow.AddDays(1), signingCredentials: signIn);

                    return Ok(new JwtSecurityTokenHandler().WriteToken(token));
                }
                else
                {
                    return BadRequest("Invalid credentials");
                }
            }
            else
            {
                return BadRequest();
            }
        }

        private async Task<UserInfo> GetUser(string email, string password)
        {
            return await _context.UserInfos.FirstOrDefaultAsync(u => u.Email == email && u.Password == password);
        }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接