반응형
반응형

개요

 OAuth2와 JWT를 사용하여 인증 정책을 수립했다. 그런데 문제가 발생했다. 인증이 필요한 API 요청 시 403 에러가 발생하는 것이다. 헤더에 JWT 토큰도 잘 들어가 있고, 토큰 값을 통해 생성한 인증객체도 Security Context Holder에 들어가 있으며, 인가 권한은 ROLE_USER 인데 말이다. 원인을 파악해보자.

뜬금없이 발생하는 403


Security Filter Chain 주요 필터

Security Filter Chain 주요 필터

 

 원인을 찾기 전 현재 Security Filter의 구성이 어떻게 이루어져있는지 간단하게 정리해보았다.

 

1. OAuth2AuthorizationRequestRedirectFilter

 OAuth2AuthorizationRequestRedirectFilter에서는 yml 또는 properties에 설정한 값을 기반으로,  registrationId에 대한 OAuth2 인증 서버의 로그인 URI를 redirect 해준다.

 시큐리티 설정에 OAuth2 로그인 요청 URI는 아래와 같은 형식으로 정의해달라고 하는데, 그 이유가 바로 이 필터를 거치게 하려 함이다.

/oauth2/authorization/[registrationId]

 

2. OAuth2LoginAuthenticationFilter

OAuth2LoginAuthenticationFilter 에서 하는 일이 아주 많다.

 첫째, 로그인 성공 시 자신의 서버로 redirect 되는 authorization code를 받고,

 둘째,  authorization code 를 활용하여 OAuth2 인증 서버로 accessToken을 요청하고,

 셋째, accessToken을 활용하여 OAuth2 리소스 서버로 유저 정보를 요청한다.

 넷째, OAuth2UserService 구현체 인스턴스를 호출하며, 내부에서 Authentication 인스턴스를 생성한다.

 

 정리하면, OAuth2 인증을 받고, 인증 유저에 대한 정보를 획득한 후 인증 객체를 생성하는 역할이다.

 

참고로 인증이 성공하면 추후 AuthenticationSuccessHandler의 구현체 인스턴스를 호출하게 되는데 이때 JWT 토큰을 발급하며, 클라이언트는 이 값을 받아 헤더에 추가한다. (이건 필자의 인증 정책 중 하나이니 굳이 이해할 필요는 없다. JWT 를 발급한다는 것만 알면 된다.

 

3. JWT Filter

 헤더의 JWT 토큰 값을 추출 후 검증한다. 유효한 토큰일 경우 Authentication 객체를 생성하여 Security Context Holder 내에 저장한다.

 


로그인은 성공했으나, 인증된 자원에 대한 요청은 실패

 OAuth2 로그인은 성공하고 JWT 토큰도 잘 발급이 되고, 헤더로 유효한 JWT 토큰도 오는 걸 확인했으나, 인증이 필요한 API 요청에만 실패했다. SpringSecurity 설정에 인증 필터를 거치지 않도록 설정한 view/login, /error 등에 대해서는 403 에러가 발생하지 않는 점을 고려해봤을 때 시큐리티 필터에서 '인증' 관련 문제가 있음을 추정하게 되었다. 

public class SecurityConfig {

    private final UserRepository userRepository;

    private final JwtProvider jwtProvider;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        http
                .authorizeHttpRequests((authz -> authz
                        .requestMatchers("/api/**").hasRole("USER")
                        .anyRequest().authenticated())
                )
		...
        return http.build();
    }


    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers("/view/login", "/error", "/error/*", "/img/**", "/favicon.ico");
    }
}

 


발견되지 않는 에러 로그

 일반적으로 예외가 발생하면 에러 로그가 콘솔에 출력될텐데, 에러 로그가 출력되지 않았다. 시큐리티 로그에 /error 경로에 대한 요청이 들어오는걸로 봐서 내부 어딘가에서 예외가 발생했고, /error 경로로 리다이렉트 된것 같은데, 에러 로그가 보이지 않아 근원지를 찾을 수 없었다.

 


루트 로그 레벨을 trace 로 변경

 /error 리다이렉트 원인이 되는 로그가 분명 찍었을 것이라 판단하고 루트 로그 레벨을 trace로 변경해보았다. 그리고 다시 실행을 하니 원인을 찾을 수 있었다. AuthrozationFilter에서 AccessDeniedException이 발생했고, 이로 인해 /error 경로로 리다이렉트 되고 있던 것이었다. 놀랍게도 로그 레벨은 TRACE 였다. 

TRACE 에러는 왜...
TRACE 레벨로 발생하는 예외


