diary 프로그램에서 사용중인 로그인 폼은 Boot Strap 예시에서 가져온거야.
가운데에 Remember me 라는 체크박스가 있는데, 이번 포스트에서는 이 체크박스에 기능을 연결해보려고 해.
Remember me 라는 체크박스를 체크해두면 일단 한번 로그인한 후에 일정시간 동안에는 별도로 로그인을 하지 않고도 백그라운드에서 로그인처리되게 해서 매번 로그인하지 않고도 이용할 수 있어. 지금은 이 기능이 구현되어 있지 않기 때문에 로그인을 한 후에 브라우저를 종료시켰다가 다시 접속하면 다시 로그인을 해야 하지.
Spring Security 는 RememberMe 기능을 포함하고 있는데, 우선 아래 코드를 볼께.
SecurityConfig.java
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
//.csrf(csrf -> csrf.disable())
.csrf((csrf) -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// swagger 에서는 PUT, POST, DELETE method 를 위해서 필수(필수아님)로 기록해야 한다.
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/process_login"
, "/signup"
).permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").hasRole("USER")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/process_login")
.usernameParameter("email")
.failureHandler(customAuthFailureHandler)
.permitAll()
)
.rememberMe(rememberme -> rememberme
.alwaysRemember(true)
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.deleteCookies("remember-me")
)
.httpBasic(Customizer.withDefaults())// swagger 에서는 필수로 기록해야 한다.
.build();
}
...
alwaysRemember 함수에 true 값을 전달해서 rememberme 기능이 항상 동작하도록 설정해봤어.
이 상태에서 diary 프로그램에 접속해보면 로그인화면이 표시되고 개발자도구를 통해서 Cookie 목록을 살펴보면 JSESSIONID 라는 이름의 쿠키(값:5D301A4D46304039A9EF8DAC39B1FFE7)가 발급되어 있는 것을 확인할 수가 있어.
이 상태에서 로그인을 해볼께.
JSESSIONID 값이 7686CECDDEB2472C51A52908D23333C9 으로 변경되었고, 이전에는 보이지 않았던 remember-me 라는 이름의 쿠키가 생성된 것을 확인할 수가 있어. (그 값은 d29vaGFoYSU0MGdtYWlsLmNvbToxNzEyMzc0MjY5NDExOlNIQTI1Njo5NDFhZGU4ODY2MDI0YTUwM2I0Nzk0OTFhODBiODQ5OWM5MGE2OTBiMmMyZDNlNTY4ZTdhNzA3Y2E3YzY0OTBl 이야.)
이 쿠키가 바로 로그인을 유지시켜주기 위한 쿠키야. 이 상태에서 웹브라우저를 종료했다가 다시 diary 프로그램에 접속하더라도 이 쿠키에 의해서 로그인 화면 표시과정이 생략되고(백그라운드로 로그인처리가 되는거야) 바로 로그인한 후의 화면으로 이동할 수 있게 되지.
위 스크린샷은 웹브라우저를 종료했다가 다시 띄워서 diary 에 접속한 상태인데, JSESSIONID 는 변경되었는데, remember-me 토큰값은 유지되고, 로그인 화면도 표시가 되지 않았어.
앞에서 추가했던 코드에서 alwaysRemember 필드의 속성값은 기본값이 false 야. 그래서 이번에는 alwaysRemember 필드의 속성값을 별도로 설정하지 않고, 로그인 화면에 얹어 둔 Remember me 체크박스를 사용하도록 수정해볼께.
SecurityConfig.java
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
//.csrf(csrf -> csrf.disable())
.csrf((csrf) -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// swagger 에서는 PUT, POST, DELETE method 를 위해서 필수(필수아님)로 기록해야 한다.
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/process_login"
, "/signup"
).permitAll()
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").hasRole("USER")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/process_login")
.usernameParameter("email")
.failureHandler(customAuthFailureHandler)
.permitAll()
)
.rememberMe(rememberme -> rememberme
// .alwaysRemember(true)
.rememberMeParameter("remember-me")
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.deleteCookies("remember-me")
)
.httpBasic(Customizer.withDefaults())// swagger 에서는 필수로 기록해야 한다.
.build();
}
...
위 코드에서 새로 추가한 rememberMeParameter 필드의 기본값은 “remember-me” 지만, 코드의 가독성을 위해서 기록해봤어.
이제 프로그램을 재실행시키면 로그인화면이 표시될거야. Remember me 체크박스를 체크한 상태에서 로그인을 해볼께.
로그인했더니
remember-me 쿠키가 생성된 것을 볼 수가 있어.
그런데 이 과정에서 아주 중요한 사실이 하나가 있어. rememberMeParameter 필드의 속성값으로 설정한 remember-me 로그인 화면에서 Remember me 체크박스의 name 속성값이어야 한다는 점과 Remember me 체크박스의 value 속성값은 true 이거나 on 이거나 yes 이거나 1 이어야 한다는 사실이야.
내가 설명에 포함시키지 않고 아래와 같이 로그인폼 소스를 고쳐두었었지.
login.html
...
<main class="form-signin w-100 m-auto">
<th:block th:if="${error}">
<div th:if="${message}" class="alert alert-danger" role="alert" th:text="${message}"></div>
</th:block>
<form th:action="@{/process_login}" method="POST">
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<div class="form-floating">
<input type="email" class="form-control" id="floatingInput" name="email" placeholder="name@example.com">
<label for="floatingInput">Email address</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPassword" name="password" placeholder="Password">
<label for="floatingPassword">Password</label>
</div>
<div class="form-check text-start my-3">
<input class="form-check-input" type="checkbox" value="yes" name="remember-me" id="flexCheckDefault">
<label class="form-check-label" for="flexCheckDefault">
Remember me
</label>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
<a href="/signup">
<button class="btn btn-success w-100 py-2" type="button">Sign up</button>
</a>
</div>
<p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
</form>
</main>
...
부트스트랩에서 제공하는 예시의 소스는 이렇게 되어 있었지.
<input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
즉, name 속성이 존재하지 않았었고, value 속성값도 remember-me 라고 설정되어 있었어. 이런 상태였기 때문에 아무리 테스트를 해도 remember-me 라는 이름의 쿠키가 생성이 되질 않더라고. 결국 LoginService 의 loadUserByUsername 함수에 BreakPoint 를 걸어놓고 하나씩 쫒아가다보니 아래와 같은 코드를 발견하고 나서야 알게되었어.
AbstractRememberMeServices.class
...
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (this.alwaysRemember) {
return true;
} else {
String paramValue = request.getParameter(parameter);
if (paramValue == null || !paramValue.equalsIgnoreCase("true") && !paramValue.equalsIgnoreCase("on") && !paramValue.equalsIgnoreCase("yes") && !paramValue.equals("1")) {
this.logger.debug(LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter));
return false;
} else {
return true;
}
}
}
...
어쨌든 추상화 기본 클래스의 로직에 따라 Remember Me 기능이 기본값대로 동작하게 되었어.
이제 몇 가지 옵션을 더 설정해볼건데, 발급된 토큰의 유효시간과 토큰을 생성할 때 사용하는 키값이야.
...
.rememberMe(rememberme -> rememberme
.rememberMeParameter("remember-me")
.tokenValiditySeconds(3600)// 60분(=60초*60분)
.key("!#yraid#!")
)
...
tokenValiditySeconds 에 설정하는 값은 초 단위의 값으로서 remember-me 토큰의 유효시간이야. 즉, 발급된지 3600초(60분==1시간)가 지나면 이 토큰을 계속 사용할 수가 없으므로 자동 로그인되지 못하고 로그인 화면이 표시되는거야.
key 에 설정하는 값은 remember-me 토큰을 생성할 때 사용할 키의 값이야. 이 값은 암호화된 토큰값이 유효한지를 검증할 때 다시 사용되지. 이 값이 중간에 변경된다면, 유효한 시간의 토큰이 클라이언트에 있더라도 암호화된 값이 불일치하게 되기 때문에 모두 초기화가 되어버리는거야.
이제 마지막으로 Remember Me 기능을 사용했을 때 로그아웃 처리하는 로직을 재정리하는 차원에서 살펴볼께.
기존에 로그아웃 버튼에 대한 처리를 /logout URL 을 호출하는 방법(GET METHOD)으로 코딩했었어.
navigator.html
...
<th:block sec:authorize="isAuthenticated()">
<!-- 인증 받음 -->
<div th:width="10"> </div>
<div sec:authentication="principal.username"></div>
<div th:width="10"> </div>
<a href="/logout">
<button class="btn btn-primary w-100 py-2" type="submit">Logout</button>
</a>
</th:block>
...
그래서 /logout URL 에 대한 GET Method 핸들러가 필요했었지.
LoginController.java
...
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login";
}
}
사실 이 상태까지만 구현된 상태라면 아래 코드는 불필요했었어.
SecurityConfig.java
...
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.deleteCookies("remember-me")
)
...
이번에 Remember Me 기능을 구현하면서 remember-me 라는 이름의 쿠키가 새롭게 추가되었잖아. 그래서 logout 할 때 이 쿠키를 삭제하지 않는다면, 현재 구현된 상태에서는 반복적으로 로그아웃 -> 로그인 이 되고 말거야.
그래서 logout URL 핸들러에 쿠키 삭제를 위한 코드를 추가해주었어.
LoginController.java
...
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
Cookie[] cookies = request.getCookies();
for (Cookie c : cookies) {
Cookie cookie = new Cookie(c.getName(), null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
return "redirect:/login";
}
}
이렇게 수정하고 나면 로그아웃으로 쿠키를 모두 삭제해주게 되므로 로그인이 반복 수행되는 일은 없어지는거야.
그럼 이번에는 Spring Security 에 자체 구현되어 있는 로그아웃 로직을 태워보도록 할께.
Spring Security 에서 로그아웃은 GET Method 가 아닌 POST Method 로 보내지도록 구현되어 있어.
navigator.html 에서 작성한 로그아웃 버튼에 대한 처리를 아래와 같이 변경해볼께.
navigator.html
...
<th:block sec:authorize="isAuthenticated()">
<!-- 인증 받음 -->
<div th:width="10"> </div>
<div sec:authentication="principal.username"></div>
<div th:width="10"> </div>
<!-- <a href="/logout">-->
<!-- <button class="btn btn-primary w-100 py-2" type="submit">Logout</button>-->
<!-- </a>-->
<form th:action="@{/logout}" method="post">
<button class="btn btn-primary w-100 py-2" type="submit">Logout</button>
</form>
</th:block>
...
기존 GET METHOD 방식의 /logout URL 호출을 POST METHOD 방식으로 변경했기 때문에 LoginController 클래스에 구현되어 있는 logout 함수는 동작하지 않을거야.
SecurityConfig.java 에서 아래 코드도 주석처리하고 나서 프로그램을 실행시켜볼께.
SecurityConfig.java
...
// .logout(logout -> logout
// .logoutUrl("/logout")
// .logoutSuccessUrl("/login")
// .invalidateHttpSession(true)
// .deleteCookies("remember-me")
// )
...
Remember me 에 체크를 하고 로그인한 상태에서 로그아웃하면 remember-me 토큰이 정상적으로 지워지는 걸 확인할 수가 있어. 즉, Spring Security 에서 기본적으로 제공하는 로그아웃은 POST method 를 사용하고 있는거지.
logoutUrl 의 기본값이 “/logout” 으로 설정되어 있기 때문에 이 URL 로 로그아웃이 전송되게 해주기만 하면 되는거야.
만약에 로그아웃 후에 표시될 URL (/logoutdone)이 별도로 존재한다면 SecurityConfig 에서 아래처럼 작성해주면 되겠어.
SecurityConfig.java
...
.logout(logout -> logout
.logoutSuccessUrl("/logoutdone")
)
...
물론 /logoutdone 에 대한 URL 핸들러와 허용 목록에 추가 작업이 필요하겠지.
LoginController.java
...
@GetMapping("/logoutdone")
@ResponseBody
public String logoutdone() {
return "로그아웃 성공";
}
}
SecurityConfig.java
...
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/process_login"
, "/signup"
, "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**"
, "/logoutdone"
).permitAll()
//.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").hasRole("USER")
.anyRequest().authenticated()
)
...
지금까지 Remember Me 옵션을 사용하는 방법과 그에 대한 처리 방법, 로그아웃을 처리하는 방법에 대해서 다시 정리해봤어.