이전 게시글에 이어 간단하게 JWT를 이용하여 실습 예제를 만들어 보겠습니다.
JWT패키지 생성 및 토큰 설정
TokenProvider클래스 생성
토큰 생성 및 토근 유효성 검증을 담당합니다.
@Component
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
}
- InitializingBean을 implements 해서 afterPropertiesSet을 Override
- 빈이 생성되고 의존성 주입을 받은 후 시크릿값을 Base64로 Decode 해서 key변수에 할당
createToken 메소드 추가
Authentication 객체에 포함되어 있는 권한 정보들을 담은 토큰을 생성하는 기능을 합니다.
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
- Authentication 권한설정과 application.yml에서 설정했던 만료시간을 설정하고 토큰 생성
getAuthentication 메소드 생성
Token에 담겨있는 정보를 이용해 Authentication 객체를 리턴
public Authentication getAuthentication(String token) {
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
- Token을 이용하여 클레임을 만들고 클레임에서 권한 정보를 빼냄
- 권한 정보를 이용하여 User객체를 만듦
- 최종적으로 User객체, 토큰, 권한 정보를 이용하여 authentication 객체를 리턴
validateToken 메소드 추가
토큰 유효성 검사를 할 수 있습니다.
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
- 토큰을 파싱 해보고 발생하는 exception 을 캐치
- 문제가 있으면 false, 정상이면 true를 리턴
JwtFilter 클래스 생성
JWT 커스텀 필터를 만들기 위해 작성합니다.
public class JwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
//...
}
}
- GenericFilterBean을 extends해서 doFilter 메소드를 오버라이딩
- 실제 필터링 로직은 doFilter 내부에 작성됨
- doFilter의 역할은 JWT의 인증정보를 현재 실행 중인 SecurityContext에 저장하는 역할
- JwtFilter는 방금 만들었던 TokenProvider를 주입 받음
resolveToken 메소드 추가
필터링을 하기 위해서 토큰 정보가 있어야 한다.
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
- Request Header에서 꺼내오는 메소드
doFilter내부 작성
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
- resolveToken을 통해 request에서 토큰을 받아서 방금 전 만들었던 유효성 검사를 통과하고 토큰이 정상적이면 Authentication 객체를 받아와 SecurityContext에 setAuthenciation해줌
JwtSecurityConfig클래스 추가
TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용됩니다.
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
- SecurityConfigurerAdapter를 extends 하고 TokenProvider를 주입 받음
- configure메소드를 override 해서 방금 만들었던 JwtFilter를 Security로직에 필터를 등록함
JwtAuthenticationEntryPoint클래스 생성
유효한 자격증명을 제공하지 않고 접근하려고 할 때 401 에러를 리턴
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
- AuthenticationEntryPoint를 impliments 하고 401 에러를 send
JwtAccessDeniedHandler클래스 생성
필요한 권한이 존재하지 않는 경우 403 에러를 리턴
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
- AccessDeniedHandler를 impliments 해서 403에러를 send
SecurityConfig
jwt패키지 안에 만들었던 클래스를 SecurityConfig에 추가
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
public SecurityConfig(
TokenProvider tokenProvider,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler
) {
this.tokenProvider = tokenProvider;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) {
web.ignoring()
.antMatchers(
"/h2-console/**"
,"/favicon.ico"
,"/error"
);
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// token을 사용하는 방식이기 때문에 csrf를 disable합니다.
.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// enable h2-console
.and()
.headers()
.frameOptions()
.sameOrigin()
// 세션을 사용하지 않기 때문에 STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/hello").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/signup").permitAll()
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
}
@EnableGlobalMethodSecurity추가
- 나중에 @PreAuthorize 어노테이션을 메소드 단위로 추가하기 위해서 사용
- SecurityConfig
- TokenProvider, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler주입
- PasswordEncoder는 BCryptPasswordEncoder사용
Configure 메소드 추가
- Token방식을 사용하기 때문에 csrt설정은 disable
.csrf().disable()
- Exception을 핸들링할 때 AuthenticationEntryPoint와 accessDeniedHandler를 아까 만들었던 class로 추가해줌
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
- h2-console을 위한 설정 추가
.headers()
.frameOptions()
.sameOrigin()
- session을 사용하지 않기 때문에 session설정을 STALELESS
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
- 로그인, 회원가입은 토큰이 없는 상태로 요청이 들어오기 때문에 permitAll
.antMatchers("/api/hello").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/signup").permitAll()
- JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스 적용
.apply(new JwtSecurityConfig(tokenProvider));
기본적인 JWT코드 개발과 Security설정이 끝이 났습니다.
다음으로는 로그인과 권한 검증을 확인해보는 시간을 가져보도록 합시다.