这是StarBlog系列在2023年的第二篇更新😂
这几个月都在忙,更新变得很不勤快,但是拖着不更新我的心里更慌,很久没写,要开头就变得很难😑
说回正题,之前的文章里,我们已经把博客关键的接口都开发完成了,但还少了一个最关键的「认证授权」,少了这东西,网站就跟筛子一样,谁都可以来添加和删除数据,乱套了~
关于「认证授权」的知识,会比较复杂,要学习这块的话,建议分几步:
关于基础概念可以看看我之前写的这篇: Asp-Net-Core学习笔记:身份认证入门
PS:Identity 框架的还没写好😂
为了避免当复读机,本文就不涉及太多概念的东西了,建议先看完上面那篇再来开始使用JWT~
前面介绍文章的CRUD接口时,涉及到修改的接口,都加了 [Authorize]
特性,表示需要登录才能访问,本文就以最简单的方式来实现这个登录认证功能。
在 AspNetCore 中,使用 JWT 的工作流程大概如下:
JWT 还有其他一些特性,比如说是没有状态的,这就很符合我们用的 RESTFul 接口了,不像传统使用 session 和 cookies 那样,原版 JWT 只要签发之后,在有效期结束前就不能取消,用户也没法注销,为了避免泄露 JWT token 导致安全问题,一般过期时间都设置得比较短。(这个不能取消的问题,也是可以通过曲线救国解决的,不过不在本文的讨论范围哈)
OK,说了那么多,还是开始来写代码吧
要生成的话很简单,不需要什么额外的配置,几行代码就搞定了
public LoginToken GenerateLoginToken(User user) { var claims = new List<Claim> { new(JwtRegisteredClaimNames.Sub, user.Id), // User.Identity.Name new(JwtRegisteredClaimNames.GivenName, user.Name), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("jwt key")); var signCredential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var jwtToken = new JwtSecurityToken( issuer: "jwt issuer 签发者", audience: "jwt audience 接受者", claims: claims, expires: DateTime.Now.AddDays(7), signingCredentials: signCredential); return new LoginToken { Token = new JwtSecurityTokenHandler().WriteToken(jwtToken), Expiration = TimeZoneInfo.ConvertTimeFromUtc(jwtToken.ValidTo, TimeZoneInfo.Local) }; }
最开始的 claims
就是前面说的后端往JWT里面存的数据
"The set of claims associated with a given entity can be thought of as a key. The particular claims define the shape of that key; much like a physical key is used to open a lock in a door. In this way, claims are used to gain access to resources." from MSDN
Claim
的构造方法可以接收 key
和 value
参数,都是字符串
对于 key
,.Net 提供了一些常量,在 JwtRegisteredClaimNames
和 ClaimTypes
类里边,这俩的区别就是后者是老的,一般在Windows体系下使用,比如说同样是 Name
这个 key
ClaimTypes.Name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
JwtRegisteredClaimNames.Name = "name"
我们是在 JWT 里面设置 Claim,用 JwtRegisteredClaimNames
就好了
参考:https://stackoverflow.com/questions/50012155/jwt-claim-names
也就是读取放在里面的各个 Claim
在正确配置 Authentication
服务和 JwtBearer
之后,已登录的客户端请求过来,后端可以在 Controller 里面拿到 JWT 数据
像这样
var name = HttpContext.User.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
还可以用 System.Security.Claims.PrincipalExtensions
的扩展方法 FindFirstValue
直接拿到字符串值。
吐槽:如果对应的 Claim 不存在的话,这个扩展方法返回的值是
null
,但不知道为啥,他源码用的是string
作为返回值类型,而不是string?
,真是令人遗憾
了解 JWT 的使用方式之后,终于可以把 JWT 应用到博客项目中了~
为了避免硬编码,我们把 JWT 需要的 Issuer
, Audience
, Key
三个参数写在配置里面
形式如下
"Auth": { "Jwt": { "Issuer": "starblog", "Audience": "starblog-admin-ui", "Key": "F2REaFzQ6xA9k77EUDLf9EnjK5H2wUot" } }
接着需要定义一个类来方便映射配置。
在 StarBlog.Web/Models/Config
下添加 Auth.cs
public class Auth { public Jwt Jwt { get; set; } } public class Jwt { public string Issuer { get; set; } public string Audience { get; set; } public string Key { get; set; } }
注册一下
builder.Services.Configure<Auth>(configuration.GetSection(nameof(Auth)));
这部分代码比较多,写成扩展方法,避免 Program.cs
文件代码太多
添加 StarBlog.Web/Extensions/ConfigureAuth.cs
文件
public static class ConfigureAuth { public static void AddAuth(this IServiceCollection services, IConfiguration configuration) { services.AddScoped<AuthService>(); services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { var authSetting = configuration.GetSection(nameof(Auth)).Get<Auth>(); options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, ValidateLifetime = true, ValidateIssuer = true, ValidateIssuerSigningKey = true, ValidIssuer = authSetting.Jwt.Issuer, ValidAudience = authSetting.Jwt.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authSetting.Jwt.Key)), ClockSkew = TimeSpan.Zero }; }); } }
然后在 Program.cs
里,需要使用这个扩展方法来注册服务
builder.Services.AddAuth(builder.Configuration);
还得配置一下中间件,这个顺序很重要,需要使用身份认证保护的接口或资源,必须放到这俩 Auth...
中间件的后面。
app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); // ... app.MapControllerRoute(...); app.Run();
还是那句话,为了方便使用balabala……
新建 StarBlog.Web/Services/AuthService.cs
文件
public class AuthService { private readonly Auth _auth; private readonly IBaseRepository<User> _userRepo; public AuthService(IOptions<Auth> options, IBaseRepository<User> userRepo) { _auth = options.Value; _userRepo = userRepo; } public LoginToken GenerateLoginToken(User user) { var claims = new List<Claim> { new(JwtRegisteredClaimNames.Sub, user.Id), // User.Identity.Name new(JwtRegisteredClaimNames.GivenName, user.Name), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_auth.Jwt.Key)); var signCredential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var jwtToken = new JwtSecurityToken( issuer: _auth.Jwt.Issuer, audience: _auth.Jwt.Audience, claims: claims, expires: DateTime.Now.AddDays(7), signingCredentials: signCredential); return new LoginToken { Token = new JwtSecurityTokenHandler().WriteToken(jwtToken), Expiration = TimeZoneInfo.ConvertTimeFromUtc(jwtToken.ValidTo, TimeZoneInfo.Local) }; } }
因为篇幅关系,只把关键的生成 JWT 代码贴出来,还有一些获取用户信息啥的代码,还不是最终版本,接下来随时会修改,而且也比较简单,就没有放出来~
再来写个登录接口
添加 StarBlog.Web/Apis/AuthController.cs
文件
[ApiController] [Route("Api/[controller]")] [ApiExplorerSettings(GroupName = ApiGroups.Auth)] public class AuthController : ControllerBase { private readonly AuthService _authService; public AuthController(AuthService authService) { _authService = authService; } /// <summary> /// 登录 /// </summary> [HttpPost] [ProducesResponseType(typeof(ApiResponse<LoginToken>), StatusCodes.Status200OK)] public async Task<ApiResponse> Login(LoginUser loginUser) { var user = await _authService.GetUserByName(loginUser.Username); if (user == null) return ApiResponse.Unauthorized("用户名不存在"); if (loginUser.Password != user.Password) return ApiResponse.Unauthorized("用户名或密码错误"); return ApiResponse.Ok(_authService.GenerateLoginToken(user)); } }
之后我们请求这个接口,如果用户名和密码正确的话,就可以拿到 JWT token 和过期时间
{ "statusCode": 200, "successful": true, "message": "Ok", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR123I6IkpXVCJ9.eyJ1c2VybmFtZSI6ImRlYWxpIiwibmFC1kYJ9.DaJEmBAVdXks8MOedVee4xxrB-RvUSg2wIJGc30HGkk", "expiration": "2023-05-04T22:29:04+08:00" }, "errorData": null }
接下来,请求添加了 [Authorize]
的接口时,需要在 HTTP header 里面加上:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR123I6IkpXVCJ9.eyJ1c2VybmFtZSI6ImRlYWxpIiwibmFC1kYJ9.DaJEmBAVdXks8MOedVee4xxrB-RvUSg2wIJGc30HGkk
加了 [Authorize]
之后,在swagger里就没法调试接口了,得用 postman 之类的工具,添加 HTTP header
不过swagger这么好用的工具肯定不会那么蠢,它是可以配置支持 JWT 的
添加 nuget 包 Swashbuckle.AspNetCore.Filters
然后编辑 StarBlog.Web/Extensions/ConfigureSwagger.cs
来配置一下(上一篇关于swagger的还没忘记吧?)
在 AddSwaggerGen
里面,添加配置代码
var security = new OpenApiSecurityScheme { Description = "JWT模式授权,请输入 \"Bearer {Token}\" 进行身份验证", Name = "Authorization", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey }; options.AddSecurityDefinition("oauth2", security); options.AddSecurityRequirement(new OpenApiSecurityRequirement {{security, new List<string>()}}); options.OperationFilter<AddResponseHeadersFilter>(); options.OperationFilter<AppendAuthorizeToSummaryOperationFilter>(); options.OperationFilter<SecurityRequirementsOperationFilter>();
搞定。这样swagger页面右上角就多了个锁头图标,点击就可以输入 JWT token
不过有一点不方便的是,每个接口分组都要输入一次,切换了就得重新输入了…
但至少不用postman了~