| @@ -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<String, Object> 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; | |||
| } | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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' | |||