| @@ -10,6 +10,7 @@ import java.util.function.Function; | |||||
| import org.slf4j.Logger; | import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | import org.slf4j.LoggerFactory; | ||||
| import org.springframework.beans.factory.annotation.Value; | |||||
| import org.springframework.beans.factory.config.ConfigurableBeanFactory; | import org.springframework.beans.factory.config.ConfigurableBeanFactory; | ||||
| import org.springframework.context.annotation.Scope; | import org.springframework.context.annotation.Scope; | ||||
| import org.springframework.security.core.userdetails.UserDetails; | import org.springframework.security.core.userdetails.UserDetails; | ||||
| @@ -30,13 +31,14 @@ public class JwtTokenUtil implements Serializable { | |||||
| private static final long serialVersionUID = -2550185165626007488L; | 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 AES_SECRET = "ffii"; | ||||
| public static final String TOKEN_SEPARATOR = "@@"; | 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); | 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) | // 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 | // compaction of the JWT to a URL-safe string | ||||
| private String doGenerateToken(Map<String, Object> claims, String subject) { | 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())) | 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(); | .signWith(secretKey).compact(); | ||||
| } | } | ||||
| @@ -92,10 +95,11 @@ public class JwtTokenUtil implements Serializable { | |||||
| } | } | ||||
| public RefreshToken createRefreshToken(String username) { | public RefreshToken createRefreshToken(String username) { | ||||
| long refreshExpirationMs = (long) refreshExpirationDays * 24 * 60 * 60 * 1000; | |||||
| RefreshToken refreshToken = new RefreshToken(); | RefreshToken refreshToken = new RefreshToken(); | ||||
| refreshToken.setUserName(username); | 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)); | refreshToken.setToken(AES.encrypt(username + TOKEN_SEPARATOR + instantNum, AES_SECRET)); | ||||
| return refreshToken; | return refreshToken; | ||||
| } | } | ||||
| @@ -23,6 +23,10 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic | |||||
| import com.ffii.fpsms.config.security.jwt.JwtRequestFilter; | import com.ffii.fpsms.config.security.jwt.JwtRequestFilter; | ||||
| import jakarta.servlet.http.HttpServletResponse; | |||||
| import java.io.IOException; | |||||
| import java.nio.charset.StandardCharsets; | |||||
| @Configuration | @Configuration | ||||
| @EnableWebSecurity | @EnableWebSecurity | ||||
| @EnableMethodSecurity | @EnableMethodSecurity | ||||
| @@ -36,6 +40,7 @@ public class SecurityConfig { | |||||
| INDEX_URL, | INDEX_URL, | ||||
| LOGIN_URL, | LOGIN_URL, | ||||
| LDAP_LOGIN_URL, | LDAP_LOGIN_URL, | ||||
| "/refresh-token", | |||||
| "/py/**" | "/py/**" | ||||
| }; | }; | ||||
| @@ -79,10 +84,19 @@ public class SecurityConfig { | |||||
| .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() | .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() | ||||
| .anyRequest().authenticated()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> response.sendError(HttpStatus.UNAUTHORIZED.value()))) | |||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | |||||
| .sessionManagement( | .sessionManagement( | ||||
| sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||||
| .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) | .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) | ||||
| .build(); | .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; | package com.ffii.fpsms.config.security.jwt; | ||||
| import java.io.IOException; | import java.io.IOException; | ||||
| import java.nio.charset.StandardCharsets; | |||||
| import org.springframework.beans.factory.annotation.Autowired; | import org.springframework.beans.factory.annotation.Autowired; | ||||
| import org.springframework.http.MediaType; | |||||
| import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||||
| import org.springframework.security.core.context.SecurityContextHolder; | import org.springframework.security.core.context.SecurityContextHolder; | ||||
| import org.springframework.security.core.userdetails.UserDetails; | 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 com.ffii.fpsms.config.security.jwt.service.JwtUserDetailsService; | ||||
| import io.jsonwebtoken.ExpiredJwtException; | import io.jsonwebtoken.ExpiredJwtException; | ||||
| import io.jsonwebtoken.JwtException; | |||||
| import jakarta.servlet.FilterChain; | import jakarta.servlet.FilterChain; | ||||
| import jakarta.servlet.ServletException; | import jakarta.servlet.ServletException; | ||||
| import jakarta.servlet.http.HttpServletRequest; | import jakarta.servlet.http.HttpServletRequest; | ||||
| @@ -42,10 +45,14 @@ public class JwtRequestFilter extends OncePerRequestFilter { | |||||
| jwtToken = requestTokenHeader.substring(7).replaceAll("\"", ""); | jwtToken = requestTokenHeader.substring(7).replaceAll("\"", ""); | ||||
| try { | try { | ||||
| username = jwtTokenUtil.getUsernameFromToken(jwtToken); | username = jwtTokenUtil.getUsernameFromToken(jwtToken); | ||||
| } catch (IllegalArgumentException e) { | |||||
| logger.error("Unable to get JWT Token"); | |||||
| } catch (ExpiredJwtException e) { | } catch (ExpiredJwtException e) { | ||||
| logger.error("JWT Token has expired"); | 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 { | } else { | ||||
| logger.warn("JWT Token does not begin with Bearer String"); | logger.warn("JWT Token does not begin with Bearer String"); | ||||
| @@ -72,4 +79,14 @@ public class JwtRequestFilter extends OncePerRequestFilter { | |||||
| chain.doFilter(request, response); | 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: | spring: | ||||
| datasource: | datasource: | ||||
| jdbc-url: jdbc:mysql://127.0.0.1:3306/fpsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 | jdbc-url: jdbc:mysql://127.0.0.1:3306/fpsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 | ||||
| @@ -31,6 +31,11 @@ spring: | |||||
| dialect: | dialect: | ||||
| storage_engine: innodb | 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: | logging: | ||||
| config: 'classpath:log4j2.yml' | config: 'classpath:log4j2.yml' | ||||