Spring Security

[Spring Security] 로그인/로그아웃 구현 및 타임리프 적용

코더 제이콥 2023. 7. 5. 16:25

1. 개요

엑트와 같은 클라이언트 사이드 렌더링일 경우 JWT 토큰을 사용해 로그인 처리를 했겠지만, 저같은 경우 서버 사이드 렌더링인 타임리프를 사용해 JWT 토큰 사용은 곤란했습니다. 따라서 시큐리티의 세션으로 로그인 로그아웃을 구현했고, formLogin을 사용했습니다.

2. 설정 정보

전체 설정 코드

아래는 SecurityConfig 설정입니다. 저는 스프링 부트 3.1, 스프링 시큐리티 6.1 버전을 사용하고 있습니다.

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

  private final MemberDetailsService memberDetailsService;
  private static final int ONE_MONTH = 2678400;

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
      CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
      requestHandler.setCsrfRequestAttributeName("_csrf");
      return http
              .csrf(csrf -> csrf
                  .csrfTokenRequestHandler(requestHandler)
                  .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                  .ignoringRequestMatchers("/", "/account/login/**", "/logout/**", "/register/validate/email")
              )
              .authorizeHttpRequests(request -> request
                      .requestMatchers("/cart").hasRole("MEMBER")
                      .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                      .anyRequest().permitAll()
              )
              .formLogin(customizer -> customizer
                      .loginPage("/account/login")
                      .loginProcessingUrl("/account/login")
                      .usernameParameter("email")
                      .successHandler(new LoginSuccessHandler("/"))
                      .failureHandler(new LoginFailHandler())
                      .permitAll()
              )
              .rememberMe(customizer -> customizer
                      .rememberMeParameter("remember-me")
                      .tokenValiditySeconds(ONE_MONTH)
                      .userDetailsService(memberDetailsService)
                      .authenticationSuccessHandler(new LoginSuccessHandler())
              )
              .logout(customizer -> customizer
                      .logoutUrl("/logout")
                      .logoutSuccessUrl("/")
                      .deleteCookies("remember-me")
                      .permitAll()
              )
              .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
              .httpBasic(Customizer.withDefaults())
              .build();
    }


    @Bean
    public PasswordEncoder passwordEncoder (){
        return new BCryptPasswordEncoder();
    }
}

코드 설명

CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName("_csrf");
return http
        .csrf(csrf -> csrf
            .csrfTokenRequestHandler(requestHandler)
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .ignoringRequestMatchers("/", "/account/login/**", "/logout/**", "/register/validate/email")
        )

csrf 공격 설정입니다. 세션 쿠키로 로그인을 구현하지 않는 Restful API에서 JWT 토큰을 이용해 로그인을 구현한다면 해당 설정을 disable해도 무방합니다. 다만 위에서 언급한 것처럼, 저와 같이 타임리프와 같은 뷰 템플릿(서버 사이드 렌더링)을 사용한다면 설정해야 합니다.

  • .csrfTokenRequestHandler(requestHandler) : CsrfTokenRequestAttributeHandler 인터페이스를 구현하면 추가 작업을 수행하거나 어플리케이션에서 토큰을 사용할 수 있는 방법을 커스텀할 수 있습니다.
  • .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) : 앵귤러나 리엑트와 함께 사용하면 withHttpOnlyFalse() 옵션을 사용해야 합니다. 쿠키의 http only 설정에 대해 검색해보시길 바랍니다.

참고
1. https://docs.spring.io/spring-security/site/docs/current-SNAPSHOT/api/org/springframework/security/web/csrf/CsrfTokenRequestHandler.html
2. https://docs.spring.io/spring-security/site/docs/4.2.15.RELEASE/apidocs/org/springframework/security/web/csrf/CookieCsrfTokenRepository.html

