书接上文 技术选型篇,我们做了【用户身份认证】的技术选型说明,对基于Session、Token、JWT的方案进行了详细的对比分析,详细说明了它们都是什么和各自的优缺点!这些是实战的基础,还没看过的同学,建议先看上文。最终我和狗哥(博客主页) 采用的是目前流行的基于JWT的Token用户身份认证机制!
本文是实战核心篇,重点是把JWT的核心代码实现! 基于上文我们分析的【用户身份认证】的流程(如下图),我们可以确定使用JWT的核心是实现两点:生成Token、校验Token! 接下来我们就来实现它!
PS,完整的用户身份认证代码早已实现,和狗哥也已联调通过,正在赶工博文,预告一下我将分三篇来写,非常详细,料很足,准备好发车喽,Let’s go!
因为可能还有很多同学还不清楚上下文,所以简单介绍一下这个专栏要做的事:
天罡老哥和狗哥(博客主页)有意
从0到1
带大家搭建一个SpringBoot+SpringCloud+Vue
的前后端分离项目! 打造一个短小精悍、技术主流、架构规范的前后端分离实战项目!我负责后端,狗哥负责前端! 目的就是让大家通过项目实战,学到一些真东西,将所学理论落地,助力有心强大的你更快的成长!开启你的工作之旅,让开发游刃有余!
详细的后端规划和后端大纲思维导图在开篇已经给出,你可以到开篇查收:基于SpringBoot+SpringCloud+Vue前后端分离项目实战 --开篇
官方推荐Java的JWT开源库中,收藏数最高的是:java-jwt
和jjwt-root
我们选择使用java-jwt
库,项目中将认证相关的通用实现
会封装到common
层!提前展示一下目录结构,方便大家对照实战:
pom中引入依赖,版本号依然定义在父pom定义!
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.11.0</version>
</dependency>
令牌的提供者接口。
从用户身份认证对Token的应用场景来看,核心实现就两个方法:创建Token 和 校验Token。
所以,这里提取一个接口TokenProvider,虽然目前只有JWT一种实现,但JWT实际上也只是Token中的一种,所以,以后想用其它Token,只要实现TokenProvider接口,就可以平滑的切过去。
符合开闭原则
:对扩展开放,对修改关闭!
public interface TokenProvider {
/**
* 根据用户授权信息,创建token
*/
String create(AuthContextInfo authContextInfo);
/**
* 校验token,解析出用户授权信息
*/
AuthContextInfo verify(String token) ;
}
AuthContextInfo
里保存的是认证信息,包含两个重要字段(也就是要存入Payload中的信息):
private String userId;
private String userName;
基于JWT实现的令牌提供者,快速预览说明如下:
从上图可以看出,除了两个核心方法,还定义了两个Payload相关的常量,不过这不是重点。
重点
是红框处的【将依赖由构造函数传入】,说明一下为什么这么做!
JWT的签名算法(JwtAlgorithm)和 过期时间(expire)都是变化点,根据依赖倒置原则
,要依赖抽象接口,不依赖具体实现,所以我们将它交给外部传入!
另外,在common层实现的类,对变化点应
不做决定
,而是交给上层决定将依赖注入。
@Override
public String create(AuthContextInfo authContextInfo) {
Date issuedAt = new Date();
Calendar expiresAt = Calendar.getInstance();
expiresAt.add(Calendar.SECOND, expire);
return JWT.create()
// 签发者
.withIssuer(authContextInfo.getUserId())
// 主题
.withSubject(SUBJECT)
// 签发时间
.withIssuedAt(issuedAt)
// 过期时间
.withExpiresAt(expiresAt.getTime())
// 在签发时间之前不可用
.withNotBefore(issuedAt)
// 自定义 userName
.withClaim(CLAIM_USERNAME, authContextInfo.getUserName())
.sign(this.jwtAlgorithm.getAlgorithm());
}
with
开头的方法都是构建payload
字段信息,withClaim
是构建自定义字段,可以构建多个自定义字段!
sign
方法是指定签名算法
这里不依赖具体算法,而是依赖JwtAlgorithm
接口!说完校验token再具体说 JwtAlgorithm。
@Override
public AuthContextInfo verify(String token) {
DecodedJWT decodedJWT;
try {
// 校验token,无效或过期会抛异常
decodedJWT = this.jwtAlgorithm.getJwtVerifier().verify(token);
} catch (Exception e) {
e.printStackTrace();
return null;
}
// 主题不一致,被修改了
if (!SUBJECT.equals(decodedJWT.getSubject())) {
return null;
}
// 返回userId和userName
AuthContextInfo authInfo = new AuthContextInfo();
authInfo.setUserId(decodedJWT.getIssuer());
authInfo.setUserName(decodedJWT.getClaim(CLAIM_USERNAME).asString());
return authInfo;
}
verify
方法不报错,说明token合法且未过期,解析的decodedJWT
对象里面包含了我们创建时存储的payload载荷信息(也就是数据)。JwtAlgorithm接口是 JwtTokenProvider 的重要依赖,主要包括获取【签名算法】和【验证方法】,定义如下:
public interface JwtAlgorithm {
/**
* 获取JWT使用的算法
*/
Algorithm getAlgorithm();
/**
* 获取JWT使用的验证方法
*/
JWTVerifier getJwtVerifier();
}
对应的是RSA算法的实现类JwtRsaAlgorithm
,这里的公钥(publicKey)和私钥(privateKey)也是通过构造函数
由外部指定,也是将依赖倒置!
public class JwtRsaAlgorithm implements JwtAlgorithm {
private final Algorithm algorithm;
private final JWTVerifier jwtVerifier;
@Override
public Algorithm getAlgorithm() {
return algorithm;
}
@Override
public JWTVerifier getJwtVerifier() {
return jwtVerifier;
}
/**
* 有参构造函数,将依赖倒置
*/
public JwtRsaAlgorithm(String publicKey, String privateKey) {
// 获取公钥对象
RSAPublicKey rsaPublicKey;
try {
rsaPublicKey = RsaKeyUtils.getPublicKey(publicKey);
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("Get RSA public key error!", e);
}
// 获取私钥对象
RSAPrivateKey rsaPrivateKey;
try {
rsaPrivateKey = RsaKeyUtils.getPrivateKey(privateKey);
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("Get RSA public key error!", e);
}
// 创建RSA256签名算法
this.algorithm = Algorithm.RSA256(rsaPublicKey, rsaPrivateKey);
// 构建JWTVerifier对象
this.jwtVerifier = JWT.require(this.algorithm)
.acceptLeeway(10)
.acceptExpiresAt(5)
.build();;
}
}
Algorithm.RSA256
和JWT.require
都是开源库提供的,用于生成最终的Algorithm
和JWTVerifier
;public class RsaKeyUtils {
public static RSAPublicKey getPublicKey(String base64String) throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] b = Base64.getDecoder().decode(base64String);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(b);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
Key key = keyFactory.generatePublic(keySpec);
return (RSAPublicKey) key;
}
public static RSAPrivateKey getPrivateKey(String base64String) throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] b = Base64.getDecoder().decode(base64String);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(b);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
Key key = keyFactory.generatePrivate(keySpec);
return (RSAPrivateKey)key;
}
}
支持的算法很多,我们再扩展JwtAlgorithm接口,实现一下
HMAC
算法,你可以实现你需要的算法!
对应的是HMAC算法的实现类JwtHmacAlgorithm
,这里的秘钥(secret)也是通过构造函数
由外部指定,也是将依赖倒置!
public class JwtHmacAlgorithm implements JwtAlgorithm {
private final Algorithm algorithm;
private final JWTVerifier jwtVerifier;
@Override
public Algorithm getAlgorithm() {
return algorithm;
}
@Override
public JWTVerifier getJwtVerifier() {
return jwtVerifier;
}
/**
* 有参构造函数,将依赖倒置
*/
public JwtHmacAlgorithm(String secret) {
// 创建HMAC256签名算法
this.algorithm = Algorithm.HMAC256(secret);
// 构建JWTVerifier对象
this.jwtVerifier = JWT.require(this.algorithm)
.acceptLeeway(10)
.acceptExpiresAt(5)
.build();
}
}
Algorithm.HMAC256
生成算法,再根据Algorithm
生成JWTVerifier
;因为我们写的类不依赖Spring容器,所以直接在JwtTokenProvider里写个main方法就可以测试,这里使用RSA算法测试,如下:
public static void main(String[] args) throws InterruptedException {
String publicKey = "太长,省略,可以到 http://www.metools.info/code/c80.html 自行生成。。。";
String privateKey = "太长,省略,可以到 http://www.metools.info/code/c80.html 自行生成。。。";
// 创建RSA算法
JwtAlgorithm jwtRsaAlgorithm = new JwtRsaAlgorithm(publicKey, privateKey);
// 创建JWT提供者:10秒过期 + RSA算法
TokenProvider jwtTokenProvider = new JwtTokenProvider(10, jwtRsaAlgorithm);
// 测试保存到Token中的授权信息
AuthContextInfo authContextInfo = new AuthContextInfo();
authContextInfo.setUserId("123456");
authContextInfo.setUserName("admin");
// 创建token
String token = jwtTokenProvider.create(authContextInfo);
System.out.println("token:" + token);
// 循环校验token何时过期
while (true) {
Thread.sleep(2000);
// 校验token
AuthContextInfo authInfo = jwtTokenProvider.verify(token);
if (authInfo == null) {
break;
}
System.out.println("校验ok:" + authInfo.toString());
}
}
上面的测试代码,既包括了【创建Token】方法,也包括了【校验Token】方法,主逻辑如下:
测试结果,刚好10秒过期!
在SpringBoot中,我们通常将类交给Spring管理,首先复习一下之前讲过的常用的组件注解:
打上这些注解的类,在Spring中称之为Bean。
本文,我们将学习一种新的IOC注入方式:通过JavaConfig的方式注入Bean
,即在类上加@Configuration
注解,代表这是一个配置类,里面通过添加@Bean
注解注入Bean对象,如下:
@Configuration
public class AuthConfig {
@Bean
public JwtAlgorithm jwtRsaAlgorithm(@Value("${auth.jwt.rsa.publicKey}") String publicKey, @Value("${auth.jwt.rsa.privateKey}") String privateKey ) {
return new JwtRsaAlgorithm(publicKey, privateKey);
}
@Bean
public TokenProvider jwtTokenProvider(@Value("${auth.jwt.expire}") int expire, JwtAlgorithm jwtAlgorithm) {
return new JwtTokenProvider(expire, jwtAlgorithm);
}
}
通过以上方式,我们就向Spring的IOC容器注入了TokenProvider、JwtAlgorithm,这样我们就可以通过@Autowired
注解直接使用了!以后如果想将切换算法,只需要修改这里的配置类,而不用去修改已实现的RSA算法类。
这里还有一个知识点:@Value
注解,用于读取配置文件(application.properties或application.yml)中读取字段的值,格式:@Value(“$配置字段名”),这里是用在方法参数
里
@Value("${auth.jwt.expire}") int expire
代表:expire = 配置文件中auth.jwt.expire的值
这里到了3个@Value,对应application.properties
中的3个配置如下(以\换行):
auth.jwt.expire =300
auth.jwt.rsa.publicKey=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArK+NOK/89rNAWeAguHti\
91QpMaHDZ6EaaySu5dyEvw6oUs4t8AiEc6HC7iTl1U2fxvuukk6P3e96V5w+fb+S\
UFUUaO+oocsKOOxwXcfJ1uQorMsEns1PjYB9weOOYYQoE2KY34AE6+zRT3w8uMXX\
pBmazZbPhUP8cGAOimUv4nSIK4n/nwBezEEeFM5dREaxabiDBe9HvOXmu8EfO2/P\
MsE5K9x/GP/wNbE+yzP+rC6rr3mgJNugUmE7BB1Usl7pS1myukiFz+PXoE/nibed\
k5FWzL5jeV8M8F7AZ404DdVhyN5dbLvwAI8jnnJ1nNRVEh5+1H0rvwSSlTAo+Po+\
bwIDAQAB
auth.jwt.rsa.privateKey=MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCsr404r/z2s0BZ\
4CC4e2L3VCkxocNnoRprJK7l3IS/DqhSzi3wCIRzocLuJOXVTZ/G+66STo/d73pX\
nD59v5JQVRRo76ihywo47HBdx8nW5CisywSezU+NgH3B445hhCgTYpjfgATr7NFP\
fDy4xdekGZrNls+FQ/xwYA6KZS/idIgrif+fAF7MQR4Uzl1ERrFpuIMF70e85ea7\
wR87b88ywTkr3H8Y//A1sT7LM/6sLquveaAk26BSYTsEHVSyXulLWbK6SIXP49eg\
T+eJt52TkVbMvmN5XwzwXsBnjTgN1WHI3l1su/AAjyOecnWc1FUSHn7UfSu/BJKV\
MCj4+j5vAgMBAAECggEBAJ9/djzJsChc4C8jKJW8wWgYQAQrmUR6NOCJfVGqIKIn\
c6kn7p4p/8yduGIlinM9wzoS9OcF0TP4IVQSaFXVP9sa+kMCOQtXchWprQ+xnOfy\
zO7shVP35maYK4+OEtBXNHzTMMgegm02yw1TfvJbKhXT4HvLs9kvNlbFIikJ1PSf\
kRdruq8/SiqDAiwtN4OUn7X3/pIx6b9P7hbO95aNUi1Dxb8xjQA05QVlqA8OwNyq\
ORUHI0ayZI6dmyTA5FUkZZf1tS0PzVLjubBOjZHRSq1a8Eg2qV+e/zDNPkuKQZ3g\
jyy6PamkRlSbfel6+8zacQVC8QRe1AAX68HFe/WKz2ECgYEA2WZySOPBJF85KuK1\
Tv8rgNAoRZZNZbH/0YT2OkBOprOX7bOtvSx+nTZPw0U7nR3nMqnJ9uk/gVfmkbD6\
WzHaSNEpxim2lT+A9jMC5FZcaQxJDHHBpUdMbPssvPGkE8i0XY+rxyQCugVp7+Jg\
mTHISfaZCSBmAG09qtp3Wuk8li0CgYEAy1iv53kChnVvBTckQYHWI5R5ByzPOEum\
EHLo8fvEvUSWaVlDDoPeFw1XtybNVBeyeu/c3HLi7/Z1836PwtpCCAF9XSIq8N/B\
PUR2hKDlg4j3m6BvR25Pu54ORbyevL1LugV+iGVfQ9lWjeV6XeYoN/jGTwSY/Hb+\
dc4rur8sBIsCgYBYlFx2hI460q3JYow7fs7r8mSmTeKFUCyK4yEshO1HESATU0W0\
Mb/5MJr5Vmk+0GNWikXnXAxrGDSzIigwJjTpvIfH3VEuqKxUJF7GSMXoa4AMGQGs\
5UsnkIQfDFotUXbkNFjqkCqoPvJ2Mofng5g3QsoCJPhKrjgVOGSvXx83lQKBgQCG\
0Y8WveFRumxYHd4Y3HdYcajoe+oLngRFJZqSTWV8QwwiXr8Z0Y4e5IbCdKRv26JG\
5d8d/cG+bT54qPGxs7lRy4MNi4jC2OcqssiNWIuy8M2RzgXZaybL8pft3oe0BSE+\
/UOONP+7YU6El5/Qv7bsnTEF1LuFr3M4MfBGSVdqzwKBgQDJBulXrWWQujQlQ+9/\
u7YoCwIr6N/ZL0fpnKtaQ7WfHs7zy6QUhu6skufFJKmWehOD6i+SWBmuhv4PPMCS\
IPhjChIh8AL8AVfSCjrksP0YENOHtbBhSE9bBHdH4u9VBy+6lbErSLl0867Qy4Z4\
LAN0Bjc+5MNy0vMQmqat/EKlHA==
web层对应的目录结构:
OK,就说这么多了,我们下文见!