Spring Security

Ryu·2023년 3월 6일
0

[ 개요 ]


왜 Spring Security 를 사용했나 ?

팀 프로젝트에서 처음으로 Spring Security를 적용해보았고, 이에 대해 정리해보려 한다.

우선, Spring Security는 ‘인증’ 과 ‘인가’와 같은 보안에 대해 체계적으로 많은 옵션들을 제공하기 때문에, 이를 사용하면 보안과 관련한 로직을 하나하나 작성하지 않아도 되는 장점이 있다.

비즈니스 상황

본 프로젝트에서는, ‘관리자’가 로그인을 해서 ‘매장’을 관리하는 기능이 있기 때문에, 로그인 및 권한 부여가 반드시 필요한 상황이었다.

참고). 프로젝트 주소

다음은 Spring Security를 적용한 실제 팀 프로젝트다.

https://github.com/yoodongan/jumun

[ SecurityConfig - 1. SecurityFilterChain, 로그인 기능 구현 ]


우선, SecurityConfig라는 클래스를 통해 Spring Security 설정 정보들을 관리한다. 아래 코드는 실제 프로젝트에서 사용한 Spring Security 코드로, 순서대로 정리해보려 한다.

설정 클래스만 설명해도 내용이 방대하기 때문에, 최대한 필터 체인 메서드들을 기준으로 여러 파트로 나누어 설명하겠다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
@AutoConfigureAfter(SecurityAutoConfiguration.class)
public class SecurityConfig{

    private final OwnerSecurityService ownerSecurityService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .csrf().disable()

                .formLogin()
                .loginPage("/admin/login")
                .successHandler(new LoginAuthHandler())

                .and()
                .rememberMe()
                .rememberMeParameter("remember-me")
                .key("key")
                .alwaysRemember(false)
                .tokenValiditySeconds(86400 * 30) // 1달
                .userDetailsService(ownerSecurityService)

                .and()
                .authorizeRequests()
                .mvcMatchers("/","/css/**","/scripts/**","/plugin/**","/fonts/**").permitAll()
                .antMatchers("/admin/login", "/admin/new", "/admin/store", "/admin/store/new").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")

                .and()
                .logout()
                .logoutUrl("/admin/logout")
                .logoutSuccessUrl("/admin/login")
                .deleteCookies("JSESSIONID", "remember-me")
                .invalidateHttpSession(true);

        return httpSecurity.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> { web.ignoring().antMatchers("/resources/**"); };
    }
}

.csrf().disable()로 CSRF 기능을 비활성화 한 이유

  • CSRF(Cross-Site Request Forgery)는 사전적 의미로 사이트 간 위조 요청을 의미한다.
    즉, 인터넷 사용자가 자신의 의지와는 무관하게 공격자(해커)가 의도한 행위를 특정 웹사이트에 요청하도록 만드는 공격이다.
  • 보안을 위해 @EnableWebSecurity 를 적용하면 자동으로 csrf 기능이 활성화된다. 그럼에도 이를 비활성화 한 이유는, form 태그로 요청 시 csrf 속성이 추가되어 서버 쪽에서 만들어 준 form 태그로만 요청이 가능해지기 때문이다.
    • 즉, 이로 인해 POSTMAN을 사용한 request 가 불가능해지기 때문에, 일반적으로 개발단계에서는 .csrf.disable()을 많이 사용한다.

WebSecurityConfigurerAdapter ? SecurityFilterChain ?

포스팅이 오래된 블로그 글들을 살펴보면, WebSecurityConfigurerAdapter 를 상속받아서 WebSecurityConfig라는 커스텀 클래스를 만들어주는 경우가 있다. (configure()메서드로 보안 구성을 진행)
그러나 이 방식은 Spring Security 5 버전부터 사용하지 않도록 권장되었다.

다음은 실제 Spring 에서 작성한, WebSecurityConfigurerAdapter 를 추천하지 않는다는 문구다.

In Spring Security 5.7.0-M2 we deprecated the WebSecurityConfigurerAdapter, as we encourage users to move towards a component-based security configuration.

Spring Security 5 이후 도입된 새로운 보안 구성 방법이 바로 SecurityFilterChain이다.

필요한 수만큼의 SecurityFilterChain 을 생성할 수 있어서 보다 세밀한 구성이 가능하다는게 특징이다.

  • 참고로, HTTP 요청의 처리에 참여하는 여러 개의 필터들의 체인으로 구성된다.각 필터는 보안과 관련된 다양한 작업을 수행한다.

AuthenticaionSuccessHandler

