사용자가 서버에 접근을 하면 먼저 JwtExceptionFilter를 타게 된다. 아직 Exception을 발생시키지 않았으니 그대로 통과되고 JwtAuthenticationFilter에서 JwtTokenProvider를 통해서 Jwt 토큰을 검증 받는다.
토큰 인증이 완료되었는데 비정상적인 토큰이면 SecurityContextHolder에 담기지 않아서 CustomEntryPoint로 넘어가게 된다.
토큰 인증이 완료되어 정상적인 토큰임을 알게되면 SecurityContextHolder에 담아서 다음 차례인 권한처리로 넘어간다. 권한이 정상적이면 우리가 만들어 놓은 스프링 Controller에 넘어가고(중간 과정은 생략), 비정상적인 권한(유저가 어드민 페이지)에 접근한 것이면 CustomAccessDeniedHandler로 넘어간다.
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가(SecurityConfig.class) 스프링 필터체인에 등록이 된다.
@EnableGlobalMethodSecurity(securedEnabled = true) // secure 어노테이션 활성화
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomEntryPoint customEntryPoint;
private final ObjectMapper objectMapper;
@Bean
public BCryptPasswordEncoder encodePwd(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/{id}").access("hasAnyRole('ROLE_USER', 'ROLE_ADMIN')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll()
.and()
.exceptionHandling().authenticationEntryPoint(customEntryPoint).accessDeniedHandler(customAccessDeniedHandler)
// .and()
// .formLogin()
// .loginPage("/login")
// .usernameParameter("email") // UserDtoDetailsService에 loadUserByUsername의 파라미터 이름 설정
// .loginProcessingUrl("/login") // /login URL이 호출되면 시큐리티가 낚아채서 대신 로그인을 진행해준다.
// .defaultSuccessUrl("/")// 특정 페이지("/random")에서 로그인페이지로 넘어와서 로그인을 하게되면 리턴을 "/"로 하는게 아니라 접근했던 특정 페이지("/random")로 반환을 해준다.
.and()
.httpBasic().disable()
.formLogin().disable()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(objectMapper), JwtAuthenticationFilter.class);
}
}
스프링 시큐리티 설정 파일인 SecurityConfig.class를 보자.
SecurityConfig.class라는 클래스를 하나 만들고 설정 파일 등록인 @Configuration과 @EnableWebSecurity를 달아주자.
그리고 configure 메소드를 오버라이딩하고 시큐리티 설정을 시작한다.
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
http의 csrf 기능을 사용하지 않을 것이고, 우리는 JWT 토큰을 사용할 것이기 때문에 sessionManagement의 세션 정책에서 STATELESS를 설정한다.
.antMatchers("/user/{id}").access("hasAnyRole('ROLE_USER', 'ROLE_ADMIN')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll()
이 부분은 어떤 Url 주소를 어떤 권한을 가진 사람에게 허용할 것인지를 설정하는 페이지다. 일단 우리는 /user와 /admin에 대한 권만 설정하고 나머지는 모두 permitAll을 시키자.
.exceptionHandling().authenticationEntryPoint(customEntryPoint).accessDeniedHandler(customAccessDeniedHandler)
이 부분은 추 후에 설명할테니 우리가 EntryPoint와 accessDenieHandler를 설정했다는 것만 기억하자.
// .and()
// .formLogin()
// .loginPage("/login")
// .usernameParameter("email") // UserDtoDetailsService에 loadUserByUsername의 파라미터 이름 설정
// .loginProcessingUrl("/login") // /login URL이 호출되면 시큐리티가 낚아채서 대신 로그인을 진행해준다.
// .defaultSuccessUrl("/")// 특정 페이지("/random")에서 로그인페이지로 넘어와서 로그인을 하게되면 리턴을 "/"로 하는게 아니라 접근했던 특정 페이지("/random")로 반환을 해준다.
다음 주석 처리 되어있는 이 부분은 JWT를 사용하지않고 세션을 사용할 때 쓰는 설정들인데 내용은 대충 적어놨으니 필요한 사람은 찾아보자.
.and()
.httpBasic().disable()
.formLogin().disable()
.addFilterBefore(newJwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionFilter(objectMapper), JwtAuthenticationFilter.class);
JWT 토큰을 사용하려면 httpBasic과 formLogin을 disable 해야한다. 두 개는 ID-Password를 이용하는 기본적인 방법인데 우리는 이것을 커스터마이징 할 것이다.
그리고 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter.class 필터 전에 필터체인을 탈 수 있게 addFiterBefore를 하고
예외 처리를 위한 JwtExceptionFilter를 위에 등록한 JwtAuthenticationFilter 전에 등록하자.
자 그럼 본격적으로 JWT 토큰을 제작, 검증, 활용 하는 클래스를 설명하겠다.
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenProvider {
private final UserDetailsServiceImpl userDetailsService;
@Value("${jwt.secret}")
private String secretKey;
private final Long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;
private final Long REFRESH_TOKEN_EXPIRE_TIME = 60 * 60 * 24 * 14 * 1000L;
/**
* methodName : generateRefreshToken
* author : Jaeyeop Jung
* description : User를 통해서 RefreshToken을 생성하고 반환한다.
*
* @param user User Entity
* @return RefreshToken 정보
*/
public String generateRefreshToken(User user){
Date now = new Date();
return Jwts.builder()
.setHeaderParam("typ", "REFRESH_TOKEN")
.setHeaderParam("alg", "HS256")
.setSubject(user.getId().toString())
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME))
.claim("role", user.getRole().toString())
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* methodName : generateAccessToken
* author : Jaeyeop Jung
* description : User를 통해서 AceesToken 생성하고 반환한다.
*
* @param user User Entity
* @return AccessToken 정보
*/
public String generateAccessToken(User user){
Date now = new Date();
return Jwts.builder()
.setHeaderParam("typ", "ACCESS_TOKEN")
.setHeaderParam("alg", "HS256")
.setSubject(user.getId().toString())
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME))
.claim("role", user.getRole().toString())
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* methodName : findUserIdByJwt
* author : Jaeyeop Jung
* description : Jwt 토큰에 담긴 Uesr.id를 찾아낸다.
*
* @param token Jwt Token
* @return 토큰에 담긴 User.id
*/
public Long findUserIdByJwt(String token){
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
Long userId = Long.valueOf(claims.getSubject());
return userId;
}
/**
* methodName : validateToken
* author : Jaeyeop Jung
* description : Token을 검증한다.
*
* @param token Jwt Token
* @return 검증 결과
*/
public boolean validateToken(String token){
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} 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.");
} catch (NullPointerException ex){
log.error("JWT RefreshToken is empty");
}
return false;
}
/**
* methodName : getAuthentication
* author : Jaeyeop Jung
* description : Jwt Token에 담긴 유저 정보를 DB에 검색하고, 해당 유저의 권한처리를 위해 Context에 담는 Authentication 객체를 반환한다.
*
* @param token Jwt Token
* @return Context에 담을 Authentication 객체
*/
public Authentication getAuthentication(String token){
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(String.valueOf(this.findUserIdByJwt(token)));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
}
JwtProvider.class의 전체 코드이다.
여기서 설명한 메소드는 밑에 3개다. generateRefreshToken(User user)와 generateAccessToken(User user)는 로그인 컨트롤러에서 사용하면된다.
public Long findUserIdByJwt(String token){
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
Long userId = Long.valueOf(claims.getSubject());
return userId;
}
findUserIdByJwt는 token에서 user의 ID를 추출하여 반환하는 메소드이다.
public boolean validateToken(String token){
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} 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.");
} catch (NullPointerException ex){
log.error("JWT RefreshToken is empty");
}
return false;
}
validateToken 메소드는 token을 검증하고 만약 검증에 실패한다면 로그에 실패 내용을 출력하고 false를 반환하는 역할을 한다.
public Authentication getAuthentication(String token){
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(String.valueOf(this.findUserIdByJwt(token)));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
getAuthentication 메소드는 SecurityContextHolder에 담을 Authentication을 가져오는 역할을 한다.