AuthorizationFilter 예외 발생 원인 분석

 내부 소스를 확인해 보니 authrozationManager.check() 호출 후 얻은 AuthroziationDecision의 isGranted() 메서드의 호출 결과에 따라 Access Denied 예외가 발생함을 확인할 수 있었다. isGranted의 구현체 클래스를 따라가니 아래의 세 조건을 만족할 경우에만 true를 리턴하고 있었다.

예외 발생 부분
isGranted 메서드

 

  그런데 필자가 생성했던 Authentication 객체는... authenticated 값이 false 였던..것이었다.

지나갑니다~

 

 

아... 나 커스텀 Authentication 클래스 사용했었구나.

 

 

커스...터뮤ㅠㅠ

 


커스텀 Authentication 클래스 확인

 헐레벌떡 확인해보니 부모 클래스에 authenticated 값이 있었지만, 따로 설정해주지 않았고, 부모 생성자만 호출해서는 authenticated 값도 설정되지 않고 있었다. 그렇다. 따로 설정을 해줬어야 했다.

public class UserAuthenticationToken extends AbstractAuthenticationToken {

    private final Long principal;
    private final String credentials;


    public UserAuthenticationToken(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = userId;
        this.credentials = email;
    }

    @Override
    public String getCredentials() {
        return credentials;
    }

    @Override
    public Long getPrincipal() {
        return principal;
    }
}

 

 super.setAuthenticated(true) 코드를 넣어 authenticated 값을 true로 설정했다.

public UserAuthenticationToken(Long userId, String email, Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    super.setAuthenticated(true);
    this.principal = userId;
    this.credentials = email;
}

 


테스트

잘된다.   👷 (삽을 어디뒀더라...)


회고

 유저에 대한 '인가'를 하면 당연히 '인증'된 객체로 처리될 줄 알았으나, 안된다. 비록 삽질을 하긴 했지만 매우 건강한 삽질이 아니었나 싶다.

 Authentication 객체의 authenticated 값을 AuthorizationFilter 에서 확인한다는 것과 커스텀 Authentication 클래스를 사용할 때 권한을 인가할지라도 인증은 되지 않아 authenticated 값이 자동으로 설정되지 않는다는 것도 알게 되었다. 인증 객체의 부모 생성자 메서드를 좀더 자세히 확인했더라면, 이 문제는 발생하지 않았을 테지만 OAuth2 필터 복습도 하고, 인증 매커니즘에 대해 다시 한번 정리를 하게 된 건강한 삽질이었다!

 

 

반응형
반응형

1. 개요

 스프링 시큐리티에서 제공하는 OAuth2Login을 통해 사용자 정보를 받아오고, 자체적으로 JWT 토큰을 발급하고 있다. JwtAuthorizationFilter에서 JWT 토큰에 대한 인증 처리 로직을 구현하였고, 구글링을 통해 필터의 위치를 UsernamePasswordAuthenticationFilter 이후로 설정하였다.

 요청마다 JWT Filter가 호출되긴 했지만 막상 Security Filter 로그를 통해 Security Filter Chain 리스트를 보니  UsernamePasswordAuthenticationFilter 가 보이지 않았다. 😲

눈씻고 찾아봐도 없는&nbsp;UsernamePasswordAuthenticationFilter

 이 필터가 없는 이유없는 필터에 커스텀 필터가 추가되는 것이 이해 되지 않았다. 이 이유를 알아보자.


2. UsernamePasswordAuthenticationFilter 가 없는 이유

 이유는 매우 간단했다. UsernamePasswordAuthenticationFilter는 클라이언트에서 요청한 username과 password를 통해 인증을 처리하는 필터이다. formLogin 시 UsernamePasswordAuthenticationFilter 가, OAuth2Login시 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter 가 추가된다.

OAuth2Login 을 사용하므로 UsernamePasswordAuthenticationFilter 가 없는건 매우 당연했다. 

oauth2Login


3. OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter

 OAuth2AuthorizationRequestRedirectFilter는 OAuth2 서비스 제공 서버의 로그인 페이지를 호출하는 필터이다. 기본 제공하지 않는 Naver나 Kakao의 로그인 페이지는 yml 설정한 정보를 조합하여 uri를 만든 후 호출한다.

 

 OAuth2LoginAuthenticationFilter는 AbstractAuthenticationProcessingFilter의 서브클래스로 RedirectURI를 통해 받은 AuthorizationCode토큰 인증 API를 호출하여 accessToken 및 refreshToken을 받아오고, 이를 통해 유저 정보 조회 API를 호출하여 유저 정보도 받아온다. 관련 로직은 아래와 같다.

 

1) getAuthenticationManager().authenticate() 메서드를 호출

OAuth2LoginAuthenticationFilter 의 AuthorizationCode를 통한 인증 메서드

 

2) OAuth2LoginAuthenticationProvider 클래스의 authorizationCodeAuthenticationProvider.authenticate() 메서드 호출하여 accessToken과 refreshToken 취득

