MSA 도입을 위해 기존 Session 형식의 로그인에서 JWT 형식의 로그인으로 바꾸었다
최대한 기존 코드에서 변화를 줄이고 싶어 전에 쓰던 Spring Security + JWT 구현을 목표로 하였다
openssl rand -hex 64
@Slf4j
@Component
public class TokenProvider {
private final CustomUserDetailsService customUserDetailsService;
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30 ; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey, CustomUserDetailsService customUserDetailsService) {
this.customUserDetailsService = customUserDetailsService;
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
CustomUserDetails userDetailss = (CustomUserDetails) authentication.getPrincipal();
String userEmail = userDetailss.getEmail();
log.info(authorities);
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(userEmail) // payload "sub": "name" // email로 저장하도록 바꾸기
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 1516239022 (예시)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
UserDetails userDetails = customUserDetailsService.loadUserByUsername(claims.getSubject());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info(ErrorCode.TOKEN_INVALID_SIGN.getMessage());
} catch (ExpiredJwtException e) {
log.info(ErrorCode.TOKEN_IS_EXPIRED.getMessage());
} catch (UnsupportedJwtException e) {
log.info(ErrorCode.TOKEN_IS_NOT_SUPPORTED.getMessage());
} catch (IllegalArgumentException e) {
log.info(ErrorCode.TOKEN_IS_NOT_VALID.getMessage());
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JWT 토큰을 생성, 검증
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("of: "+authentication.getName());
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
}
HTTP 요청 헤더에 포함된 JWT 토큰의 유효성을 검증하고, 유효한 경우 인증 객체(Authentication)를 생성하여 Spring Security 인증 프로세스를 진행
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
@Override
public void configure(HttpSecurity http) {
// security 로직에 JwtFilter 등록
http.addFilterBefore(
new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
}
}
JwtFilter를 Spring Security 필터 체인에 등록
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig{
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// token을 사용하는 방식이기 때문에 csrf를 disable합니다.
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// 세션을 사용하지 않기 때문에 STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다.
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/auth/login").permitAll() // 로그인 api
.requestMatchers("/auth/signup").permitAll() // 회원가입 api
.anyRequest().permitAll()
//.anyRequest().authenticated() // 그 외 인증 없이 접근X
.and()
.apply(new JwtSecurityConfig(tokenProvider)); // JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig class 적용
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("*"); // site address 수정하기?
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
인가(권한) 실패 시 적절한 응답(예: 403 Forbidden)을 처리
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
인증 실패 시 적절한 응답(예: 401 Unauthorized)을 처리
// 게시물 삭제
@GetMapping("/delete")
public ResponseEntity deleteById(@RequestParam("postId") Long postId, @AuthenticationPrincipal CustomUserDetails customUserDetails) {
User user = customUserDetails.getUser();
if (!postingService.checkUser(postId, user)) {
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
postingService.delete(postId);
return new ResponseEntity(HttpStatus.ACCEPTED);
}
@AuthenticationPrincipal CustomUserDetails customUserDetails
를 이용하여 유저 정보를 받아오도록 하였다
https://velog.io/@seon7129/Spring-Security-CustomUserDetails-구현하고-AuthenticationPrincipal로-유저-정보-받아오기
코드 수정이 조금 있지만 CustomerUserDetails 사용법은 전에 올린 적이 있다
포스트맨에서 테스트 할 때, 로그인 할 때 받아온 AccessToken을 다음과 같이 Authorization에 넣어줘야 한다
https://ws-pace.tistory.com/250
https://velog.io/@limsubin/Spring-Security-JWT-%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EC%9E%90
https://velog.io/@jkijki12/Jwt-Refresh-Token-%EC%A0%81%EC%9A%A9%EA%B8%B0
https://velog.io/@goat_hoon/Spring-Security%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-JWT-%EB%8F%84%EC%9E%85%EA%B8%B0
https://sbs1621.tistory.com/7
https://studyandwrite.tistory.com/499