Design of Web security module based on Token

Posted by Phoenixheart on Sun, 16 Jan 2022 17:35:02 +0100

preface

Recently, I was working on a Web project. At first, I used Spring boot + Spring security, and then found that Spring security was too bloated (maybe I haven't used it yet).
Just now, a WeChat official account tweeted me into the Token authentication mode. After understanding it, I thought it was very useful, so I began to change it to my own project. Just in time to do modular processing for the project, I directly wanted to write my own security module, and then I had this article (actually I wanted to sort it out).

Module introduction

modularfunction
accessaccess control
authenticationauthentication
authorityjurisdiction
codeVerification code (not yet implemented)
exceptionsabnormal
tokentoken

Function list

At present, two basic functions are implemented, both of which are based on Token.

  • authentication
  • access control

Token module

Let's start with Token. After all, it's the foundation here. The choice is JWT (Json Web Token). For a brief introduction, JWT is generally composed of three parts: HEADER, PAYLOAD and verify signal. The HEADER generally uses two fields: alg to indicate the method used to generate the last VERIFY SIGNATURE (signature algorithm) and typ to indicate the type of token (JWT is uniformly set to JWT); PAYLOAD is the load used to carry data; VERIFY SIGNATURE is the signature generated by the specified algorithm to verify whether the token is signed by us. In fact, JWT only refers to HEADER and PAYLOAD, and the signature part is also called JWS.

Token

Firstly, the structure diagram is given

In the module, the interface Token is used to abstract the Token, defining the Token number, Token type, signature algorithm, issuer, issuance time and expiration time, as shown in the table below.

attributetypedescribe
idStringToken number
typeTokenTypeToken type
signAlgorithmSignatureAlgorithmsignature algorithm
issuerStringIssuer
issueTimeLocalDateTimeDate of issue
expiredTimeLocalDateTimeDue date

  • The encode method means to load the attributes in the token into a Map for storage. In the construction method of AbstractToken, you can restore a token (decode) through this Map.
  • The isExpired method is used to determine whether it has expired.

Take a look at AbstractToken

It is a simple implementation of token, leaving the type to be implemented by subclasses, but it is almost encapsulated here. You can see that a construction parameter that accepts a Map is provided, and this Map is the result of encode.
The purposes of the two construction methods are also different. All parameters are used to construct a new token, while Map decodes a token.

	/**
	 * Use map to initialize the instance. The map here should be the result of {@ link Token#encode()} and need to contain all necessary attributes.
	 *
	 * @param map map containing requirement attributes
	 */
	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);
		// Type isolation
		if (type != getType()) {
			throw new TokenTypeException("Wrong type, unable to get from" + type + "Token creation of type" + getType() + "Token of type.");
		}
	}

Look at the construction method with Map, which adds type isolation, so that the access token cannot be decoded into a refresh token after encoding.

The remaining specific tokens are used in different ways.

  • AccessToken should be the basis for access control
  • RefreshToken should be used to obtain AccessToken
  • DataToken is used by users to implement comprehensive stateless services (the server does not save any state)

Specifically
AccessToken

The constructor adds custom permissions, which are consistent with AbstractToken
Because it is used for permission control, a permission list must be required. Here are authorities.
refreshTokenId is the refresh token number used to construct this access token.
Both the encode method and the construction method with Map support the encoding and decoding of authorities and refreshTokenId.

RefreshToekn

Refresh token is mainly used to construct access token, so it is temporarily set to final.
The subject property refers to the body of the token.

DataToken

You can see that it is only a simple implementation, and the other specific functions are implemented by the user.

TokenFactory

The token factory is used to create access tokens and refresh tokens.

The name attribute is used to identify a factory and corresponds to the Issuer attribute of the token.
All maps here are the result of the corresponding token encode.
The createRefreshToken method receives a String representing the subject.
Other functions should be known by name.

There is no specific implementation yet. It is left to the user-defined implementation of the module.

TokenHolder

Token holder, here is the source code of spring security, written according to.
The specific implementation depends on the code.
TokenHolder

/**
 * Token holder. The token is held through the holding policy ({@ link TokenHolderStrategy}).
 *
 * @author Dis
 * @version V1.0
 * @see TokenHolderStrategy
 * @since 2022/1/12
 */
@Slf4j
public class TokenHolder {
	/**
	 * Thread holding policy mode
	 */
	public static final String MODE_THREAD_LOCAL = "thread local";

	/**
	 * Policy name
	 */
	private static String strategyName;
	/**
	 * strategy
	 */
	private static TokenHolderStrategy strategy;

	static {
		initialize();
	}

	/**
	 * Initialization, the holding policy is determined according to the name.
	 */
	private static void initialize() {
		// enumeration

		// Set default
		if (null == strategyName) {
			strategyName = MODE_THREAD_LOCAL;
		}

		// Loading policy
		if (MODE_THREAD_LOCAL.equals(strategyName)) {
			strategy = new ThreadLocalTokenHolderStrategy();
		} else {
			// Custom policy
			try {
				Class<?> clazz = Class.forName(strategyName);
				Constructor<?> constructor = clazz.getConstructor();
				strategy = (TokenHolderStrategy) constructor.newInstance();
			} catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
				e.printStackTrace();
			}
		}
	}

	/**
	 * Get token
	 *
	 * @return token
	 */
	public static AccessToken getToken() {
		AssertUtil.notNull(strategy, "Policy is empty.");
		return strategy.get();
	}

	/**
	 * Set token
	 *
	 * @param accessToken Access token
	 */
	public static void setToken(AccessToken accessToken) {
		AssertUtil.notNull(strategy, "Policy is empty.");
		AssertUtil.notNull(accessToken, "Token cannot be set to null");
		strategy.set(accessToken);
	}

	/**
	 * Clear token
	 */
	public static void clearToken() {
		strategy.clear();
	}
}

