使用注解的形式對token進行驗證


前言

現在很多系統都是都用上了springboot、springcloud,系統也偏向分布式部署、管理,最早的用戶令牌方案:session、cookie已經不能夠滿足系統的需求,使用一些特殊操作完成令牌的生成及校驗會造成更多的服務器開銷及客戶端開銷,為此許多項目都使用上了token。

token的原理即為將一串加密字符,寄存在請求頭中,隨着請求頭往返與前后端,以校驗該訪問是否有權限。

如果每一個系統都去寫一套token的生成和驗證,是一個很繁瑣的重復造輪子,讓人有點難受。所以趁着空隙,生成了我使用注解就可以驗證token的想法,並且也有一個提供token的方式,那我每個項目只需要一個接口、一個注解就能完成token的驗證機制豈不是很方便?說干就干!

設計思路

首先需要一個注解,該注解可以在controller的類上生效,也可以在controller的接口方法上生效,即我可以指定某個方法需要驗證,也可以指定某個類下的所有方法可以生效。

該注解進行了token的驗證,驗證是否過期,是否能夠被解密,是否能夠解析等,完成這一系列之后才可以繼續進行該次請求,否則會返回相應的錯誤信息。流程圖如下:
流程

同時還要配置忽略地址,用於靈活配置token的驗證位置。

驗證token首先得有token,所以也要提供一個生成token的位置。

實現方案

注解及aop切面實現

首先,實現一個注解,當類或者方法加上這個注解就能讓系統知道必須要檢驗token:

@Documented
@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TokenVerification {
}

我把這個注解命名為TokenVerification。注解目標使用Type和METHOD,這樣可以使注解在類及方法上生效。具體可查看源碼:

public enum ElementType {
    /** Class, interface (including annotation type), or enum declaration */
    TYPE,

    /** Field declaration (includes enum constants) */
    FIELD,

    /** Method declaration */
    METHOD,

    /** Formal parameter declaration */
    PARAMETER,

    /** Constructor declaration */
    CONSTRUCTOR,

    /** Local variable declaration */
    LOCAL_VARIABLE,

    /** Annotation type declaration */
    ANNOTATION_TYPE,

    /** Package declaration */
    PACKAGE,

    /**
     * Type parameter declaration
     *
     * @since 1.8
     */
    TYPE_PARAMETER,

    /**
     * Use of a type
     *
     * @since 1.8
     */
    TYPE_USE
}

想讓這個注解在接口方法進行前就進行生效,接入AOP切面,使用@before,將驗證放在接口方法進行前:

@Aspect
@Component
@Order(1)
@Slf4j
public class JwtAspect {
	
	/**
	 * token驗證主入口
	 * @throws Throwable 異常拋出
	 */
	@Before(value = " @within(com.wyb.util.annotation.TokenVerification) || @annotation(com.wyb.util.annotation.TokenVerification)")
	public void verifyTokenForClass() throws Throwable{
		checkToken();
	}

這里使用了@within和@annotation來判定觸發注解的范圍,因為單獨使用annotation的話在方法上添加注解不生效,故此添加@within使方法上的注解也可生效。

token的加密生成及解析

token采用Jwt加密生成,加密算法使用了RSA算法,同時采用了64位序列化的公私密鑰對token進行加解密,私鑰進行加密,公鑰進行解密。公私密鑰需要統一生成,生成代碼:

/**
	 * 注意:下面都是生成密鑰對相關方法,除特殊情況外無需調用
	 * main 方法用於生成密鑰對,配置密鑰時使用
	 * 已經生成好,考慮后期添加入配置中,目前寫成final,公私密鑰必須同時生成
	 * @param args args
	 * @throws NoSuchAlgorithmException 解析異常
	 */
	public static void main(String[] args) throws NoSuchAlgorithmException {
		KeyPair keyPair = generateKeyPair();
		String privateKey = base64Encode(keyPair.getPrivate());
		String publicKey = base64Encode(keyPair.getPublic());
		System.out.printf("Private Key: %s\nPublic Key: %s", privateKey, publicKey);
	}

	/**
	 * 生成密鑰對
	 *
	 * @return KeyPair
	 * @throws NoSuchAlgorithmException 解析異常
	 */
	private static KeyPair generateKeyPair() throws NoSuchAlgorithmException {
		// algorithm RSA
		KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM_FAMILY_NAME);
		return keyPairGenerator.genKeyPair();
	}

	/**
	 * 把私鑰轉化成base64字符串
	 *
	 * @param key 密鑰
	 * @return 序列化后得密鑰
	 */
	private static String base64Encode(PrivateKey key) {
		return new String(Base64Utils.encode(key.getEncoded()));
	}

	/**
	 * 把公鑰轉化成base64字符串
	 * @param key 密鑰
	 * @return 序列化后得密鑰
	 */
	private static String base64Encode(PublicKey key) {
		return new String(Base64Utils.encode(key.getEncoded()));
	}

