提出问题
在上一篇我们搭建了一个基础的项目框架,并介绍了怎么向其中引入jwt鉴权,不知小伙伴们有没有注意到我们用于生成token的代码片段:
[HttpGet("login")]
public ActionResult Login(string username, string password)
{
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
// token中的claims用于储存自定义信息,如登录之后的用户id等
var claims = new[]
{
new Claim("userId", username)
};
// 获取SecurityKey
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Authentication")["SecurityKey"]));
var token = new JwtSecurityToken(
issuer: _configuration.GetSection("Authentication")["Issure"], // 发布者
audience: _configuration.GetSection("Authentication")["Audience"], // 接收者
notBefore: DateTime.Now, // token签发时间
expires: DateTime.Now.AddMinutes(30), // token过期时间
claims: claims, // 该token内存储的自定义字段信息
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256) // 用于签发token的秘钥算法
);
// 返回成功信息,写出token
return Ok(new { code = 200, message = "登录成功", data = new JwtSecurityTokenHandler().WriteToken(token) });
}
// 返回错误请求信息
return BadRequest(new { code = 400, message = "登录失败,用户名或密码为空" });
}
在这段代码里,我们着重看下面这一段:
// token中的claims用于储存自定义信息,如登录之后的用户id等
var claims = new[]
{
new Claim("userId", username)
};
// 获取SecurityKey
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.GetSection("Authentication")["SecurityKey"]));
var token = new JwtSecurityToken(
issuer: _configuration.GetSection("Authentication")["Issure"], // 发布者
audience: _configuration.GetSection("Authentication")["Audience"], // 接收者
notBefore: DateTime.Now, // token签发时间
expires: DateTime.Now.AddMinutes(30), // token过期时间
claims: claims, // 该token内存储的自定义字段信息
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256) // 用于签发token的秘钥算法
);
// 返回成功信息,写出token
return Ok(new
{
code = 200,
message = "登录成功",
data = new JwtSecurityTokenHandler().WriteToken(token)
});
从上面代码可以看出,要想生成一个完整的token,我们至少需要知道6个类:
- Claim:向token中添加自定义信息
- SymmetricSecurityKey:使用对称方法生成秘钥
- JwtSecurityToken:初始化JwtToken
- SigningCredentials:使用秘钥以及算法生成加密证书
- SecurityAlgorithms:保存了加密方法字符串的常量
- JwtSecurityTokenHandler:JwtToken处理器
理论上来说,框架封装时对外暴露的类型及方法应越少越好(使用者只要知道尽可能少的几个类就可以实现预期的功能),基于此出发点,我们可以使用设计模式对这个生成token的过程进行改造。
开始改造
先说一说什么是单例模式:
单例模式是GoF总结的23种常见设计模式之一,它保证了在整个程序的运行过程中,有且只有一个调用类的实例。
接下来,就使用单例模式来创建新的Token生成器——JwtGenerator
创建JwtGenerator类
在解决方案中右键项目,创建Services目录,并在其下创建JwtGenerator.cs文件,代码如下:
using System;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication;
using System.IdentityModel.Tokens.Jwt;
using System.Collections.Generic;
using System.Text;
namespace JwtTest.Services
{
public class JwtGenerator
{
// static保证了本类的对象只有一个,且封装在本类内部
private static JwtGenerator _generator = null;
// 用于产生JwtToken的本体生成器
private JwtSecurityToken token = null;
// token自定义的Claim信息
private IEnumerable Claims { get; set; } = null;
// 定义token基础信息
// token的颁发者
private string Issuer { get; set; } = string.Empty;
// token的接收者
private string Audience { get; set; } = string.Empty;
// 用于颁布token的秘钥
private string SecurityKey { get; set; } = string.Empty;
// token的秘钥算法
private string Alg { get; set; } = SecurityAlgorithms.HmacSha256;
// token的颁发时间,默认取当前时间
private DateTime NotBefore { get; set; } = DateTime.Now;
// token的过期时间,默认30分钟后
private DateTime Expires { get; set; } = DateTime.Now.AddMinutes(30);
// 构造函数使用private定义,这样外面就无法通过new构造函数来实例化JwtGenerator类
private JwtGenerator() { }
// 这里使用了C#新版本的写法,等同于:
// public static JwtGenerator GetInstance() {
// if (_generator == null) {
// _generator = new JwtGenerator();
// }
// return _generator;
// }
// 第一次调用GetInstance时,会实例化static标注的_generator对象,后续调用会返回已经实例化的对象,从而保证本类只有一个对象
public static JwtGenerator GetInstance() => _generator ??= new JwtGenerator();
// 添加自定义信息
public JwtGenerator AddClaims(IEnumerable claims)
{
_generator.Claims = claims;
return _generator;
}
// 添加发布者
public JwtGenerator AddIssuer(string issuer)
{
_generator.Issuer = issuer;
return _generator;
}
// 添加接收者
public JwtGenerator AddAudience(string audience)
{
_generator.Audience = audience;
return _generator;
}
// 添加发布时间
public JwtGenerator AddNotBefore(DateTime notBefore)
{
_generator.NotBefore = notBefore;
return _generator;
}
// 添加过期时间
public JwtGenerator AddExpires(DateTime expires)
{
_generator.Expires = expires;
return _generator;
}
// 添加用于生成token的秘钥
public JwtGenerator AddSecurityKey(string securityKey)
{
_generator.SecurityKey = securityKey;
return _generator;
}
// 添加token生成算法
public JwtGenerator AddAlgorithm(string securityAlgorithm)
{
_generator.Alg = securityAlgorithm;
return _generator;
}
// 生成token
public string Generate()
{
// 必备参数,若没有初始化,则抛出空指针异常
if (string.IsNullOrEmpty(_generator.SecurityKey)) throw new NullReferenceException("SecurityKey is null");
if (string.IsNullOrEmpty(_generator.Issuer)) throw new NullReferenceException("Issuer is null");
if (string.IsNullOrEmpty(_generator.Audience)) throw new NullReferenceException("Audience is null");
// 调用Generate方法之前,已经调用过上面的Add方法添加了对应的初始化token的参数
_generator.token = new JwtSecurityToken(
issuer: this.Issuer,
audience: this.Audience,
claims: this.Claims,
notBefore: this.NotBefore,
expires: this.Expires,
// 创建token颁发证书
signingCredentials: new SigningCredentials(
// 使用秘钥字符串跟加密算法生成加密token的对称加密秘钥
key: new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.SecurityKey)),
algorithm: this.Alg
)
);
// 调用Token处理器,写出token字符串
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
}
上述代码只是对token生成器的简单封装,token的接收者可能有很多个,这时我们可以参考上面的AddXXXX方法,添加AddAudiences功能,其他功能也一样可以自定义。
接下来我们来体验一下刚刚创建的JwtGenerator
使用JwtGenerator
打开AuthController,添加一个新方法Signin()
// GET: api/auth/signin
[HttpGet("signin")]
[AllowAnonymous]
public ActionResult Signin(string username, string password)
{
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
// 调用JwtGenerator,生成一个新的token
string token = JwtGenerator.GetInstance()
.AddIssuer(_configuration.GetSection("Authentication")["Issure"])
.AddAudience(_configuration.GetSection("Authentication")["Audience"])
.AddSecurityKey(_configuration.GetSection("Authentication")["SecurityKey"]);
.AddClaims(new[]
{
new Claim("userId", username)
}).Generate();
return Ok(new { code = 200, message = "登录成功", data = new { token } });
}
return BadRequest(new { code = 400, message = "登录失败,用户名或密码不能为空" });
}
运行程序,使用Postman测试GET /api/auth/signin接口吧。
再次思考
经过上面步骤,我们已经封装好了JwtGenerator,代码调用虽然简单了,但整体看上去并没有什么很大的优化。
请跟随我的脚步继续思考
实际开发过程中我们经常会遇到这样一个场景:
用户登录时颁发一个token、用户需要重置密码时颁发一个token、敏感资源访问时又颁发一个token
token经常需要携带允许访问的动作信息供后端校验,来保证自身不被用于其他接口
也就是说在整个后端代码中可能有不少地方我们都会生成新的token,难道每次我们都需要这么写?
string token = JwtGenerator.GetInstance()
.AddIssuer(_configuration.GetSection("Authentication")["Issure"])
.AddAudience(_configuration.GetSection("Authentication")["Audience"])
.AddSecurityKey(_configuration.GetSection("Authentication")["SecurityKey"]);
.AddClaims(new[]
{
new Claim("userId", username)
}).Generate();
其实不然,仔细思考一下,不管新的token怎么变,总是有一些参数不会变的,比如说秘钥。
那么由此,我们就可以得到下面的写法
打开Startup.cs,在ConfigureServices方法中添加下面代码:
// 配置Token生成器
JwtGenerator.GetInstance()
.AddIssuer(_configuration.GetSection("Authentication")["Issure"])
.AddAudience(_configuration.GetSection("Authentication")["Audience"])
.AddSecurityKey(_configuration.GetSection("Authentication")["SecurityKey"]);
因为我这个项目,Issuer跟Audience,SecurityKey都不会变,所以我在Startup中拿到JwtGenerator的实例,并初始化了其中不会改变的配置。
这样我就可以在需要的地方这么调用它:
// 登录鉴权动作
string token = JwtGenerator.GetInstance()
.AddClaims(new[]
{
new Claim("action", "login"),
new Claim("uid", username)
}).Generate();
// 重置密码动作
string token = JwtGenerator.GetInstance()
.AddClaims(new[]
{
new Claim("action", "reset"),
new Claim("uid", username)
}).Generate();
// 敏感资源访问动作
string token = JwtGenerator.GetInstance()
.AddClaims(new[]
{
new Claim("action", "oauth"),
new Claim("source", "/api/admin/xxx")
}).Generate();
怎么样。这token生成操作可还行?