token 취득 부분

 

3) OAuth2LoginAuthenticationProvider 클래스의 userService.loadUser() 메서드 호출하여 유저 정보 취득 (OAuth2UserService 를 재정의하여 사용하기도 함.)

유저 정보 취득 부분


4. Filter Chain에 없는 AbstractAuthenticationProcessingFilter 

 디버깅을 하면서 추적해 나가다보니 AbstractAuthenticationProcessingFilter 클래스의 특정 메서드가 호출되는 부분이 있었다. 이 필터에서는 요청 URI를 추출하여 OAuth2 서비스에 대한 redirectURI로 온 요청일 경우 attemptAuthentication 메서드를 통해 OAuth2LoginAuthenticationFilter의 인증 처리를 하고 있었다. 그런데 이 필터는 Security Filter Chain 리스트에 없다. 이 녀석의 정체는 뭘까? 🤔

AbstractAuthenticationProcessingFilter 코드 일부

 

 스프링 공식문서를 보면 아래와 같이 해당 클래스의 서브 클래스 리스트가 나온다. 그런데 아주 눈에 익은 클래스가 보인다. 그렇다. OAuth2LoginAuthenticationFilter가 이 클래스의 서브클래스였다.  😲

AbstractAuthenticationProcessingFilter

Direct Known Subclasses:
CasAuthenticationFilter, OAuth2LoginAuthenticationFilter, Saml2WebSsoAuthenticationFilter, UsernamePasswordAuthenticationFilter

 

 필터를 신경써서 봤다면 OAuth2LoginAuthenticationFilter는 이 클래스를 상속받고 있고, 추상 메서드인 attemptAuthentication 를 구현함을 알 수 있었을것이다. 참고로 OAuth2LoginAuthenticationFilter는 Security Filter Chain 목록에 있다.

OAuth2LoginAuthenticationFilter


5. UsernamePasswordAuthenticationFilter 가 없어도 JwtAuthorizationFilter 가 추가된 이유

 두번째로 궁금했던 UsernamePasswordAuthenticationFilter 가 없어도 커스텀한 필터가 추가된 이유를 알아보았다. 추가에 사용한 메서드는 addFilterAfter() 이며 Security 설정 부분에 아래와 같이 사용하였다.