生成了公私密鑰之后,利用jwt的token生成方法,生成包含用戶或者應用信息的已經加密的token字符串,該字符串即為token令牌,請求接口時,帶上該字符串即可。以下為token的生成,及解析代碼:

token的生成:

@Override
	public String generateToken(Object object, Date expireAt, String subject) {
		//建立jwt
		JwtBuilder jwtBuilder = Jwts.builder();

		//設置jwt的body
		subject = subject != null ? subject: UUID.randomUUID().toString();
		Map<String,Object> objectMap = new HashMap<>();
		if(Objects.nonNull(object)){
			objectMap.put("data",object);
		}
		jwtBuilder.setClaims(objectMap).setSubject(subject);

		//設置過期時間-時間設置要放在最后,否則設置了body之后會把時間蓋掉,無法獲取到時間
		if(Objects.isNull(expireAt)){
			log.info("沒有設置過期時間,自動設置過期時間三十分鍾");
			long currentTime = System.currentTimeMillis();
			currentTime += 30*60*1000;
			Date newDate = new Date(currentTime);
			jwtBuilder.setExpiration(newDate);
		}else{
			//設置過期時間
			jwtBuilder.setExpiration(expireAt);
		}
		//生成加密jwt
		return jwtBuilder.signWith(SignatureAlgorithm.RS256,privateKeyFromBase64()).compact();
	}

token的解析:

	@Override
	public JwtBody signatureToken(String token) {

		Jws<Claims> claimsJws = authToken(token);

		return new JwtBody(claimsJws.getBody());
	}

	/**
	 * 解析token
	 * @param token token
	 * @return claims
	 */
	private Jws<Claims> authToken(String token){
		if(null == token){
			log.info("本次請求token不存在");
			throw new TokenException(TokenReturnCode.RESOLVE_FAILED);
		}
		Jws<Claims> claimsJws;
		//解析token
		try {
			//如果傳來的token不對,會對異常進行捕獲
			claimsJws = Jwts.parser().setSigningKey(publicKeyFromBase64()).parseClaimsJws(token);
		}catch (JwtException e){
			log.error("token:\"{}\" 不正確",token);
			log.error(e.getMessage());
			throw new TokenException(TokenReturnCode.RESOLVE_FAILED);
		}

		return claimsJws;
	}

以下是全篇的代碼:

	/**
	 * 加密方式
	 */
	private static final String ALGORITHM_FAMILY_NAME = "RSA";

	/**
	 * 私鑰,加密用
	 */
	private static final String PRIVATE_KEY = "生成的私鑰";

	/**
	 * 公鑰,解密用
	 */
	private static final String PUBLIC_KEY = "生成的公鑰";


	@Override
	public JwtBody signatureToken(String token) {

		Jws<Claims> claimsJws = authToken(token);

		return new JwtBody(claimsJws.getBody());
	}

	@Override
	public String generateToken(Object object, Date expireAt, String subject) {
		//建立jwt
		JwtBuilder jwtBuilder = Jwts.builder();

		//設置jwt的body
		subject = subject != null ? subject: UUID.randomUUID().toString();
		Map<String,Object> objectMap = new HashMap<>();
		if(Objects.nonNull(object)){
			objectMap.put("data",object);
		}
		jwtBuilder.setClaims(objectMap).setSubject(subject);

		//設置過期時間-時間設置要放在最后,否則設置了body之后會把時間蓋掉,無法獲取到時間
		if(Objects.isNull(expireAt)){
			log.info("沒有設置過期時間,自動設置過期時間三十分鍾");
			long currentTime = System.currentTimeMillis();
			currentTime += 30*60*1000;
			Date newDate = new Date(currentTime);
			jwtBuilder.setExpiration(newDate);
		}else{
			//設置過期時間
			jwtBuilder.setExpiration(expireAt);
		}
		//生成加密jwt
		return jwtBuilder.signWith(SignatureAlgorithm.RS256,privateKeyFromBase64()).compact();
	}



	/**
	 * 解析token
	 * @param token token
	 * @return claims
	 */
	private Jws<Claims> authToken(String token){
		if(null == token){
			log.info("本次請求token不存在");
			throw new TokenException(TokenReturnCode.RESOLVE_FAILED);
		}
		Jws<Claims> claimsJws;
		//解析token
		try {
			//如果傳來的token不對,會對異常進行捕獲
			claimsJws = Jwts.parser().setSigningKey(publicKeyFromBase64()).parseClaimsJws(token);
		}catch (JwtException e){
			log.error("token:\"{}\" 不正確",token);
			log.error(e.getMessage());
			throw new TokenException(TokenReturnCode.RESOLVE_FAILED);
		}

		return claimsJws;
	}

	/**
	 * 公鑰64位序列化
	 * @return PublicKey
	 */
	private static PublicKey publicKeyFromBase64() {
		try {
			byte[] keyBytes = Base64Utils.decodeFromString(PUBLIC_KEY);
			X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
			KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM_FAMILY_NAME);
			return keyFactory.generatePublic(keySpec);
		} catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
			throw new IllegalArgumentException(ex);
		}
	}

	/**
	 * 私鑰64位序列化
	 * @return PrivateKey
	 */
	private static PrivateKey privateKeyFromBase64() {
		try {
			byte[] keyBytes = Base64Utils.decodeFromString(PRIVATE_KEY);
			PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
			KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM_FAMILY_NAME);
			return keyFactory.generatePrivate(keySpec);
		} catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
			throw new IllegalArgumentException(ex);
		}
	}

