-
7. Spring Boot + Spring Security + JWT + access token + refresh token + 토큰 갱신Project/React+Java 2022. 8. 2. 15:50
https://github.com/dchkang83/project-board
refresh token 설정을 하다 보니 부족한 부분들이 보여서 수정된 부분이 많다.
사실... 기분내키는 대로 많이 수정해서.. 기억나는 부분만 내용 정리하였는데 암튼.. 포인트만 정리 하였다!
최대한 dto, dao, vo의 성격에 맞게 사용하려고 노력하였으며 보안을 위하여 토큰들은 모두 header에 담아서 클리아언트와의 통신을 할 수 있도록 구성해 보았다.
어플리케이션이 실행될때 마다 편하게 작업하기 위해서 어플리케이션 실행을 할때 마다 JPA entity 설정에 맞춰서 테이블을 생성하고 하이버네이트가 초기화 된 이후에 sql문에 실행하도록 하여서 기본적인 데이터를 미리 넣어두어 개발 및 테스트 시간을 줄이도록 하였다.https://dchkang83.tistory.com/43
1. WebSecurityConfig cors 설정
@Bean protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http ... .cors() // cors .and() ... }
2. Core 설정 및 header 접근 가능 설정
react 등에서 header 에 접근하려면 configuration.setExposedHeaders 설정이 필요함
@Configuration @Slf4j public class CorsConfig { @Bean CorsConfigurationSource corsConfigurationSource(){ CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("*")); // Origin URL 등록 configuration.setAllowedMethods(Arrays.asList("*")); // 사용할 CRUD 메소드 등록 configuration.setAllowedHeaders(Arrays.asList("*")); // 사용할 Header 등록 configuration.setExposedHeaders(Arrays.asList("authorization", "refreshToken")); // ExpoesdHeader에 클라이언트가 응답에 접근할 수 있는 header들을 추가 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }
3. JwtToenProvider
public class JwtTokenProvider { private final int JWT_EXPIRATION_MS = 60000 * 1; // 만료 시간 세팅 : 60000 (1분) * 10 => 10분 private final int JWT_REFRESH_EXPIRATION_MS = 60000 * 10; // 만료 시간 세팅 : 60000 (1분) * 10 => 10분 private final Key key; private final UserDetailsService userDetailsService; public JwtTokenProvider( String secret, UserDetailsService userDetailsService) { byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret); this.key = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName()); this.userDetailsService = userDetailsService; } /** * jwt 토큰 생성 * * @param authentication * @return */ public String generateAccessToken(Authentication authentication) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS); PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); /* * iss: 토큰 발급자 (issuer) * sub: 토큰 제목 (subject) * aud: 토큰 대상자 (audience) * exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) * 언제나 현재 시간보다 이후로 설정되어있어야합니다. * nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념입니다. 여기에도 NumericDate 형식으로 날짜를 지정하며, * 이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다. * iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다. * jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다. (id) */ Claims claims = Jwts.claims().setSubject(String.valueOf(principalDetails.getUser().getUserNo())); // JWT payload 에 저장되는 정보단위 // 표준 클레임 셋팅 JwtBuilder builder = Jwts.builder() .setClaims(claims) // 정보 저장 .setId(String.valueOf(principalDetails.getUser().getUserNo())) // jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다. .setIssuedAt(now) // iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다. - 현재시간 기반으로 생성 .setSubject(principalDetails.getUser().getUsername()) // sub: 토큰 제목 (subject) - 사용자 .setIssuer("gundam.com") // iss: 토큰 발급자 (issuer) .signWith(key, SignatureAlgorithm.HS512) // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅 .setExpiration(expiryDate) // exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370), 언제나 현재 시간보다 이후로 설정되어있어야합니다. ; return builder.compact(); } // jwt refresh 토큰 생성 public String generateRefreshToken() { Date now = new Date(); Date expiryDate = new Date(now.getTime() + JWT_REFRESH_EXPIRATION_MS); return Jwts.builder() .setIssuedAt(now) .setExpiration(expiryDate) .signWith(key, SignatureAlgorithm.HS512) .compact(); } /** * Jwt 토큰에서 유져이름 추출 * * @param accessToken * @return */ public String getUsernameByAccessToken(String accessToken) { Jws<Claims> claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(accessToken); return claims.getBody().getSubject(); } // Jwt 토큰 유효성 검사 public boolean validateToken(String accessToken) { try { Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken); return !claims.getBody().getExpiration().before(new Date()); } catch (SignatureException ex) { log.error("Invalid JWT signature"); } catch (MalformedJwtException ex) { log.error("Invalid JWT token"); } catch (ExpiredJwtException ex) { log.error("Expired JWT token"); } catch (UnsupportedJwtException ex) { log.error("Unsupported JWT token"); } catch (IllegalArgumentException ex) { log.error("JWT claims string is empty."); } return false; } // bearer 빼고, 순수 토큰 변환 public String getBearerTokenToString(String bearerToken) { if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring("Bearer ".length()); } return null; } // 엑세스 토큰 헤더 설정 public void setHeaderAccessToken(HttpServletResponse response, String accessToken) { response.setHeader("authorization", "Bearer " + accessToken); } // 리프레시 토큰 헤더 설정 public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) { response.setHeader("refreshtoken", "Bearer " + refreshToken); } // get authentication by access token public Authentication getAuthenticationByAccessToken(String token) { UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsernameByAccessToken(token)); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } // get authentication by user email public Authentication getAuthenticationByUsername(String username) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } }
4. JwtAuthorizationFilter
원래는 인증시점에 access token에 존재하는 username까지 db에 존재유무를 체크하였으나 속도 등 불필요한 체크를 피하기 위해서 통큰이 유효한 지만 체크하는 로직으로 변경하였다.
// 시큐리티가 filter 가지고 있는데 그 필터중에 BasicAuthenticationFilter 라는 것이 있음. // 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음. // 만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안탐 @Slf4j @RequiredArgsConstructor public class JwtAuthorizationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; /** * 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게됨 */ @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws IOException, ServletException { log.info("{} - successfulAuthentication -> 인증이나 권한이 필요한 주소 요청이 됨", this.getClass()); // DB까지 체크 할 필요성...?? 암튼 아래와 같이 변경 String bearerAccessToken = httpServletRequest.getHeader("X-ACCESS-TOKEN"); String accessToken = jwtTokenProvider.getBearerTokenToString(bearerAccessToken); if (StringUtils.hasText(accessToken) && jwtTokenProvider.validateToken(accessToken)) { Authentication authentication = jwtTokenProvider.getAuthenticationByAccessToken(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); // resolveToke을 통해 토큰을 받아와서 유효성 검증을 하고 정상 토큰이면 SecurityContext에 저장 log.debug("Security Context에 '{}' 인증 정보를 저장했습니다", authentication.getName()); } else { log.debug("유효한 JWT 토큰이 없습니다"); } filterChain.doFilter(httpServletRequest, httpServletResponse); // 다음 Filter를 실행하기 위한 코드. 마지막 필터라면 필터 실행 후 리소스를 반환한다. } }
5. JwtAuthenticationFilter
@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; // @Autowired private final JwtTokenProvider jwtTokenProvider; private final JwtService jwtService; // /login 요청을 하면 로그인 시도를 위해서 실행되는 함수 @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { log.info("{} - attemptAuthentication -> 로그인 시도중", this.getClass()); // request에 있는 username과 password를 파싱해서 자바 Object로 받기 ObjectMapper om = new ObjectMapper(); try { /* * log.info(request.getInputStream().toString()); * BufferedReader br = request.getReader(); * String input = null; * while((input = br.readLine()) != null) { * log.info(input); * } */ UserDto userDto = om.readValue(request.getInputStream(), UserDto.class); log.info("user.getUsername() : {}", userDto.getUsername()); log.info("user.getPassword() : {}", userDto.getPassword()); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( userDto.getUsername(), userDto.getPassword()); // DB에 있는 username과 password가 일치한다. Authentication authentication = authenticationManager.authenticate(authenticationToken); // 로그인이 되었다는 뜻. PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); log.info(principalDetails.getUser().getUsername()); // 로그인 정상적으로 되었다는 뜻 // authentication 객체가 session 영역에 저장을 해야하고 그방법이 return 해주면 됨. // 리턴의 이유는 권한 관리를 security가 대신 해주기 때문에 편하려고 하는거임 // 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session에 넣어준다. return authentication; } catch (Exception e) { e.printStackTrace(); } return null; } // attemptAuthentication실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행됨 // JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response 해주면 됨 @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException { log.info("{} - successfulAuthentication -> 인증 완료", this.getClass()); PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); String accessToken = jwtTokenProvider.generateAccessToken(authentication); String refreshToken = jwtTokenProvider.generateRefreshToken(); Long userNo = principalDetails.getUser().getUserNo(); TokenDto tokenDto = TokenDto.builder().userNo(userNo).refreshToken(refreshToken).build(); jwtService.saveRefreshToken(tokenDto); jwtTokenProvider.setHeaderAccessToken(response, accessToken); jwtTokenProvider.setHeaderRefreshToken(response, refreshToken); } }
6. Controller
현재는 회원가입 & access token & refresh token 발행
@Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/user/") public class UserController { private final UserService userService; @PostMapping("signup") public ResponseEntity<User> signup( @RequestBody UserDto userDto) throws Exception { return ResponseEntity.ok(userService.signup(userDto)); } } @Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/auth/") public class AuthController { private final AuthenticationManager authenticationManager; // @Autowired private final JwtTokenProvider jwtTokenProvider; private final JwtService jwtService; @RequestMapping(value = "refresh", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE) public TokenDto refresh( final HttpServletRequest request, final HttpServletResponse response, @RequestHeader(value = "X-REFRESH-TOKEN", required = true) String bearerRefreshToken) { TokenDto tokenDto = jwtService.refresh(bearerRefreshToken); jwtTokenProvider.setHeaderAccessToken(response, tokenDto.getAccessToken()); jwtTokenProvider.setHeaderRefreshToken(response, tokenDto.getRefreshToken()); return tokenDto; } // @PostMapping("signin") @RequestMapping(value = "signin", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE) public TokenDto signin( final HttpServletRequest request, final HttpServletResponse response, @RequestBody LoginDto loginDto) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( loginDto.getUsername(), loginDto.getPassword()); // PrincipalDetailsService의 loadUserByUsername 함수가 실행된 후 정상이면 authentication이 // 리턴됨 // DB에 있는 username과 password가 일치한다. Authentication authentication = authenticationManager.authenticate(authenticationToken); // 로그인이 되었다는 뜻. PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); log.info("LOGIN SUCCESS >>> " + principalDetails.getUser().getUsername()); String accessToken = jwtTokenProvider.generateAccessToken(authentication); String refreshToken = jwtTokenProvider.generateRefreshToken(); TokenDto jwtDto = TokenDto.builder().accessToken(accessToken).build(); jwtTokenProvider.setHeaderAccessToken(response, accessToken); jwtTokenProvider.setHeaderRefreshToken(response, refreshToken); return jwtDto; } }
7. Service
refresh token 체크 후 새로운 access token & refresh token 발행
@Slf4j @Service @RequiredArgsConstructor @Transactional public class UserService { private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; public Optional<User> findByUsername(String username) { return userRepository.findByUsername(username); } public Optional<User> findByIdPw(String username) { return userRepository.findByUsername(username); } public Optional<User> findOneWithAuthoritiesByUsername(String username) { return userRepository.findOneWithAuthoritiesByUsername(username); } /** * 회원가입 * * @param userDto * @return * @throws Exception */ public User signup(UserDto userDto) throws Exception { // 빌더 패턴의 장점 Authority authority = Authority.builder() .authorityName("ROLE_USER") .build(); User user = User.builder() .username(userDto.getUsername()) .password(passwordEncoder.encode(userDto.getPassword())) .authorities(Collections.singleton(authority)) .build(); return userRepository.save(user); } } @Slf4j @Service @RequiredArgsConstructor @Transactional public class JwtService { private final ApplicationContext context; // @Autowired private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; public Optional<RefreshToken> findByRefreshToken(String refreshToken) { return refreshTokenRepository.findByRefreshToken(refreshToken); } /** * save refresh token * * @param tokenDto */ public void saveRefreshToken(TokenDto tokenDto) { refreshTokenRepository.findByUserNo(tokenDto.getUserNo()) .ifPresentOrElse( r -> { r.setRefreshToken(tokenDto.getRefreshToken()); }, () -> { RefreshToken token = RefreshToken.builder().userNo(tokenDto.getUserNo()) .refreshToken(tokenDto.getRefreshToken()).build(); refreshTokenRepository.save(token); }); } public TokenDto refresh(String bearerRefreshToken) { JwtTokenProvider jwtTokenProvider = context.getBean(JwtTokenProvider.class); String refreshToken = jwtTokenProvider.getBearerTokenToString(bearerRefreshToken); // 유효한 refresh token 인지 체크 if (!jwtTokenProvider.validateToken(refreshToken)) { throw new AccessDeniedException("AccessDeniedException 2"); } // refresh token 있으면 값 반환, 없으면 Exception RefreshToken findRefreshToken = this.findByRefreshToken(refreshToken) .orElseThrow(() -> new UsernameNotFoundException("refresh token was not found")); // refresh token 을 활용하여 user email 정보 획득 User user = userRepository.findByUserNo(findRefreshToken.getUserNo()); // access token 과 refresh token 모두를 재발급 Authentication authentication = jwtTokenProvider.getAuthenticationByUsername(user.getUsername()); String newAccessToken = jwtTokenProvider.generateAccessToken(authentication); String newRefreshToken = jwtTokenProvider.generateRefreshToken(); TokenDto tokenDto = TokenDto.builder().userNo(findRefreshToken.getUserNo()).accessToken(newAccessToken) .refreshToken(newRefreshToken).build(); this.saveRefreshToken(tokenDto); return tokenDto; } }
8. Test
8.1 회원가입
8.2 로그인 및 access token & refresh token 획득8.2 Security 로그인
access token 및 refresh 토큰을 획득한다.
8.3 refresh token을 통한 갱신된 access token 및 refresh token 획득
refresh token 을 활용하여 보안을 좀더 강화하기 위해서 access token도 갱신하고, refresh token도 갱신하여 클라이언트에 전달한다.
8.4 access token을 통한 spring security 권한 체크
참조)
- Spring-boot를-활용한-JWT-구현
ㄴ https://velog.io/@seho100/Spring-boot%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-JWT-%EA%B5%AC%ED%98%84
- SpringBoot의-자동-sql문-실행방법
ㄴ https://velog.io/@bey1548/SpringBoot%EC%9D%98-%EC%9E%90%EB%8F%99-sql%EB%AC%B8-%EC%8B%A4%ED%96%89%EB%B0%A9%EB%B2%95
- 백업-Refresh-Token-발급과-Access-Token-재발급
ㄴ https://velog.io/@solchan/%EB%B0%B1%EC%97%85-Refresh-Token-%EB%B0%9C%EA%B8%89%EA%B3%BC-Access-Token-%EC%9E%AC%EB%B0%9C%EA%B8%89
- spring-sequrity-jwt-사용하기access-token-refresh-token
ㄴ https://velog.io/@dyparkkk/spring-sequrity-jwt-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0access-token-refresh-token
- @Component 와 @Bean, @Autowired 어노테이션 알아보기
ㄴ 추가적으로 @Autowired를 사용할 경우 순환 참조가 발생할 수 있기 때문에 @Autowired 보다 생성자 주입 방식이 더 권장되고 있습니다.
ㄴ https://wildeveloperetrain.tistory.com/26
- DAO-DTO-VO
ㄴ https://velog.io/@jun0922/DAO-DTO-VO'Project > React+Java' 카테고리의 다른 글
9. React/router, redux, saga 등 설정 (0) 2022.08.10 8. React 프로젝트 생성 (0) 2022.08.10 6. Spring Boot + Spring Security + JWT + access token (0) 2022.07.22 5. SpringBoot + Rancher + VsCode Mysql Client (0) 2022.07.22 4. SpringBoot + JPA + H2 database (0) 2022.07.21