.addFilterAfter(new JwtAuthorizationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

 

 결론부터 말하면 스프링 시큐리티에서 필터를 유연하게 추가할 수 있도록 내부 로직이 구성되어 있기 때문이었다. UsernamePasswordAuthenticationFilter 처럼 실제로 사용하지 않는 필터라도 말이다.

 

 아래 코드는 addFilterAfter 메서드를 실행했을 때 FilterOrderRegistration 클래스의 getOrder 메서드를 통해 UsernamePasswordAuthenticationFilter의 위치를 Integer로 추출하는 로직이다.

필터의 순서를 조회하는 getOrder

 

 filterToOrder은 필터 클래스 이름과 순서를 Map 타입으로 관리하는 변수이다. 이는 FilterOrderRegistration 클래스의 생성자 메서드를 통해 생성된다. 여기서 중요한 점은 이 값은 순수하게 필터의 순서를 관리하기 위해 존재한다는 것이다.

필터의 클래스명과 순서를 관리하는 filtersToOrder

 

 위 로직을 통해 UsernamePasswordAuthenticationFilter가 1900번째 필터임을 알았으며, 해당 값에 offset 인 1을 추가하여 새로 추가될 필터인 JwtAuthorizationFilter를 1901번 필터로 추가하였다. 즉, UsernamePasswordAuthenticationFilter의 바로 다음 순서의 필터로 JwtAuthorizationFilter를 지정한 것이다.

그 후 filters에 JwtAuthorizationFilter와 1901 정보를 갖는 OrderedFilter 타입 인스턴스를 추가하였다.

filters가 실제 어플리케이션에서 사용될 필터들을 관리한다.

filters에 추가되는 부분

 

 최종적으로 등록된 filters 리스트를 살펴보면 filter와 order 필드를 갖는 OrderedFilter 타입의 객체들이 있으며, order를 오름차순으로 정렬해보면 이 포스팅의 제일 첫 그림과 동일한 순서를 갖는 필터 리스트임을 확인할 수 있다. 이러한 내부 로직에 의해 실제로 사용하지 않는 필터에 대해 addFilterAfter와 같은 메서드를 사용하여 필터를 추가해도 에러가 발생하지 않았던 것이었다.

filters 리스트


6. JwtAuthrizationFilter의 위치

 위를 근거로 하여 JwtAuthrizationFilter는 OAuth2LoginAuthenticationFilter 다음으로 수정하였다. 

.addFilterAfter(new JwtAuthorizationFilter(jwtProvider), OAuth2LoginAuthenticationFilter.class);

7. 회고

 단순히 UsernamePasswordAuthenticationFilter가 왜 없지? 라는 단순한 호기심으로 시작했지만 내부 코드를 까보며 OAuth2 인증이 스프링 내부에서 어떻게 처리되는지, 필터는 어떻게 구성되는지에 대해 이해하게 되었고, 1,2년 전쯤 스프링 시큐리티의 폼 인증에 대한 내부 로직에 대해 정리한 적이 있는데, 이를 한번 더 상기하게 되었고, 모든 코드에는 근거가 있어야함을 다시한번 느끼게된 좋은 계기가 되었다.

반응형
반응형

1. 개요

  • CSRF의 정의
  • Spring Boot에서의 CSRF Filter 처리 방식

 

2. CSRF란?

 사이트 간 요청 위조(Cross-site request forgery, CSRF)는 웹사이트 취약점 공격의 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 말한다. - 위키백과

 

 

  • 필자는 처음 CSRF라는 용어를 접할 때 정의는 이해가 갔으나 실제 어떤 원리를 이용해 '공격'이라는 행위를 하는지 전혀 이해가 가지 않았다... 나와 같은 사람들의 이해를 돕기 위해 CSRF 공격에 대한 예를 스토리 텔링 기법으로 들어보도록 하겠다. (기억에 잘 남는다고 함...)
안녕하세요. 저는 아무개 커뮤니티의 회원 "talmobil"이라고 합니다.
유머게시판에 게시글 하나를 썼는데 좋아요가 너무 없어서 자괴감이 들었습니다. 아니, 열받더라구요. 제 글이 정말 재밌는데, 사람들이 일부러 좋아요를 눌러주지 않는 것 같거든요. -ㅅ-
그러던 중 좋은 방법이 하나 떠올랐습니다. 다른 사람이 제 게시글을 보면 좋아요를 자동으로 누르게 하는거죠!
방법은 이래요.
커뮤니티 사이트에서 게시글에 좋아요를 누르니  "http://community.com/like?post=[게시글 id]"이더군요.
제 게시글 ID를 확인해보니 9566 이구요. 그래서 게시글 안에 이미지 태그를 하나 삽입하고 src 값에 "http://community.com/like?post=9566"을 넣어봤습니다. 그랬더니 사람들이 제 게시글을 조회하게 되면 이미지 태그의 URL인 "http://community.com/like?post=9566" 가 자동으로 호출되었습니다! @_@.. 자동으로 제 게시글의 좋아요 수가 올라갔어요. 후후.. 다른 커뮤니티 사이트에도 어그로 게시글을 올리고 마찬가지로 src 태그를 넣었더니 더 빨리 오르더군요!
사용자들은 아무것도 모르고 제가 의도한대로 요청을 하고있는겁니다! 이제는 좋아요가 아닌 다른걸 해봐야겠어요 흐ㅏㅎ하
  • 아무것도 모르는 사용자들은 공격자가 의도한 행위를 사이트간 위조 요청으로 하게 되었다. 이런 공격이 바로 CSRF 이다. 그렇다면 이러한 공격을 막는 방법이 있을까? 당연히 있다. 서버에서 요청에 대한 검증을 하는 것이다. 이를테면 토큰 값으로 말이다. 이것도 예를 들어보겠다.
안녕하세요. 아무개 커뮤니티 담당자 "ㅅㄱ"입니다.
제가 집에 우환이 생겼습니다. 웃음이 필요해서 유머 게시판을 쓱 둘러봤는데, 개노잼 글이 하나 있더군요. 뭐지 하고 넘어가려는데 이 게시글의 좋아요 수가 무려 1만이 넘어갔습니다. 그리고 다시 들어가보니 누르지도 않은 좋아요가 눌러져있더라구요. 이게 말이되나? 싶었습니다.
 서버 로그를 확인해보니 제가 좋아요를 누른 기록도 있었습니다. 뭔가 이상해서 게시글 내용을 살펴봤는데 이미지 태그의 src에 좋아요 처리를 하는 URL이 들어가 있었습니다. 해당 URL을 구글링 해보니 이미 다른 웹사이트 게시글에도 포함되어 있더군요. 말로만 듣던 CSRF 공격이었습니다.
 막을 방법은 클라이언트마다 토큰을 발급하는 겁니다. 서버는 토큰 값을 검증하고요. 프로세스는 다음과 같습니다.
1. 저희 커뮤니티를 접근하면 특정 토큰을 클라이언트에게 발급함과 동시에 저희 서버 세션 안에 넣습니다.
    > A 클라이언트에 대해 A 토큰을, B클라이언트에 대해 B 토큰을 이렇게 각각 발급하는 겁니다.
2. 클라이언트는 모든 API를 호출할 때 필수적으로 이 토큰 값을 헤더에 넣어 보냅니다.
3. 서버에서는 요청을 수행하기전 Filter 레벨에서 세션 안에 들어있는 토큰 값과 요청 토큰 값을 비교합니다.
4. 토큰 값이 불일치할 경우 비정상적인 요청으로 판단하고 Access Denied 시킵니다.

토큰 검증을 성공하려면 요청 시 CSRF Token 값을 헤더에 넣어줘야하는데, 공격자는 사용자마다 각각 발급된 토큰 값을 알 수 없기때문에 막힐겁니다.
추가적으로, 이러한 방식을 스프링 시큐리티에서 기본적으로 지원하고 있더라구요!
  •  이처럼 서버에서 토큰을 발급 및 검증하고 클라이언트에서는 발급받은 토큰을 요청 값에 포함시켜 보내는 방식으로 CSRF 공격을 막을 수 있다. 스프링 시큐리티 의존성을 추가하면 이와 같은 방식을 제공하는 CSRF Filter가 자동으로 추가된다. csrf().disable() 설정을 통해 해제도 가능하다. 그럼 Spring Security에서 제공하는 CSRF Filter는 요청을 어떻게 처리하는지 알아보자.

 

3. CSRF Filter

CsrfFilter.java

  • 빨간색 표시된 부분이 중요한 부분이다. 하나하나 설명해보겠다.

 

  3.1. tokenRepository.loadToken(request)

  • 요청 세션에서 CSRF Token 객체를 조회한다. key는 HttpSessionCsrfTokenRepository.CSRF_TOKEN 이다.

tokenRepository 구현체

 

  3.2. tokenRepository.generateToken(request)

  • CSRF Token이 없을 경우 DefaultCsrfToken 생성자를 통해 CSRF Token을 발급한다.
  • 생성자 마지막 파라미터로 createNewToken() 리턴 값을 넣고있는데, token 값으로 사용할 랜덤 값을 생성한다.

createNewToken 메서드
DefaultCsrfToken 생성자

  • 생성자 메서드 호출 후 CsrfToken 객체를 생성하면 다음과 같은 형태의 CSRF Token 객체가 생성된다.
CsrfToken
token Random UUID
parameterName _csrf
headerName X-CSRF-TOKEN

 

  3.3. tokenRepository.saveToken()

  •  세션 내에 key = HttpSessionCsrfTokenRepository.CSRF_TOKEN, value = 생성한 CsrfToken 객체를 생성한다.

 

  3.4. request.setAttribute(CsrfToken.class.getName(), csrfToken)

  • HttpServletRequest 를 통해 csrfToken 값을 조회할 수 있도록 설정해주는 부분이다.

 

  3.5. requireCsrfProtectionMatcher.matches(request)

  • Csrf 검증 메서드를 체크한다. 기본적으로 GET, HEAD, TRACE, OPTIONS를 제외한 모든 메서드에 대해서는 CsrfToken을 검증한다. 만약 GET으로 요청이 들어왔다면 검증 없이 다음 Filter로 넘어간다.

 

  3.6. request.getHeader(csrfToken.getHeaderName()) , getParameter(csrfToken.getParameterName());

  • 요청 헤더에 X-CSRF-TOKEN 값이 있는지 확인하고, 없을 경우 요청 바디에서 _csrf 값이 있는지 확인한다.
  • 클라이언트는 요청 헤더의 X-CSRF-TOKEN 혹은 요청 바디의 _csrf 값 둘 중 하나로 CsrfToken 값을 보내면 된다.

 

  3.7. equalsConstantTime(csrfToken.getToken(), actualToken)

  • 세션에 저장(혹은 생성)한 토큰값과 클라이언트에서 보낸 토큰 값을 비교하여 일치할 경우 다음 필터를 호출하고 불일치할 경우 accessDeniedHandler 메서드를 호출하여 예외 처리한다.

 

4. 테스트

   4.1. CSRF 기능 활성화

  • 기본적인 Spring Security 설정 후 csrfFilter가 활성화 되도록 csrf().disable 메서드를 주석처리 한 후 post 메서드를 호출하였다. 그 결과 필자가 Custom한 AccessDeniedHandler 메서드가 호출되어 /denied 페이지로 이동되었다.
  • 예외가 발생한 이유는 요청 헤더 혹은 바디에 csrf 토큰이 없기 때문이다. 

SecurityConfig.java

 

post 메서드 호출

 

  4.2. 화면단에 CSRF 토큰 추가

  • form 태그 안에 아래와 같은 코드를 추가하거나,  스프링 시큐리티 taglib 추가 후 <s:csrfInput/> 태그를 추가하면 발급받은 _csrf 토큰을 자동으로 set 해준다.
  • API 호출 시 정상적으로 처리되는 것을 확인할 수 있다.

csrf 구문 추가
자동 추가된 csrf 값
정상 처리된 post 메서드

 

 

5. 회고

  • 스프링 시큐리티를 설정 시 갑자기 발생한 403 에러에 당황했던 적이 많다. 찾아보면  csrf().disable() 한줄을  추가 하면 됐고, 간단한 설정처럼 보였던 이 CSRF가 뭔지는 크게 궁금하지 않았다. 개념정도만 짚고 넘어갔다. 그런데 나중에 똑같은 에러에 똑같이 당황하게 됐고, 똑같이 구글링하고 개념만 쓱 보게 되더라. 그때도 머릿속에 개념이 잘 잡히지 않았는데, 프로세스 정리를 쭉 하니 확실하게 이해하게 된 것 같다.
반응형
반응형

1. 개요

  • 토이 프로젝트 진행 중 관리자 권한에 따른 기능 분리가 필요하여 기존 Gradle 프로젝트에 SpringSecurity 인증을 끼얹어보았다.
  • 스프링 시큐리티 온라인 강의를 들은 적이 있다. 이해를 할때마다 고개를 끄덕거리며 만족해하던 내 자신이 기억났다. '생겨버렸다. 자신감'. 하지만 정작 기억해야할 건 전혀 기억하지 못하더라... 자신감은 잠시 버려두고 강의노트를 펼쳤다.

 


2. 환경

  • SpringBoot
  • Gradle
  • Mybatis

3. 스프링 시큐리티 인증 절차

  • 스프링 시큐리티를 사용하면 여러 인증 및 인가 필터를 거친다. 필터를 거칠때마다 이와 관련된 여러 인터페이스 구현체를 거치게 되는데, 이걸 우리의 서비스에 맞게 커스텀하면 된다. 그러려면? 내부적으로 어떤 클래스(절차)들을 거치는지 알아볼 필요가 있다. 
    SpringSecurity Form 인증 절차
     
  • UsernamePasswordAuthenticationFilter > AuthenticationManager > AuthenticationProvider > UserDetailsService > Repository(DB)
    1. UsernamePasswordAuthenticationFilter에서 요청값에 대한 Authentication 객체를 생성한다.
    2. 생성한 Authentication 객체를 AuthenticationManager에게 전달한다.
    3. AuthenticationManager는 실제 인증 처리를 하지 않고, 적절한 인증 처리 클래스에게 인증 처리를 위임하는, 말 그대로 매니저 역할을 하는 클래스이다. 폼 인증 요청에 대한 적절한 인증 처리 클래스인 AuthenticationProvider에게 Authentication 객체를 전달하여 인증 처리를 위임한다.
    4. AuthenticationProvider는 들어온 Authentication 객체의 username을 값을 추출하여 UserDetailsService의 loadUserByUsername 메서드 호출에 사용한다. 말 그대로 유저 ID에 대한 유저 정보를 조회한다.
    5. UserDetailsService는 username 값을 통해 DB에서 유저 상세 정보(아이디, 비밀번호, 권한 등)를  UserDetails 타입으로 조회한다.
    6. 조회한 UserDetilas 타입의 객체를 AuthenticationProvider에게 return한다.
    7. AuthenticationProvider는 DB에서 조회한 비밀번호와 요청으로 들어온 비밀번호를 체크한다. 만약 암호화 된 비밀번호라면 암호화 클래스를 사용해 체크한다.
    8. 비밀번호가 일치할 경우 인증 토큰에 들어갈 권한 객체인 authorities를 생성 후 원하는 권한을 넣어준다.
    9. AuthenticationProvider는 유저 정보 + authorities를 담은 Authentication 객체를 생성하여 AuthenticationManager에게 return한다.
    10. AuthenticationManager는 Authentication 객체를 UsernamePasswordAuthenticationFilter에게 return 한다.
    11. UsernamePasswordAuthenticationFilter는 Authentication 객체를 SecurityContext에 저장한다.

4. 구현

  • 필자가 정리한 위 인증 절차에서 커스텀해야할 녀석은 누구일까? 당연히 실질적인 인증처리를 하고있는 녀석들을 커스텀해야한다. AuthenticationProvider와 UserDetailsService가 핵심이다. 둘 다 인터페이스이므로 구현체를 생성하여 주입시켜주기만 하면 된다. SecurityConfig 부터 시작하자.

 

1) SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Autowired
	private AuthenticationSuccessHandler authenticationSuccessHandler;
	
	@Autowired
	private AuthenticationFailureHandler authenticationFailureHandler;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http
			.csrf().disable(); //일반 사용자에 대해 Session을 저장하지 않으므로 csrf을 disable 처리함.
		
		http
			.authorizeRequests()
			.antMatchers("/admin").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/swagger-ui.html").hasRole("ADMIN")
			.antMatchers("/schedule/insert","/schedule/update","/schedule/delete").hasRole("ADMIN")
			.antMatchers("/comment/insert","/comment/update","/comment/delete").hasRole("ADMIN")
			.anyRequest().permitAll();
		
		http
			.formLogin()
			.loginPage("/admin")
			.loginProcessingUrl("/admin/login")
			.usernameParameter("username")
			.passwordParameter("password")
			.successHandler(authenticationSuccessHandler)
			.failureHandler(authenticationFailureHandler);
			
		http
			.logout()
			.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
			.logoutSuccessUrl("/admin")
			.invalidateHttpSession(true);
	
	}	
}

 - csrf().disable()

  •  먼저 csrf는 Cross Site Request Forgery의 약자로 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게하는 공격을 말한다.
  •  예를들어 은행 홈페이지에 특정 사용자에게 송금을 시키는 API(test.com/transMoney?money={송금할 금액}&to={송금받을 유저})가 있다고 하자.  A라는 공격자는 해당 홈페이지를 사용하는 유저들에게 다음과 같은 이미지 태그를 포함한 메일을 전송한다.
