개발블로그

Authentication/Authorization 기능 구현(2) - interceptor vs spring security 본문

Spring

Authentication/Authorization 기능 구현(2) - interceptor vs spring security

개발자수니 2019. 1. 24. 00:00

이 포스트에서는 Spring-security Authentication의 구조적 흐름을 살펴보고 Authentication/Authorization 기능을 구현하겠습니다. 

 

[1] Spring-security Authentication Structure

Spring-security는 많은 Filter의 chain으로 구성되어 있습니다. 따라서 요청이 오면 Authentication/Authorization을 위해 일련의 Filter를 거치게 됩니다. 사용자 인증 요청시에는 인증 모델을 기반으로 적합한 Filter를 찾을 때까지 Filter chain을 통과합니다. 

이 포스트에서 사용한 로그인 양식 인증 요청은 UsernamePasswordAuthenticationFilter에 도달할 때까지 Filter chain을 통과합니다. 

 

UsernamePasswordAuthenticationFilter를 AuthenticationFilter라고 칭할 것이며 이 Filter의 동작 방식을 그림과 함께 살펴보겠습니다.

 

 

 

 

[request with invalid session] : 인증하기 전, 인증이 필요한 화면에 요청

1. Spring-security를 이용하면 모든 요청은 Session을 발급받습니다. (Session을 발급받으면 클라이언트의 쿠키에 JSESSIONID라는 키로 SessionID가 저장됩니다. 이에 대한 내용은 HttpSession은 언제 만들어질까? 를 참고해주세요.) AuthenticationFilter는 해당 요청의 JSESSIONID(SessionID)를 확인하여 JSESSIONID와 매핑되는 인증 정보(Authentication)가 Security Context에 있는지 판단합니다. JSESSIONID(SessionID)에 매핑되는 Authentication이 Security Context 내에 없으므로 LoginPage로 이동시킵니다. 

 

[request for authentication with username/password] : 로그인 양식으로 인증을 요청. 

2. AuthenticationFilter는 기본적으로 로그인 폼으로부터 오는 데이터를 username과 password로 인식하고 있습니다. 따라서 로그인 폼의 input name도 username/password로 맞춰줘야 합니다. 

1
2
3
4
5
<form action="/login" method="post">
        <input type="text" id="username" name="username"/>
        <input type="password" id="password" name="password"/>
        <button type="submit">Log in</button>
</form>
cs

 

또한 1라인의 action을 "/login"으로 입력했는데, 이것 역시 Spring-security가 default로 설정한 MappingURL입니다. 따라서 action을 맘대로 바꿔버리면 Spring-security의 인증 요청으로 받아들여지지 않습니다. 

AuthenticationFilter는 입력받은 username, password를 이용해 UsernamePasswordAuthenticationToken을 만듭니다. 이 클래스는 Authentication 인터페이스의 구현체입니다. 이것을 AuthenticationToken이라고 통칭하겠습니다. 그리고 AuthenticationToken에 있는 username, password가 유효한 계정인지 판단하기 위해 AuthenticationManager에게 위임합니다. 

 

3. AuthenticationManager는 기 등록한 AuthenticationProvider들을 연쇄적으로 실행시킵니다. AuthenticationProvider의 구현체에서는 다음과 같은 작업이 필요합니다. 

  1) AuthenticationToken에 있는 계정 정보가 유효한지 판단하는 로직 (DB로부터 조회)

  2) 계정 정보가 유효하다면 유저의 상세 정보(이름, 나이 등 필요한 정보)를 이용해 새로운 UserPasswordAuthenticationToken을 발급 

 

4, 5. 새롭게 발급받은 AuthenticationToken을 Security Context에 저장합니다. 

 

이 그림에는 포함되어있지 않지만, 계정 정보가 유효하다면 AuthenticationFilter는 AuthenticationSuccessHandler에 따라 요청을 redirect시킵니다.

 

[request with valid sessionId] : 인증 후, 인증이 필요한 화면에 요청

6. AuthenticationFilter가 해당 요청의 JSESSIONID(SessionID)와 매핑되는 AuthenticationToken이 있는지 판단합니다. 존재하면 해당하는AuthenticationToken을 반환받아 이후 요청 흐름에 포함시킵니다. 따라서 Controller, Service, jsp에서 이 AuthenticationToken 정보를 사용할 수 있습니다. 

(Spring MVC Lifecycle에서 알 수 있듯이, 요청은 Filter chain을 통과 후 DispatcherServlet에 의해 전달됩니다. 이에 대한 내용은  이전 포스트를 참고하세요)

 


 

[2] 구현 방법

