07. 로그인/로그아웃 커스터마이징
- -
이번 포스트에서는 로그인/로그아웃 과정을 커스터마이징 해보자.
로그인/로그아웃 과정
Form Login 절차
- 인증되지 않은 사용자가 secured resource인 /private 를 요청한다.
- Spring Security의 AuthenticationFilter는 AccessDeniedException을 던져서 요청 거부를 나타낸다.
- 예외를 ExceptionTranslationFilter가 받아서 AuthenticationEntryPoint 중 하나인 LoginUrlAuthenticationEntryPoint를 이용해 /login으로 redirection 보낸다.
- 브라우저가 /login을 요청하면
- LoginController가 login.html 을 응답한다. 따라서 프로그래머는 get 방식의 /login을 처리할 Controller를 작성하면 된다.
Username/Password를 통한 인증 절차
다음은 username과 password를 통한 인증 절차에 대해서 살펴보자.
- 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 username/password를 추출하여 UsernamePasswordAuthenticationToken을 생성한다.
- 인증을 수행할 AuthenticationManager에게 token을 전달하여 인증을 처리한다.
- 인증에 실패하면
- SecurityContextHolder를 정리한다.
- RememberMeService를 사용중이라면 RememberMeService.loginFail을 호출한다.
- AuthenticationFailureHandler를 호출한다. 이 객체는 사용자를 인증 페이지로 다시 redirect 시키는 일 등을 한다.
- 인증에 성공하면
- SessionAuthenticationStragety가 새로운 login 정보를 받는다. 내부적으로 세션을 확인하거나 세션 고정 공격을 방지하기 위해 세션 ID를 변경하는등의 작업을 수행한다.
- Authentication이 SecurityContextHolder에 설정된다.
- RememberMeService를 사용중이라면 RememberMeService.loginSuccess가 호출된다.
- ApplicationEventPublisher가 InteractiveAuthenticationSuccessEvent를 발행한다.
- 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"));
}
}
'Spring security > 01.Security' 카테고리의 다른 글
09. UserDetails와 UserDetailsService 커스터마이징 (0) | 2022.11.22 |
---|---|
08. JDBC를 이용한 인증 (0) | 2022.11.22 |
06. 사용자 및 경로 관리 (0) | 2022.11.18 |
05. 프로젝트 구성과 초기 동작 (0) | 2020.08.20 |
04. Servlet Authorization Architecture (0) | 2020.07.16 |
소중한 공감 감사합니다