<img src="test.com/transMoney?money=100000&to=A />
  • 이 메일을 열어본 사용자는 src에 기입된 API가 호출되게 된다. 만약 해당 홈페이지에 로그인하여 세션이 브라우저에 남아있는 상태라면 공격자 A에게 100000원이 송금되는 것이다. 이런 공격을 csrf 공격이라고 한다.
  • http.csrf() 구문을 사용하면 클라이언트가 서버 통신 시 csrf 토큰 값을 함께 전달하고, 서버는 토큰에 대한 유효성을 체크하여 일치하지 않을 경우 요청을 차단하게 된다. 위 API를 호출한다 해도 csrf 토큰 값이 없으므로 서버에서는 401 에러가 발생할것이다. 하지만 내가 만든 서비스에서는 이러한 방식에 대한 필요성을 느끼지 못해 disable 처리를 하였다.

 

 - authorizeRequest() ~ anyRequest().permitAll()

  • 요청에 대한 인가를 설정한다. 현재 필자의 토이프로젝트 권한에 맞게 설정하였으므로 참고만 하고 넘어가면 된다.

 

 - formLogin()

  • formLogin 인증 방식을 사용한다는 의미이다. 

 

 - usernameParameter("username")와 passwordParameter("password")

  • 클라이언트가 전송한 폼 데이터 중 "username"과 "password"라는 name을 가진 값을 스프링 시큐리티에서 username, password로 사용한다. 즉, UsernamePasswordAuthenticationFilter 에서 Authentication 객체를 생성할때 각각의 변수 값을 사용한다.
