AspNet Core: Jwt 身份认证

发布时间 2023-11-21 17:26:29作者: 一只小青蛙-呱-呱-dyj

AspNet Core: Jwt 身份认证

 

 

 

AspNet Core: Jwt 身份认证

资源服务器

创建项目

新建一个“AspNetCore WebApi” 项目,名为:DotNet.WebApi.Jwt.ApiResources

依赖包

添加依赖包:

<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.3" />

添加API

新建控制器 Controllers/StudentController.cs:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DotNet.WebApi.Jwt.ApiResources.Controllers
{
    //[Authorize(Policy = "OnlyRead")]
    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class StudentController : ControllerBase
    {
        [Authorize(Policy = "ReadWrite")]
        [HttpGet("GetStudents")]
        public ActionResult<dynamic> GetStudents()
        {
            return new List<dynamic>()
            {
                new {Id=1,Name="张三",Age=21 },
                new {Id=2,Name="李四",Age=22 },
                new {Id=3,Name="王五",Age=23 },
            };
        }

        [Authorize(Policy = "OnlyRead")]
        [HttpGet("GetStudent")]
        public ActionResult<dynamic> GetStudent()
        {
            return new List<dynamic>()
            {
            new { Id = 10, Name = "钱六", Age = 19 }
            };
        }
    }
}

Program

将 Program.cs 修改为:


using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

namespace DotNet.WebApi.Jwt.ApiResources
{
    public class Program
    {
        public static void Main(string[] args)
        {
            Console.Title = "API资源服务器";

            var builder = WebApplication.CreateBuilder(args);

            //设置跨域
            builder.Services.AddCors(options =>
            {
                options.AddDefaultPolicy(
                    builder =>
                    {
                        //允许任何来源访问。
                        builder.AllowAnyOrigin().AllowAnyHeader();
                        //将isexpired头添加到策略中。
                        builder.WithExposedHeaders(new string[] { "isexpired" });
                    });
            });

            //配置策略授权
            builder.Services.AddAuthorization(options => {
                options.AddPolicy("OnlyRead", policy => policy.RequireRole("Read").Build());
            });

            //配置JWT。
            builder.Services.AddAuthentication(a =>
            {
                a.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                a.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(j =>
            {
                j.RequireHttpsMetadata = false;
                j.SaveToken = true;
                j.TokenValidationParameters = new TokenValidationParameters
                {
                    //是否调用对签名securityToken的SecurityKey进行验证
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("798654167464654646")),//签名秘钥
                    ValidateIssuer = true,//是否验证颁发者
                    ValidIssuer = "dotnet-jwt",//颁发者
                    ValidateAudience = true, //是否验证接收者
                    ValidAudience = "StudentAPI",//接收者
                    ValidateLifetime = true,//是否验证失效时间
                };
                //捕获Token过期事件
                j.Events = new JwtBearerEvents
                {
                    OnAuthenticationFailed = context =>
                    {
                        //出现此类异常。
                        if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                        {
                            //在响应头中添加isexpired:true键值对。
                            context.Response.Headers.Add("isexpired", "true");
                        }
                        return Task.CompletedTask;
                    }
                };
            });

            builder.Services.AddAuthorization();
            builder.Services.AddControllers();
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            if (app.Environment.IsProduction())
            {
                //生产环境端口号
                app.Urls.Add("https://*:6002");
            }

            app.UseHttpsRedirection();
            app.UseCors();   //启用跨域
            app.UseAuthentication(); //身份验证
            app.UseAuthorization();  //授权
            app.MapControllers();

            app.Run();
        }
    }
}

代码解析:
(1)添加JWT 身份认证中间件

