前言
最近在搞一个Web项目嘛,一开始用了Spring全家桶(SpringBoot + SpringSecurity),然后发现SpringSecurity这个东西太臃肿了(
可能是自己还没用透)。
正好这个时候有一个微信公众号推文让我接触到了Token这种认证模式。了解之后觉得这玩意挺好用呀,于是就开始改动到自己的项目里去,正好赶上在给项目做模块化处理,就直接想着写一个自己的安全模块,然后就有了这篇文章(其实是想梳理一遍)。
模块介绍
模块 | 功能 |
---|---|
access | 访问控制 |
authentication | 认证 |
authority | 权限 |
code | 验证码(还没实现) |
exceptions | 异常 |
token | 令牌 |
功能列表
目前实现了两个最基本的功能,这两个都是基于Token来实现的。
- 认证
- 访问控制
Token模块
最先开始讲Token吧,毕竟是这里的基础。选用的是JWT(Json Web Token)。简单介绍一下,JWT一般由3部分组成,分别是HEADER、PAYLOAD和VERIFY SIGNATURE。其中HEADER一般用到两个字段分别是alg表示最后的VERIFY SIGNATURE是用什么方法生成的(签名算法)和typ表示令牌的类型(JWT统一设置为JWT);PAYLOAD是载荷,用于承载数据的;VERIFY SIGNATURE是用指定算法生成的签名,用于验证Token是不是我们签发的,其实JWT指的只是HEADER和PAYLOAD,签名部分也算上的是叫JWS。
Token
首先给出结构图
模块中,使用接口Token抽象令牌,定义了令牌编号、令牌类型、签名算法、签发者、签发时间和过期时间见下表。
属性 | 类型 | 描述 |
---|---|---|
id | String | 令牌编号 |
type | TokenType | 令牌类型 |
signAlgorithm | SignatureAlgorithm | 签名算法 |
issuer | String | 签发者 |
issueTime | LocalDateTime | 签发日期 |
expiredTime | LocalDateTime | 到期日期 |
- encode方法,意思是把令牌中的属性装入一个Map中存储。在AbstractToken的构造方法中可以通过这个Map还原一个令牌(decode)。
- isExpired方法,用来判断有没有过期。
看一下AbstractToken
就是对token的一个简单实现,留出了type交由子类实现,但是这里已经封装的差不多了。可以看到提供了一个接受Map的构造参数,这个Map就是encode的结果。
这两个构造方法的用途也是不同的,全参数用于构造一个全新的令牌,而Map的是解码一个令牌。
/**
* 使用Map初始化实例,这里的map应该是{@link Token#encode()}得出的结果,需要包含所有必须的属性。
*
* @param map 包含需求属性的map
*/
public AbstractToken(Map<String, Object> map) {
this(
ConvertUtil.convertNullSafe(map.get(KEY_ID), String.class),
ConvertUtil.convertNullSafe(map.get(KEY_SIGNATURE_ALGORITHM), SignatureAlgorithm.class),
ConvertUtil.convertNullSafe(map.get(KEY_ISSUER), String.class),
ConvertUtil.convertNullSafe(map.get(KEY_ISSUE_TIME), LocalDateTime.class),
ConvertUtil.convertNullSafe(map.get(KEY_EXPIRED_TIME), LocalDateTime.class)
);
TokenType type = ConvertUtil.convertNullSafe(map.get(KEY_TYPE), TokenType.class);
// 类型隔离
if (type != getType()) {
throw new TokenTypeException("类型错误,无法从" + type + "类型的令牌创建" + getType() + "类型的令牌。");
}
}
看这个带Map的构造方法,里面加入了类型隔离,使得访问令牌编码之后不能解码为刷新令牌。
剩下几个具体Token使用途径也不同。
- AccessToken应该是进行访问控制的依据
- RefreshToken应该是用来获取AccessToken的
- DataToken则是交给用户使用,用以实现全面的无状态服务(服务端不保存任何状态)
具体来看
AccessToken
构造函数添加了自定义的权限,其他和AbstractToken一致
因为是用来进行权限控制,所以一定是需要权限列表的,这里是authorities。
而refreshTokenId是用来构造这块访问令牌的刷新令牌编号。
encode方法和带Map的构造方法都对authorities和refreshTokenId进行编解码的支持。
RefreshToekn
刷新令牌主要用来构造访问令牌,所以暂时设置为final。
subject属性指的是令牌的主体。
DataToken
可以看到只是简单的实现了一下,其余特定功能交由用户自己实现。
TokenFactory
令牌工厂用于对访问令牌和刷新令牌的创建。
name属性是用来标识一个工厂,对应到令牌的Issuer属性。
这里所有的Map都是对应令牌encode的结果。
createRefreshToken方法接收一个String代表subject。
其他的应该看名字就知道功能了。
还没有具体实现,交由使用的模块自定义实现。
TokenHolder
令牌的持有器,这里是看了SpringSecurity的源码,依照着写的。
具体实现看代码吧。
TokenHolder
/**
* 令牌持有器。通过持有策略({@link TokenHolderStrategy})进行持有令牌。
*
* @author Dis
* @version V1.0
* @see TokenHolderStrategy
* @since 2022/1/12
*/
@Slf4j
public class TokenHolder {
/**
* 线程持有策略模式
*/
public static final String MODE_THREAD_LOCAL = "thread local";
/**
* 策略名称
*/
private static String strategyName;
/**
* 策略
*/
private static TokenHolderStrategy strategy;
static {
initialize();
}
/**
* 初始化,依照除了名称确定持有策略。
*/
private static void initialize() {
// 枚举
// 设定缺省
if (null == strategyName) {
strategyName = MODE_THREAD_LOCAL;
}
// 加载策略
if (MODE_THREAD_LOCAL.equals(strategyName)) {
strategy = new ThreadLocalTokenHolderStrategy();
} else {
// 自定义的策略
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> constructor = clazz.getConstructor();
strategy = (TokenHolderStrategy) constructor.newInstance();
} catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
e.printStackTrace();
}
}
}
/**
* 获取令牌
*
* @return 令牌
*/
public static AccessToken getToken() {
AssertUtil.notNull(strategy, "策略为空。");
return strategy.get();
}
/**
* 设置令牌
*
* @param accessToken 访问令牌
*/
public static void setToken(AccessToken accessToken) {
AssertUtil.notNull(strategy, "策略为空。");
AssertUtil.notNull(accessToken, "不可以设置令牌为null");
strategy.set(accessToken);
}
/**
* 清除令牌
*/
public static void clearToken() {
strategy.clear();
}
}
这里面向的令牌是访问令牌。
具体的功能实现委托给了TokenHolderStrategy定义如下
只有一个实现类(懒 )
ThreadLocalTokenHolderStrategy
/**
* 线程的令牌持有策略
* @author Dis
* @version V1.0
* @since 2022/1/15
*/
public class ThreadLocalTokenHolderStrategy implements TokenHolderStrategy {
/**
* 线程持有
*/
private final ThreadLocal<AccessToken> tokenThreadLocal;
public ThreadLocalTokenHolderStrategy() {
this.tokenThreadLocal = new ThreadLocal<>();
}
/**
* 设置令牌
*
* @param accessToken 访问令牌
*/
@Override
public void set(AccessToken accessToken) {
tokenThreadLocal.set(accessToken);
}
/**
* 获取令牌
*
* @return 令牌
*/
@Override
public AccessToken get() {
return tokenThreadLocal.get();
}
/**
* 清除令牌
*/
@Override
public void clear() {
tokenThreadLocal.remove();
}
}
TokenMapper
令牌的映射器,用于把Token映射为字符串,把字符串映射为Map(为什么不是Token呢?因为不知道映射为什么类型的,应该交由工厂进行实例化)。
定义
就两个方法很简单。
默认通过JJWT实现,提供实现类:JwtMapper
/**
* 基于JWT的映射器
*
* @author Dis
* @version V1.0
* @since 2022/1/14
*/
public class JwtMapper implements TokenMapper {
/**
* RSA工具
*/
private final RsaUtil rsaUtil;
public JwtMapper() throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
this.rsaUtil = RsaUtil.getInstance();
}
/**
* 解码
*
* @param encodedToken {@link #encode(Token)}产生的编码令牌字符串
* @return Token的构造参数
* @see Token
*/
@Override
public Map<String, Object> decode(String encodedToken) {
Key verifyKey = rsaUtil.getPublicKey();
try {
Jws<Claims> jws = Jwts.parser()
.setSigningKey(verifyKey)
.parseClaimsJws(encodedToken);
return jws.getBody();
} catch (Exception e) {
throw new DecodeTokenException("解析失败", e);
}
}
/**
* 编码
*
* @param token 令牌
* @return 字符串,代表Token
*/
@Override
public String encode(Token token) {
JwtBuilder jwtBuilder = Jwts.builder();
Key signatureKey;
if (token.getSignAlgorithm() == SignatureAlgorithm.RS256 ||
token.getSignAlgorithm() == SignatureAlgorithm.RS384 ||
token.getSignAlgorithm() == SignatureAlgorithm.RS512) {
signatureKey = rsaUtil.getPrivateKey();
} else {
signatureKey = rsaUtil.getPublicKey();
}
// 编码令牌
Claims claims = Jwts.claims();
claims.putAll(token.encode());
// LocalDate和LocalDateTime无法被JJWT序列化(因为内置Jackson的Mapper不可被修改且不支持这两个日期类)
// 所以进行转换
Set<String> localDateTypeKeys = new HashSet<>();
Set<String> localDateTimeTypeKeys = new HashSet<>();
claims.forEach((key, val) -> {
if (val instanceof LocalDate) {
// 记录Key
localDateTypeKeys.add(key);
} else if (val instanceof LocalDateTime) {
localDateTimeTypeKeys.add(key);
}
});
localDateTypeKeys.forEach((key) -> {
LocalDate date = ConvertUtil.convertNullSafe(claims.get(key), LocalDate.class);
claims.put(key, DateUtil.convertLocalDate(date));
});
localDateTimeTypeKeys.forEach((key) -> {
LocalDateTime time = ConvertUtil.convertNullSafe(claims.get(key), LocalDateTime.class);
claims.put(key, DateUtil.convertLocalDateTime(time));
});
// 构造
jwtBuilder
// Claims
.setClaims(claims)
//签名
.signWith(convertSignAlgorithm(token.getSignAlgorithm()), signatureKey)
;
return jwtBuilder.compact();
}
/**
* 转换签名算法
*
* @param signAlgorithm 本模块的签名算法
* @return JJWT模块的签名算法
*/
private io.jsonwebtoken.SignatureAlgorithm convertSignAlgorithm(SignatureAlgorithm signAlgorithm) {
io.jsonwebtoken.SignatureAlgorithm algo = null;
if (SignatureAlgorithm.NONE == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.NONE;
} else if (SignatureAlgorithm.HS256 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.HS256;
} else if (SignatureAlgorithm.HS384 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.HS384;
} else if (SignatureAlgorithm.HS512 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.HS512;
} else if (SignatureAlgorithm.RS256 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.RS256;
} else if (SignatureAlgorithm.RS384 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.RS384;
} else if (SignatureAlgorithm.RS512 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.RS512;
} else if (SignatureAlgorithm.ES256 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.ES256;
} else if (SignatureAlgorithm.ES384 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.ES384;
} else if (SignatureAlgorithm.ES512 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.ES512;
} else if (SignatureAlgorithm.PS256 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.PS256;
} else if (SignatureAlgorithm.PS384 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.PS384;
} else if (SignatureAlgorithm.PS512 == signAlgorithm) {
algo = io.jsonwebtoken.SignatureAlgorithm.PS512;
}
return algo;
}
}
因为模块中的签名算法枚举和JJWT中的不一样,所以要进行枚举转换。
还有一点,JJWT的序列化实现是通过Jackson的,但是默认的ObjectMapper不支持LocalDate和LocalDateTime,而且还写死了,不给修改。所以只能转化为Date。
TokenFilter
这里是核心了,组合了基本上所有的token模块的类。
具体功能是从请求的Authentication属性中取出Token的字符串,然后进行解码和验证,如果可以转化为访问令牌的话放入到令牌持有器,放行请求,请求结束之后再清除Token;如果不能就尝试转化为刷新令牌,然后创建并且返回一个访问令牌;如果为空,创建一个访客的访问令牌放入令牌持有器,视为用户放入Authentication的;如果都不能就抛出一个非法令牌的错误。
具体看代码:
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
AssertUtil.notNull(tokenFactory, "令牌工厂为空。");
// 获取token字符串
String tokenStr = getToken((HttpServletRequest) servletRequest);
AccessToken token;
if (null == tokenStr) {
// 访客令牌
token = tokenFactory.createAnonymousToken();
} else {
AssertUtil.notNull(mapper, "令牌映射器为空。");
// 解析令牌
Map<String, Object> decodedMap = mapper.decode(tokenStr);
try {
// 尝试转化为访问令牌
token = tokenFactory.createAccessTokenByMap(decodedMap);
} catch (TokenTypeException ignored) {
try {
// 尝试转化为刷新令牌
RefreshToken refreshToken = tokenFactory.createRefreshTokenByMap(decodedMap);
// 创建访问令牌
token = tokenFactory.createAccessToken(refreshToken);
// 响应
AuthenticationResult result = new AuthenticationResult(mapper.encode(token));
ResponseUtil.setToJson(servletResponse, result);
} catch (TokenTypeException ignored1) {
throw new TokenInvalidException("令牌无效");
}
return;
}
}
// 检查令牌是否有效
if (token.isExpired()) {
// 过期
throw new TokenExpiredException("令牌已过期。");
}
// 存放入Holder
TokenHolder.setToken(token);
// 通过
filterChain.doFilter(servletRequest, servletResponse);
// 清除Token
TokenHolder.clearToken();
}
结语
至此,token模块就结束啦,剩下的我缓缓再写。