.authorizeHttpRequests(request -> request
        .requestMatchers("/cart").hasRole("MEMBER")
        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
        .anyRequest().permitAll()
)
  • .requestMatchers("/cart").hasRole("MEMBER") : 로그인 시 MEMBER 역할이 있어야 해당 url에 접근할 수 있습니다.
  • .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() : PathRequest.toStaticResources().atCommonLocations()을 사용하면, resources의 static에서 다음과 같은 경로 파일들을 모두 다 허용합니다. 경로는 다음과 같습니다. /css/**, /js/**, /images/**, /webjars/**, /favicon.*, /*/icon-*
  • .anyRequest().permitAll() : 위에서 해당 역할의 회원만 접근 가능한 url을 설정하고, 설정되지 않은 모든 url은 로그인하지 않아도 접근할 수 있습니다.

역할과 관련한 설정 때문에 Authentication Provider를 보여드려야 할 거 같아 제가 구현한 AuthenticationProvider 클래스를 보시겠습니다.

@RequiredArgsConstructor
@Service
public class ReadingBooksAuthenticationProvider implements AuthenticationProvider {
    private final MemberDetailsService memberDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String email = authentication.getName();
        UserDetails memberDetails = memberDetailsService.loadUserByUsername(email);

        String rawPassword = authentication.getCredentials().toString();
        String hashPassword = memberDetails.getPassword();
        checkPassword(rawPassword, hashPassword);

        return new UsernamePasswordAuthenticationToken(email, rawPassword, memberDetails.getAuthorities());
    }

    private void checkPassword(String rawPassword, String hashPassword) {
        boolean isPasswordMatch = passwordEncoder.matches(rawPassword, hashPassword);
        if(isPasswordMatch == false){
            throw new LoginCheckFailException();
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

MemberDetailsService에서 loadUserByUsername을 통해 아이디에 대한 검증을 시작합니다. 다음으로 PasswordEncoder를 통해 비밀번호 검증을 시작합니다. 검증을 완료하면 Authentication 인터페이스를 구현한 인증 객체인 UsernamePasswordAuthenticationToken에 정보들을 담습니다.

@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(()-> new LoginCheckFailException());

        return new MemberDetails(member);
    }
}

MemberDetailsService입니다. UserDetailsService는 아이디에 관련한 검증만 실행합니다. 이메일에 해당하는 계정이 없으면 LoginCheckFailException을 던집니다.

public class LoginCheckFailException extends AuthenticationException {
    public LoginCheckFailException() {
        super("회원가입하지 않았거나 이메일 또는 비밀번호를 다시 확인해주세요.");
    }
}

관련 예외입니다. 기본 생성자를 생성하면 자동으로 위의 메세지가 초기화하도록 설정했습니다. AuthenticationException을 상속받은 이유는 나중에 로그인 실패 핸들러에서 사용하기 위해 해당 예외를 상속받았습니다. 참고로 AuthenticationException은 RuntimeException을 상속 받습니다.

.formLogin(customizer -> customizer
        .loginPage("/account/login")
        .loginProcessingUrl("/account/login")
        .usernameParameter("email")
        .successHandler(new LoginSuccessHandler("/"))
        .failureHandler(new LoginFailHandler())
        .permitAll()
)
  • .loginPage("/account/login") : 로그인 페이지 url 입니다. 예를 들어 로그인하지 않은 상태에서 /cart url에 접근하려고 할 때 /account/login으로 리다이렉트 됩니다. 디폴트는 /login 입니다.
  • .loginProcessingUrl("/account/login") : 폼 태그의 action, ajax의 url 보낼 때 /account/login으로 보내도록 설정했습니다.
  • .usernameParameter("email") : 저는 아이디가 email이었기 때문에 email로 했습니다.
  • .successHandler(new LoginSuccessHandler()) .failureHandler(new LoginFailHandler()) : 로그인 성공 또는 실패 시 핸들러를 실행하는 설정입니다.
