최주호님의 스프링 시큐리티 & JWT 강의를 들으며 만든
예제 파일을 중심으로 스프링 시큐리티의 전반적인 작동 흐름을 정리해보려고 합니다.
또한 왜 이 필터를 커스텀해야 하는지 그 이유를 정리해보려고 합니다.
위와 같은 구조를 가지고 있습니다.
스프링 공식 문서를 보면 아래와 같은 많은 필터가 있다는 것을 확인할 수 있습니다.
The following is a comprehensive list of Spring Security Filter ordering:
ForceEagerSessionCreationFilter
ChannelProcessingFilter
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CorsFilter
CsrfFilter
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
Saml2WebSsoAuthenticationRequestFilter
X509AuthenticationFilter
AbstractPreAuthenticatedProcessingFilter
CasAuthenticationFilter
OAuth2LoginAuthenticationFilter
Saml2WebSsoAuthenticationFilter
UsernamePasswordAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
ConcurrentSessionFilter
DigestAuthenticationFilter
BearerTokenAuthenticationFilter
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
JaasApiIntegrationFilter
RememberMeAuthenticationFilter
AnonymousAuthenticationFilter
OAuth2AuthorizationCodeGrantFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
SwitchUserFilter
이 많은 필터 중에서 제가 커스텀한 필터는 굵은 글씨로 표시한 3가지 필터입니다.
이 수많은 필터들은 실제로 모두 사용되며 개발자가 커스텀하여 사용할 수 있습니다.
제가 이번 강의를 통해서 배운 3가지 필터에 대해서만 정리하고 공부하면서 추가하도록 하겠습니다.
(스프링 시큐리티 필터 외에 개발자가 직접 필터를 만들어 추가할 수도 있습니다. 이 부분은 뒤에 설명하겠습니다.)
일단 위와 같은 필터를 왜 제가 커스텀해야 했는지 그 이유를 아는게 중요하다고 생각해서 정리를 시작해보려고 합니다.
먼저 UsernamePasswordAuthenticationFilter를 커스텀했습니다.
그 이유는 제가 "/login" 요청을 막아두어서 스프링 시큐리티가 작동하지 않기 때문입니다.
.formLogin().disable()//formLogin() : Spring Security에서 제공하는 인증방식
.httpBasic().disable()
"/login"요청이 와야 class PrincipalDetailsService (implements UserDetailsService)의
UserDetailsService 타입으로 IoC 되어 있는 loadUserByUsername 메서드가 자동으로 실행됩니다.
그렇다면 커스텀하기 전에 UsernamePasswordAuthenticationFilter가 어떻게 작동하는지 알아보겠습니다.
먼저 UsernamePasswordAuthenticationFilter를 커스텀했습니다.
그 이유는 제가 "/login" 요청을 막아두어서 스프링 시큐리티가 작동하지 않기 때문입니다.
.formLogin().disable()//formLogin() : Spring Security에서 제공하는 인증방식
.httpBasic().disable()
"/login"요청이 와야 class PrincipalDetailsService (implements UserDetailsService)의
UserDetailsService 타입으로 IoC 되어 있는 loadUserByUsername 메서드가 자동으로 실행됩니다.
그렇다면 커스텀하기 전에 UsernamePasswordAuthenticationFilter가 어떻게 작동하는지 알아보겠습니다.
Form based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임합니다.
login을 시도할 때 보내지는 요청에서 아이디, 패스워드 데이터를 가져온 후 token을 생성하고 인증을 다른 쪽에 위임합니다.
자세히 들여다보면
<1번부터 5번까지의 과정은
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException
즉 attemptAuthentication()를 오버라이딩 하며 이루어집니다.>
아이디와 패스워드에 대한 데이터를 받고 토큰을 생성합니다.
ObjectMapper om = new ObjectMapper();
//LoginReuestDto 클래스에는 username과 password가 필드로 존재
LoginRequestDto loginRequestDto = null;
loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
//토큰을 만든다
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken
(loginRequestDto.getUsername(),loginRequestDto.getPassword());
생성한 토큰을 가지고 AuthenticationManager에게 Authentication 객체와 함께 인증 작업을 위임합니다.
Authentication authentication
= authenticationManager.authenticate(authenticationToken);
그러면 AuthenticationManager는 인증 필터에서 준 인증객체 토큰에 대한 처리를 해주는 AuthenticationProvider를 내부적으로 for문으로 순회하면서 찾습니다. 적절한 AuthenticationProvicer를 찾으면 인증 작업을 위임합니다.
AuthenticationProvider는 전달받은 인증 객체를 UserDetailsService에게 전달하면서 인증 작업을 또 위임합니다.
UserDetailsService(예제 파일에서는 PrincipalDetailsService)는 내부적으로 사용자 정보를 인증객체의 id 정보를 사용해서 가져오고 만약에 찾이 못하면 예외를 날립니다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
// 중요 여기서 String username이라는 변수명은
//loginForm 의 <input type="text" name="username" placeholder="Username"/><br/>
// <input의 name 속성과 변수명 통일>
// 만약 다르다면 SecurityConfig에서
// .loginPage -> .usernameParameter("input의 name 변수명")
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
System.out.println("PrincipalDetailsService loadUserByUsername()");
User userEntity = userRepository.findByUsername(username);
System.out.println("userEntity:"+userEntity);
return new PrincipalDetails(userEntity);
}
}
이제 attempAuthentication() 메서드 실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행됩니다.
JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response해주면 됩니다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain
, Authentication authResult) throws IOException, ServletException {
System.out.println("successfulAuthentication 실행됨 : 인증이 완료 ");
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
//RSA 방식이 아닌 Hash암호 방식
//Hash는 secret key
String jwtToken = JWT.create()
.withSubject(principalDetails.getUsername())
.withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME))// 토큰 만료시간
.withClaim("id", principalDetails.getUser().getId())
.withClaim("username", principalDetails.getUser().getUsername())
.sign(Algorithm.HMAC256(JwtProperties.SECRET));
response.addHeader(JwtProperties.HEADER_STRING,JwtProperties.TOKEN_PREFIX+jwtToken);
}
JWT 토큰을 응답 header에 Authorization에 실어서 보냅니다.
즉 Client로부터 "/login"요청이 들어와야 UsernamePasswordAuthenticationFilter가 실행되어 PrincipalDetailsService(implements UserDetailsService)가 실행되어 loadUserByUsername()이라는 메서드가 실행됩니다. 그러나 설정해서 "/login"요청을 막아두었기 때문에 이를 커스텀하여 만들고
http.csrf().disable(); http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilter(corsFilter) // @CrossOring은 인증이 없을 때 인증이 있을 때는 시큐리티 필터에 등록 .formLogin().disable()//formLogin() : Spring Security에서 제공하는 인증방식 .httpBasic().disable() .addFilter(new JwtAuthenticationFilter(authenticationManager())
위와 같이
formLogin().disable()
뒤에 필터를 추가해줍니다.
Authorization
서버로 요청을 보낼 때, 요청 헤더에 Authorization :<type> <credentials>
을 담아서 보냅니다.
type에는 여러가지가 있는데 우리가 알아볼 타입 두 가지를 알아보갰습니다.
Bearer
일반적으로 JWT(RFC 7519) 같은 OAuth 토큰을 사용한다. (RFC 6750)
Basic 방식과는 달리 토큰에 ID, PW 값을 넣지 않는다.
로그인 시 토큰을 부여받고, 이후 요청할 때 요청 헤더에 토큰을 실어서 보낸다.
세션 저장소가 필요가 없고, 토큰 자체에 내장이 되어있다.
STATELESS, 무결성, 보안성이 장점
먼저 HTTP Basic Authentication에 대해서 알아봐야할거 같습니다.
HTTP Basic Authentication이란
특정 resource에 대한 접근을 요청할 때 브라우저가 사용자에게 username과 password를 확인해 인가(접근 권한이 있는가)를 제한하는 방법입니다.
"username:password" 형태는 Base64로 인코딩하여 Athorization 헤더에 붙여서 보낸다.
헤더에 Authorization : Basic 방식으로 인증을 시도합니다.
그러면 BasicAuthenticationFilter에서 해당 토큰을 검증하여 인증을 처리하는데,
우리는 Basic 방식이 아닌, JWT를 사용할 것이기 때문에 해당 메소드를 오버라이딩 해주도록 하겠습니다.
// 시큐리티가 filter 가지고 있는데 그 필터중에서 BasicAuthenticationFilter라는 것이 잇음
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음
// 만약에 인증이 필요한 주소가 아니라면 안탄다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
@Autowired
private UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
//인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게된다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("인증이나 권한이 필요한 주소 요청입니다.");
String header = request.getHeader(JwtProperties.HEADER_STRING);
//header가 있는지 확인
if(header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)){
chain.doFilter(request,response);
System.out.println("header가 없거나 Bearer이 아니다");
return;
}
//JWT 토큰을 검증을 해서 정상적인 사용자인지 확인
String token = request.getHeader(JwtProperties.HEADER_STRING).replace(JwtProperties.TOKEN_PREFIX,"");
String username = JWT.require(Algorithm.HMAC256(JwtProperties.SECRET)).build().verify(token)
.getClaim("username").asString();
//서명이 제대로 됨
if(username!=null){
System.out.println("서명이 제대로 되엇다면 username: "+username);
User userEntity = userRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
//JWT 토큰 서명을 통해서 서명이 정상이면 Authenticaion 객체를 만들어준다.
//실제 로그인을 통해 만들어진 Authentication이 아님
Authentication authentication
= new UsernamePasswordAuthenticationToken(principalDetails, null,principalDetails.getAuthorities());
//SecurityContextHolder.getContext(): 시큐리티의 세션 공간 찾기
//강제로 시큐리티 세션에 접근하여 Authentication 객체를 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
//다시 체인을 탄다
chain.doFilter(request,response);
}
}
Cors에 대해서 내용을 채우기 못하고 있다가 다시 정리해보려고 합니다.
테코톡을 통해 개념을 숙지했다고 생각했는데
실제로 프로젝트에서 Cors에러가 발생했을 때 왜 발생했는지에 대한 물음을 완전히 해소하지 못했습니다.
그러나 Origin에 대한 개념을 다시 생각해보고 접근하니 해소하게 되었습니다.
이제부터 제가 이해한 Cors에 대해서 정리해보겠습니다.
Same Origin Policy에 대해 먼저 알아보겠습니다.
HTTP Request를 전송 할 때 다른 Origin으로의 전송을 금지합니다. 즉, 같은 Origin에서만 리소스를 공유할 수 있다는 규칙을 가진 정책이며, 이 정책을 적용하는 호스트는 CSRF(Cross-Site Request Forgery), XSS(Cross-Site Scripting)과 같은 공격으로부터 방어할 수 있습니다.
여기서 Origin이란 무엇일까요?
바로 프로토콜,호스트,포트를 Origin이라고 합니다.
위 3가지 중 하나라도 다르다면 다른 Origin입니다.
CSRF(Cross-Site Request Forgery)
위 공격이 일어나기 위해서는 2가지 전제 조건이 필요합니다.
위 두 가지 조건을 만족한다면 피싱사이트는 사용자의 정보를 가지고 위조 요청을 보낼 사이트에 사용자의 권한을 이용해서 게시글을 삭제, 수정하는 등의 행위로 보안 취약 서버를 공격합니다.
XSS(Cross-Site Scripting)
이건 웹 어플리케이션에 악의적인 스크립트를 올려 이 곳을 접속하는 사용자를 공격하는 것입니다.
만약에 제가 보안이 취약한 웹 사이트 게시글에 악의적인 스크립트를 심은 내용을 올린다면 이 게시글을 본 사용자는 세션ID나 쿠키 등을 탈취당하게 됩니다.
안녕하세요 <script> alert('XSS 취약점 점검') </script> 테스트합니다.
따라서 브라우저는 동일 출처 정책을 가지고 있지만
서비스가 발달함에 따라 출처가 다른 서버로부터 자원을 가져오는 경우가 빈번해지면서 동일 출처 정책이 불편으로 다가옵니다.
저와 같은 경우에도 프론트 서버와 백엔드 서버를 분리해서 진행하다보니
서로 사용하는 포트 번호가 달라 Cors Error가 발생했습니다.
따라서 위 문제를 해결하기 위해서 백엔드에서 SpringSecurity에서 제공하고 CorsFilter를 커스텀합니다.
프론트 서버에서 아래와 같은 요청을 보낸다고 했을 때
const xhr = new XMLHttpRequest();
const url = 'http://localhost:8080/refresh';
xhr.open('GET', url);
xhr.onreadystatechange = someHandler;
xhr.send();
HTTP request 다음과 같은 구조를 가지고 있습니다.
GET /localhost:8080/refresh HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
**Origin: http://localhost:3000**
아래 Origin이라는 부분을 눈여겨 보아야 합니다.
지금 보내는 곳의 출처의 포트번호는 3000이지만 요청을 받는 서버의 포트 번호는 8080입니다.
따라서 다른 Origin입니다.
이에 대해 서버는 어떤 응답을 주게 될까요?
다음과 같은 응답이 돌아왔습니다.
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml
[…XML Data…]
Access-Control-Allow-Origin: *
이 부분을 눈여겨 보아야 합니다.
저 부분이 *
으로 설정되어 있기 때문에 백엔드 서버는 모든 Origin에 대한 요청을 허용한다는 의미입니다. 특정 Origin에서만 접근하도록 변경할 수도 있습니다.
다음과 같습니다. (아래 예시는 최주호님의 시큐리티 강의를 듣고 만든 예시입니다.)
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*"); // e.g. http://domain1.com
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
보이시나요?
백엔드 서버에서는 모든 Origin, Header, Method에 대한 요청을 허락했습니다.
http.addFilterAfter(new MyFilter3(), BasicAuthenticationFilter.class); // 스프링 필터 순서를 알아야 한다.
http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class); // 스프링 필터 순서를 알아야 한다.
결론
내가 만든 필터는 시큐리티 필터 체인보다 늦게 작동한다.
따라서 만약에 내가 시큐리티 필터 체인보다 전에 작동하게 하려면
시큐리티 체인에 http.addFilterBefore
https://velog.io/@dailylifecoding/spring-security-authentication-process-flow
DigestAuthenticationFilter에 대한 추가 학습을 할 때 여기 들어가서 보자
cors 전반적인 그림에 대한 예시는 이곳에서 가져왔습니다. 감사합니다.