튜토리얼 코드
https://github.com/SilverNine/spring-boot-jwt-tutorial
바로가기
Spring Boot JWT Tutorial (1) – JWT 소개, 프로젝트 생성
Spring Boot JWT Tutorial (2) – Security 기본 설정, Data 설정
Spring Boot JWT Tutorial (3) – JWT 코드, Security 설정 추가
Spring Boot JWT Tutorial (4) – Repository, 로그인
Spring Boot JWT Tutorial (5) – 회원가입, 권한검증
JWT 설정 추가
application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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 jwt: header: Authorization secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK token-validity-in-seconds: 86400 logging: level: me.silvernine: DEBUG |
application.yml에 jwt 관련 설정을 추가합니다.
HS512 알고리즘을 사용할 것이기 때문에 secret key는 512bit, 즉 64byte 이상을 사용해야 합니다.
터미널에서 secret key를 base64로 인코딩하여 secret 항목에 채워넣습니다.
$ echo ‘silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret’|base64
build.gradle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
plugins { id 'org.springframework.boot' version '2.4.1' id 'io.spring.dependency-management' version '1.0.10.RELEASE' id 'java' } group = 'me.silvernine' version = '0.0.1-SNAPSHOT' sourceCompatibility = '1.8' configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2' runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2' } test { useJUnitPlatform() } |
build.gradle에 jwt 라이브러리 3개를 추가합니다.
compile group: ‘io.jsonwebtoken’, name: ‘jjwt-api’, version: ‘0.11.2’
runtime group: ‘io.jsonwebtoken’, name: ‘jjwt-impl’, version: ‘0.11.2’
runtime group: ‘io.jsonwebtoken’, name: ‘jjwt-jackson’, version: ‘0.11.2’
JWT 관련 코드 추가
TokenProvider.java
jwt 패키지를 생성한 후 TokenProvider.java 파일을 생성합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
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; } } |
TokenProvider 빈은 application.yml에서 정의한 jwt.secret, jwt.token-validity-in-seconds 값을 주입받도록 합니다.
InitializingBean을 구현하고 afterPropertiesSet()을 오버라이드한 이유는 빈이 생성되고 의존성 주입까지 끝낸 이후에 주입받은 secret 값을 base64 decode하여 key 변수에 할당하기 위함입니다.
createToken 메소드는 Authentication 객체에 포함되어 있는 권한 정보들을 담은 토큰을 생성하고
jwt.token-validity-in-seconds 값을 이용해 토큰의 만료 시간을 지정합니다.
getAuthentication 메소드는 토큰에 담겨있는 권한 정보들을 이용해 Authentication 객체를 리턴합니다.
validateToken 메소드는 토큰을 검증하는 역할을 수행합니다.
JwtFilter.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
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; } } |
JWT를 위한 Custom Filter를 만들기 위해 GenericFilterBean을 extends한 JwtFilter.java를 생성합니다.
JwtFilter빈은 TokenProvider를 주입받습니다.
실제 필터링 로직은 doFilter 메소드를 오버라이드하여 작성합니다.
resolveToken 메소드는 HttpServletRequest 객체의 Header에서 token을 꺼내는 역할을 수행합니다.
doFilter 메소드는 jwt 토큰의 인증 정보를 현재 실행중인 스레드 ( Security Context ) 에 저장합니다.
JwtSecurityConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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); } } |
JwtSecurityConfig.java 는 SecurityConfigurerAdapter를 extends하며 configure메소드를 오버라이드하여 위에서 만든 JwtFilter를 Security 로직에 적용하는 역할을 수행합니다.
JwtAuthenticationEntryPoint.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
package me.silvernine.tutorial.jwt; 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 { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } } |
유효한 자격증명을 제공하지 않고 접근하려 할때 401 UNAUTHORIZED 에러를 리턴하기 위해 AuthenticationEntryPoint를 구현한 JwtAuthenticationEntryPoint 클래스를 작성합니다.
JwtAccessDeniedHandler.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package me.silvernine.tutorial.jwt; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.sendError(HttpServletResponse.SC_FORBIDDEN); } } |
필요한 권한이 존재하지 않은 경우 403 FORBIDDEN 에러를 리턴하기 위해 AccessDeniedHandler를 구현한 JwtAccessDeniedHandler 클래스를 작성합니다.
Security 설정 추가
SecurityConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
package me.silvernine.tutorial.config; import me.silvernine.tutorial.jwt.JwtAccessDeniedHandler; import me.silvernine.tutorial.jwt.JwtAuthenticationEntryPoint; import me.silvernine.tutorial.jwt.JwtSecurityConfig; 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; @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" ); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .exceptionHandling() .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) .and() .headers() .frameOptions() .sameOrigin() .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(prePostEnabled = true) 어노테이션은 메소드 단위로 @PreAuthorize 검증 어노테이션을 사용하기 위해 추가합니다.
위에서 만들었던 TokenProvider, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler를 주입받는 코드를 추가합니다.
Password Encode는 BCryptPasswordEncoder()를 사용하겠습니다.
1 |
.csrf().disable() |
우리는 Token 방식을 사용하므로 csrf 설정을 disable 합니다.
1 2 3 |
.exceptionHandling() .authenticationEntryPoint(jwtAuthenticationEntryPoint) .accessDeniedHandler(jwtAccessDeniedHandler) |
예외처리를 위해 만들었던 코드를 지정해줍니다.
1 2 3 4 |
.and() .headers() .frameOptions() .sameOrigin() |
데이터 확인을 위해 사용하고 있는 h2-console을 위한 설정을 추가해줍니다.
1 2 3 |
.and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) |
우리는 세션을 사용하지 않기 때문에 세션 설정을 STATELESS로 지정해줍니다.
1 2 3 4 5 6 |
.and() .authorizeRequests() .antMatchers("/api/hello").permitAll() .antMatchers("/api/authenticate").permitAll() .antMatchers("/api/signup").permitAll() .anyRequest().authenticated() |
/api/hello, /api/authenticate, /api/signup 3가지 API는 Token이 없어도 호출할 수 있도록 허용합니다.
/api/authenticate는 로그인을 위한 API이고 /api/signup 회원가입에 대한 API입니다. 관련 코드는 다음편에서 다루겠습니다.
1 2 |
.and() .apply(new JwtSecurityConfig(tokenProvider)); |
위에서 만들었던 JwtFilter를 addFilterBefore 메소드로 등록했던 JwtSecurityConfig 클래스도 적용해줍니다.
자 이제 기본적인 JWT, Security 설정이 끝났습니다.
다음편에서는 Database와 연결하는 Repository를 만들고 회원가입, 로그인을 구현하겠습니다.