2023. 5. 15. 18:16ㆍSpring
JWT (Json Web Token)
일반적으로 클라이언트와 서버 통신 시 권한 인가를 위해 사용하는 토큰
클라이언트의 세션 상태를 저장하는 것이 아니라 필요한 정보를 토큰 body에 저장해 클라이언트가 가지고 있고 그것을 증명서처럼 사용
웹에서는 주로 Cookie에 저장하여 사용
기본 구성
xxxxx.yyyyy.zzzzz
- Header (xxxxx)— JWT인 토큰의 유형이나 HMAC SHA256 또는 RSA와 같이 사용되는 해시 알고리즘이 무엇으로 사용했는지 등 정보가 담긴다. Base64Url로 인코딩되어있다.
- Payload (yyyyy)— 클라이언트에 대한 정보나, meta Data같은 내용이 들어있고, Base64Url로 인코딩되어있다.
- Signature (zzzzz)— header에서 지정한 알고리즘과 secret 키, 서명으로 payload와 header를 담는다.
기본 인증 과정
- 클라이언트가 로그인을 하면, 서버로부터 access 토큰을 부여받는다.
- 이후 클라이언트가 모든 api 요청을 할 때 access 토큰을 포함시킨다.
- 서버는 access 토큰을 해독해 확인하고 검증되면 해당 api 기능을 수행한다.
- 기한이 만료되었으면 access 토큰을 지워주고 재로그인을 하게 한다.
문제점 :
- 클라이언트가 계속 시스템을 이용하다가 access 토큰 기한이 만료된다면 사용중에 갑자기 로그인을 하라고 할 것이다.
- 수명이 짧다면 만료될때마다 로그인 해주어야 한다.
- 수명이 길면 해커에게 해독되어 사용될 가능성이 높아진다.
Refresh Token
access 토큰이 만료되었을 때, Refresh 토큰으로 서벙새로운 access 토큰을 발급받을 수 있다.
필요성 :
- 서버 데이터베이스에 Refresh Token이 저장되어 있을때 클라이언트가 블랙리스트에 포함되어 있다면, access 토큰을 발급해주는 것을 막을 수 있다.
- Refresh Token으로 access 토큰이 만료되면 알아서 갱신한다.
동작흐름
JWT 설정 코드
1. build.gradle _ JWT 의존성 추가
dependencies {
...
**// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'**
}
2. DTO 생성
import lombok.Builder;
import lombok.Data;
@Builder
@Data // @Getter, @Setter, @ToString, @EqialsAndHashCode, @RequiredArgsConstructor 모두 설정 어노테이션
public class TokenDTO {
private String grantType; // JWT 인증 타입
private String accessToken;
private String refreshToken;
}
3. application.yml
# jwt 설정
jwt:
header: Authorization # JWT를 검증하는데 필요한 정보
secret: ${env.jwt.secret} #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다
token-validity-in-seconds: 10000 # 토큰의 만료시간을 지정함 (단위는 초)
# env.properties
[#](<https://www.notion.so/Spring-b1a1461da9024196960bc800c54cceb4>) JWT
# 해당 값은 "Secret Key" 를 Base64 로 인코딩한 값
env.jwt.secret = c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
4. TokenProvider
jwt 패키지를 생성하고, 토큰의 생성과 토큰의 유효성 검증등을 담당
- 전체 코드
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.InitializingBean;
import io.jsonwebtoken.io.Decoders;
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
// jwt 패키지를 생성하고, 토큰의 생성과 토큰의 유효성 검증등을 담당
public class TokenProvider implements InitializingBean {
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
private static final String AUTHORITIES_KEY = "auth";
// 생성자
public TokenProvider(
@Value("${jwt.secret}") String secret, //jwt secret
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds //토큰 유효기간
) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
}
// 빈이 생성이 되고 의존성 주입 받은 secret값을 Base64 Decode해서 key변수에 할당
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes); // HMAC-SHA 로 암호화
}
// access 토큰 생성
public String createToken(String userEmail, String userRole, long tokenValidTime){
Date now = new Date(); //현재 시간
long nowTime = now.getTime();
Date validity = new Date(nowTime + tokenValidTime); // 토큰의 유효기간 설정
return Jwts.builder() // 빌더 객체 생성
.setHeaderParam("type", "jwt")
.setSubject(userEmail) // 클레임중 subject 클레임 이름 생성
.claim(AUTHORITIES_KEY, userRole) // payload에 들어갈 정보 <key, value>
.signWith(key, SignatureAlgorithm.HS512) // signature
.setExpiration(validity) // 만료 시간 설정
.compact();
}
// refreshToken 생성
public String createRefreshToken(long refreshTokenValidTime){
long now = (new Date()).getTime(); //현재 시간 가져오고
Date validity = new Date(now + refreshTokenValidTime); // 토큰의 유효기간
return Jwts.builder() // 빌더 객체 생성
.setHeaderParam("type", "jwt")
.signWith(key, SignatureAlgorithm.HS512) // signature
.setExpiration(validity) // 만료 시간 설정
.compact();
}
// authentication 객체 리턴
public Authentication getAuthentication(String token){
// token -> claim 생성 (Claims : JWT 의 속성정보, java 에서 Claims 는 Json map 형식의 인터페이스)
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;
}
}
- 생성자
public TokenProvider(
@Value("${jwt.secret}") String secret, //jwt secret
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInMilliseconds //토큰 유효기간
) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
}
- afterPropertiesSet()
// 빈이 생성이 되고 의존성 주입 받은 secret값을 Base64 Decode해서 key변수에 할당
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes); // HMAC-SHA 로 암호화
}
- createTocken()
// 토큰 생성
public String createToken(String userEmail, String userRole, long tokenValidtime){
Date now = new Date(); //현재 시간
long nowTime = now.getTime();
Date validity = new Date(nowTime + tokenValidtime); // 토큰의 유효기간 설정
return Jwts.builder() // 빌더 객체 생성
.setHeaderParam("type", "jwt")
.setSubject(userEmail) // 클레임중 subject 클레임 이름 생성
.claim(AUTHORITIES_KEY, userRole) // payload에 들어갈 정보 <key, value>
.signWith(key, SignatureAlgorithm.HS512) // signature
.setExpiration(validity) // 만료 시간 설정
.compact();
}
- createRefreshToken
// refreshToken 생성
public String createRefreshToken(long refreshTokenValidTime){
long now = (new Date()).getTime(); //현재 시간 가져오고
Date validity = new Date(now + refreshTokenValidTime); // 토큰의 유효기간
return Jwts.builder() // 빌더 객체 생성
.setHeaderParam("type", "jwt")
.signWith(key, SignatureAlgorithm.HS512) // signature
.setExpiration(validity) // 만료 시간 설정
.compact();
}
- getAuthentication()
Authentication 객체 : Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체, 사용자가 인증 과정을 성공적으로 마치면, Spring Security는 사용자의 정보 및 인증 성공여부를 가지고 Authentication 객체를 생성한 후 보관
// authentication 객체 리턴
public Authentication getAuthentication(String token){
// token -> claim 생성 (Claims : JWT 의 속성정보, java 에서 Claims 는 Json map 형식의 인터페이스)
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);
}
- 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;
}
5. JwtFilter class
JWT를 위한 커스텀 필터를 만들기 위한 클래스
- 전체 코드
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
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;
@Component
// JWT를 위한 커스텀 필터를 만들기 위한 클래스
public class JwtFilter extends GenericFilterBean {
private TokenProvider tokenProvider;
public static final String AUTHORIZATION_HEADER = "Authorization";
// 생성자
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
// doFilter : JWT 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역활 (실제 필터링 로직은 doFilter 내부에 작성)
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String requestURI = httpServletRequest.getRequestURI();
String jwt = resolveToken(httpServletRequest); // 토큰 정보 획득
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // 토큰 유효성 검사
Authentication authentication = tokenProvider.getAuthentication(jwt); // 토큰의 인증 정보 가져오기
SecurityContextHolder.getContext().setAuthentication(authentication); // 토큰 인증 정보를 security context에 저장
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {} " + requestURI);
}
chain.doFilter(request, response);
}
// 토큰 정보를 가져오는 메소드
private String resolveToken(HttpServletRequest request){
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
// 인증타입 : Bearer
if (StringUtils.hasText(bearerToken)
&& bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
- 생성자
// 생성자
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
- resolveToken()
// 토큰 정보를 가져오는 메소드
private String resolveToken(HttpServletRequest request){
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
// 인증타입 : Bearer
if (StringUtils.hasText(bearerToken)
&& bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
- doFilter()
// doFilter : JWT 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역활 (실제 필터링 로직은 doFilter 내부에 작성)
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String requestURI = httpServletRequest.getRequestURI();
String jwt = resolveToken(httpServletRequest); // 토큰 정보 획득
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { // 토큰 유효성 검사
Authentication authentication = tokenProvider.getAuthentication(jwt); // 토큰의 인증 정보 가져오기
SecurityContextHolder.getContext().setAuthentication(authentication); // 토큰 인증 정보를 security context에 저장
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {} " + requestURI);
}
chain.doFilter(request, response);
}
6. JwtSecurityConfig
TokenProvider, JwtFilter 를 SecurityConfig에 적용할때 사용할 클래스
- 전체 코드
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;
// TokenProvider, JwtFilter 를 SecurityConfig에 적용할때 사용할 클래스
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
// 생성자
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
// JwtFilter를 Security 로직에 필터로 등록
@Override
public void configure(HttpSecurity http) throws Exception {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
- configure()
// JwtFilter를 Security 로직에 필터로 등록
@Override
public void configure(HttpSecurity http) throws Exception {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
7. JwtAuthenticationEntryPoint
유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴할 클래스
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴할 클래스
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
8. JwtAccessDeniedHandler
필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴하기 위한 클래스
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
// 필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴 클래스
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
9. SecurityConfig
작성한 정보 필터 추가
- 전체 코드
import com.example.yutnoribackend.jwt.JwtAccessDeniedHandler;
import com.example.yutnoribackend.jwt.JwtAuthenticationEntryPoint;
import com.example.yutnoribackend.jwt.JwtSecurityConfig;
import com.example.yutnoribackend.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터 체인에 등록됨
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
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;
}
// 인증, 인가 서비스가 필요하지 않은 endpoin 적용
@Bean
public WebSecurityCustomizer configure(){
return (web) -> web.ignoring()
.antMatchers(
"/v3/api-docs/**",
"/swagger-ui/**"
);
}
// CORS 설정
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*")); // 모든 패턴에 대해 허용
configuration.setAllowedMethods(Arrays.asList("HEAD","POST","GET","DELETE","PUT"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable() // csrf 비활성화
// JWT 관련 에러 발생시 처리 (401, 403)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
.accessDeniedHandler(jwtAccessDeniedHandler) // 403
.and()
.headers().frameOptions().sameOrigin() // 동일 도메인에서는 X-Frame-Option 활성화
.and()
.cors(Customizer.withDefaults()) // cors 설정
.authorizeRequests() // 요청에 의한 보안 검사 시작
.antMatchers("/*").permitAll() //antMatchers 에 설정한 리소스의 접근을 인증 절차 없이 허용
.anyRequest().authenticated() // 위에서 설정하지 않은 나머지 부분들은 인증 절차 수행
// JwtSecurityConfig 실행
.and()
.apply(new JwtSecurityConfig(tokenProvider))
.and()
.build();
}
}
- filterChain()
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable() // csrf 비활성화
// JWT 관련 에러 발생시 처리 (401, 403)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
.accessDeniedHandler(jwtAccessDeniedHandler) // 403
.and()
.headers().frameOptions().sameOrigin() // 동일 도메인에서는 X-Frame-Option 활성화
.and()
.cors(Customizer.withDefaults()) // cors 설정
.authorizeRequests() // 요청에 의한 보안 검사 시작
.antMatchers("/*").permitAll() //antMatchers 에 설정한 리소스의 접근을 인증 절차 없이 허용
.anyRequest().authenticated() // 위에서 설정하지 않은 나머지 부분들은 인증 절차 수행
// JwtSecurityConfig 실행
.and()
.apply(new JwtSecurityConfig(tokenProvider))
.and()
.build();
}
참고자료
https://yonghyunlee.gitlab.io/node/jwt/
'Spring' 카테고리의 다른 글
SpringSecurity_CORS 설정 (0) | 2023.04.10 |
---|---|
Spring Security Swagger 예외처리 (0) | 2023.04.03 |
Spring Swagger (0) | 2023.04.03 |
Spring Security (0) | 2023.04.03 |
JPA Mapping (0) | 2023.03.31 |