17 How to Perform Security Verification and Unify Authentication After Integrating Gateways

17 How to Perform Security Verification and Unify Authentication After Integrating Gateways #

In the scenario of a shopping mall parking lot, except for a few functions that do not require user login (such as the number of available parking spaces), the rest of the functions require users to be in a session state to use them normally. In the previous chapter, it was mentioned that unified authentication operations should be implemented at the gateway layer. This article will guide you directly on how to add a common authentication function at the gateway layer to achieve simple authentication using the lightweight solution JWT.

Why choose JWT #

JSON Web Token (JWT) is a popular lightweight cross-domain authentication solution. The Tomcat Session approach is not suitable for distributed environments with multiple instances and multiple applications. JWT is generated and parsed according to certain rules and does not need to be stored. This alone makes it superior to the storage method of Session. Additionally, Session in a multi-instance environment needs to consider synchronization issues, which increases complexity.

Due to this characteristic of JWT, once a JWT is generated, it can be used as long as it has not expired. This may introduce vulnerabilities in business scenarios. For example, when a session is logged out but the token can still be used (once the token is generated, it cannot be changed), third-party means need to be used to configure token verification to prevent malicious use.

Services can be better scaled if they are stateless. Otherwise, maintaining state adds additional overhead and makes maintenance and scalability more difficult. JWT helps achieve statelessness for server instances.

Two special scenarios for JWT applications #

  1. Active logout: This requires the cooperation of a third-party solution, such as Redis. When a session is actively logged out, the token is written to the cache. When all requests are verified at the gateway layer, the cache is checked first. If the token exists in the cache, it means the token is invalid, and the user is prompted to log in again.
  2. JWT expiration while the user is active: Suppose the JWT expiration period is 30 minutes. If the user is actively using the system, it indicates an active state. In this case, it is not ideal to automatically log out the user after 30 minutes, as this would result in a poor user experience. According to the termination solution in the Session approach, if the user is active, the expiration period needs to be extended. However, JWT itself cannot be changed, so refreshing the JWT is necessary to ensure a smooth user experience. The solution is as follows: When it is detected that the token is about to expire or has expired, but the user is still active (how to determine if the user is active? User requests can be recorded in cache and compared based on time intervals), a new token is generated and returned to the front-end. The new token is then used to make requests until the user actively logs out or the token becomes invalid.

Using JWT #

Add the following dependency to introduce the JWT jar package at the gateway layer:

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

Creating the JWT utility class #

The utility class focuses on generating and validating tokens:

@Slf4j
public class JWTUtils {

    /**
     * Generates an encryption key from a string. The key is not hard-coded in this code and can be flexibly configured.
     *
     * @return
     */
    public static SecretKey generalKey(String stringKey) {
        byte[] encodedKey = Base64.decodeBase64(stringKey);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * createJWT: Creates a JWT.
     *
     * @param id        Unique id, such as UUID.
     * @param subject   JSON-format or plain text string to store user non-sensitive information, such as user tid, to compare with the parsed token to prevent abuse.
     * @param ttlMillis Expiration time.
     * @param stringKey
     * @return JWT token.
     * @throws Exception
     */
    public static String createJWT(String id, String subject, long ttlMillis, String stringKey) throws Exception {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        SecretKey key = generalKey(stringKey);
        JwtBuilder builder = Jwts.builder().setIssuer("").setId(id).setIssuedAt(now).setSubject(subject)
                .signWith(signatureAlgorithm, key);
        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);
        }
        return builder.compact();
    }

    /**
     * parseJWT: Decrypts a JWT.
     *
     * @param jwt
     * @param stringKey
     * @return
     * @throws ExpiredJwtException
     * @throws UnsupportedJwtException
     * @throws MalformedJwtException
     * @throws SignatureException
     * @throws IllegalArgumentException
     */
    public static Claims parseJWT(String jwt, String stringKey) throws ExpiredJwtException, UnsupportedJwtException,
            MalformedJwtException, SignatureException, IllegalArgumentException {
        SecretKey key = generalKey(stringKey);
        Claims claims = Jwts.parser().setSigningKey(key).parseClaimsJws(jwt).getBody();
        return claims;
    }

    public static boolean isTokenExpire(String jwt, String stringKey) {
        Claims aClaims = parseJWT(jwt, stringKey);
        // Compare the current time with the token expiration time
        if (LocalDateTime.now().isAfter(LocalDateTime.now()
                .with(aClaims.getExpiration().toInstant().atOffset(ZoneOffset.ofHours(8)).toLocalDateTime()))) {
            log.info("Token is valid");
            return true;
        } else {
            return false;
        }
    }

    public static void main(String[] args) {
        try {
            String key = "eyJqdGkiOiI1NGEzNmQ5MjhjYzE0MTY2YTk0MmQ5NTg4NGM2Y2JjMSIsImlhdCI6MTU3OTE2MDkwMiwic3ViIjoiMTIxMiIsImV4cCI6MTU3OTE2MDkyMn0";
            String token = createJWT(UUID.randomUUID().toString().replace("-", ""), "1212", 2000, key);
            System.out.println(token);
            parseJWT(token, key);
//          Thread.sleep(2500);
            Claims aClaims = parseJWT(token, key);
            System.out.println(aClaims.getExpiration());
            if (isTokenExpire(token, key)) {
                System.out.println("Expired");
            } else {
                System.out.println("Normal");
            }
            System.out.println(aClaims.getSubject().substring(0, 2));

Note: The translation for the code is cut off and it is not clear what follows.

} catch (ExpiredJwtException e) {
    System.out.println("Token has expired again");
} catch (Exception e) {
    e.printStackTrace();
}

}
}

