Spring security/01.Security

07. 로그인/로그아웃 커스터마이징

  • -

이번 포스트에서는 로그인/로그아웃 과정을 커스터마이징 해보자.

로그인/로그아웃 과정

 

Form Login 절차

form login 절차: 출처: docs.spring.io

  1. 인증되지 않은 사용자가 secured resource인 /private 를 요청한다.
  2. Spring Security의 AuthenticationFilter는 AccessDeniedException을 던져서 요청 거부를 나타낸다.
  3. 예외를 ExceptionTranslationFilter가 받아서 AuthenticationEntryPoint 중 하나인 LoginUrlAuthenticationEntryPoint를 이용해 /login으로 redirection 보낸다.
  4. 브라우저가 /login을 요청하면
  5. LoginController가 login.html 을 응답한다. 따라서 프로그래머는 get 방식의 /login을 처리할 Controller를 작성하면 된다.

 

Username/Password를 통한 인증 절차

다음은 username과 password를 통한 인증 절차에 대해서 살펴보자.

Username과 Password 기반의 인증 절차: 출처: docs.spring.io

  1. 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 username/password를 추출하여 UsernamePasswordAuthenticationToken을 생성한다.
  2. 인증을 수행할 AuthenticationManager에게 token을 전달하여 인증을 처리한다.
  3. 인증에 실패하면 
    1. SecurityContextHolder를 정리한다.
    2. RememberMeService를 사용중이라면 RememberMeService.loginFail을 호출한다.
    3. AuthenticationFailureHandler를 호출한다. 이 객체는 사용자를 인증 페이지로 다시 redirect 시키는 일 등을 한다.
  4. 인증에 성공하면
    1. SessionAuthenticationStragety가 새로운 login 정보를 받는다. 내부적으로 세션을 확인하거나 세션 고정 공격을 방지하기 위해 세션 ID를 변경하는등의 작업을 수행한다.
    2. Authentication이 SecurityContextHolder에 설정된다.
    3. RememberMeService를 사용중이라면 RememberMeService.loginSuccess가 호출된다.
    4. ApplicationEventPublisher가 InteractiveAuthenticationSuccessEvent를 발행한다.
    5. AuthenticationSuccessHandler 중 하나인 SimpleUrlAuthenticationSuccessHandler가 ExceptionTranslationFilter에 의해 login 페이지로 redirect를 유발했던 경로로 redirect 한다.

 

Logout 아키텍쳐

@EnableWebSecurity를 사용하면 자동으로 GET /logout과 POST /logout에 대한 엔드포인트가 생성된다. GET /logout을 호출하면 로그아웃 확인 페이지가 표시된다. 이 화면은 기본적으로 사용자에게 이중 확인을 위한 메커니즘을 제공할 뿐 아니라 CSRF 토큰을 POST /logout에 제공하는 역할을 수행한다. 따라서 csrf 보호를 해지하면 GET /logout을 호출하지 않고 바로 로그아웃 된다.

<!-- 제공되는 logout.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Confirm Log Out?</title>
  </head>
  <body>
     <div class="container">
      <form class="form-signin" method="post" action="/logout">
        <h2 class="form-signin-heading">Are you sure you want to log out?</h2>
        <input name="_csrf" type="hidden" value="csrf_토큰" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Log Out</button>
      </form>
    </div>
  </body>
</html>

 

로그인/로그아웃 과정 커스터마이징

 

login 과정 커스터마이징을 위한 메서드들

login 과정을 커스터마이징 하기 위해서는 HttpSecurity#formLogin 메서드를 이용한다. 이 메서드의 파라미터는 Customer<T> 타입의 파라미터로 T는 FormLoginConfigurer<HttpSecurity> 타입인데 주요 메서드를 갖는다. 

Customizer<T>는 consumer 타입의 @FunctionalInterface로 void customizer(T t)를 가지므로 이 메서드에 전달되는 객체를 lambda 식으로 처리하는 방식으로 사용한다. 또는 withDefaults()메서드는 T가 가진 기본값을 사용하겠다는 의도이다. 
메서드명 설명 기본 값
loginPage() 로그인이 필요할 때 나타날 페이지 지정 /login
loginProcessingUrl() 로그인 처리 url (<form>의 action 속성) /login
defaultSuccessUrl() 로그인 성공시 이동 URL /
failureUrl() 실패 시 이동할 URL /login?error
usernameParameter() <form>에서 사용자 이름 필드의 이름 /username
passwordParameter() <form>에서 사용자 비밀번호 필드의 이름 /password

특별히 기본 값을 재정의 할 필요 없다면 그대로 둬도 된다. 하지만 사용자 지정의 login 페이지를 사용하려면 loginPage()를 설정해두어야 한다. withDefault()를 사용하면 DefaultLoginPageGeneratingFilter등이 동작해서 기본 url이 /login이라고 하더라도 @Controller를 타지 않는다.

 