<form action="/admin/login" id="form" method="post">
	<input type="password" name="password" id="adminPassword" />
    <input type="hidden" name="username" id="adminId" />
    <input type="submit" class="loginBtn" value="로그인">
</form>

 

 - loginPage()

  • 로그인할 페이지의 주소를 입력한다.

 

 - loginProcessingUrl()

  • 로그인을 처리할 Url을 입력한다.

 

 - successHandler()

  • 인증이 성공한 후 호출되는 핸들러 클래스이다.

  

 - failureHandler()

  • 인증이 실패한 후 호출되는 핸들러 클래스이다.

 

 - logout() ~ invalidateHttpSession()

  • /logout 을 호출하면 /admin 페이지로 이동하며 로그아웃이 되며, 이와 동시에 session이 무효화(invaildate)된다.

 

2) CustomAuthenticationSuccessHandler.java

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		
		logger.info("admin login Success !!");
		response.sendRedirect("/");
	}

}

 

  • AuthenticationSuccessHandler 인터페이스의 구현체이다.
  • 로그인이 성공할 경우 메인 페이지("/")로 리다이렉트 되도록 하였다.

 

3) CustomAuthenticationFailureHandler.java

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
		logger.error("admin login Failed !!");
		response.sendRedirect("/admin?auth=fail");
	}

}
  • AuthenticationFailureHandler 인터페이스의 구현체이다.
  • 로그인이 실패할 경우 쿼리 스트링을 포함하여 "/admin"로 이동시켰다.
  • 참고로 /admin 페이지는 관리자 로그인을 시도하는 페이지이며, 클라이언트단에서 auth값에 따른 알림 메시지를 출력하기 위해 쿼리스트링을 포함시켰다.

 