이전 포스트에서 구현한 Authentication/Authorization 기능을 Spring-security를 적용하여 구현하도록 하겠습니다. 

(1) Maven dependency추가 

1
2
3
4
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
</dependency>
cs

 

 

(2) Spring-security config

보안 흐름을 정의하기 위해서 WebSecurityConfigurerAdapter를 확장하여, 설정을 추가합니다.

◆ 어떤 요청에 대해서 인증을 요구할 것인지

◆ 특정 요청에 대해서 어떤 권한을 요구할 것인지

◆ 인증되지 않은 요청을 어떤 url로 redirect시킬지

◆ 로그인 성공 후 어느 화면으로 이동시킬지

◆ 로그아웃시 어떤 작업을 수행시킬지 

◆ 403에러에 대해 어떻게 처리할지

◆ Authentication 유효성을 어떻게 판단할지 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Autowired
    private AuthProvider authProvider;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/""/main").permitAll()
                .antMatchers("/admin/*").hasAuthority("ADMIN")
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .usernameParameter("userId"
                .successHandler(new LoginSuccessHandler("/home")) 
                .permitAll()
                .and()
            .logout()
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .permitAll()
                .and()
            .authenticationProvider(authProvider)
            .exceptionHandling().accessDeniedHandler(accessDeniedHandler());
 
    }
}
cs
 

11라인 : authorizeRequest() 요청에 대한 인증/권한 설정을 담당합니다.

12라인 : "/", "main" 요청은 인증 체크를 하지 않습니다.

13라인 : "admin/*" 에 해당하는 요청은 "ADMIN" 권한이 있어야 접근 가능합니다. 

14라인 : 위에서 정의한 요청을 제외한 모든 요청에 대해서 인증을 요구합니다.

 

16라인 : 로그인 관련 설정을 담당합니다.

17라인 : 아직 인증 전 상태이며 인증을 요구하는 요청이라면 "/login" 으로 redirect시킵니다.

18라인 : 앞서 말했듯 Spring-security는 기본적으로 로그인 폼으로부터 오는 데이터를 username과 password로 인식하고 있습니다. 따라서 jsp에서 input의 name을 username/password로 일치시켜주어야 합니다.

1
2
3
4
5
<form action="/login" method="post">
        <input type="text" id="username" name="username"/>
        <input type="password" id="password" name="password"/>
        <button type="submit">Log in</button>
</form>
cs

이를 변경하길 원한다면, usernameParameter, passwordParameter 설정을 해주어야 합니다. 필자는 username대신 loginId로 명명했기 때문에 usernameParameter 설정을 했습니다.

<form action="/login" method="post">
        <input type="text" id="loginId" name="loginId"/>
        <input type="password" id="password" name="password"/>
        <button type="submit">Log in</button>
</form>

19라인 : 인증에 성공했을 때 어떤 URL로 Redirect시킬지 정의합니다. 이를 위해 AuthenticationSuccessHandler를 구현했고, 자세한 내용은(3)AuthenticationSuccessHandler 구현에 있습니다.

22라인 : 로그아웃 관련 설정을 담당합니다.

23라인 : 로그아웃 요청이 오면 Session을 무효화합니다.

24라인 : 로그아웃 요청이 오면 키-JSESSIONID로 저장된 쿠키를 제거합니다.

27라인 : 로그인 폼으로부터 오는 데이터가 유효한 계정 정보인지를 판단하기 위해 AuthenticationProvider를 구현했고, 자세한 내용은 (4)AuthenticationProvider 구현에서 다루겠습니다.

28라인 : 인증에는 성공했으나 권한이 맞지 않을 경우 실행될 Handler를 등록합니다. 이는 (5)AccessDeniedHandler 구현에서 다루겠습니다. 

 

(3)AuthenticationSuccessHandler 구현

인증 성공 후에 Redirect시킬 URL을 설정하기 위해 AuthenticationSuccessHandler를 구현해야 합니다. Spring-security는 AuthenticationSuccessHandler의 구현체인 SavedRequestAwareAuthenticationSuccessHandler를 내장하고 있습니다. 

따라서 config에서 별도의 successHandler를 지정해주지 않으면 SavedRequestAwareAuthenticationSuccessHandler를 실행합니다. 이를 사용하면 인증 성공 후 원래 요청했던 URL로 redirect시켜줍니다. 그리고 원래 요청했던 URL이 없다면, "/"(defaultTargetUrl)로 redirect합니다. 

그러나 필자는 defaultTargetUrl을 다르게 설정하기 위해 SavedRequestAwareAuthenticationSuccessHandler를 구현한 LoginSuccessHandler를 작성했습니다. 

