~☆~ 우하하!!~ 개발블로그

[연재] SpringBoot diary - Spring Security remember-me, logout 처리 재정리 본문

SpringBoot

[연재] SpringBoot diary - Spring Security remember-me, logout 처리 재정리

iwoohaha 2024. 11. 15. 10:36
728x90
반응형

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">&nbsp;</div>
                    <div sec:authentication="principal.username"></div>
                    <div th:width="10">&nbsp;</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">&nbsp;</div>
                    <div sec:authentication="principal.username"></div>
                    <div th:width="10">&nbsp;</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 옵션을 사용하는 방법과 그에 대한 처리 방법, 로그아웃을 처리하는 방법에 대해서 다시 정리해봤어.


반응형