            //配置JWT。
            builder.Services.AddAuthentication(a =>
            {
                a.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                a.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(j =>
            {
                j.RequireHttpsMetadata = false;
                j.SaveToken = true;
                j.TokenValidationParameters = new TokenValidationParameters
                {
                    //是否调用对签名securityToken的SecurityKey进行验证
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("798654167464654646")),//签名秘钥
                    ValidateIssuer = true,//是否验证颁发者
                    ValidIssuer = "dotnet-jwt",//颁发者
                    ValidateAudience = true, //是否验证接收者
                    ValidAudience = "StudentAPI",//接收者
                    ValidateLifetime = true,//是否验证失效时间
                };
                //捕获Token过期事件
                j.Events = new JwtBearerEvents
                {
                    OnAuthenticationFailed = context =>
                    {
                        //出现此类异常。
                        if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                        {
                            //在响应头中添加isexpired:true键值对。
                            context.Response.Headers.Add("isexpired", "true");
                        }
                        return Task.CompletedTask;
                    }
                };
            });

(2)捕获 Token 事件,处理refreshToken:

                //捕获Token过期事件
                j.Events = new JwtBearerEvents
                {
                    OnAuthenticationFailed = context =>
                    {
                        //出现此类异常。
                        if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                        {
                            //在响应头中添加isexpired:true键值对。
                            context.Response.Headers.Add("isexpired", "true");
                        }
                        return Task.CompletedTask;
                    }
                };

(3)添加基于策略的授权:

            //配置策略授权
            builder.Services.AddAuthorization(options => {
                options.AddPolicy("OnlyRead", policy => policy.RequireRole("Read").Build());
            });

然后在控制器中使用基于策略的授权:
代码:Controllers/StudentController.cs:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DotNet.WebApi.Jwt.ApiResources.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class StudentController : ControllerBase
    {
        [Authorize(Policy = "ReadWrite")]
        [HttpGet("GetStudents")]
        public ActionResult<dynamic> GetStudents()
        {
            ......
        }

        [Authorize(Policy = "OnlyRead")]
        [HttpGet("GetStudent")]
        public ActionResult<dynamic> GetStudent()
        {
            ......
        }
    }
}

认证服务器

创建项目

新建一个“AspNetCore 空” 项目,名为:DotNet.WebApi.Jwt.Authentication

依赖包

添加依赖包:

<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.3" />

数据库

创建一个数据库,用于保存登录用户

JWTUser

namespace DotNet.WebApi.Jwt.Authentication.Data
{
    public class JWTUser
    {
        //用户Id。
        [Key]
        public int UserId { get; set; }
        //用户名。
        public string? UserName { get; set; }
        //用户密码。
        public string? UserPwd { get; set; }
        //用户邮箱。
        public string? UserEmail { get; set; }
    }
}

JWTDbContext

using Microsoft.EntityFrameworkCore;

namespace DotNet.WebApi.Jwt.Authentication.Data
{
    /// <summary>
    /// 数据库上下文。
    /// </summary>
    public class JWTDbContext : DbContext
    {
        public JWTDbContext(DbContextOptions<JWTDbContext> options) : base(options)
        {
        }

        public DbSet<JWTUser>? JWTUsers { get; set; }
    }
}

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "JwtDbConnection": "Server=localhost;Database=JWTDb;Uid=sa;Pwd=123456;Encrypt=True;TrustServerCertificate=True;"
  }
}

用户注册

Controllers/AccountController.cs

using DotNet.WebApi.Jwt.Authentication.Data;
using Microsoft.AspNetCore.Mvc;

namespace DotNet.WebApi.Jwt.Authentication.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AccountController : ControllerBase
    {
        private readonly JWTDbContext _context;

        public AccountController(JWTDbContext context)
        {
            _context = context;
        }

        /// <summary>
        /// 添加用户。
        /// </summary>
        /// <param name="user"></param>
        /// <returns></returns>
        [HttpPost("Register")]
        public async Task<ActionResult<int>> RegisterUser(JWTUser user)
        {
            //如果user参数为空,则返回404错误。
            if (user == null) return NotFound();
            //添加用户
            _context.JWTUsers?.Add(user);
            //执行操作。
            var count = await _context.SaveChangesAsync();
            return count;
        }
    }
}

Token 控制器

Controllers/TokenController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using DotNet.WebApi.Jwt.Authentication.Data;