(안타깝게도 SavedRequestAwareAuthenticationSuccessHandler는 defaultTargetUrl을 매개변수로 가지는 생성자를 보유하지 않아 custom AutenticationSuccessHandler를 구현해야만 했습니다.)

 

1
2
3
4
5
6
7
8
9
10
/**
 * @author sujin
 */
public class LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
 
    public LoginSuccessHandler(String defaultTargetUrl) {
        setDefaultTargetUrl(defaultTargetUrl);
    }
}
 
cs
 

 

 

(4) AuthenticationProvider 구현

실제로 인증을 수행하기 위해 AuthenticationProvider를 구현한 AuthProvider를 작성했습니다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
 
        String userId = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();
 
        User user = userService.findByUserId(userId);
        if(user == null) {
            throw new UsernameNotFoundException(userId);
        }
        
        if (!matchPassword(password, user.getPassword())) {
            throw new BadCredentialsException(userId);
        }
 
        if (!user.isEnabled()) {
            throw new BadCredentialsException(userId);
        }
        
        ArrayList<GrantedAuthority> auth = new ArrayList<GrantedAuthority>();
        auth.add(new SimpleGrantedAuthority(user.getAuthority()));
        return new UsernamePasswordAuthenticationToken(user, password, auth);
    }
cs

2라인 : [1]Spring-security Authentication structure의 그림에서 봤듯이, 파라미터로 전달받은 Authentication은 AuthenticationFilter에 의해 생성되고, 로그인 폼으로부터 입력받은 loginId/password로 생성한 AuthenticationToken입니다.

4라인 : loginId는 AuthenticationToken의 principal에 저장되어 있습니다.

5라인 : password는 AuthenticationToken의 credentials에 저장되어 있습니다. 

7라인 : Mongodb로부터 조회해 유효한 사용자인지 확인합니다. 이때 사용되는 User DTO는 필자가 정의한 Custom DTO입니다. 

8라인~18라인 : 유효성을 판단하여 유효하지 않으면 Exception을 발생시킵니다. 여기서 사용된 UsernameNotFoundException과 BadCredentialsException은 모두 AuthenticationException입니다. 

앞서 AuthenticationManager가 등록된 AuthenticationProvider들을 chain으로 실행한다고 언급했었는데, AuthenticationException이 발생하면 Exception을 전파하지 않고 chain에 엮여있는 다음 AuthenticationProvider를 실행합니다. 

(이에 대한 구현 내용은 AuthenticationManager를 구현하는 ProviderManager에서 확인할 수 있습니다. ProviderManager는 Spring-security의 내장 객체입니다.)

22라인 : 새로운 AuthenticationToken을 만듭니다. 파라미터로 받은 AuthenticationToken과의 차이를 살펴보겠습니다. 

  1) principal의 정보가 확장되었습니다. (loginId -> Mongodb로부터 조회한 User 정보)

  2) 권한 목록을 3번째 파라미터에 추가합니다. 이는 권한 체크를 할 때 사용됩니다. 

 

 

(5) AccessDeniedHandler 구현 

인증에 성공했으나 권한이 적합하지 않을 경우 다음과 같이 403에러 화면이 노출됩니다. 

이는 사용자 입장에서 시스템의 에러처럼 느껴질 수 있으므로 일관적인 UI를 유지시켜야 합니다. 이런 경우에 요청을 redirect시키기 위해 AccessDeniedHandler를 구현했습니다. 이 Handler의 handle method는 AccessDeniedException이 발생했을 때 실행됩니다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exc)
            throws IOException, ServletException {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null) {
            LOGGER.info("User: " + auth.getName() + " attempted to access the protected URL: " + request.getRequestURI());
        }
 
        response.sendRedirect(request.getContextPath() + "/accessDenied");
    }
}
cs

 

MappingURL "/accessDenied"에 대한 컨트롤러와 Jsp를 추가하여 다음과 같은 화면으로 대체되어 노출됩니다.

 

 

(6) jsp에서 AuthenticationToken 정보 조회

pom.xml에 spring-security-taglibs 패키지를 추가합니다. 

1
2
3
4
<dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-taglibs</artifactId>
</dependency>
cs

 

jsp 상단에 추가한 taglib를 import합니다.

1
<%@ taglib uri="http://www.springframework.org/security/tags" prefix="sec" %>
cs

그러면 taglib를 이용해 다음과 같이 AuthenticationToken 정보를 조회할 수 있습니다.

1
<sec:authentication property="principal.userId"/>

cs

 

전체 소스는 Github을 참고해주세요.

 

Comments