-
Spring Boot JWT Tutorial (2)JWTSpring/SpringSecurity 2022. 4. 12. 12:41
===
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard
[무료] Spring Boot JWT Tutorial - 인프런 | 강의
Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...
www.inflearn.com
====================================
JWT 설정 추가, JWT 관련 코드 개발, Security 설정 추가
HS512 알고리즘 사용 하기 때문에 Secret Key는 64Byte 이상이 돼야한다
spring: h2: console: enabled: true datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop properties: hibernate: format_sql: true show_sql: true defer-datasource-initialization: true jwt: header: Authorization #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다. #echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64 secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK token-validity-in-seconds: 86400 logging: level: me.silvernine: DEBUG
참고로 이 값은 Secret Key를 Base64로 인코딩한 값을 사용했습니다
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
===
JWT 코드 개발
jwt 패키지!
토큰의 생성, 토큰의 유효성 검증등을 담당할 TokenProvider를 만들어보겠습니다.
package me.silvernine.tutorial.jwt; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.stereotype.Component; import java.security.Key; import java.util.Arrays; import java.util.Collection; import java.util.Date; import java.util.stream.Collectors; @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); } 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(); } 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); } 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; } }
===
나눠서 보자
@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을 오버라이드 한 이유는
빈이 생성이 되고(@Component) 의존성 주입을 받은 후에(생성자로)
주입받은 secret값을 Base64 Decode한 다음 key변수에 할당하기 위함(메소드 내용)
===
Authentication 객체의 권한 정보를 이용해서 토큰을 생성하는 createToken메소드 추가
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 파라미터를 받아서
authorities... 권한들
long now = 는 application.yml에서 설정했던 만료시간을 설정하고 토큰 생성
jwt토큰을 생성해서 리턴
===
이번에는 역으로 Token을 파라미터로 받아서 토큰에 담겨있는 권한정보들을 이용해서 Authentication 객체를 리턴하는 getAuthentication메소드
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을 받아서 토큰으로 claims를 만들고
claims에서 권한정보들을 빼내서 권한정보를 이용해서 User principal 객체를 만들고
유저객체, 토큰, 권한정보를 이용해서 최종적으로 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; }
토큰을 파라미터로 받아서, try 파싱을 해보고
나오는 익셉션들을 캐치하고 문제가 있으면 return false, 문제가 없으면 return true
===
JWT를 위한 커스텀 필터를 만들기 위해 JwtFilter라는 클래스를 만들어보기
package me.silvernine.tutorial.jwt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; import org.springframework.web.filter.GenericFilterBean; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import java.io.IOException; 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) 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); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader(AUTHORIZATION_HEADER); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }
JwtFilter는 방금 만들었던 TokenProvider를 주입받음.
필터링을 하기 위해 토큰 정보가 있어야 하니까 resolveToken이라는 메소드 추가
request Header에서(request.getHeader) 토큰 정보를 꺼내오는 메소드
GenericFilterBean을 extends해서 doFilter메소드를 오버라이드. 실제 필터링 로직은 doFilter 내부에 들어감
doFilter메소드의 역할은 jwt토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하기 위한 역할 수행
doFilter메소드의 내용은
String jwt = resolveToken(httpServletRequest);
: request에서 토큰을 받아서
이 jwt토큰을 우리가 방금전에 만들었던 tokenProvider.validateToken 유효성 검증 메소드를 통과하고
토큰이 정상이면 토큰에서 authentication 객체를 받아와서 SecurityContext에 set해준다
===
자 이제 우리가 만든 TokenProvider와 JwtFilter를 SecurityConfig에 적용할 때 사용할 JwtSecurityConfig를 만들것
package me.silvernine.tutorial.jwt; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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); } }
extends SecurityConfigurerAdapter하고
TokenProvider를 주입받음.
configure 메소드를 오버라이드해서 우리가 방금 만들었던 JwtFilter를 Security로직에 필터로 등록하는 역할을 합니다=http.addFilterBefore(....)
===
이번엔 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴하기 위해서 JwtAuthenticationEntryPoint implements AuthenticationEntryPoint 만들기
import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } }
401UNAUTHORIZED 를 send하는 클래스입니다.
===
필요한 권한이 존재하지 않은 경우에 403 Forbidden에러를 리턴하기 위해서
AccessDeniedHandler를 구현한 JwtAccessDeniedHandler
@Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { //필요한 권한이 없이 접근하려 할때 403 response.sendError(HttpServletResponse.SC_FORBIDDEN); } }
===
jwt에 만든 5개의 클래스를 SecurityConfig에 추가하는 작업
강의에는 private final CorsFilter corsFilter;가 없다. 깃헙에만 있어
import me.silvernine.tutorial.jwt.JwtSecurityConfig; import me.silvernine.tutorial.jwt.JwtAccessDeniedHandler; import me.silvernine.tutorial.jwt.JwtAuthenticationEntryPoint; import me.silvernine.tutorial.jwt.TokenProvider; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.filter.CorsFilter; @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { private final TokenProvider tokenProvider; private final CorsFilter corsFilter; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; public SecurityConfig( TokenProvider tokenProvider, CorsFilter corsFilter, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler ) { this.tokenProvider = tokenProvider; this.corsFilter = corsFilter; 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라고 하는 애너테이션을 메소드단위로 추가하기 위해 사용
▽원래 (1)에서 처음에 만들 땐 생성자 아무것도 안받았었는데
tokenProvider, jwtAuthenticationEntryPoint, jwtAccessDeniedHandler 주입받음
PasswordEncoder는 BCryptPasswordEncoder 사용
configure 메소드를 오버라이드한 부분에서 많은 내용 추가.
우리는 토큰방식을 사용하기 때문에 csrf설정을 disable
exception을 Handling할 때 authenticationEntryPoint와 accessDeniedHandler를 우리가 만들었던 클래스들 추가
얘네는 h2-console을 위한 설정 추가한 것
우리는 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 설정
토큰을 받기 위한 로그인 API(authenticate)와, 회원가입 API는 토큰이 없는 상태에서 요청이 들어오기 때문에 모두 permitAll 설정
마지막으로 방금 만들었던 jwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용
서버 잘 켜짐
'Spring > SpringSecurity' 카테고리의 다른 글
인증, 인가를 프로젝트에 끼워넣기 (0) 2022.04.23 Spring Boot JWT Tutorial (4)권한 다른 API (0) 2022.04.13 Spring Boot JWT Tutorial (3) (0) 2022.04.12 Spring Boot JWT Tutorial (1)설정, DB (0) 2022.04.12