위에서 작성한 SecurityFilterChain 을 반환하는 메서드를 살펴보면, 다음과 같이 successHandler()를 처리하는 부분에 LoginAuthHandler를 생성하는 것을 확인할 수 있다.

이는 인증에 성공했을 때, 실행할 로직을 정의하는 인터페이스이다.
인증 성공 이후, 로그인 이전 페이지로 리다이렉트하거나 특정 로직을 수행하는 등의 작업 수행이 가능하다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
            .csrf().disable()

            .formLogin()
            .loginPage("/admin/login")
            .successHandler(new LoginAuthHandler())
						...

LoginAuthHandler 는 다음과 같이 구현했으며, AuthenticationSuccessHandler 를 구현했다.

@Slf4j
public class LoginAuthHandler implements AuthenticationSuccessHandler {

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

        HttpSession httpSession = request.getSession(true);
        httpSession.setAttribute("ownerId", authentication.getName());

        log.info("ownerId가 세션에 저장되었습니다. ownerId = {}", httpSession.getAttribute("ownerId"));

        response.sendRedirect("/admin/store");
    }
}
  • 세션에 “ownerId” 를 key로 하고, 인증된 유저명을 값으로 하도록 저장한다.

  • 실제로 로그를 통해 출력해보면 다음과 같다.

    관리자 명을 test 라고 설정하니, 실제로 다음과 같이 ownerId = test 로 로그가 출력되는 것을 확인할 수 있다.

  • 이후 response.sendRedirect()를 통해 로그인 시 해당 경로로 리다이렉팅 해준다.

참고). SimpleUrlAuthenticationSuccessHandler 를 사용해 AuthenticationSuccessHandler를 구현한 클래스를 바로 사용할 수도 있다.

  • 이 역시 onAuthenticationSuccess() 를 통해 인증이 성공하면 호출되는 동작을 정의할 수 있다.

[ SecurityConfig - 2. RememberMe 토큰, 로그아웃 기능 구현 ]


.and()
.rememberMe()
.rememberMeParameter("remember-me")
.key("key")
.alwaysRemember(false)
.tokenValiditySeconds(86400 * 30) // 1달
.userDetailsService(ownerSecurityService)

.rememberMe()

  • 로그인 폼에서 ‘기억하기’ 체크박스를 선택하면, .rememberMeParameter("remember-me") 를 통해 토큰의 파라미터 명으로 설정한 “remember-me”가 전송된다.
  • key(”key”) 를 통해 rememberMe 에 필요한 ‘토큰’을 생성할 때 사용되는 키를 지정할 수 있다.
  • alwaysRember(false)
  • 실제 토큰의 유효기간을 ‘1달’로 지정한 것을 확인할 수 있다. 이 기간 동안 Remember Me 토큰이 유효하며, 사용자는 토큰을 통해 자동으로 인증이 가능하다.
  • Remember Me 토큰을 사용해 자동 로그인을 하기 위해서는, 사용자 인증 정보가 필요하다.
    userDetailsService()를 통해 사용자의 인증 정보를 가져올 수 있다. ownerSecurityService는 UserDetailsService를 구현한 커스터마이징 클래스로, 아래에서 더 설명하겠다.
.and()
.authorizeRequests()
.mvcMatchers("/","/css/**","/scripts/**","/plugin/**","/fonts/**").permitAll()
.antMatchers("/admin/login", "/admin/new", "/admin/store", "/admin/store/new").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")

.and()

  • Spring Security에서 여러 설정 메서드들을 통해 연결할 수 있는데, 이전 설정을 종료하고 다음 설정을 시작하고 싶을 때 사용한다.

.authorizeRequests()

  • 요청에 대한 인가 설정을 시작하는 메서드이다. 이후의 메서드 호출들은 요청에 대한 인가 규칙을 추가한다.
  • .mvcMatchers(…).permitAll()을 통해 기본 css, scripts, plugin, fonts 들에 대한 접근을 허용하고 있다. 주로 정적 자원에 대한 요청을 허용할 때 사용한다.
  • .antMatchers(…).permitAll() 을 살펴보면, /admin 이 붙어있는 것을 확인할 수 있다. 해당 부분은 관리자 영역이며, “ADMIN”역할만 가진 사용자만 접근이 가능하다. 즉, 이를 통해 ‘관리자(사장님)’만 매장 관리에 접근할 수 있는 것이다.
    • .antMatchers("/admin/login", "/admin/new", "/admin/store", "/admin/store/new").permitAll() 이 부분은 관리자가 로그인 하기 이전에, 보여져야 하는 페이지이기 때문에, permitAll()로 항상 허용을 해야 한다!
    • .antMatchers("/admin/**").hasRole("ADMIN") 이 부분을 통해 바로 ‘권한’을 부여한다. 위의 경로를 제외한 /admin 이 붙은 모든 경로들에 대해 “ADMIN”역할이 있어야만 접근 가능하도록 설정한다.