logout 과정 커스터마이징을 위한 메서드들

logout을 커스터마이징 하는 과정도 login과 동일하다. logout이 파라미터로 갖는 LogoutConfigurer<HttpSecurity>는 다음의 메서드를 갖는다.

메서드명 설명 기본 값
invalidateSession() 세션을 모두 초기화 한다. true
logoutUrl() 로그아웃의 경로를 설정한다. /logout
logoutSuccessUrl() 로그아웃이 성공한 경우 이동하는 URL 설정 /login?logout
deleteCookies() 삭제할 쿠키의 이름들 설정  

 

403 forbidden 커스터마이징을 위한 메서드들

권한이 없는 페이지에 대한 접근에서 페이지에 대한 커스터마이징을 해보자. 이 때는 exceptionHandling 메서드에 제공되는 ExcepionHandlingConfigurer<HttpSecurity>의 메서드를 재정의한다.include

메서드명 설명 기본 값
accessDeniedPage() 403 오류가 발생했을 때 호출할 페이지 /error

 

로그인 정보의 확인

로그인에 성공하면 Spring Security는 사용자 정보를 Authentication 객체에 담아 SecurityContext에 저장하게 된다. 이 SecurityContext는 HttpSession에 SPRING_SECURITY_CONTEXT라는 이름으로 저장된다.

하지만 보다 직관적으로 사용자 정보를 받아볼 수 있는데 @Controller에서 @AuthenticationPrincipal 애너테이션과 함께 UserDetails를 받으면 된다.

@GetMapping({ "/", "/index" })
public String home(Model model, HttpSession session, @AuthenticationPrincipal UserDetails userDetails) {
    model.addAttribute("type", "home");
    Object user = session.getAttribute("SPRING_SECURITY_CONTEXT");
    Optional.ofNullable(user).ifPresent(u -> {
        SecurityContext securityContext = (SecurityContext) u;
        Object principal = securityContext.getAuthentication().getPrincipal();
        // 간단히 @AuthenticalPrincipal을 사용하자
        System.out.println(principal == userDetails);
    });
    return "index";
}

 

코드 적용

이제까지의 내용을 코드에 반영해보자. 일단 화면에서 로그인 정보를 얻기 위해서는 세션에서 SPRING_SECURITY_CONTEXT를 가져와서 principal의 정보를 확인한다.

