基于Token的Web安全模块设计

前言

最近在搞一个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抽象令牌,定义了令牌编号、令牌类型、签名算法、签发者、签发时间和过期时间见下表。

属性类型描述
idString令牌编号
typeTokenType令牌类型
signAlgorithmSignatureAlgorithm签名算法
issuerString签发者
issueTimeLocalDateTime签发日期
expiredTimeLocalDateTime到期日期

  • 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模块就结束啦,剩下的我缓缓再写。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Dis2017

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值