#### **Token Validation**

To validate the availability of the token, we need to create a filter that works with Spring Cloud Gateway as the gateway filter:

```java
@Component
@Slf4j
public class JWTFilter implements GlobalFilter, Ordered {

    @Autowired
    JWTData jwtData;

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        String url = exchange.getRequest().getURI().getPath();

        // Skip validation for specific paths
        if (null != jwtData.getSkipUrls() && Arrays.asList(jwtData.getSkipUrls()).contains(url)) {
            return chain.filter(exchange);
        }

        // Get token
        String token = exchange.getRequest().getHeaders().getFirst("token");
        ServerHttpResponse resp = exchange.getResponse();
        if (StringUtils.isEmpty(token)) {
            // No token
            return authError(resp, "Please log in first!");
        } else {
            // Has token
            try {
                JWTUtils.parseJWT(token, jwtData.getTokenKey());
                log.info("Validation passed");
                return chain.filter(exchange);
            } catch (ExpiredJwtException e) {
                log.error(e.getMessage(), e);
                return authError(resp, "Token expired");
            } catch (Exception e) {
                log.error(e.getMessage(), e);
                return authError(resp, "Authentication failed");
            }
        }
    }

    /**
     * Output authentication error
     * 
     * @param resp    response object
     * @param message error message
     * @return
     */
    private Mono<Void> authError(ServerHttpResponse resp, String message) {
        resp.setStatusCode(HttpStatus.UNAUTHORIZED);
        resp.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        CommonResult<String> returnData = new CommonResult<>(org.apache.http.HttpStatus.SC_UNAUTHORIZED + "");
        returnData.setRespMsg(message);
        String returnStr = "";
        try {
            returnStr = objectMapper.writeValueAsString(returnData.getRespMsg());
        } catch (JsonProcessingException e) {
            log.error(e.getMessage(), e);
        }
        DataBuffer buffer = resp.bufferFactory().wrap(returnStr.getBytes(StandardCharsets.UTF_8));
        return resp.writeWith(Flux.just(buffer));
    }

    @Override
    public int getOrder() {
        return -200;
    }

}

As mentioned before, the key is a crucial parameter when generating or validating a token with JWT. It is like a seed for generating a key. This value can be configured in the application.properties configuration file or written to Nacos. The JWTData class used in the filter is mainly used to store the request URLs that do not require authentication and the value of the JWT seed key.

jwt:
  token-key: eyJqdGkiOiI1NGEzNmQ5MjhjYzE0MTY2YTk0MmQ5NTg4NGM2Y2JjMSIsImlhdCI6MTU3OTE2MDkwMiwic3ViIjoiMTIxMiIsImV4cCI6MTU3OTE2MDkyMn0
  skip-urls: 
  - /member-service/member/bindMobile
  - /member-service/member/logout
@Component
@Data
@ConfigurationProperties(prefix = "jwt")
public class JWTData {

    public String tokenKey;

    private String[] skipUrls;
}

With these configurations and code, the basic setup and related features are complete. The next step is to proceed with testing.

Testing Availability #

The main purpose of this test is to verify if the token is validated under specific conditions. Since the filter is based on the gateway’s GlobalFilter, it intercepts all routing requests. If the request does not require authentication, it will be directly forwarded to the route.

First, use the JWTUtils utility to generate a normal token. Use Postman to verify the “user daily check-in feature request”. The request is successful.

img

Wait a few seconds until the token automatically expires, then resend the request. The result is as shown in the following image. The request is intercepted and returned by the gateway layer with the message “Token expired”. It will not be forwarded to the backend service.

img

Perform another test: forge an incorrect token and send a request. The result is shown in the following image. The request is intercepted and returned by the gateway layer, and will not be forwarded to the backend service.

img

With that, a lightweight gateway authentication scheme is complete. Although it is simple, it is practical. To deal with complex scenarios, other components or features need to be combined to strengthen the service and ensure its security. For example, after authentication is passed, you need to determine which operations can be performed and which cannot, based on role-based permissions. This is common in management systems. This feature is not demonstrated in this example, but you can try to add it to deepen your understanding of JWT.