4) CustomAuthenticationProvider.java

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{

	@Autowired
	private UserDetailsService userDetailsService; //CustomUserDetails Class Autowired.
	
	@Autowired
	private PasswordEncoder passwordEncoder; //BCryptPasswordEncoder Class Autowired.
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		String username = authentication.getName();
		String password = (String)authentication.getCredentials();
		
		CustomUserDetails customUserDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
	
		if(!passwordEncoder.matches(password, customUserDetails.getPassword())) {
			throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
		}
				
		List<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
		
		return new UsernamePasswordAuthenticationToken(username,password,authorities);

	}

	
	@Override
	public boolean supports(Class<?> authentication) {
		return authentication.equals(UsernamePasswordAuthenticationToken.class);
	}

}
  • AuthenticationProvider 인터페이스의 구현체이다.
  • 요청에 대한 ID와 PW 값이 포함된 Authentication이라는 객체가 요청 파라미터 값으로 들어온다. 인증절차의 4번째 단계이다.
  • authentication 객체에서 username 값을 사용하여 userDetailsService.loadUserByUsername 메서드를 호출한다.
  • 참고로 userDetailsService 구현체 내에서는 username을 가진 admin 계정의 정보를 조회하는 기능을 한다.
  • 비밀번호가 일치하지 않을 경우 BadCredentialsException을, 일치할 경우 GrantedAuthority 리스트 내에 ROLE_ADMIN 권한을 추가시킨 후 UsernamePasswordAuthenticationToken 생성자를 호출해 return한다. 이 생성자를 호출할 경우 내부적으로 AbstractAuthenticationToken 객체를 생성하는데, 이 객체는 Authentication 객체의 구현체이다. 즉, 인증절차의 9번째 단계이다.
  • supprotes 메서드는 authenticate 메서드의 동작 여부를 결정한다. false를 return하면 동작하지 않는다. 위 코드에서는 Form 인증에 대한 authentication 객체. 즉 UsernamePasswordAuthenticationToken 자료형으로 요청이 들어올 경우에만 인증 절차를 진행한다.