<header>
  <h1>Welcome Doding's Spring Security</h1>
  <!-- 로그인 상태일 때 표시 -->
  {{#SPRING_SECURITY_CONTEXT.authentication.principal.username}}
  <div>
    <span>환영합니다. {{.}}님</span>
    <a href="/logout">로그아웃</a>
  </div>
  {{/SPRING_SECURITY_CONTEXT.authentication.principal.username}}
  <hr />
</header>

이제 필요한 곳에 {{>page}} 구문을 이용해서 위 header를 include 시킨다.

<!-- /template/index.html-->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body>
    {{>include/header}}
    <h1>지금 타입: {{type}}</h1>
    ...
  </body>
</html>

다음으로 로그인을 위한 화면을 만들어보자. login 과정에서 failureUrl()이나 logout 과정에서 logoutSuccessUrl()의 기본 값은 각각 /login?error, /login?logout이다. request 영역에 상황에 error와 logout 속성이 있을 경우 각각 메시지를 출력해보자. 

<!-- /templates/user/login.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    {{>include/header}}
    <!---->
    {{#error}}
    <div style="color: red">잘못된 아이디나 비밀번호입니다.</div>
    {{/error}} {{#logout}}
    <div style="color: green">로그아웃되었습니다.</div>
    {{/logout}}
    <!-- 로그인되지 않은 경우에만 표시 -->
    {{^SPRING_SECURITY_CONTEXT}}
    <form action="/login" method="post">
      <label for="username">아이디:</label>
      <input type="text" id="username" name="username" required />
      <label for="password">비밀번호:</label>
      <input type="password" id="password" name="password" required />
      <button type="submit">로그인</button>
    </form>
    {{/SPRING_SECURITY_CONTEXT}}

    <!-- 로그인된 경우에만 표시 -->
    {{#SPRING_SECURITY_CONTEXT}}
    <div>이미 로그인 되어있습니다.</div>
    {{/SPRING_SECURITY_CONTEXT}}
  </body>
  </body>
</html>

 

@Controller에서는 @AuthenticationPrincipal을 통해 로그인한 사용자 정보를 확인할 수 있다. 또한 login 시 Model에 /login?error와 /login?logout 속성이 전달될 때 관련 메시지를 전달해보자.

@Controller
@Slf4j
public class HomeController {
    @GetMapping({ "/", "/index" })
    public String home(Model model, @AuthenticationPrincipal UserDetails userDetails) {
        model.addAttribute("type", "home");
        Optional.ofNullable(userDetails).ifPresent(u -> {
            log.debug("사용자명: {}, 권한: {}", userDetails.getUsername(), userDetails.getAuthorities());
        });        
        return "index";
    }

    @GetMapping("/login")
    public String login(Model model, @RequestParam(name = "error", required = false) String error,
            @RequestParam(name = "logout", required = false) String logout) {
        Optional.ofNullable(error).ifPresent(e -> model.addAttribute("error", "ID/비밀번호를 확인하세요."));
        Optional.ofNullable(logout).ifPresent(l -> model.addAttribute("logout", "로그아웃되었습니다."));

        return "user/login";
    }

    @GetMapping("/accessdenied")
    public String accessdenied(Model model) {
        return "/error/accessdenied";
    }
}

logout은 페이지 연결이 없기 때문에 특별한 요청 처리 메서드가 불필요하다.

추가로 HttpSecurity에서 csrf에 대한 체크를 불가능하게 처리하자. 나중에 켜긴 할껀데 지금은 너무 번거롭기 때문이다.

@Bean
public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf.disable());
    http.authorizeHttpRequests(requests -> requests
      .requestMatchers("/secured/user").authenticated()
      .requestMatchers("/secured/staff").hasRole("STAFF")
      .requestMatchers("/secured/admin").hasRole("ADMIN")
      .anyRequest().permitAll());
      // http.formLogin(Customizer.withDefaults());
      http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll());
      http.exceptionHandling(ex -> ex.accessDeniedPage("/secured/access-denied"));
 
    return http.build();
}

 

동작 확인

동작의 확인은 단위테스트로 대체한다.

public class SecurityWebTest {
    @Autowired
    MockMvc mockMvc;

    @Test
    @DisplayName("인증없는 상태에서 여러 엔드포인트 접근 테스트")
    void 인증없는_사용자_엔드포인트_접근() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/"))
                .andExpect(MockMvcResultMatchers.status().isOk());
        mockMvc.perform(MockMvcRequestBuilders.get("/secured/user"))
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login"));
    }

    @Test
    @DisplayName("STAFF 권한자는 USER와 STAFF 엔드포인트 접근가능하며 ADMIN 엔드포인트 접근불가")
    @WithMockUser(roles = "STAFF")
    void STAFF_권한자_접근_테스트() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/secured/user"))
                .andExpect(MockMvcResultMatchers.status().isOk());
        mockMvc.perform(MockMvcRequestBuilders.get("/secured/staff"))
                .andExpect(MockMvcResultMatchers.status().isOk());
        mockMvc.perform(MockMvcRequestBuilders.get("/secured/admin"))
                .andExpect(MockMvcResultMatchers.status().isForbidden());
    }

    @Test
    @DisplayName("login/logout 수정 후 테스트 - 인증 이전")
    void 인증된_사용자_접근_테스트() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/secured/staff"))
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login"));
    }

    @Test
    @DisplayName("로그인에 실패하면 /login?error로 redirect되고 redirect된 페이지에서 메시지 확인하기")
    void 인증_처리_테스트_로그인실패() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/login")
                .param("username", "doding")
                .param("password", "wrong"))
                .andDo(MockMvcResultHandlers.print()) // 실행 결과를 콘솔에 출력
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrl("/login?error"));

        mockMvc.perform(MockMvcRequestBuilders.get("/login").param("error", ""))
                .andExpect(MockMvcResultMatchers.model().attribute("error", "ID/비밀번호를 확인하세요."));
    }

    @Test
    @DisplayName("로그인 성공한 사용자의 로그아웃 처리")
    void 로그인성공_로그아웃처리() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/login")
                .param("username", "doding")
                .param("password", "1234"))
                .andDo(MockMvcResultHandlers.print()) // 실행 결과를 콘솔에 출력
                .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
                .andExpect(MockMvcResultMatchers.redirectedUrl("/"));

        mockMvc.perform(MockMvcRequestBuilders.post("/logout"))
                .andExpect(MockMvcResultMatchers.redirectedUrl("/login?logout"));

        mockMvc.perform(MockMvcRequestBuilders.get("/login").param("logout", ""))
                .andExpect(MockMvcResultMatchers.view().name("user/login"))
                .andExpect(MockMvcResultMatchers.model().attribute("logout", "로그아웃되었습니다."));
    }

    @Test
    @WithMockUser(roles = "STAFF")
    void 인증성공후_인가에_실패한_경우() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/secured/admin"))
                .andExpect(MockMvcResultMatchers.forwardedUrl("/secured/access-denied"));
    }
}

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.