.mvcMatchers().antMatchers() 로 나눈 이유 ?

  • 사실 두 메서드 모두 특정 경로에 대한 권한을 부여하는 역할을 수행하기 때문에 하나만 사용해도 가능하지만, 이렇게 2가지로 나누어서 사용하게 되면 코드의 의도가 더욱 명확해진다.(가독성 증가)

로그아웃 기능 구현, .logout() 사용하기

.and()
.logout()
.logoutUrl("/admin/logout")
.logoutSuccessUrl("/admin/login")
.deleteCookies("JSESSIONID", "remember-me")
.invalidateHttpSession(true);
  • .logout() 을 통해 로그아웃 설정을 시작할 수 있다.
  • .logoutUrl("/admin/logout") 은 로그아웃 요청을 처리하는 경로이다.
  • .logoutSuccessUrl("/admin/login") 은 로그아웃 성공 시, 해당 페이지로 리다이렉팅 한다는 뜻이다. 다시 로그인 페이지를 보여준다.
  • .deleteCookies("JSESSIONID", "remember-me") 를 통해 로그아웃 시 삭제할 쿠키의 이름을 지정한다.
    • JSESSIONID, Remember Me 토큰으로 지정한 “remember-me”쿠키가 삭제된다.
    • JSESSIONID는 세션 식별자로 사용되는 쿠키로, 클라이언트와 서버 간 세션을 식별하는 데 사용된다.
    • remember-me는 위에서 다룬 기억하기 기능과 관련된 쿠키이다. 이것 덕분에, 사용자가 로그인 후에도 장기간 인증 상태를 유지할 수 있었다.
  • .invalidateHttpSession(true);
    • 로그아웃 시 현재 사용자의 HTTP 세션을 무효화한다. true로 설정 시, 세션을 무효화한다.

[ UserDetailsService ]


다음은 관리자 owner 패키지 안에 구현한 OwnerSecurityService 이다. 여기서 UserDetailsService를 구현한다.

@Service
@RequiredArgsConstructor
public class OwnerSecurityService implements UserDetailsService {

    private final OwnerRepository ownerRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Optional<Owner> loginOwner = ownerRepository.findByLoginId(username);

        if (loginOwner.isEmpty()) {
            throw new OwnerNotFoundException("관리자를 찾을 수 없습니다.");
        }

        Owner owner = loginOwner.get();
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ADMIN"));

        return new User(owner.getLoginId(), owner.getPassword(), authorities);
    }
}

UserDetails

  • Spring Security에서 사용자의 정보를 담는 인터페이스이다.
  • 기본 오버라이딩 메서드로 getAuthorities(), getPassword(), getUsername() 등 다양한 메서드들을 지원한다.

MemberContext 또는 CustomUserDetails

  • 기본 UserDetails로는 실무에서 필요한 정보를 모두 담을 수 없어서, MemberContext를 통해 새로운 필드들을 추가해 사용자 정보를 커스터마이징 할 수 있다.
  • 클래스 명은 MemberContext가 아닌 CustomUserDetails로도 많이 사용한다.

UserDetailsService

  • Spring Security에서 유저의 정보를 생성해 가져오는 인터페이스다.
  • loadUserByUsername() 을 기본 오버라이딩 메서드로 제공하며, 거의 무조건 해당 메서드를 구현해야 한다.

OwnerSecurityService 또는 UserDetailsServiceImpl

프로젝트에서는 owner(관리자)만 로그인 기능을 사용하기 때문에, owner 패키지에 OwnerSecurityService를 만들어서 UserDetailsService를 구현했다.

  • 쉽게 말해, UserDetailsService를 구현한 커스터마이징 클래스이다.
  • 코드를 살펴보면, 여기서 GrantedAuthority 타입의 권한들을 만들고, “ADMIN”이라는 권한을 추가했다.
    이후, return new User(…)를 통해 사용자 인증 정보를 반환하는 식으로 작성했다.
    • 참고로 사용자 정보는 MemberContext 또는 UserDetails 라는 커스터마이징 클래스를 통해 User를 상속받는 식으로 작성해줘야, 애플리케이션 상황에 맞는 유저 정보를 더 추가해줄 수 있다. (예를 들면, 유저명이 아닌, nickname과 같은 기타 정보들)
    • 그러나 본 프로젝트에서는 딱히 추가할 내용이 없어서, 그냥 User 를 그대로 사용했다.
profile
Strengthen the core.

0개의 댓글