namespace DotNet.WebApi.Jwt.Authentication.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TokenController : ControllerBase
    {
        private readonly JWTDbContext _context;

        private const string signingKey = "798654167464654646";
        private const string issuer = "dotnet-jwt";
        private const string audience = "StudentAPI";

        public TokenController(JWTDbContext context)
        {
            _context = context;
        }

        /// <summary>
        /// 生成Token
        /// </summary>
        /// <returns></returns>
        [HttpGet("Get")]
        public async Task<ActionResult> BuildAccessToken(string userName, string userPwd)
        {
            //判断用户信息是否为空
            if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(userPwd))
            {
                return NotFound();
            }
                
            //根据用户名和密码找到用户实体
            var user = await _context.JWTUsers!.AsNoTracking()
                .FirstOrDefaultAsync( u =>
                   u.UserName!.Equals(userName) && u.UserPwd!.Equals(userPwd)
                 );
            if (user == null) 
            {
                return BadRequest("用户名或密码错误。");
            }
                
            //声明
            var claims = new[]
            {
                new Claim(ClaimTypes.Sid,user.UserId.ToString()),
                new Claim(ClaimTypes.Name, userName),
                new Claim(ClaimTypes.Role,"Read")
            };
            //设置密钥
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
            //设置凭据
            var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            //生成token
            var jwtToken = new JwtSecurityToken(issuer, 
                                                audience, 
                                                claims, 
                                                expires: DateTime.UtcNow.AddMinutes(30), 
                                                signingCredentials: credentials);
            var token = new JwtSecurityTokenHandler().WriteToken(jwtToken);

            return Ok(token);
        }

        /// <summary>
        /// 根据Token获取身份声明。
        /// </summary>
        /// <param name="token">token</param>
        /// <returns></returns>
        private ClaimsPrincipal GetPrincipalFromAccessToken(string token)
        {
            var jwtSecurityToken = new JwtSecurityTokenHandler();
            var claimsPrincipal = jwtSecurityToken.ValidateToken(token, new TokenValidationParameters
            {
                ValidateAudience = false,
                ValidateIssuer = false,
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
                ValidateLifetime = false
            }, out SecurityToken validatedToken);

            return claimsPrincipal;
        }

        /// <summary>
        /// 根据旧Token换取新Token
        /// </summary>
        /// <param name="accessToken"></param>
        /// <returns></returns>
        [HttpGet("Refresh")]
        public ActionResult BuildRefreshToken(string accessToken)
        {
            if (string.IsNullOrWhiteSpace(accessToken)) return NotFound();
            var userClaims = GetPrincipalFromAccessToken(accessToken);
            if (userClaims == null) return NotFound();
            
            //获取旧Token中的声明
            var claims = new[]
            {
                //用户ID
                new Claim(ClaimTypes.Sid,userClaims.FindFirst(u=>u.Type.Equals(ClaimTypes.Sid))!.Value),
                //用户名
                new Claim(ClaimTypes.Name,userClaims.FindFirst(u=>u.Type.Equals(ClaimTypes.Name))!.Value),
                //角色
                new Claim(ClaimTypes.Role,userClaims.FindFirst(u=>u.Type.Equals(ClaimTypes.Role))!.Value)
            };
            //设置密钥
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
            //设置凭据
            var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            //生成token
            var jwtToken = new JwtSecurityToken(issuer, audience, claims, expires: DateTime.UtcNow.AddMinutes(30), signingCredentials: credentials);
            var token = new JwtSecurityTokenHandler().WriteToken(jwtToken);

            return Ok(token);
        }
    }
}

代码分析:
(1).生成token:BuildAccessToken();
(2).刷新token:BuildRefreshToken(accessToken),调用GetPrincipalFromAccessToken(accessToken) 方法传入过期的 token 解析出 userClaims,用于生成新的token。

Program

Program.cs


using DotNet.WebApi.Jwt.Authentication.Data;
using Microsoft.EntityFrameworkCore;

namespace DotNet.WebApi.Jwt.Authentication
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            //注册数据库上下文服务
            //UseSqlServer表示使用SQLServer数据库。
            builder.Services.AddDbContext<JWTDbContext>(options =>
                options.UseSqlServer(builder.Configuration.GetConnectionString("JwtDbConnection")
            ));

            //设置跨域
            builder.Services.AddCors(options =>
            {
                options.AddDefaultPolicy(
                    builder =>
                    {
                        //允许任何来源访问。
                        builder.AllowAnyOrigin().AllowAnyHeader();
                    });
            });

            builder.Services.AddAuthorization();
            builder.Services.AddControllers();
            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();
            //生成数据库和表结构
            var scope = app.Services.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<JWTDbContext>();
            //如果数据库不存在,则生成表结构。
            context.Database.EnsureCreated();
           
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.Urls.Add("https://*:6001"); // 修改端口
            app.UseHttpsRedirection();
            app.UseCors();     //启用跨域
            app.UseAuthorization();
            app.MapControllers();

            app.Run();
        }
    }
}

客户端

创建项目

创建一个 “AspNet Core 空项目”,名为:DotNet.WebApi.Jwt.WebClient。这个项目没用到 AspNet Core 的任何功能,仅仅只是作为一个静态文件站点,即:一个纯前端项目。

添加 JS 库

创建"wwwroot"文件夹,然后选择该文件夹,右键【添加/客户端库】,添加 jquery.min.js、bootstrap.min.css 文件。

用户注册

新建Html页面:wwwroot/Users/Register.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>用户注册</title>
    <link href="../bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <script src="../jquery/jquery.min.js"></script>
</head>
<body>
    <div style="padding:20px;width:600px;margin:30px;">
        <h3>用户注册</h3>
        <hr />
        <div class="form-floating">
            <div class="mb-3">
                <label class="form-label">用户名:</label>
                <input type="text" id="userName" class="form-control">
            </div>
            <div class="mb-3">
                <label class="form-label">密  码:</label>
                <input type="password" id="userPwd" class="form-control">
            </div>
            <div class="mb-3">
                <input type="submit" id="btn" value="注册" class="btn btn-primary" />
            </div>
            <div>
                <span id="msg" style="color:red"></span>
            </div>
        </div>
    </div>
    <script>
        $("#btn").click(function () {
            $.ajax({
                //请求类型
                type: "post",
                //请求路径
                url: "https://localhost:6001/api/Account/Register",
                //预期服务器返回的数据类型
                dataType: "text",
                data: JSON.stringify({ UserName: $("#userName").val(), UserPwd: $("#userPwd").val() }),
                contentType: "application/json",
                //请求成功时的回调函数
                success: function (result) {
                    if (result == "1") {
                        $("#msg").text("用户注册成功。");
                    }
                }
            });
        });
    </script>
