2018년 7월 26일 목요일

Spring Boot에 Security 적용하기


 이번 포스팅은 Spring Boot로 Web app을 개발할 때 Spring Security를 적용하는 방법에 대해서 정리해보려고 한다. 가능하면 가장 기본적인 설정만으로 구현할 것이고 Thymeleaf와의 연동에 대해서도 같이 정리할 것이다.

 일단 간단한 Web app이 개발된 상태라고 가정하는 게 Security에만 집중해서 설명할 수 있기 때문에 프로젝트를 생성한다던가 Spring Boot의 설정에 대해서는 건너 뛰도록 하겠다.

 가장 먼저 해야할 일은 Spring Security 모듈에대한 dependency를 설정하는 것이다. 아래 그림과 같이 build.gralde 파일을 열어서 2개의 모듈을 추가하도록 하자. (Maven은 pom.xml에 추가한다.)


 필자는 Thymeleaf를 이용해서 View를 렌더링할 것이기 때문에 thymeleaf-extras-springsecurity4를 추가했지만 Thymeleaf를 이용하지 않는다면 spring-boot-starter-security만 추가해도 상관없으므로 참고하기 바란다. 

 현재 사용되는 Spring 버전이 5.x이기 때문에 혹시 thymeleaf-extras-springsecurity5가 있는지 찾아봤으나 아직 없었다. 그래서 thymeleaf-extras-springsecurity4를 적용하였고 동작에는 아무런 문제가 없는 것으로 확인되었다. (현재는 thymeleaf-extras-springsecurity5가 릴리즈되었다. 따라서 이제는 4 대신 5를 사용해야 한다.)

 기존 프로젝트에 이렇게 모듈만 추가를 하고 실행을 해도 Security가 적용되어 어떤 URL에 접근을 해도 아래 그림과 같은 로그인 창이 뜨게된다. 등록된 사용자가 하나도 없으므로 당연히 로그인을 할 수 없고 페이지도 볼 수가 없다.


 이 기본 로그인 창은 Spring Security에서 제공하는 페이지인데 원한다면 사용할 수는 있지만 사실 별로 쓸모는 없다. 아마도 절대로 쓸 일은 없을 것이다.

 필요한 모듈을 추가했으니 이제 설정을 할 차례이다. 전에는 막연하게 이 부분이 복잡하다고 느꼈었지만 사실 설정 클래스를 하나 추가하면 된다. 그럼 Security 설정을 위한 설정 클래스를 추가하도록 하자. Spring에서 자동으로 scan을 할 수 있는 위치 아무 곳에나 아래와 같은 설정 클래스를 추가하도록 한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserAuthenticationProvider authenticationProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/css/**", "/js/**", "/img/**").permitAll()
                .antMatchers("/auth/admin/**").hasRole("ADMIN") // 내부적으로 접두어 "ROLE_"가 붙는다.
                .antMatchers("/auth/**").hasAnyRole("ADMIN", "USER") // 내부적으로 접두어 "ROLE_"가 붙는다.
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login") // default
                .loginProcessingUrl("/authenticate")
                .failureUrl("/login?error") // default
                .defaultSuccessUrl("/home")
                .usernameParameter("email")
                .passwordParameter("password")
                .permitAll();

        http.logout()
                .logoutUrl("/logout") // default
                .logoutSuccessUrl("/login")
                .permitAll();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider);
    }
}

 위 설정 코드가 좀 복잡해 보일지도 모르지만 여기서는 샘플로서의 역할을 위해서 자주 사용될 만한 것들을 억지로 넣었기 때문에 그런 것이고 실제로는 더 간단할 수도 있고 더 복잡할 수도 있다. 일단 각 라인에 대한 설명을 해보도록 하겠다.

  • 1~3 : WebSecurityConfigurerAdapter를 상속하는 클래스를 하나 만들고 Annotation들도 위와 같이 붙여준다. (클래스 이름은 원하는 대로)
  • 6 : UserAuthenticationProvider는 커스텀 인증을 구현하는 클래스이다. AuthenticationProvider를 구현하는 클래스이며 자세한 사항은 아래에서 설명할 것이므로 일단 이렇게 Autowired로 주입되는 Component라는 사실만 이해하도록 하자.
  • 9 : Spring Security의 설정을 구현하는 메소드이다. 필수 메소드이며 설정가능한 값들은 여기서 제시하는 것 말고도 더 많은 것들이 있으므로 필요한 경우 관련 문서를 참고하기 바란다.
  • 10 ~14 : 인증이 필요하지 않은 경로와 인증이 필요한 경로를 설정한다. 여기서는 순서가 중요하다. 앞에서부터 검사해서 매칭이 일어나면 바로 규칙이 적용되어 그 뒤의 규칙은 무시되므로 우선순위를 고려하여 순서를 정해야 한다. 
  • 11 : 정적 자원에 대해서는 인증 없이 접근이 가능하도록 완전히 허용하고 있다.
  • 12 : "/auth/admin/**" 경로에 대해서는 "ROLE_ADMIN" 권한이 있어야 접근이 가능하도록 하고 있다.
  • 13 : "/auth/**" 경로에 대해서 ROLE_ADMIN, ROLE_USER 중에 어느 하나라도 권한이 있어야 접근이 가능하도록 하고 있다. 12라인의 규칙도 여기에 해당하지만 먼저 선언되어있기 때문에 이 규칙이 우선 적용될 일은 없다.
  • 14 : 기타 나머지 요청에 대해서는 인증된 사용자만 접근이 되도록 설정하고 있다.
  • 16~23 : 로그인 폼에 관련된 설정을 하고 있다. 
  • 17 : 로그인 UI를 제공하는 페이지의 경로를 설정한다. 여기서 지정된 경로가 Controller에 GET 요청으로 들어온다. 원하는 경로를 지정하면 된다.
  • 18 : 인증을 처리하는 경로를 지정한다. 즉, 로그인 페이지의 form 태그에서 action 속성에 지정할 URL이다. 이 경로는 Controller로 들어오는 요청은 아니고 Spring Security에서 요청을 가로채서 인증 루틴을 실행해줄 것이다. 
  • 19 : 인증 실패 시 돌아갈 경로를 설정한다. 위 예의 경우 "/login?error"가 원래 디폴트 값이기 때문에 사실 지정하지 않아도 된다. 
  • 20 : 인증이 성공하면 원래 접근하려고 했던 경로로 돌아가는 게 기본 동작이지만 처음부터 로그인 페이지를 요청한 경우라면 돌아갈 경로가 없으므로 이 때는 여기서 지정된 URL로 이동하게 된다.
  • 21 : 로그인 페이지에서 제공하는 username 파라메터의 이름이 무엇인지 지정한다. 여기서는 'email'이라는 이름의 파라메터를 사용하도록 지정하고 있다.
  • 22 : 로그인 페이지에서 제공하는 password 파라메터의 이름이 무엇인지 지정한다.
  • 23 : 로그인 페이지의 접근을 완전 허용하도록 설정한다.
  • 25 ~ 28 : 로그아웃 관련된 설정을 하고 있다. 기본 로그아웃 URL은 "/logout" 이다.
  • 26 : 로그아웃 경로를 지정한다. 위 예의 경우 "/logout"이 원래 디폴트 경로이기 때문에 사실 지정하지 않아도 된다.
  • 27 : 로그아웃 후에 이동할 경로를 지정한다.
  • 28 : 로그아웃 경로의 접근을 완전 허용하도록 설정한다.
  • 32 ~ 34 : 커스텀 인증을 구현한 (6 라인에서 Autowired로 주입된) authenticationProvider를 AuthenticationManagerBuilder에 AuthenticationProvider로서 추가한다.(UserAuthenticationProvider의 설명은 아래 계속.) 가끔 어떤 샘플에서는 이 메소드를 구현하지 않고 9라인의 configure() 메소드 내에서 http.authenticationProvider()를 이용하여 AuthenticationProvider를 설정하기도 하는데 테스트 결과 authenticate() 메소드가 두 번 호출되는 오동작이 있었다. 참고하기 바란다.
 이제 위에서 설명되지 않은 AuthenticationProvider를 추가할 차례이다. 아래와 같이 클래스를 만들어서 Spring이 scan할 수 있는 곳에 추가해 주도록 하자.

@Component
public class UserAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    UserService userService;

    @Override
    public Authentication authenticate(Authentication authentication) 
            throws AuthenticationException {
        String email = authentication.getName();
        String password = (String) authentication.getCredentials();

        UserVO userVO = userService.authenticate(email, password);
        if (userVO == null)
            throw new BadCredentialsException("Login Error !!");
        userVO.setPassword(null);

        ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
        return new UsernamePasswordAuthenticationToken(userVO, null, authorities);
    }

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

 그럼 간단하게 설명을 하도록 하겠다. 구체적인 인증 구현 방법은 상황에 따라서 알아서 구현하면 되고 이 예제에서는 인증의 성공, 실패시에 어떻게 처리하는지를 이해하는데 집중하도록 하자.

  • 1 ~ 2 : AuthenticationProvider를 상속하는 클래스를 하나 만들어준다. Autowired로 주입할 것이므로 scan이 될 수 있도록 @Component Annotation을 붙여주어야 한다.
  • 5 : 실질적으로 인증 로직을 구현하는 service 객체이다. 인증 로직은 각자 알아서 구현해야 하므로 따로 설명하지는 않겠다.
  • 8 ~ 21 : Spring Security가 인증을 수행하기 위해서 호출을 해주는 메소드이다. 이 메소드로 넘겨지는 authentication 인자를 이용해서 사용자의 입력값을 얻고 인증을 수행한다. 인증이 성공적인 경우 Authentication 인터페이스를 구현한 객체를 반환하고 실패한 경우 예외를 발생시키면 된다. 여기서 제시하는 로직은 사실 매우 부실하다. 하지만 어떤 동작을 해야 하는 것인지만 이해하면 어떻게든 응용가능하므로 인증의 성공과 실패에 대한 동작이 무엇인지에 대해서 확실히 하고 넘어가길  바란다.
  • 10 : 로그인 페이지에서 username 파라메터로 전송한 값을 얻어온다. 설정쪽 코드의 21라인에서 usernameParameter의 이름을 "email"로 설정했으므로 로그인 페이지에서 email 파라메터로 전송된 값이 여기서 얻어질 것이다.
  • 11 : 로그인 페이지에서 password 파라메터로 전송한 값을 얻어온다.
  • 13 : userService.authenticate()의 구체적인 코드는 여기서 따로 제공하지 않을 것이다. 여기에서는 userService.authenticate()가 인증을 수행한 후 인증이 성공적인 경우 등록된 사용자의 정보를 UserVO 객체에 담아서 반환한다고 가정하자. 인증이 실패한 경우에는 null을 반환한다고 가정한다.
  • 14 ~ 15 : 인증 실패 시 AuthenticationException 예외를 발생시키면 된다. 여기서는 AuthenticationException를 상속하는 BadCredentialsException을 발생시켰다. AuthenticationException을 상속하는 Exception은 여러가지가 있으므로 적당한 다른 Exception이 있다면 그것을 사용하면 되고 직접 AuthenticationException을 상속하는 클래스를 만들어서 사용해도 된다.
  • 16 : 반환된 UserVO 객체를 함수 마지막에 반환할 Authentication 객체의 pincipal 파라메터로 전달할 계획이므로 password 같은 민감한 정보는 삭제해두자. 나중에 설명하겠지만 이 객체는 다른 코드에서도 Security Context를 통해서 접근이 가능하기때문에 민감한 정보는 미리 삭제해 두는 것이다. 이 부분도 역시 예제일 뿐이므로 각자의 상황에 맞게 구현하면 된다.
  • 18 ~ 19 : 사용자 권한(authority) 정보를 만들어서 설정한다. 실제로는 DB 등의 여러가지 자원에서 가져와서 권한 정보를 만들어야 겠지만 여기서는 설정하는 방법을 보여주는 게 목적이므로 "ROLE_USER"라는 String을 직접 사용해서 설정하고 있다. 
  • 20 : Authentication 인터페이스를 구현한 UsernamePasswordAuthenticationToken 클래스를 생성해서 반환한다. 이 클래스는 Spring Security에서 제공되는 클래스인데 대부분의 경우에 사용하기 적당하다. UsernamePasswordAuthenticationToken을 생성할 때 첫 번째 인자(principal)로 userVO를 직접 설정했다.(16 라인에서 password 정보를 삭제한 이유가 여기서 파라메터로 전달하하면 다른 코드에서 접근이 가능하기 때문이다.) 흔히 principal 파라메터에 사용자 이름을 지정하는데 문자열 말고도 원하는 객체를 지정할 수 있으므로 참고하도록 하자. 두 번째 인자(credential)도 역시 인증이 완료된 후에는 지정하지 않는 게 보안상 안전하므로 null로 지정한다. 세 번째 인자는 18~19라인에서 생성한 authority list를 넘겨주면 된다.
  • 24 ~ 26 : 이 AuthenticationProvider가 지원하는 클래스를 판단하는 메소드이다. boilerplate 같은 코드이다. 그대로 사용하면 된다.
 Spring Security 설정과 인증 로직을 구현해 넣었으므로 이제 "/login" 요청을 처리할 수 있게 Controller에 Request Mapping을 하나 추가하도록 하자. 물론 기존에 이미 login 페이지를 가지고 있어서 Controller에 Request Mapping이 되어있었다면 그 경로를 Security 설정 코드에서 사용하면 된다.

@Controller
public class UserController {
    ...
    @GetMapping("/login")
    public String login(@ModelAttribute("loginForm") LoginForm loginForm, Model model) {
        return "login";
    }
    ...
}

 별로 특별할 건 없다. LoginForm 클래스 객체를 ModelAttribute로 사용하려고 한다는 것을 알 수 있다. LoginForm 클래스는 email, password를 property로 갖는 단순 DTO 객체이다.

 이제 접근이 허용되지 않은 경로를 요청하게되면 "/login"으로 리다이렉트 되어 여기로 요청이 들어오게 될 것이고 login.html (Thymeleaf 템플릿 파일)이 렌더링되어 보이게 될 것이다.

 그러면 간단하게 로그인 폼이 어떻게 생겼는지 보도록 하자. Thymeleaf 기준으로 대충 아래와 같은 코드면 된다. 실제로는 화려한 UI를 위해 더 복잡한 코드가 필요하겠지만 여기서는 이해하기 쉽도록 최대한 단순한 코드를 사용하겠다.

...
<form action="#" th:action="@{/authenticate}" method="post" th:object="${loginForm}">
  <div>
    Email: <input type="email" th:field="*{email}" autofocus />
  </div>
  <div>
    Password: <input type="password" th:field="*{password}" />
  </div>
  <div>
    <input type="submit" value="Log in" />
  </div>
  <!--<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>-->
</form>
...

 매우 간단한 폼이므로 이해가 어려운 부분은 없을 것이다. 이번에도 라인별로 간단히 설명해 보겠다.

  • 2 : form의 action 속성의 값(/authenticate)으로는 Security 설정할 때 login processing url의 값으로 지정했던 값(설정 코드의 18 라인)을 사용해야 한다. 여기서 한 가지 알아두어야 할 것은 반드시 th:action 속성을 사용하라는 것이다. th:action을 사용하면 Thymeleaf 엔진이 자동으로 CSRF를 위한 토큰값이 필요할 때 12번 라인의 주석과 같은 hidden 필드를 삽입해서 전송되도록 해준다. 만약에 th:action을 사용하지 않고 action 속성에 직접 "/authenticate"를 지정했다면 12라인의 주석을 해제해서 직접 hidden 필드를 넣어주어야 한다. 그리고 Thymeleaf에 익숙하다면 이미 알겠지만 Controller에서 ModelAttribute로 지정했던 "loginForm"이 th:object 속성에 지정되어야 하며 loginForm에는 email, password property가 있어야 한다.
  • 4 : E-mail 주소를 입력받는 input 필드. 설정 코드 21 라인에서 username 파라메터의 이름을 'email'로 지정했었음을 기억할 것이다. 이 필드의 값이 인증 시 username 값으로 사용될 것이다. 
  • 7 : Password를 입력받는 input 필드.
  • 10 : form의 submit 버튼.
  • 12 : 앞에서 설명한 것 처럼 form의 th:action 속성을 지정하면 Thymeleaf가 자동으로 전송해주기 때문에 현재는 주석처리가 되어있다. 만약 th:action을 사용하지 않는 경우라면 주석을 해제해서 직접 CSRF 토큰 전송을 위한 hidden 필드를 넣어주어야 한다. 이 경우 주석은 설명을 위해 남겨둔 것이며 당연히 없어도 된다. 기본적으로 Spring Security는 CSRF를 enable시킨 상태로 설정되어있다. CSRF가 enable 된 상태에서는 POST 요청을 할 때 위 예제처럼 hidden 필드를 넣어주어야 한다. CSRF를 disable 시켰다면 없어도 되지만 CSRF 공격에 대비하여 enable 시켜놓는게 안전하다. CSRF 공격이 무엇인지는 구글을 검색해 보도록 하자.
 위 예제에서 보여지는 것처럼 몇몇 필요한 값들과 설정만 있으면, 그리고 제대로 이해만 하고 있다면 로그인 창의 디자인은 얼마든지 자유롭게 해도 상관이 없다.

 이제 로그인을 성공해서 특정 페이지에 진입했을 때 페이지 내에서 (UserAuthenticationProvider.authenticate() 메소드가 반환했던) Authentication 객체에 접근하는 방법을 간단하게 보도록 하겠다. 아래 코드는 이 글 맨 처음에 언급한 대로 thymeleaf-extras-springsecurity4 모듈이 dependency에 추가되어 있어야 동작하므로 다시 한번 확인하길 바란다.

<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
...
<div sec:authorize="isAuthenticated()">
  Name: <span sec:authentication="name">SomeName</span><br />
  Principal: <span sec:authentication="principal">PrincipalString</span><br />
  Principal.email: <span sec:authentication="principal.email">Email</span><br />
  HasRole(USER): <span sec:authorize="hasRole('ROLE_USER')">YES</span><br />
  HasRole(ADMIN): <span sec:authorize="hasRole('ROLE_ADMIN')">YES</span>
</div>
<form action="#" th:action="@{/logout}" method="post">
  <input type="submit" value="Logout" />
  <!--<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>-->
</form>
...

 위 예제는 간단하게 Thymeleaf 템플릿에서 Security 관련하여 할 수 있는 것들을 보여주기 위한 예제 소스이다. 로그인 후에 위 정보들이 어떻게 표시될 지에 대해서 간단하게 설명을 해보자.

 일단 예제에서 보여지는 Security 관련 attribute는 "sec:authorize"와 "sec:authentication" 두 가지가 있다.

  • sec:authorize 속성은 th:if와 비슷한 역할을 한다. 값에 지정된 표현식을 평가하여 true인 경우에 해당 tag를 표시한다. 가능한 표현식은 위 예제에서 보듯이 매우 직관적으로 알 수 있을 것이다.
  • sec:authentication 속성은 AuthenticationProvider가 인증을 수행하고 반환했던 Authentication 객체의 특정 property값을 th:text 속성에 지정하는 것과 같은 일을 한다. 이전 예제에서 보여준 AuthenticationProvider의 authenticate() 함수에서는 UsernamePasswordAuthenticationToken객체를 반환했었으며 이 객체의 principal 파라메터로 UserVO 객체를 직접 지정했었다. 위 코드는 이 값을 조회하는 코드의 예이다. 
 그럼 각 라인에 대한 설명을 간단히 해보자.

  • 1 : spring security를 위한 thymeleaf 속성들의 namespace를 추가해주자. 
  • 3 : isAuthenticated()는 인증이 되었으면 true, 아니면 false를 반환한다. 즉 인증이 안된 상태라면 <div> 태그는 렌더링되지 않고 사라질 것이다.
  • 4 : Authentication 객체의 "name" property를 반환한다. 인증 함수가 반환했던 UsernamePasswordAuthenticationToken 클래스는 name property 값으로 생성자에 전달했던 principal 파라메터의 toString() 값을 반환하도록 구현되어있다. 따라서 우리는 이전 예제에서 UserVO 객체를 principal 파라메터로 지정했었으므로 그 객체의 toString() 값이 출력될 것이다. 자바 코드로 같은 값을 가져오려고 한다면 아래 코드를 참고하기 바란다.
    SecurityContextHolder.getContext().getAuthentication().getName()
  • 5 : 여기서는 "principal"을 직접 지정했으므로 principal 파라메터로 지정한 UserVO 객체가 반환될 것이고 결국 그 객체의 toString() 값이 출력될 것이다. 결국 4번 라인과 같은 값이 출력될 것이다. 자바코드로는 아래와 같다.
    SecurityContextHolder.getContext().getAuthentication().getPrincipal()
  • 6 : "principal.email"은 "principal"의 "email" property 값이 출력된다. 앞에서 얘기했듯이 principal 파라메터는 UserVO 객체이므로 UserVO객체의 email property가 출력될 것이다. 자바코드로는 아래와 같다.
    ((UserVO)SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getEmail()
    만약에 우리가 지정한 principal 객체에 email property가 없으면 예외가 발생하여 에러 페이지가 출력된다. 
  • 7 : hasRole('ROLE_USER') 는 authority 리스트에 'ROLE_USER'가 있는 경우에 true를 반환한다. 따라서 이 경우에는 'ROLE_USER' 권한이 있는 경우에만 span 태그가 표시되어 YES가 출력될 것이다. 
  • 8 : 'ROLE_ADMIN' 권한을 체크할 뿐 7번 라인과 기능은 같다.
  • 10 ~ 13 : 로그아웃 기능을 위한 <form> 예제이다. form의 action속성은 Security 설정코드에서 지정한 로그아웃용 경로를 사용해야 한다. 즉, 설정 코드 26라인에서 지정된 값이 여기서 action 값으로 사용되어야 한다. 여기서도 마찬가지로 CSRF가 enable 상태이면 logout도 post로 요청되어야 하며 form의 action값으로 th:action 속성을 이용해서 경로를 지정해야 한다. th:action을 사용하면 Thymeleaf 엔진에서 자동으로 12라인 주석과 같은 hidden 필드를 삽입해 줄 것이다. 만약 th:action을 사용하지 않았다면 12번 라인의 주석을 해제해서 직접 hidden 필드가 포함되도록 해야 한다. 종종 이 사실을 잊고 헤매는 경우가 있으므로 잊지말도록 하자. 
 위 예제가 모든 경우에 대한 예시를 제공하는 건 아니지만 위의 예제만 잘 이해하고 있으면 대부분의 경우를 커버할 수 있으므로 자세히 보고 익혀놓는 게 좋겠다.

 매번 Spring Security를 적용할 때 마다 꼭 블로그에 정리해 놓으리라 다짐만하고 실행하지 못했었는데 드디어 정리를 하게되서 매우 홀가분한 기분이다. 필요한 많은 사람들에게 도움이 되었으면 좋겠다.

댓글 1개:

  1. userService.authenticate()의 구체적인 코드는 여기서 따로 제공하지 않을 것이다. <-- ??

    답글삭제