@Slf4j
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        super.clearAuthenticationAttributes(request);

        RequestCache requestCache = new HttpSessionRequestCache();
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if(savedRequest != null){
            String url = savedRequest.getRedirectUrl();
            if(url == null || url.equals("")){
                url = "/";
            }
            if(url.contains("/register")){
                url = "/";
            }
            if(url.contains("/account/login")){
                url = "/";
            }
            requestCache.removeRequest(request, response);
            getRedirectStrategy().sendRedirect(request, response, url);
        }
        super.onAuthenticationSuccess(request, response, authentication);
    }
}

로그인 성공 시 핸들러입니다. 로그인 성공 시 이전 url로 리다이렉트 하기 위해 위와 같이 설정했습니다.

@Slf4j
public class LoginFailHandler extends SimpleUrlAuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        String message = getMessage(exception);
        String redirectUrl = "/account/login?hasMessage=true&message=" + message;
        setDefaultFailureUrl(redirectUrl);
        super.onAuthenticationFailure(request, response, exception);
    }

    private static String getMessage(AuthenticationException exception) throws UnsupportedEncodingException {
        String message = exception.getMessage();
        String encodeMessage = URLEncoder.encode(message, "UTF-8");
        return encodeMessage;
    }
}

로그인 실패 시 핸들러입니다. 저같은 경우 로그인을 실패하면 AuthenticationException을 상속 받은 LoginCheckFailException 던졌습니다.

메소드 스펙을 봅시다. onAuthenticationFailure 메소드는 파라미터로 AuthenticationException을 받습니다. 이것이 제가 LoginCheckFailException을 AuthenticationException으로 상속받은 이유입니다. AuthenticationException만 로그인 실패 핸들러를 통해 예외 처리를 할 수 있기 때문입니다.

예외를 던질 때 생성자에 왜 로그인이 실패했는지 메세지를 담았는데, 해당 메세지를 꺼내서 url의 쿼리 파라미터로 던졌습니다. LoginCheckFailException은 위에서 설명했으므로 넘어가겠습니다. 컨트롤러를 보겠습니다.

@Controller
@RequiredArgsConstructor
@Slf4j
public class LoginViewController {
    @GetMapping("/account/login")
    public String login(@RequestParam(required = false) boolean hasMessage,
                        @RequestParam(required = false) String message,
                        Model model){

        model.addAttribute("hasMessage", hasMessage);
        model.addAttribute("message", message);
        return "login/login";
    }

모델에 hasMessage와 message를 담고 뷰 템플릿을 반환했습니다.

<form action="/account/login" method="post" name="login">
    <div class="top">
        <input type="email" class="form-control mb-1 p-3" name="email" id="email" placeholder="이메일을 입력해주세요."/>
        <input type="password" class="form-control mb-1 p-3" name="password" id="password" placeholder="비밀번호를 입력해주세요"/>
        <div class="remember-box p-1">
            <input type="checkbox" class="form-check-input" name="remember-me" id="remember-me">
            <label for="remember-me" class="form-label">로그인 상태 유지</label>
        </div>
    </div>
    <div class="middle d-flex flex-column">
        <div class="message" th:if="${hasMessage == true}" th:text="${message}">
        </div>
        <button type="button" class="login btn btn-primary form-control p-3 mb-1" onclick="signin()">로그인</button>
    </div>
    <div class="bottom">
        <a href="">아이디 찾기</a>
        <a href="">비밀번호 찾기</a>
        <a href="/account/register">회원가입 하기</a>
    </div>
</form>

제가 구현한 폼 태그의 일부입니다.

<div class="middle d-flex flex-column">
    <div class="message" th:if="${hasMessage == true}" th:text="${message}">
    </div>
    <button type="button" class="login btn btn-primary form-control p-3 mb-1" onclick="signin()">로그인</button>
</div>
  • th:if="${hasMessage == true}" th:text="${message}" : hasMessage가 true일 때 message를 출력했습니다.

이렇게 예외의 메세지를 쿼리 파라미터로 보내 로그인 실패 메세지를 구현했습니다.

스프링 시큐리티의 formLogin을 사용하면, 로그인의 성공 여부 상관 없이 페이지를 무조건 리다이렉트 합니다. 로그인 실패 시 쿼리 파라미터를 추가해서 리다이렉트하도록 했고, 해당 쿼리 파라미터를 컨트롤러에서 모델에 담아 렌더링 시 위와 같이 메세지를 뜨게 한 것입니다.

.rememberMe(customizer -> customizer
        .rememberMeParameter("remember-me")
        .tokenValiditySeconds(ONE_MONTH)
        .userDetailsService(memberDetailsService)
        .authenticationSuccessHandler(new LoginSuccessHandler())
)

로그인 상태 유지 기능입니다.