The token oriented here is an access token.
The specific function implementation is delegated to TokenHolderStrategy, which is defined as follows

Only one implementation class (lazy)
ThreadLocalTokenHolderStrategy

/**
 * Token holding policy for threads
 * @author Dis
 * @version V1.0
 * @since 2022/1/15
 */
public class ThreadLocalTokenHolderStrategy implements TokenHolderStrategy {
	/**
	 * Thread holding
	 */
	private final ThreadLocal<AccessToken> tokenThreadLocal;

	public ThreadLocalTokenHolderStrategy() {
		this.tokenThreadLocal = new ThreadLocal<>();
	}

	/**
	 * Set token
	 *
	 * @param accessToken Access token
	 */
	@Override
	public void set(AccessToken accessToken) {
		tokenThreadLocal.set(accessToken);
	}

	/**
	 * Get token
	 *
	 * @return token
	 */
	@Override
	public AccessToken get() {
		return tokenThreadLocal.get();
	}

	/**
	 * Clear token
	 */
	@Override
	public void clear() {
		tokenThreadLocal.remove();
	}
}

TokenMapper

The Token mapper is used to Map a Token to a string and a string to a Map (why not a Token? Because you don't know the type of mapping, it should be instantiated by the factory).
definition

Just two methods are simple.
By default, it is implemented through JJWT, and the implementation class is provided: JwtMapper

/**
 * JWT based mapper
 *
 * @author Dis
 * @version V1.0
 * @since 2022/1/14
 */
public class JwtMapper implements TokenMapper {
	/**
	 * RSA tool
	 */
	private final RsaUtil rsaUtil;

	public JwtMapper() throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException {
		this.rsaUtil = RsaUtil.getInstance();
	}

	/**
	 * decode
	 *
	 * @param encodedToken {@link #encode(Token)}Generated encoded token string
	 * @return Token Construction parameters of
	 * @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("Parsing failed", e);
		}
	}

	/**
	 * code
	 *
	 * @param token token
	 * @return String representing 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();
		}

		// Encoding token
		Claims claims = Jwts.claims();
		claims.putAll(token.encode());

		// LocalDate and LocalDateTime cannot be serialized by JJWT (because the Mapper built-in Jackson cannot be modified and does not support these two date classes)
		// So convert
		Set<String> localDateTypeKeys = new HashSet<>();
		Set<String> localDateTimeTypeKeys = new HashSet<>();
		claims.forEach((key, val) -> {
			if (val instanceof LocalDate) {
				// Record 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));
		});

		// structure
		jwtBuilder
				// Claims
				.setClaims(claims)
				//autograph
				.signWith(convertSignAlgorithm(token.getSignAlgorithm()), signatureKey)
		;

		return jwtBuilder.compact();
	}

	/**
	 * Transform signature algorithm
	 *
	 * @param signAlgorithm Signature algorithm of this module
	 * @return JJWT Module signature algorithm
	 */
	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;
	}
}

Because the signature algorithm enumeration in the module is different from that in JJWT, enumeration conversion is required.
Another point is that the serialization of JJWT is implemented through Jackson, but the default ObjectMapper does not support LocalDate and LocalDateTime, and it is dead and will not be modified. So it can only be converted to Date.

TokenFilter

Here is the core, which combines basically all the token module classes.
The specific function is to take the Token string from the Authentication attribute of the request, decode and verify it, put it into the Token holder if it can be converted into an access Token, release the request, and then clear the Token after the request is completed; If not, try to convert to a refresh Token, and then create and return an access Token; If it is empty, create a visitor's access Token and put it into the Token holder, which is regarded as the one put into Authentication by the user; If not, an illegal Token error is thrown.
See code for details:

@Override
	public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
			throws IOException, ServletException {
		AssertUtil.notNull(tokenFactory, "Token factory is empty.");

		// Get token string
		String tokenStr = getToken((HttpServletRequest) servletRequest);
		AccessToken token;

		if (null == tokenStr) {
			// Visitor token
			token = tokenFactory.createAnonymousToken();
		} else {
			AssertUtil.notNull(mapper, "Token mapper is empty.");
			// Parse token
			Map<String, Object> decodedMap = mapper.decode(tokenStr);
			try {
				// Attempt to convert to an access token
				token = tokenFactory.createAccessTokenByMap(decodedMap);
			} catch (TokenTypeException ignored) {
				try {
					// Attempt to convert to refresh token
					RefreshToken refreshToken = tokenFactory.createRefreshTokenByMap(decodedMap);
					// Create access token
					token = tokenFactory.createAccessToken(refreshToken);
					// response
					AuthenticationResult result = new AuthenticationResult(mapper.encode(token));
					ResponseUtil.setToJson(servletResponse, result);
				} catch (TokenTypeException ignored1) {
					throw new TokenInvalidException("Invalid token");
				}
				return;
			}
		}

		// Check whether the token is valid
		if (token.isExpired()) {
			// be overdue
			throw new TokenExpiredException("The token has expired.");
		}

		// Save to Holder
		TokenHolder.setToken(token);

		// adopt
		filterChain.doFilter(servletRequest, servletResponse);

		// Clear Token
		TokenHolder.clearToken();
	}

epilogue

So far, the token module is over, and I'll write the rest slowly.

Topics: Java Web Development Spring Boot security Web Security