提出问题
在上一篇我们搭建了一个基础的项目框架,并介绍了怎么向其中引入 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<Claim> 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<Claim> 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 生成操作可还行?