  • .rememberMeParameter("remember-me") : 디폴트 설정은 remember-me입니다. 디폴트 설정이라 기입하지 않아도 되지만, 가독성 때문에 기입했습니다.
  • .tokenValiditySeconds(ONE_MONTH) : 자동 로그인 기간 설정입니다. 초 단위이며, 저 같은 경우 31일로 설정했습니다.
  • .userDetailsService(memberDetailsService) : 해당 설정은 무조건 설정해야 합니다. 저는 제가 만든 MemberDetailsService를 재활용했습니다. 관련 설정은 위에서 언급했으니 참조해주세요.
  • .authenticationSuccessHandler(new LoginSuccessHandler()) : 인증 성공 시 핸들러입니다. 추가 로직을 따로 구현하셔도 되지만, 저같은 경우 아까 위에서 작성한 LoginSuccessHandler를 재활용했습니다.
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
  • addFilterAfter() : BasicAuthenticationFilter가 작업을 완료하면 로그인 작업이 끝납니다. 제가 작성한 CsrfCookieFilter는 BasicAuthenticationFilter가 실행된 이후 작업을 실행합니다.
@Slf4j
public class CsrfCookieFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        if(csrfToken.getHeaderName() != null){
            response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
        }
        filterChain.doFilter(request, response);
    }
}

CsrfToken을 헤더에 보내면 스프링 시큐리티가 자동으로 해당 토큰과 동일한 쿠키를 생성해서 반환합니다.

3. 타임리프 구현

csrf 설정에 따른 프론트 엔드에서 신경 써야 하는 것들

타임리프 구현입니다. 세션 쿠키로 로그인을 구현했기 때문에, CSRF 공격에 대비해야 합니다. 따라서 클라이언트인 타임리프에서의 모든 백엔드 요청은 csrf 토큰을 담아야 합니다. form 태그로 요청할 경우 hidden 태그의 value로 담고, ajax일 경우 헤더 설정을 통해 토큰을 넣어야 합니다.

<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>

우선 기본 설정입니다. 저는 모든 html의 헤더에 해당 코드를 삽입했습니다.

<input type="hidden" id="csrfToken" name="_csrf" th:value="${csrfToken}">

form 태그로 값을 보내는 경우 위와 같이 hidden 태그에 설정해주세요.

히든 태그의 name이 _csrf라면 submit 될 때 별도의 인증 로직을 구현하지 않더라도, 스프링 시큐리티가 알아서 csrf 토큰 인증 로직을 처리해줍니다.

타임리프에서 랜더링 되면 위와 같이 value에 값이 찍힙니다. 토큰의 value가 맞는지 확인해보겠습니다.

780ce 시작으로 hidden태그의 값과 이미지의 값이 동일한 것을 확인할 수 있습니다.

다음으로 ajax입니다. ajax를 사용하는 경우, 다음과 같이 설정했습니다. 회원가입 버튼을 클릭하면 동작하는 함수입니다. jquey를 사용했습니다.

