이전에 막 맨땅에 해딩을 하면서,,,spring security를 구현해본 적이있는데 다시 해보려고하니 이해가 턱없이 부족함을 느꼈다. 그래서 다시 조사를 시작했고 내가 이것저것 조사한 것들에 대해서 공유를 하려고한다!!
(틀린 점이 있다면 팩트로 때려주시면 감사하겠습니다 :) )
spring security의 도움을 받아서 인증, 인가, 보안 등의 기능을 JWT token기반으로 구현하려고한다. 그러기 위해서는 spring security를 이해해야하고 이를 활용해서 jwt에 대한 인증, 인가, 보안에 대한 기능을 fetcher, service에 제공해야한다.
이번 글에서는, 구현하는 상세한 방법이라기보다는 Spring Security와 JWT를 어떻게 함께 사용할까에 대한 로직적인 부분을 주로 작성할 것이기에 구체적인 구현은 하지 않는다.
Authentication은 사용자가 사이트 접속을 위해서 입력한 사용자 정보가 실제 데이터 베이스의 정보와 일치한지 확인해서 사용자가 등록되어있는지 체크하여 등록된 사용자만 접근할 수 있도록 하는 접근제어 방식이다.
비밀번호, 생체인식, 일회용 핀 또는 앱을 사용해서 접근제어가 진행되는데 자격 증명 확인을 진행한다. 인증을 위해서는 id와 token을 사용해서 데이터를 전송한다.
Authorization은 가고 싶은 url로 갈 수 있는지 권한에 대해서 체크를 하고 권한이 있다면 원하는 요청을 들어 정보를 얻도록 허용해주는 접근제어 방식이다.
인가는 권한을 허가하거나 거부하기 위해서 제공된다. 보안 팀에서 관리하는 설정을 사용하고 사용자는 볼 수 없다. 이때 액세스 토큰 데이터 전송을 위해서 사용한다.
이는 spring 학습시에 많이 들어봤던 SQL injection문제에 해당한다고 보면 이해하기가 쉬울 것이다. 악의적인 용도로 웹사이트에 스크립트를 삽입하여 공격하는 기법을 의미하는데 다른 웹사이트와 정보를 교환하는 식으로 작동해서 사이트간의 스크립팅이라고 부른다고한다.
사이트가 사용자로부터 입력받은 스크립트를 제대로 검사하지 않고 사용할 경우에 나타나는데 공격에 성공하면 사이트에 접속한 사용자는 삽입된 코드를 실행하며 악의적으로 사용자의 토큰이나 세션과 같은 민감한 정보를 탈취하게 된다.
악의적으로 스크립트를 삽입하여 공격하기 때문에 클라이언트에 대한 악성공격을 진행한다.
이는 원클릭 공격 혹은 세션 라이딩이라고한다. 사용자가 자신의 의도와는 다르게 공격자가 의도한 행위를 특정 웹사이트에 요청하게하는 공격을 의미하는데 자신도 모르게 서버를 공격하는 경우를 의미한다.
공격자가 만든 악성 페이지를 통해서 사용자는 자신도 모르게 공격을 수행한다.
우선, 로그인 이후에 서버에 저장된 정보를 사용할 수 있는 sessionID가 사용자 브라우저 쿠키에 저장이 되어있을 것이다. 이때 공격자는 서버에 인증된 브라우저의 사용자가 악성 스크립트 페이지를 누르도록 유도하는데 이때 자동으로 쿠키에 저장된 sessionID
가 브라우저에 의해서 자동으로 함께 서버에 요청하게 된다.
이때 악성 코드가 담긴 서버는 sessionID를 활용해서 해당 요청이 인증된 사용자로부터 온 것인지 판단하고 해당 sessionID를 사용해서 사용자의 정보를 탈취하거나 조작하게 된다.
악의적으로 악성 서버를 심어놓고 사용자가 클릭했을 경우 사용자의 정보를 탈취하고 사용자의 권한을 사용해서 서버에 대한 악성공격을 진행한다.
spring security는 이미 spring에서 제공하는 프레임워크로 다양한 필터체인을 기반으로 인증, 인가, 보안 기능들을 제공해준다.
스프링 시큐리티 공식문서 를 참고할 수 있다.
Spring Security는 java application에 인증 및 권한 부여를 모두 제공하는데 중점을 둔 프레임워크라고 설명되어진다.
가장 큰 장점은 사용자 지정 요구사항 충족을 쉽게 "확장"이 가능하다는 것이고 이것은 밑에서 자세히 알아본다.
Spring Security는 필터 체인을 기반으로 인증, 인가, 보안 등의 기능을 제공한다고 하였다. 그렇다면 FilterChain이 무엇인지 먼정 알아보자!
filter chain은 보안 필터 체인이라고도 불리는데 HTTP 요청을 처리해준다. 다양한 보안 기능을 제공하기 위해서 여러 개의 필터로 구성된다.
Spring Security를 위해 다양한 보안 기능이 있는데 여러가지가 존재할 수 있고 하나하나의 기능을 가지며 보안을 제공하는 것을 Filter라고 할 수 있다.
FilterChain은 보안 필터들을 순차적으로 실행하여 요청을 처리하는데 예를 들어서 인증 필터 -> 권한 필터 -> 세션관리 필터 등등의 순서로 순차적으로 실행된다. 각 필터는 특정한 보안 작업을 수행하고 다음의 필터로 요청을 전달한다.
이때, 필터와 필터 사이를 이어주고 연결해주는 역할을 FilterChainProxy가 해준다.
FilterChainProxy는 FilterChain을 관리하고 요청을 적절한 FilterChain으로 전달해주는 Proxy 역할을 해준다. 프록시이기에 DispatherServlet앞에서 동작하고 request 요청을 처리하기 저에 FilterChain을 적용할 수 있게 되는 것이다.
특징
단일 HTTP요청이 들어왔을 때 처리기의 일반적인 계층은 위와 같고 클라이언트는 요청의 url을 기반으로 적용할 필터와 서블릿을 결정한다. 하나의 서블릿이 단일 쵸엉을 처리할 수 있지만 필터는 체인을 형성하는데 "순서가"정해져있다.
필터는 다운 스트림 필터 및 서블릿으 요청, 응답을 수정할 수 있기에 순서가 매우 중요하다.
이때, 단일 물리적 Filter이지만, 처리를 내부 필터 체인에 위임을 한다.
filterchain의 배열을 특정 url을 체크해서 해당하는 것으로 filterchainproxy가 연결을 제공한다.
filterchain의 doFilter(request, response)
void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException;
dofilter는 현재 필터에서 처리하는 요청과 응답 객체를 다음 필터로 전달해주는 기능을 하는 method이다. filterChainProxy는 보안설정에 따라서 적절한 FilterChain을 선택하고 FilterChain의 doFilter() method를 호출해서 요청을 전달하게 된다.
filterChainProxy에 의해서 doFilter method가 호출되어 요청과 응답을 다음의 필터로 넘겨지게 된다.
Spring Security는 현재 인증된 보안 주체를 다양한 downstream의 소비자가 사용할 수 있어야하기에 thread로 binding이 된다. SecurityContext가 기본이며 여기에는 Authentication
이 포함되어 현재 인증된 Authentication관련 정보를 downstream에서 사용할 수 있게 된다.
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
assert(authentication.isAuthenticated);
위처럼 context에서 getAuthentication을 사용하여 문맥정보에 담긴 authentication을 가져올 수 있게된다.
principal
principal은 user라고 생각하면 되는데 이는 HttpServletRequest
타입이기에 servlet을 기반으로하는 Authenticaion
으로 직접 사용이 가능하다.
@Async
context에 의해서 SecurityContext
는 백그라운드로 처리할 때 비동적으로 처리된다.
Jwon Web Token으로 웹에서 사용되는 Json 형식 기반의 코튼 표준규격이다.
token을 encoding할 알고리즘 방식과 token 방식을 지정해준다.
실제 data가 들어가는 부분이다.
iss(발급자), exp(만료시간), sub(주제), aud(대상) 등과 같이 상호 윤용 가능한 클레임 제공을 위해 권장되는 미리 정의된 클레임 집합을 포함할 수 있다.
JWT를 사용하는 사람들이 마음대로 정의가 가능한 공개 클래임이 포함될 수 있다.
사용에 동의한 당사자 간의 정보를 공유하기 위해서 생성된 사용자 정의 클레임인 개인 클레임이 있을 수 있다.
다만,"누구나 읽을 수 있기에" 암호화 되지 않은 경우 JWT의 페이로드 또는 헤더 요소에 비밀번호등과 같은 민감한 정보를 입력해서는 안 된다.
인코딩된 헤더, 인코딩된 페이로드, secret을 알고리즘에 적용하여 signature를 생성한다.
도중에 누군가에 의해서 메시지가 변경되지 않았는지를 확인하는데 사용되고 개인키로 서명된 토큰의경우에는 JWT 발신자가 누구인지를 알 수 있다.
xxxxx.yyyyy.zzzzz
총 3개의 영역이 합쳐지면서 왼쪽의 Encoded된 token처럼 xxxxx.yyyyy.zzzzz
해당 유형을 가진다.
Authorization: Bearer <token>
인증, 인가를 위해서 직접적으로 사용되는 토큰으로 유효기간이 짧다. 보안상의 문제가 발생할 수 있기에 유효기간을 짧게한다.
그러나, 사용자는 짧은 유효기간 때문에 로그인을 더 자주 해야한다. 불편함을 해결하기 위해서 Refresh Token을 사용한다.
더 긴 유효기간을 가지고 있으며 access token에서 invalid token errorr 발생한 경우, client는 refresh token을 서버로 보내고 새로운 Access Token을 서버에게서 다시 제공받아서 잦은 로그인으로 인한 불편함을 줄이기 위해서 사용된다.
spring security와 JWT에 대해서 이해했다면, 개발을 시작하려고 할 때 Spring security의 filter chain을 사용하면 JWT token을 사용한 authentication, authorization을 할 수 있지 않을까?에 대해서 생각해보며 구현을 시작해볼 것이다.
JWT를 사용하는 경우 사용자 정의 Fitler Chain을 만들어 사용한다.
그 이유는,
filter chain의 구성
필터체인은 Confituration에서 @Bean으로 등록한후에 사용을 해야한다.
SecurityConfig
(갑자기 뜬금없는 소스코드가 나왔는데 아래는 제가 이전에 구현해놓은 플젝의 소스코드중의 일부입니다...:) 지금으로 부터 밑에 나오는 코드는 그냥 이해를 돕기 위한 예시로만 봐주세요!!)
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/members/**").permitAll()
.antMatchers("/qrcode/create").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
다음과 같이 SecurityConfig를 custom할 수 있다.
여기서 addFilterBefore
가 존재하는데 Filter를 추가하고 있는 것이 보일 것이다.
filter custom
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 토큰을 추출
String token = jwtTokenProvider.extractTokenFromHeader((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
// 유효성 검증 이후 토큰을 context에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
token을 가지고 custom 로직 실행
위의 코드는 token과 관련된 요효기간 체크, exception처리, token 제공 등의 기능을 jwtTokenProvider
가 제공을 하고 있다.
문맥정보에 유효한 token을 저장
위에서 설명을 했듯이, SecurityContextHolder를 활용하면 스레드가 병렬처리되어도 Thread Local에서 독립적으로 동작하기에, 다른 스레드의 동작을 받지 않는다. 그리고, 해당 스레드를 통해 어디서든 그 값을 꺼내올 수 있게된다.
spring security와 jwt를 함께 사용하기 위한 filter chain을 만들었기 때문에 이제는 JWT를 다루는 메서드를 생성해주면 된다.
JwtTokenProvider
비대칭키 인증 방식을 사용하기 때문에 secret key를 생성해서 본인의 local에 보관을 해줘야한다.
또한, 사용자에 의해서 변경될 수 있는 변수는 환경변수로 등록해서 사용하는 것이 좋을 것 같다.
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey,
@Value("${jwt.expiration}") long expiration,
@Value("${jwt.refreshExpiration}") long refreshExpiration) {
this.expiration = expiration;
this.refreshExpiration = refreshExpiration;
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
이전 filterchain(username이 일치하는가)에 의해서 불러온 authenticationToken으로 생성한 Authentication 객체를 활용해서 access Token과 refresh Token을 생성한다.
accessToken을 사용해서 Authentication 객체를 획득하여 UserDetail 정보, principal 등등의 내용을 parseClaim
method를 작성해서 claim을 얻은 뒤 권한 정보를 가져옴으로써 사용자 정보를 token으로 부터 추출할 수 있다.
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
를 활용해서 accessToken으로 parseClainJws 메서드를 적용해서 그 내용을 가져올 수 있다.
try catch를 사용해서 jwt의 exception이 발생했을 경우에 각각 해당하는 exception을 처리하는 메서드를 작성한다.
위처럼 jwt token 생성 및 확보, user 데이터 확보 등등을 위한 기능을 만들면 된다.
다음으로는 만들어놓은 provider를 사용해서 service login을 구현하면 된다
예를 들어서, login을 진행할 때를 생각해보자.
username, password가 우선으로 일치하는지 먼저 체크하는 필터를 거친후, UsernamePasswordAuthenticationToken이 생성되면 이를 활용하여 Authentication을 구성한다.(detial 정보, user에 대한 princial 포함)
다음으로 우리사 만들었던 provider를 사용해서 authentication을 넘기고 jwtToken을 생성하면 될 것이다.
provider는 어떤 방법으로 JWT를 구성할 것인지에 따라서 다양하게 custom 할 수 있을 것이다.
spring security의 filter chain은 사용자가 변경을 자유자재로 할 수 있도록 엄청난 확장성을 제공한다는 것을 깨달았다. 왜 공식 홈페이지에서도 확장성이 가장 큰 장점이라고 한지 알 것 같다!