因為在解密token后會產生claims,也就是token的body,里面包含了用戶需要在token里傳遞的信息,所以解析后需要有一個方式傳遞給接口。這里采用了全局參數注入,使用了攔截器,將token解析方法產生的jwtBody注入全局,使接口可以自行獲取:

全局參數handler:

/**
 * 創建時間:2021/1/25 17:24
 * 實現攔截並注入參數
 * @author wyb
 */
public class JwtArgumentResolver implements HandlerMethodArgumentResolver {

	private TokenUtils tokenUtils;

	private String tokenHeader;

	@Override
	public boolean supportsParameter(MethodParameter methodParameter) {
		return methodParameter.getParameterType().equals(JwtBody.class);
	}

	@Override
	public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
		//將解析后的jwtBody作為全局參數,所有接口均可調用
		return tokenUtils.signatureToken(nativeWebRequest.getHeader(tokenHeader));
	}

	/**
	 * 構造方法,因為使用注解注入的形式不生效
	 * @param tokenUtils token工具bean
	 * @param tokenHeader 請求頭名字
	 */
	JwtArgumentResolver(TokenUtils tokenUtils, String tokenHeader){
		this.tokenHeader = tokenHeader;
		this.tokenUtils = tokenUtils;
	}
}

攔截器設置:

/**
 * 創建時間:2021/1/26 10:55
 * 創建攔截器
 * @author wyb
 */
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {

	@Autowired
	private TokenUtils tokenUtils;

	@Value("${token.header:token}")
	private String tokenHeader;

	@Override
	protected void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
		argumentResolvers.add(new JwtArgumentResolver(tokenUtils,tokenHeader));
	}
}

可能大家很奇怪為什么TokenUtils和配置參數需要通過構造函數傳入解析器,因為在解析器中並不會自動注入bean,只能手動實例化,所以只能使用構造函數的形式在配置bean中注入。

這里注入的全局參數是jwtBody:

@Data
public class JwtBody {

	private String subject;

	private Object object;

	public JwtBody(Claims claims){
		if(Objects.nonNull(claims)){
			this.subject = claims.getSubject();
			Map map = new HashMap<>(claims);
			map.remove("sub");
			this.object = map.get("data");
		}
	}
}

用於存放token解析后的信息返回給開發者,讓開發者專注於信息的匹配及獲取。

到此,使用注解驗證token的方案就實現完了。該工具需要在配置文件中加上一下支持:

token:
	header: token
	ignore: /test,/example,/aaa

header為token在請求頭中的名字,如果不配置則默認為token,ignore則為驗證忽略地址,配置后請求該地址后可忽略驗證直接訪問。

總結

在方案實現過程中也遇到二比較多的坑,這里列出總結一下:

1.注解雖然可以添加target注解規定它可以使用的范圍,但是進入切面后,不一定會在這個范圍內檢測到該注解,單純的使用exclution和annotation不能達到我們想要的效果。使用within注解指定注解,所有使用該注解的方法、包、類均可以檢測到,但是不能在切面方法使用參數。

2.全局參數的注入問題,網上許多資料都寫明加載解析器就可以,但是因為版本不同,所以加入的方式不通,解析器一定要手動加入:

@Override
	protected void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
		argumentResolvers.add(new JwtArgumentResolver(tokenUtils,tokenHeader));
	}

這里是在webmvc的攔截器中手動加入,如果你不加,解析器就不能生效。

3.同時為了配備項目,我還自定義了異常拋出,用以拋出token解析中的異常,具體的方案不在本文的主題中,則不列舉了。

代碼地址:utils


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM