diff --git a/src/main/java/com/ffii/core/utils/JwtTokenUtil.java b/src/main/java/com/ffii/core/utils/JwtTokenUtil.java index 4808795..a502738 100644 --- a/src/main/java/com/ffii/core/utils/JwtTokenUtil.java +++ b/src/main/java/com/ffii/core/utils/JwtTokenUtil.java @@ -10,6 +10,7 @@ import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.annotation.Scope; import org.springframework.security.core.userdetails.UserDetails; @@ -30,13 +31,14 @@ public class JwtTokenUtil implements Serializable { private static final long serialVersionUID = -2550185165626007488L; - // * 60000 = 1 Min - public static final long JWT_TOKEN_EXPIRED_TIME = 60000 * 14400; public static final String AES_SECRET = "ffii"; public static final String TOKEN_SEPARATOR = "@@"; - // @Value("${jwt.secret}") - // private String secret; + @Value("${jwt.expiration-minutes:14400}") + private long expirationMinutes = 14400; + + @Value("${jwt.refresh-expiration-days:30}") + private int refreshExpirationDays = 30; private static final Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512); @@ -79,9 +81,10 @@ public class JwtTokenUtil implements Serializable { // Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1) // compaction of the JWT to a URL-safe string private String doGenerateToken(Map claims, String subject) { - logger.info((new Date(System.currentTimeMillis() + JWT_TOKEN_EXPIRED_TIME)).toString()); + long expirationMs = expirationMinutes * 60 * 1000; + logger.info((new Date(System.currentTimeMillis() + expirationMs)).toString()); return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_EXPIRED_TIME)) + .setExpiration(new Date(System.currentTimeMillis() + expirationMs)) .signWith(secretKey).compact(); } @@ -92,10 +95,11 @@ public class JwtTokenUtil implements Serializable { } public RefreshToken createRefreshToken(String username) { + long refreshExpirationMs = (long) refreshExpirationDays * 24 * 60 * 60 * 1000; RefreshToken refreshToken = new RefreshToken(); refreshToken.setUserName(username); - refreshToken.setExpiryDate(Instant.now().plusMillis(JWT_TOKEN_EXPIRED_TIME * 60 * 24)); - long instantNum = Instant.now().plusMillis(JWT_TOKEN_EXPIRED_TIME * 60 * 24).toEpochMilli(); + refreshToken.setExpiryDate(Instant.now().plusMillis(refreshExpirationMs)); + long instantNum = Instant.now().plusMillis(refreshExpirationMs).toEpochMilli(); refreshToken.setToken(AES.encrypt(username + TOKEN_SEPARATOR + instantNum, AES_SECRET)); return refreshToken; } diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 4ad0fcf..48fca04 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -23,6 +23,10 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic import com.ffii.fpsms.config.security.jwt.JwtRequestFilter; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -36,6 +40,7 @@ public class SecurityConfig { INDEX_URL, LOGIN_URL, LDAP_LOGIN_URL, + "/refresh-token", "/py/**" }; @@ -79,10 +84,19 @@ public class SecurityConfig { .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( - (request, response, authException) -> response.sendError(HttpStatus.UNAUTHORIZED.value()))) + (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) .sessionManagement( sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) .build(); } + + /** Send 401 with JSON body so frontend can consistently handle session timeout / missing token. */ + private static void sendUnauthorizedJson(HttpServletResponse response, String message, String code) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String body = String.format("{\"message\":\"%s\",\"code\":\"%s\"}", message.replace("\"", "\\\""), code); + response.getWriter().write(body); + } } diff --git a/src/main/java/com/ffii/fpsms/config/security/jwt/JwtRequestFilter.java b/src/main/java/com/ffii/fpsms/config/security/jwt/JwtRequestFilter.java index 6797752..81658cc 100644 --- a/src/main/java/com/ffii/fpsms/config/security/jwt/JwtRequestFilter.java +++ b/src/main/java/com/ffii/fpsms/config/security/jwt/JwtRequestFilter.java @@ -1,8 +1,10 @@ package com.ffii.fpsms.config.security.jwt; import java.io.IOException; +import java.nio.charset.StandardCharsets; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; @@ -14,6 +16,7 @@ import com.ffii.core.utils.JwtTokenUtil; import com.ffii.fpsms.config.security.jwt.service.JwtUserDetailsService; import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -42,10 +45,14 @@ public class JwtRequestFilter extends OncePerRequestFilter { jwtToken = requestTokenHeader.substring(7).replaceAll("\"", ""); try { username = jwtTokenUtil.getUsernameFromToken(jwtToken); - } catch (IllegalArgumentException e) { - logger.error("Unable to get JWT Token"); } catch (ExpiredJwtException e) { logger.error("JWT Token has expired"); + sendUnauthorizedJson(response, "JWT Token has expired", "TOKEN_EXPIRED"); + return; + } catch (JwtException | IllegalArgumentException e) { + logger.error("Invalid JWT Token: {}", e.getMessage()); + sendUnauthorizedJson(response, "Invalid JWT Token", "TOKEN_INVALID"); + return; } } else { logger.warn("JWT Token does not begin with Bearer String"); @@ -72,4 +79,14 @@ public class JwtRequestFilter extends OncePerRequestFilter { chain.doFilter(request, response); } + /** Send 401 with JSON body so frontend can detect session/timeout and redirect to login or refresh. */ + private void sendUnauthorizedJson(HttpServletResponse response, String message, String code) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + String body = String.format("{\"message\":\"%s\",\"code\":\"%s\"}", + message.replace("\"", "\\\""), code); + response.getWriter().write(body); + } + } \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4791367..d627e2f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,3 +1,8 @@ +# Shorter session in production; frontend should call /refresh-token or re-login. +jwt: + expiration-minutes: 30 # 30 min access token + refresh-expiration-days: 7 + spring: datasource: jdbc-url: jdbc:mysql://127.0.0.1:3306/fpsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5869f24..c9ca48e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,11 @@ spring: dialect: storage_engine: innodb +# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. +jwt: + expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session + refresh-expiration-days: 30 # refresh token validity (days) + logging: config: 'classpath:log4j2.yml'