* 필자의 경우 DB에서 계정에 대한 권한 정보를 조회하여 부여해주는 방식이 아닌, 비밀번호 일치 시 ADMIN 권한을 주도록 로직이 짜여져있다. 이 부분은 전자와 같이 동작하도록 로직을 수정이 필요함을 느꼈다. 만약 독자분들도 전자와 같이 구현하고자한다면 loadUserByUsername 메서드를 통해 권한 정보도 조회하도록 쿼리 및 CustomUserDetails 클래스 수정 후 "ROLE_ADMIN" 부분에 customUserDetails의 get 메서드로 권한 정보를 가져와서 넣어주면 될 것이다. 

 

5) CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService{

	@Autowired
	private AdminRepository adminRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		UserDetails userDetails = adminRepository.getUserDetails(username);
		
		if(userDetails == null) {
			throw new UsernameNotFoundException("유효하지 않는 로그인 정보입니다.");
		}
		
		return userDetails;
	}

}
  • UserDetailsService 인터페이스의 구현체이다.
  • AdminRepository는 Mybatis에 대한 Mapper 인터페이스이다.
  • loadUserByUsername 메서드를 오버라이드한 후 AdminRepository 인터페이스를 사용해 admin 계정에 대한 id, pw 정보를 UserDetails 자료형으로 조회 후 리턴한다.

 

6) CustomUserDetails

@Component
public class CustomUserDetails implements UserDetails{

	private static final long serialVersionUID = 1L;

	private String username;
	private String password;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public String getPassword() {
		return this.password;
	}

	@Override
	public String getUsername() {
		return this.username;
	}

	@Override
	public boolean isAccountNonExpired() {
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	public boolean isEnabled() {
		return false;
	}
}
  • UserDetails 인터페이스의 구현체이다.

 

 

7) AdminRepository

@Repository
public interface AdminRepository {

	public UserDetails getUserDetails(String username);
}


// AdminMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.cds.repository.AdminRepository">

    <select id = "getUserDetails" resultType = "CustomUserDetails">
    	SELECT
    		id AS username
    		,password
    	FROM
    		TB_ADMIN
    	WHERE
    		id = #{username}
    		AND use_yn = 'Y'
    </select>
</mapper>
  • AdminRepository 및 AdminMapper 쿼리이다.
  • returnType의 CustomUserDetails은 typeAlias를 사용해 네이밍을 간소화시켰다.

5. 테스트

1) 로그인 실패 시 

로그인 실패 시 로그
로그인 실패 시 리다이렉트

2) 로그인 성공 시

로그인 실패 시 로그

 

로그인 성공 시 리다이렉트


6. 마치며

  • 스프링 시큐리티 폼 인증 절차에 대해 개념을 다시 정리할 수 있었던 좋은 기회였다. 또한 내가 작성한 로직들을 포스팅을 통해 재확인해보니 수정할 곳도 몇몇 보였다. 한동안 이핑계 저핑계로 포스팅을 하지 않아 쌓여있는 내용이 엄청 많아졌다. 천천히 조금씩 정리해나가야겠다.
반응형

+ Recent posts