feat: implement JWT utility class with configurable properties and token management (#293)
diff --git a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/BigtopManagerServer.java b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/BigtopManagerServer.java
index a69e7fa..fa150ea 100644
--- a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/BigtopManagerServer.java
+++ b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/BigtopManagerServer.java
@@ -18,13 +18,17 @@
  */
 package org.apache.bigtop.manager.server;
 
+import org.apache.bigtop.manager.server.config.JwtProperties;
+
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.annotation.EnableScheduling;
 
 @EnableAsync
 @EnableScheduling
+@EnableConfigurationProperties(JwtProperties.class)
 @SpringBootApplication(
         scanBasePackages = {
             "org.apache.bigtop.manager.server",
diff --git a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/config/JwtProperties.java b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/config/JwtProperties.java
new file mode 100644
index 0000000..797d466
--- /dev/null
+++ b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/config/JwtProperties.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.bigtop.manager.server.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import lombok.Data;
+
+@Data
+@ConfigurationProperties(prefix = "bigtop-manager.security.jwt")
+public class JwtProperties {
+
+    /**
+     * JWT signing secret.
+     * <p>
+     * Must be set in production (e.g. via env var) to prevent forged tokens.
+     */
+    private String secret;
+
+    /** Issuer to embed and to require during verification. */
+    private String issuer = "bigtop-manager";
+
+    /** Audience to embed and to require during verification. */
+    private String audience = "bigtop-manager";
+
+    /** Token validity period in days. */
+    private int expirationDays = 7;
+
+    /**
+     * Whether to allow a built-in dev secret as fallback.
+     * <p>
+     * Keep this false in production.
+     */
+    private boolean allowDefaultSecret = false;
+}
diff --git a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/interceptor/AuthInterceptor.java b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/interceptor/AuthInterceptor.java
index a7cc2e8..68737c5 100644
--- a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/interceptor/AuthInterceptor.java
+++ b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/interceptor/AuthInterceptor.java
@@ -48,6 +48,9 @@
     @Autowired
     private UserService userService;
 
+    @Autowired
+    private JWTUtils jwtUtils;
+
     private ResponseEntity<?> responseEntity;
 
     @Override
@@ -89,7 +92,7 @@
         }
 
         try {
-            DecodedJWT decodedJWT = JWTUtils.resolveToken(token);
+            DecodedJWT decodedJWT = jwtUtils.resolveToken(token);
             Long userId = decodedJWT.getClaim(JWTUtils.CLAIM_ID).asLong();
             Integer tokenVersion =
                     decodedJWT.getClaim(JWTUtils.CLAIM_TOKEN_VERSION).asInt();
diff --git a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/service/impl/LoginServiceImpl.java b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/service/impl/LoginServiceImpl.java
index 88d4b55..9a1c2cd 100644
--- a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/service/impl/LoginServiceImpl.java
+++ b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/service/impl/LoginServiceImpl.java
@@ -43,6 +43,9 @@
     @Resource
     private UserDao userDao;
 
+    @Resource
+    private JWTUtils jwtUtils;
+
     @Override
     public LoginVO login(LoginDTO loginDTO) {
         String username = loginDTO.getUsername();
@@ -75,7 +78,7 @@
         CacheUtils.setCache(
                 Caches.CACHE_USER, user.getId().toString(), userVO, Caches.USER_EXPIRE_TIME_DAYS, TimeUnit.DAYS);
 
-        String token = JWTUtils.generateToken(user.getId(), user.getUsername(), user.getTokenVersion());
+        String token = jwtUtils.generateToken(user.getId(), user.getUsername(), user.getTokenVersion());
         LoginVO loginVO = new LoginVO();
         loginVO.setToken(token);
         return loginVO;
diff --git a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/utils/JWTUtils.java b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/utils/JWTUtils.java
index a0f7e26..eb36b57 100644
--- a/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/utils/JWTUtils.java
+++ b/bigtop-manager-server/src/main/java/org/apache/bigtop/manager/server/utils/JWTUtils.java
@@ -18,13 +18,21 @@
  */
 package org.apache.bigtop.manager.server.utils;
 
+import org.apache.bigtop.manager.server.config.JwtProperties;
+
+import org.springframework.stereotype.Component;
+
 import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
 import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.exceptions.JWTVerificationException;
 import com.auth0.jwt.interfaces.DecodedJWT;
 
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.Date;
 
+@Component
 public class JWTUtils {
 
     public static final String CLAIM_ID = "id";
@@ -33,23 +41,69 @@
 
     public static final String CLAIM_TOKEN_VERSION = "token_version";
 
-    protected static final String SIGN = "r0PGVyvjKOxUBwGt";
+    /**
+     * Dev-only fallback secret to preserve local boot for contributors.
+     * <p>
+     * In production, configure `bigtop-manager.security.jwt.secret`.
+     */
+    static final String DEFAULT_DEV_SECRET = "r0PGVyvjKOxUBwGt";
 
-    // Token validity period (days)
-    private static final int TOKEN_EXPIRATION_DAYS = 7;
+    private final JwtProperties jwtProperties;
 
-    public static String generateToken(Long userId, String username, Integer tokenVersion) {
-        Instant expireTime = Instant.now().plus(TOKEN_EXPIRATION_DAYS, ChronoUnit.DAYS);
+    public JWTUtils(JwtProperties jwtProperties) {
+        this.jwtProperties = jwtProperties;
+    }
+
+    public String generateToken(Long userId, String username, Integer tokenVersion) {
+        Instant now = Instant.now();
+        Instant expireTime = now.plus(jwtProperties.getExpirationDays(), ChronoUnit.DAYS);
 
         return JWT.create()
+                .withIssuer(jwtProperties.getIssuer())
+                .withAudience(jwtProperties.getAudience())
+                .withIssuedAt(Date.from(now))
                 .withClaim(CLAIM_ID, userId)
                 .withClaim(CLAIM_USERNAME, username)
                 .withClaim(CLAIM_TOKEN_VERSION, tokenVersion)
-                .withExpiresAt(expireTime)
-                .sign(Algorithm.HMAC256(SIGN));
+                .withExpiresAt(Date.from(expireTime))
+                .sign(Algorithm.HMAC256(getSigningSecret()));
     }
 
-    public static DecodedJWT resolveToken(String token) {
-        return JWT.require(Algorithm.HMAC256(SIGN)).build().verify(token);
+    public DecodedJWT resolveToken(String token) throws JWTVerificationException {
+        Algorithm algorithm = Algorithm.HMAC256(getSigningSecret());
+        JWTVerifier verifier = JWT.require(algorithm)
+                .withIssuer(jwtProperties.getIssuer())
+                .withAudience(jwtProperties.getAudience())
+                .build();
+
+        DecodedJWT decodedJWT = verifier.verify(token);
+
+        // Enforce issued-at to mitigate tokens without freshness metadata.
+        Date issuedAt = decodedJWT.getIssuedAt();
+        if (issuedAt == null) {
+            throw new JWTVerificationException("Missing iat");
+        }
+
+        // Reject tokens issued too far in the future (clock skew).
+        Instant now = Instant.now();
+        if (issuedAt.toInstant().isAfter(now.plus(5, ChronoUnit.MINUTES))) {
+            throw new JWTVerificationException("iat is in the future");
+        }
+
+        return decodedJWT;
+    }
+
+    private String getSigningSecret() {
+        String secret = jwtProperties.getSecret();
+        if (secret != null && !secret.isBlank()) {
+            return secret;
+        }
+
+        if (jwtProperties.isAllowDefaultSecret()) {
+            return DEFAULT_DEV_SECRET;
+        }
+
+        throw new IllegalStateException(
+                "JWT secret is not configured. Please set bigtop-manager.security.jwt.secret (or enable allowDefaultSecret for dev only).");
     }
 }
diff --git a/bigtop-manager-server/src/main/resources/application.yml b/bigtop-manager-server/src/main/resources/application.yml
index 860573b..2b8886e 100644
--- a/bigtop-manager-server/src/main/resources/application.yml
+++ b/bigtop-manager-server/src/main/resources/application.yml
@@ -67,4 +67,15 @@
 pagehelper:
   reasonable: false
   params: count=countSql
-  support-methods-arguments: true
\ No newline at end of file
+  support-methods-arguments: true
+
+bigtop-manager:
+  security:
+    jwt:
+      # IMPORTANT: Set a strong, random secret in production (e.g. via env var BIGTOP_MANAGER_JWT_SECRET)
+      secret: ${BIGTOP_MANAGER_JWT_SECRET:}
+      issuer: bigtop-manager
+      audience: bigtop-manager
+      expiration-days: 7
+      # Dev-only compatibility switch. Keep false in production.
+      allow-default-secret: false
diff --git a/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/service/LoginServiceTest.java b/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/service/LoginServiceTest.java
index 86c06db..68bcd90 100644
--- a/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/service/LoginServiceTest.java
+++ b/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/service/LoginServiceTest.java
@@ -27,6 +27,7 @@
 import org.apache.bigtop.manager.server.model.dto.LoginDTO;
 import org.apache.bigtop.manager.server.service.impl.LoginServiceImpl;
 import org.apache.bigtop.manager.server.utils.CacheUtils;
+import org.apache.bigtop.manager.server.utils.JWTUtils;
 import org.apache.bigtop.manager.server.utils.PasswordUtils;
 import org.apache.bigtop.manager.server.utils.Pbkdf2Utils;
 
@@ -51,6 +52,9 @@
     @Mock
     private UserDao userDao;
 
+    @Mock
+    private JWTUtils jwtUtils;
+
     @InjectMocks
     private LoginService loginService = new LoginServiceImpl();
 
@@ -107,6 +111,7 @@
         LoginDTO loginDTO = createLoginDTO(RAW_PASSWORD);
 
         when(userDao.findByUsername(any())).thenReturn(mockUser);
+        when(jwtUtils.generateToken(any(), any(), any())).thenReturn("test-token");
 
         Object result = loginService.login(loginDTO);
         assertNotNull(result);
diff --git a/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/utils/JWTUtilsTest.java b/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/utils/JWTUtilsTest.java
index e931609..ed2c68a 100644
--- a/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/utils/JWTUtilsTest.java
+++ b/bigtop-manager-server/src/test/java/org/apache/bigtop/manager/server/utils/JWTUtilsTest.java
@@ -18,6 +18,8 @@
  */
 package org.apache.bigtop.manager.server.utils;
 
+import org.apache.bigtop.manager.server.config.JwtProperties;
+
 import org.junit.jupiter.api.Test;
 
 import com.auth0.jwt.JWT;
@@ -35,15 +37,26 @@
 
 public class JWTUtilsTest {
 
+    private static JWTUtils newJwtUtilsWithDevSecretAllowed() {
+        JwtProperties props = new JwtProperties();
+        props.setAllowDefaultSecret(true);
+        props.setIssuer("bigtop-manager");
+        props.setAudience("bigtop-manager");
+        props.setExpirationDays(7);
+        return new JWTUtils(props);
+    }
+
     @Test
     public void testGenerateTokenNormal() {
+        JWTUtils jwtUtils = newJwtUtilsWithDevSecretAllowed();
+
         Long id = 1L;
         String username = "testUser";
         Integer tokenVersion = 1;
-        String token = JWTUtils.generateToken(id, username, tokenVersion);
+        String token = jwtUtils.generateToken(id, username, tokenVersion);
         assertNotNull(token);
 
-        DecodedJWT decodedJWT = JWTUtils.resolveToken(token);
+        DecodedJWT decodedJWT = jwtUtils.resolveToken(token);
         assertEquals(id, decodedJWT.getClaim(JWTUtils.CLAIM_ID).asLong());
         assertEquals(username, decodedJWT.getClaim(JWTUtils.CLAIM_USERNAME).asString());
         assertEquals(
@@ -52,6 +65,8 @@
 
     @Test
     public void testResolveTokenExpired() {
+        JWTUtils jwtUtils = newJwtUtilsWithDevSecretAllowed();
+
         Long id = 2L;
         String username = "expiredUser";
         Calendar calendar = Calendar.getInstance();
@@ -59,33 +74,67 @@
         Date date = calendar.getTime();
 
         String token = JWT.create()
+                .withIssuer("bigtop-manager")
+                .withAudience("bigtop-manager")
+                .withIssuedAt(new Date())
                 .withClaim(JWTUtils.CLAIM_ID, id)
                 .withClaim(JWTUtils.CLAIM_USERNAME, username)
                 .withExpiresAt(date)
-                .sign(Algorithm.HMAC256(JWTUtils.SIGN));
+                .sign(Algorithm.HMAC256(JWTUtils.DEFAULT_DEV_SECRET));
 
-        assertThrows(JWTVerificationException.class, () -> JWTUtils.resolveToken(token));
+        assertThrows(JWTVerificationException.class, () -> jwtUtils.resolveToken(token));
     }
 
     @Test
     public void testResolveTokenIllegal() {
+        JWTUtils jwtUtils = newJwtUtilsWithDevSecretAllowed();
+
         String illegalToken =
                 "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
-        assertThrows(JWTVerificationException.class, () -> JWTUtils.resolveToken(illegalToken));
+        assertThrows(JWTVerificationException.class, () -> jwtUtils.resolveToken(illegalToken));
     }
 
     @Test
     public void testResolveTokenWrongFormat() {
+        JWTUtils jwtUtils = newJwtUtilsWithDevSecretAllowed();
+
         String wrongFormatToken = "wrong_format_token";
-        assertThrows(JWTDecodeException.class, () -> JWTUtils.resolveToken(wrongFormatToken));
+        assertThrows(JWTDecodeException.class, () -> jwtUtils.resolveToken(wrongFormatToken));
     }
 
     @Test
     public void testGenerateTokenUsernameEmpty() {
-        String token = JWTUtils.generateToken(1L, "", 1);
+        JWTUtils jwtUtils = newJwtUtilsWithDevSecretAllowed();
+
+        String token = jwtUtils.generateToken(1L, "", 1);
         assertNotNull(token);
 
-        DecodedJWT decodedJWT = JWTUtils.resolveToken(token);
+        DecodedJWT decodedJWT = jwtUtils.resolveToken(token);
         assertEquals("", decodedJWT.getClaim(JWTUtils.CLAIM_USERNAME).asString());
     }
+
+    @Test
+    public void testResolveTokenMissingIatRejected() {
+        JWTUtils jwtUtils = newJwtUtilsWithDevSecretAllowed();
+
+        String token = JWT.create()
+                .withIssuer("bigtop-manager")
+                .withAudience("bigtop-manager")
+                .withClaim(JWTUtils.CLAIM_ID, 1L)
+                .withClaim(JWTUtils.CLAIM_TOKEN_VERSION, 1)
+                .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
+                // intentionally no iat
+                .sign(Algorithm.HMAC256(JWTUtils.DEFAULT_DEV_SECRET));
+
+        assertThrows(JWTVerificationException.class, () -> jwtUtils.resolveToken(token));
+    }
+
+    @Test
+    public void testSecretRequiredByDefault() {
+        JwtProperties props = new JwtProperties();
+        props.setAllowDefaultSecret(false);
+        JWTUtils jwtUtils = new JWTUtils(props);
+
+        assertThrows(IllegalStateException.class, () -> jwtUtils.generateToken(1L, "u", 1));
+    }
 }
diff --git a/dev-support/docker/containers/build.sh b/dev-support/docker/containers/build.sh
index 88131b3..37549f8 100755
--- a/dev-support/docker/containers/build.sh
+++ b/dev-support/docker/containers/build.sh
@@ -31,6 +31,18 @@
     echo -e "\033[32m[LOG] $1\033[0m"
 }
 
+# Generate a random JWT secret for local dev if user didn't provide one.
+init_jwt_secret() {
+  if [ -z "${BIGTOP_MANAGER_JWT_SECRET}" ]; then
+    # Dev-support default. Override by exporting BIGTOP_MANAGER_JWT_SECRET before running this script.
+    BIGTOP_MANAGER_JWT_SECRET="r0PGVyvjKOxUBwGt"
+    export BIGTOP_MANAGER_JWT_SECRET
+    log "Defaulted BIGTOP_MANAGER_JWT_SECRET for dev-support"
+  else
+    log "Using provided BIGTOP_MANAGER_JWT_SECRET for dev-support"
+  fi
+}
+
 build() {
     log "Build on docker: $SKIP_COMPILE"
     if ! $SKIP_COMPILE; then
@@ -54,6 +66,7 @@
 
 create() {
   log "Create Containers!!!"
+  init_jwt_secret
   docker network inspect bigtop-manager >/dev/null 2>&1 || docker network create --driver bridge bigtop-manager
   create_db
   create_container
@@ -65,13 +78,17 @@
     container_name="bm-$i"
     log "Create ${container_name}"
     if [ $i -eq 1 ]; then
-      docker run -itd -p 15005:5005 -p 15006:5006 -p 18080:8080 --name ${container_name} --hostname ${container_name} --network bigtop-manager --cap-add=SYS_TIME bigtop-manager/develop:${OS}
+      docker run -itd -p 15005:5005 -p 15006:5006 -p 18080:8080 \
+        -e BIGTOP_MANAGER_JWT_SECRET="${BIGTOP_MANAGER_JWT_SECRET}" \
+        --name ${container_name} --hostname ${container_name} --network bigtop-manager --cap-add=SYS_TIME bigtop-manager/develop:${OS}
       docker cp ../../../bigtop-manager-dist/target/apache-bigtop-manager-*-server.tar.gz ${container_name}:/opt/bigtop-manager-server.tar.gz
       docker exec ${container_name} bash -c "cd /opt && tar -zxvf bigtop-manager-server.tar.gz"
       docker exec ${container_name} bash -c "ssh-keygen -f '/root/.ssh/id_rsa' -N '' -t rsa"
       SERVER_PUB_KEY=`docker exec ${container_name} /bin/cat /root/.ssh/id_rsa.pub`
     else
-      docker run -itd --name ${container_name} --hostname ${container_name} --network bigtop-manager --cap-add=SYS_TIME bigtop-manager/develop:${OS}
+      docker run -itd \
+        -e BIGTOP_MANAGER_JWT_SECRET="${BIGTOP_MANAGER_JWT_SECRET}" \
+        --name ${container_name} --hostname ${container_name} --network bigtop-manager --cap-add=SYS_TIME bigtop-manager/develop:${OS}
     fi
 
     docker cp ../../../bigtop-manager-dist/target/apache-bigtop-manager-*-agent.tar.gz ${container_name}:/opt/bigtop-manager-agent.tar.gz
@@ -112,6 +129,11 @@
         docker exec ${container} bash -c "PGPASSWORD=postgres psql -h bm-postgres -p5432 -U postgres -d bigtop_manager -f /opt/bigtop-manager-server/ddl/PostgreSQL-DDL-CREATE.sql"
         docker exec ${container} bash -c "sed -i 's/localhost:5432/bm-postgres:5432/' /opt/bigtop-manager-server/conf/application.yml"
       fi
+
+      # Inject JWT secret into server config inside container (for environments where env vars are not propagated)
+      docker exec ${container} bash -c "grep -q '^\s*secret:' /opt/bigtop-manager-server/conf/application.yml || true"
+      docker exec ${container} bash -c "sed -i 's/^\(\s*secret:\).*/\1 \${BIGTOP_MANAGER_JWT_SECRET}/' /opt/bigtop-manager-server/conf/application.yml || true"
+
       docker exec ${container} bash -c "nohup /bin/bash /opt/bigtop-manager-server/bin/server.sh start --debug > /dev/null 2>&1 &"
     fi
     log "All Service Started!!!"