$('#register').on("click", function (){
    ... 로직 생략

    const token = $("meta[name='_csrf']").attr("content");
    const header = $("meta[name='_csrf_header']").attr("content");

    const email = $('#email').val();
    const password = $('#password').val();
    const passwordConfirm = $('#passwordConfirm').val();
    const name = $('#name').val();
    const birthYear = $('#birthYear').val();
    const gender = $('#gender').val();

    const data = {
        "email" : email,
        "password": password,
        "passwordConfirm" : passwordConfirm,
        "name" : name,
        "birthYear" : birthYear,
        "gender": gender
    }

    $.ajax({
        type: "post",
        url: "/register",
        data: data,
        beforeSend : function(xhr) {
            xhr.setRequestHeader(header, token);
        },
        success: function (data){
            console.log(data);
            alert(data.message)
            location.replace("/");
        },
        error: function (data){
            console.log(data);
        }
    });
});

회원가입 로직입니다.

const token = $("meta[name='_csrf']").attr("content");
const header = $("meta[name='_csrf_header']").attr("content");

위에서 설정한 헤더의 메타 정보를 기억하시나요? 거기서 설정했던 헤더의 정보를 가져오는 코드입니다.

beforeSend : function(xhr) {
    xhr.setRequestHeader(header, token);
},

ajax의 beforeSend 설정을 통해 헤더의 값을 토큰의 value로 지정합니다.

로그인, 로그아웃 버튼 토글 구현

설정

로그인 시 로그아웃 버튼이 보이게하고, 로그아웃 시 로그인 버튼이 보이게 하고 싶었습니다. 구현을 위해 블로그 검색 중 타임리프의 spring-security dependency를 추가해야 이 기능을 수월하게 구현할 수 있었습니다. 따라서 다음의 dependency를 추가해야 합니다.

https://mvnrepository.com/artifact/org.thymeleaf.extras/thymeleaf-extras-springsecurity6
https://mvnrepository.com/artifact/org.thymeleaf.extras/thymeleaf-extras-springsecurity5

본인의 스프링 시큐리티 사용 버전에 따라 링크를 클릭하시면 되겠습니다. 저는 시큐리티 6.1을 사용하기 때문에 thymeleaf-extras-springseucirty6를 추가했습니다.

링크를 클릭하시면 버전이 뜨실 텐데, 본인의 스프링 부트에 맞는 스프링 시큐리티 버전을 선택하시면 되겠습니다. 본인 스프링 부트에 맞는 버전 정보를 원하시면 아래 링크를 클랙해주세요.
https://docs.spring.io/spring-boot/docs/3.1.0/reference/html/dependency-versions.html#appendix.dependency-versions

https://docs.spring.io/spring-boot/docs/**사용\_스프링부트\_버전**/reference/html/dependency-versions.html#appendix.dependency-versions

들어가시면 ctrl+f를 통해 extras를 검색하시고 내려보시면 springsecurty에 대해 나올겁니다. 제 스프링부트 3.1 버전에서는 thymeleaf-extras-springsecurity6의 3.1.1이 가장 잘 호환된다고 나옵니다. 따라서 저는 3.1.1 버전을 dependency에 추가했습니다.

implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE'

구현

이제 로그인 시 로그아웃 버튼이 보이게 하고, 로그아웃 시 로그인 버튼이 보이도록 구현해보겠습니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
... 생략
  <th:block sec:authorize="isAnonymous()">
    <!-- 인증 받지 않음 -->
    <a href="/account/login" class="btn btn-outline-grey">로그인</a>
    <a href="/account/register" class="btn btn-primary">회원가입</a>
  </th:block>

  <th:block sec:authorize="isAuthenticated()">
    <!-- 인증 받음 -->
    <a href="/logout" class="icons me-2 ms-2">로그아웃</a>
  </th:block>

해당 설정을 통해 로그인, 로그아웃 토글 기능을 구현했습니다.

  • sec:authorize="isAnonymous()" : 로그인하지 않은 상태일 때 보여줍니다.
  • sec:authorize="isAuthenticated()" : 로그인한 상태일 때 보여줍니다.

혹시 jsp를 사용하신다면 다음 글을 참조하시면 좋을 거 같습니다.
https://okky.kr/questions/325838

제 글이 도움이 되셨으면 좋겠습니다. 감사합니다.