</body>
</html>

用户登录

新建Html页面:wwwroot/Users/Login.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>用户登录</title>
    <link href="../bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <script src="../jquery/jquery.min.js"></script>
</head>
<body>
    <div style="padding:20px;width:600px;margin:30px;">
        <h3>用户登录</h3>
        <hr />
        <div class="form-floating">
            <div class="mb-3">
                <label class="form-label">用户名:</label>
                <input type="text" id="userName" class="form-control">
            </div>
            <div class="mb-3">
                <label class="form-label">密  码:</label>
                <input type="password" id="userPwd" class="form-control">
            </div>
            <div class="mb-3">
                <input type="submit" id="btn" value="登录" class="btn btn-primary" />
            </div>
            <div class="mb-3">
                <span id="msg" style="color:red"></span>
            </div>
        </div>
    </div>
    <script>
        $("#btn").click(function () {
            $.ajax({
                //请求类型
                type: "get",
                //请求路径
                url: "https://localhost:6001/api/Token/Get",
                //预期服务器返回的数据类型
                dataType: "text",
                data: { UserName: $("#userName").val(), UserPwd: $("#userPwd").val() },
                contentType: "application/json",
                //请求成功时的回调函数
                success: function (token) {
                    localStorage.setItem("token", token);
                    console.log(token);
                    location.href = 'GetData.html';
                }
            });
        });
    </script>
</body>
</html>

代码解析:
(1).获取token:调用 Get请求:https://localhost:6001/api/Token/Get, 获取token。
(2).将token保存到本地存储: localStorage.setItem("token", token);

获取API数据

新建Html页面:wwwroot/Users/GetData.html,内部调用 认证服务器【DotNet.WebApi.Jwt.Authentication】获取 token,然后使用token调用资源服务器【DotNet.WebApi.Jwt.ApiResources】的API

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>获取数据</title>
    <link href="../bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <script src="../jquery/jquery.min.js"></script>
</head>
<body style="margin:20px;">
    <table id="showTable" class="table table-bordered">
        <thead>
            <tr>
                <td>ID</td>
                <td>姓名</td>
                <td>年龄</td>
            </tr>
        </thead>
        <tbody></tbody>
    </table>
    <script>
        var tbody = $("#showTable tbody")
        //请求资源
        $.ajax({
            type: 'get',
            contentType: 'application/json',
            url: 'https://localhost:6002/api/Student/GetStudent',
            beforeSend: function (xhr) {
                //获取Token
                var accessToken = localStorage.getItem("token");
                //使用Token请求资源
                xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
            },
            //获取的数据
            success: function (data) {
                $.each(data, function (n, value) {
                    var trs = "";
                    trs += "<tr>" +
                        "<td>" + value.id + "</td>" +
                        "<td>" + value.name + "</td>" +
                        "<td>" + value.age + "</td>" +
                        "</tr>";
                    tbody += trs;
                });
                $("#showTable").append(tbody);
            },
            error: function (xhr) {
                if (xhr.status === 401 && xhr.getResponseHeader('isexpired') === 'true') {
                    //Token已过期了。
                    getRefreshAccessToken();
                }
            }
        })
        //获取刷新后的新Token。
        function getRefreshAccessToken() {
            $.ajax({
                type: 'get',
                contentType: 'application/json',
                url: 'https://localhost:6001/api/Token/Refresh',
                data: { accessToken: localStorage.getItem("token") },
                success: function (token) {
                    //将获取的新Token存储起来
                    localStorage.setItem("token", token);
                }
            })
        }
    </script>
</body>
</html>

代码解析:
(1) 从本地存储中获取token:var accessToken = localStorage.getItem("token");
(2) 刷新token: 当返回401并且响应头中有‘xhr.getResponseHeader('isexpired') === 'true'’,调用getRefreshAccessToken()从认证服务的
https://localhost:6001/api/Token/Refresh,参数为当前过期的 token,获取新的token。

Program

修改 Program.cs 为:

namespace DotNet.WebApi.Jwt.WebClient
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
            var app = builder.Build();

            app.UseStaticFiles(); //启用静态文件

            app.Run();
        }
    }
}

有关刷新token

上述获取刷新token的方式是通过返回401和返回头中带特定的字段来判断token是否过期,这种做法的缺点就是必须失败一次。更好的做法是,

  • 返回token的请求中包含其过期时间,
  • 前端封装出一个Http请求函数:每次请求前都将当前时间与过期时间比对,若token过期,使用过期的token请求新的token,并重设请求头:authorization:bearer token.