회원가입, 로그인, 권한을 체크하는 Spring boot + Spring security를 이용한 JWT 인증,인가를 구현하고 적용된 설정/기능의 개념을 이해한다.(프로젝트의 전체적인 코드는 github에 있고, 이글의 목적은 구현한 방식과 프로세스를 이해하는것! 따라서 엔티티나 db설정같은것은 따로 기록해두지 않겠다.)
작성한 코드 참고 : https://github.com/somiyv/jwt-test
- 프로젝트 기본 설정
- 회원가입/인증 프로세스 구현하기
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'
// jwt
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'
}
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) {
web
.ignoring() // 아래 관련 요청은 다무시한다.
.antMatchers("/h2-console/**"
, "/favicon.ico");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests() // httpservletrequest를 사용하는 요청들에 대한 접근 제한 설정
.antMatchers("/api/hello").permitAll() // 이 api는 인증안받을게
.anyRequest().authenticated(); // 나머지는 다인증받을거야
}
}
@EnableWebSecurity, extends WebSecurityConfigurerAdapter
WebSecurityConfigurerAdapter
를 상속받은 클래스에 @EnableWebSecurity
어노테이션을 달면 SpringSecurityFilterChain
이 자동으로 포함하며, 기본적인 Web보안을 활성화 하겠다는 의미.
WebSecurityConfigurerAdapter의 메소드를 @override 하여 기본적인 설정을 한다.
스프링 시큐리티의 전반적인 보안 기능 초기화 및 설정 담당. (HttpSecurity)
1. 토큰 생성, 유효성 검증을 담당할 TokenProvider 생성
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;
}
}
extends InitializingBean + afterPropertiesSet()
스프링 bean이 초기화,소멸시 특정 작업을 할 수있도록 함. -> 빈이 생성되고 의존성 주입을 받은 후에 secret값을 decode해서 key변수에 할당하기 위함.
createToken()
Authentication객체의 권한 정보를 이용해서 토큰 생성.
getAuthentication()
token에 담겨있는 정보를 이용해 Authentication객체 리턴.
Jwt토큰에서 claims를 생성하고, 그 권한정보를 이용해 User객체를 만들어 최종적으로 Authentication객체를 리턴.
UsernamePasswordAuthenticationToken
Authentication 인터페이스의 구현체.
2. JWT를 위한 커스텀 필터 JwtFilter 생성
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;
}
}
extends GenericFilterBean + doFilter()
JWT 토큰의 인증정보를 SecurityContext에 저장하는 로직을 구현함.
request의 header에서 토큰정보를 꺼내오고 유효성 체크후(tokenProvider.validateToken()) 유효하다면, securityContext에 인증정보(Authentication)을 저장한다.
resolveToken()
request Header에서 토큰정보를 꺼내오기 위함.
3. provider, jwtFilter를 securityConfig에 적용할 JwtSecurityConfig 작성.
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);
}
}
4. 인증/인가 체크후 error 클래스 작성
(1) 유효한 자격증명 없이 접근하려할때 401를 리턴할 클래스(JwtAuthenticationEntryPoint) 생성
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
implements AuthenticationEntryPoint
스프링 시큐리티 컨텍스트 내에 존재하는 인증절차 중, 인증과정이 실패하거나 인증헤더(Authorization)를 보내지 않게 되는 경우 401(UnAuthorized)라는 응답값을 던지는데, 이를 처리해주는 인터페이스.
commerce()
401이 떨어질만한 에러가 발생할 경우 commerce라는 메소드가 실행된다.
(2) 권한이 존재하지 않아 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);
}
}
4. securityConfig에 위의 설정들을 추가/적용.
JwtProvider, JwtFilter, JwtSecurityConfig등 각종 설정을 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()
.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));
}
}
https://devuna.tistory.com/59 [튜나 개발일기]
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/lecture/65763
https://catsbi.oopy.io/c0a4f395-24b2-44e5-8eeb-275d19e2a536
https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/