diff --git a/build.gradle b/build.gradle index ecae9d9..6594f72 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' // Thymeleaf에서 SpringSecurity를 Integration implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' + // jjwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2' // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/springsecuritystudy/config/SecurityConfig.java b/src/main/java/com/example/springsecuritystudy/config/SecurityConfig.java index 8afc814..2f8079e 100644 --- a/src/main/java/com/example/springsecuritystudy/config/SecurityConfig.java +++ b/src/main/java/com/example/springsecuritystudy/config/SecurityConfig.java @@ -1,8 +1,5 @@ package com.example.springsecuritystudy.config; -import com.example.springsecuritystudy.filter.StopwatchFilter; -import com.example.springsecuritystudy.filter.TesterAuthenticationFilter; -import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpMethod; @@ -11,11 +8,21 @@ import org.springframework.security.config.annotation.authentication.configurati import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import com.example.springsecuritystudy.filter.StopwatchFilter; +import com.example.springsecuritystudy.jwt.JwtAuthenticationFilter; +import com.example.springsecuritystudy.jwt.JwtAuthorizationFilter; +import com.example.springsecuritystudy.jwt.JwtProperties; +import com.example.springsecuritystudy.user.UserRepository; + +import lombok.RequiredArgsConstructor; + /** * Security 설정 Config */ @@ -23,6 +30,8 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @RequiredArgsConstructor public class SecurityConfig { + private final UserRepository userRepository; + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); @@ -36,16 +45,20 @@ public class SecurityConfig { new StopwatchFilter(), WebAsyncManagerIntegrationFilter.class ); - // tester authentication filter + // JWT filter http.addFilterBefore( - new TesterAuthenticationFilter(authenticationManager(http.getSharedObject(AuthenticationConfiguration.class))), + new JwtAuthenticationFilter(authenticationManager(http.getSharedObject(AuthenticationConfiguration.class))), UsernamePasswordAuthenticationFilter.class + ).addFilterBefore( + new JwtAuthorizationFilter(userRepository), + BasicAuthenticationFilter.class ); http .httpBasic().disable() - .csrf(); + .csrf().disable(); http - .rememberMe(); + .rememberMe().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); http .authorizeHttpRequests(auth -> auth .antMatchers("/", "/home", "/signup").permitAll() @@ -64,7 +77,7 @@ public class SecurityConfig { // .logoutUrl("/logout") // post 방식으로만 동작 .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) // get 방식으로도 동작 .logoutSuccessUrl("/") - .deleteCookies("JSESSIONID") + .deleteCookies(JwtProperties.COOKIE_NAME) .invalidateHttpSession(true) ); diff --git a/src/main/java/com/example/springsecuritystudy/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/springsecuritystudy/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..5563a06 --- /dev/null +++ b/src/main/java/com/example/springsecuritystudy/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,63 @@ +package com.example.springsecuritystudy.jwt; + +import java.io.IOException; +import java.util.ArrayList; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.example.springsecuritystudy.user.User; + +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + public JwtAuthenticationFilter( + AuthenticationManager authenticationManager) { + super(authenticationManager); + } + + /** + * 로그인 인증 시도 + */ + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws + AuthenticationException { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + request.getParameter("username"), + request.getParameter("password"), + new ArrayList<>() + ); + return getAuthenticationManager().authenticate(authenticationToken); + } + + /** + * 인증에 성공했을 때 사용 + * JWT Token을 생성해서 쿠키에 넣는다. + */ + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + User user = (User)authResult.getPrincipal(); + String token = JwtUtils.createToken(user); + // 쿠키 생성 + Cookie cookie = new Cookie(JwtProperties.COOKIE_NAME, token); + cookie.setMaxAge(JwtProperties.EXPIRATION_TIME); // 쿠키 만료 시간 + cookie.setPath("/"); + response.addCookie(cookie); + response.sendRedirect("/"); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + response.sendRedirect("/login"); + } +} diff --git a/src/main/java/com/example/springsecuritystudy/jwt/JwtAuthorizationFilter.java b/src/main/java/com/example/springsecuritystudy/jwt/JwtAuthorizationFilter.java new file mode 100644 index 0000000..60af9d5 --- /dev/null +++ b/src/main/java/com/example/springsecuritystudy/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,69 @@ +package com.example.springsecuritystudy.jwt; + +import java.io.IOException; +import java.util.Arrays; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.example.springsecuritystudy.common.UserNotFoundException; +import com.example.springsecuritystudy.user.User; +import com.example.springsecuritystudy.user.UserRepository; + +/** + * JWT를 이용한 인증 + */ +public class JwtAuthorizationFilter extends OncePerRequestFilter { + + private final UserRepository userRepository; + + public JwtAuthorizationFilter(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = null; + try { + // cookie에서 JWT token을 가져온다. + token = Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals(JwtProperties.COOKIE_NAME)).findFirst() + .map(Cookie::getValue) + .orElse(null); + } catch (Exception ignored) { + } + if (token != null) { + try { + Authentication authentication = getUsernamePasswordAuthenticationToken(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + Cookie cookie = new Cookie(JwtProperties.COOKIE_NAME, null); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + filterChain.doFilter(request, response); + } + + private Authentication getUsernamePasswordAuthenticationToken(String token) { + String username = JwtUtils.getUsername(token); + if (username != null) { + User user = userRepository.findByUsername(username).orElseThrow(UserNotFoundException::new); + return new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + } + return null; + } +} diff --git a/src/main/java/com/example/springsecuritystudy/jwt/JwtKey.java b/src/main/java/com/example/springsecuritystudy/jwt/JwtKey.java new file mode 100644 index 0000000..8017137 --- /dev/null +++ b/src/main/java/com/example/springsecuritystudy/jwt/JwtKey.java @@ -0,0 +1,56 @@ +package com.example.springsecuritystudy.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import io.jsonwebtoken.security.Keys; +import javafx.util.Pair; + +/** + * JWT Key를 제공하고 조회한다. + * Key Rolling을 지원한다. + */ +public class JwtKey { + /** + * Kid-Key List 외부로 절대 유출되어서는 안된다. + */ + private static final Map SECRET_KEY_SET = new HashMap() { + { + put("key1", "SpringSecurityJWTPracticeProjectIsSoGoodAndThisProjectIsSoFunSpringSecurityJWTPracticeProjectIsSoGoodAndThisProjectIsSoFun"); + put("key2", "GoodSpringSecurityNiceSpringSecurityGoodSpringSecurityNiceSpringSecurityGoodSpringSecurityNiceSpringSecurityGoodSpringSecurityNiceSpringSecurity"); + put("key3", "HelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurityHelloSpringSecurity"); + } + }; + + private static final String[] KID_SET = SECRET_KEY_SET.keySet().toArray(new String[0]); + private static Random randomIndex = new Random(); + + /** + * SECRET_KEY_SET 에서 랜덤한 KEY 가져오기 + * + * @return kid와 key Pair + */ + public static Pair getRandomKey() { + String kid = KID_SET[randomIndex.nextInt(KID_SET.length)]; + String secretKey = SECRET_KEY_SET.get(kid); + return new Pair<>(kid, Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8))); + } + + /** + * kid로 Key찾기 + * + * @param kid kid + * @return Key + */ + public static Key getKey(String kid) { + String key = SECRET_KEY_SET.getOrDefault(kid, null); + if (key == null) { + return null; + } + return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/src/main/java/com/example/springsecuritystudy/jwt/JwtProperties.java b/src/main/java/com/example/springsecuritystudy/jwt/JwtProperties.java new file mode 100644 index 0000000..5024301 --- /dev/null +++ b/src/main/java/com/example/springsecuritystudy/jwt/JwtProperties.java @@ -0,0 +1,9 @@ +package com.example.springsecuritystudy.jwt; + +/** + * JWT 기본 설정값 + */ +public class JwtProperties { + public static final int EXPIRATION_TIME = 600000; // 10분 + public static final String COOKIE_NAME = "JWT"; +} diff --git a/src/main/java/com/example/springsecuritystudy/jwt/JwtUtils.java b/src/main/java/com/example/springsecuritystudy/jwt/JwtUtils.java new file mode 100644 index 0000000..ab2839c --- /dev/null +++ b/src/main/java/com/example/springsecuritystudy/jwt/JwtUtils.java @@ -0,0 +1,52 @@ +package com.example.springsecuritystudy.jwt; + +import java.security.Key; +import java.util.Date; + +import com.example.springsecuritystudy.user.User; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import javafx.util.Pair; + +public class JwtUtils { + /** + * 토큰에서 username 찾기 + * + * @param token 토큰 + * @return username + */ + public static String getUsername(String token) { + // jwtToken에서 username을 찾는다. + return Jwts.parserBuilder() + .setSigningKeyResolver(SigningKeyResolver.instance) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); // 토큰에 담긴 정보에서 username을 가져온다. + } + + /** + * user로 토큰 생성 + * HEADER : alg, kid + * PAYLOAD : sub, iat, exp + * SIGNATURE : JwtKey.getRandomKey로 구한 Secret Key로 HS512 해시 + * + * @param user 유저 + * @return jwt token + */ + public static String createToken(User user) { + Claims claims = Jwts.claims().setSubject(user.getUsername()); + Date now = new Date(); + Pair key = JwtKey.getRandomKey(); + return Jwts.builder() + .setClaims(claims) // 토큰에 담을 정보 설정 + .setIssuedAt(now) // 토큰 발행 시간 설정 + .setExpiration(new Date(now.getTime() + JwtProperties.EXPIRATION_TIME)) // 토큰 만료 시간 설정 + .setHeaderParam(JwsHeader.KEY_ID, key.getKey()) // 토큰에 kid 설정 + .signWith(key.getValue()) // signature 생성 + .compact(); + } + +} diff --git a/src/main/java/com/example/springsecuritystudy/jwt/SigningKeyResolver.java b/src/main/java/com/example/springsecuritystudy/jwt/SigningKeyResolver.java new file mode 100644 index 0000000..e0f510c --- /dev/null +++ b/src/main/java/com/example/springsecuritystudy/jwt/SigningKeyResolver.java @@ -0,0 +1,22 @@ +package com.example.springsecuritystudy.jwt; + +import java.security.Key; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.SigningKeyResolverAdapter; + +/** + * JwsHeader를 통해 Signature 검증에 필요한 Key를 가져오는 코드를 구현합니다. + */ +public class SigningKeyResolver extends SigningKeyResolverAdapter { + public static SigningKeyResolver instance = new SigningKeyResolver(); + + @Override + public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) { + String kid = jwsHeader.getKeyId(); + if (kid == null) + return null; + return JwtKey.getKey(kid); + } +}