스프링 시큐리티 정리

박찬섭·2024년 3월 10일

스프링

목록 보기
5/14

스프링 시큐리티
스프링 시큐리티의 전체적인 흐름
password config 설정
security config 설정
filter
UserDetailsService

스프링 시큐리티

스프링 시큐리티는 자바의 프레임워크 스프링의 또다른 프레임워크이다.

사용 목적 및 장점
스프링 시큐리티의 사용 목적 및 장점은 인증, 인가가 필요한 부분을 디스패쳐 서블릿 으로 넘겨
비즈니스 로직을 담당하는 Service 에서 처리할 필요 없이 디스패쳐 서블릿 으로 넘기기 전 스프링 시큐리티를 통해 인증, 인가를 처리할 수 있다. 이로 인해 Service 부분에서 로그인 및 인증, 인가 처리를 할 필요 없이 비즈니스 로직에만 집중할 수 있다.
로직 분리

또한 스프링 시큐리티에서 기본적으로 제공하는 여러 필터를 통해 보안을 강화할 수 있으며,
개발자가 커스텀하게 필터를 만들어 유연하게 대처할 수 있다.
보안 강화


스프링 시큐리티의 흐름

1. 스프링 시큐리티는 스프링 본체와 별도의 프레임워크로 따로 추가해줘야 한다.

//gradle의 경우 추가 방법 spring security 
implementation 'org.springframework.boot:spring-boot-starter-security'

2. 개발자가 스프링 시큐리티를 어떻게 사용 할지에 대한 정의가 필요하다. config설정

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ......생략
}

@configuration 어노테이션의 경우 스프링이 실행될때 이 어노테이션이 달려있는 클래스를 기반으로 내부에 작성된 빈(Bean) 을 생성하여 의존성 주입을 시켜준다.
일종의 파일간 의존성 주입 및 설정

@EnableWebSecurity 어노테이션은 스프링 시큐리티에서 지원하는 어노테이션으로 @Configuration 과 같이 사용하고 시큐리티 구성을 설정할 수 있다.
그러나 jwt 커스텀 필터만을 사용했을때는 저 어노테이션이 없어도 정상적으로 작동하기는 한다.
스프링 시큐리티를 명시적, 사실적으로 표현하고 관련 구성을 설정

3. Security Config 설정에 대한 필터 정의

public class JwtAuthorizationFilter extends OncePerRequestFilter {
		......
}
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
		......
}

다음으로는 Config안에서 설정한 내용을 바탕으로 필터를 만든다.
위 코드를 기준으로는 Jwt토큰 에 대한
인증(유효한 토큰인지), 인가(아이디, 비밀번호 로그인의 경우 맞다면 토큰 발행) 에 대한 로직을 수행한다.
더 자세한 내용은 밑에서 정리

이렇게 시큐리티 Config안에 설정된 순서대로 필터를 거르고 인증을 받았다면 디스패쳐 서블릿을 통해 Controller까지 도달 할 수 있고 그렇지 않다면 다시 클라이언트에게 인증 거부 처리와 함께 응답이 돌아간다.


password config 설정

password에 대한 설정도 필요하다.
사용자가 회원가입을 할때 비밀번호를 그대로 DB에 저장하지 않고 해시화 시켜서 저장한다.
해시화 하지 않는다면 DB에 접근할 수 있는 개발자 혹은 사용자들은 다른 사용자들의 비밀번호를 알 수 있게 되고,
개발자들 또한 계속 DB의 내용을 보다보면 자신도 모르게 외울수 있게 되기 때문이다.

이때 가장 많이 사용하는 암호화 방식은 bcrypt 암호화 방법으로 16진수로 암호화 시켜준다.
이 해시화는 복호화 할 수 없다.

@Configuration
public class PasswordConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {//PasswordEncoder라는 인터페이스의 구현체 BCryptPasswordEncoder
        return new BCryptPasswordEncoder(); //BCrypt는 가장 많이 사용되는 hash함수
    }
}

@Configuration 을 사용하여 스프링 컨테이너를 통해 빈에 대한 의존성 주입을 맡기고
스프링 시큐리티에서 제공하는 PasswordEncoder 인터페이스에 대한 구현체를 등록한다.
PasswordEncoder 는 나중에 클라이언트에서 아이디, 비밀번호를 로그인을 시도할때 UsernamePasswordAuthenticationFilter 에서 해당 PasswordEncoder 를 사용한다.


security config 설정

위에서 언급한 EnableWebSecurity를 설정한 Config 파일에서의 설정이다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
	//jwt관련 유틸들
    private final JwtUtils jwtUtils;	
    //Authentication매니저가 사용할 UserDetailsService 등록
    private final MemberDetailServiceCustom memberDetailServiceCustom; 
    //Authentication매니저를 설정해 놓은 클래스
    private final AuthenticationConfiguration authenticationConfiguration;

	//의존성 주입
    public WebSecurityConfig(JwtUtils jwtUtils, MemberDetailServiceCustom memberDetailServiceCustom, AuthenticationConfiguration authenticationConfiguration) {
        this.jwtUtils = jwtUtils;
        this.memberDetailServiceCustom = memberDetailServiceCustom;
        this.authenticationConfiguration = authenticationConfiguration;
    }
    
    //Authentication매니저에 를 AuthenticationConfiguration에서 뽑아옴
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
    
    //따로 커스텀하게 설정해놓은 인증 필터 사용시 Authentication매니저를 사용하게 설정
    //UsernamePasswordAuthenticationFilter를 extends하여 사용하기 때문
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtils);
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        return filter;
    }

	//jwt토큰 인가 필터 빈 등록
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtils, memberDetailServiceCustom);
    }

	//클라이언트의 http 요청에 대해 처리할 필터, 보안 설정 체인형식으로 설정
    @Bean
    public SecurityFilterChain securityFilterChain (HttpSecurity httpSecurity) throws Exception {
    	//csrf 무시
        httpSecurity.csrf((csrf) -> csrf.disable()); 

		//JWT토큰으로 로그인 하기 때문에 session 비활성화
        httpSecurity.sessionManagement((sessionManagement) -> {
            sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        });

		//요청한 경로에 따라 인증처리를 할 것인지, 넘길 것인지, 어떤 역할을 필요로 하는지 정의
        httpSecurity.authorizeHttpRequests((request) -> {
            request
                    .requestMatchers("/signup").permitAll()
                    .requestMatchers("/teacher/regist").hasAuthority(Auth.ADMIN.getAuth())
                    .requestMatchers("/lecture/regist").hasAuthority(Auth.ADMIN.getAuth())
                    .requestMatchers("/lecture/{lecture_id}").permitAll()
                    .requestMatchers("/lecture**").permitAll()
                    .anyRequest().authenticated();
        });
        
        //필터 적용 순서 정의
        httpSecurity.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        httpSecurity.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        return httpSecurity.build();
    }
}

filter

필터의 경우 두 가지 필터를 사용했다.
JWT인증필터, JWT인가필터

JWT인증 필터
인증 필터는 클라이언트에서 요청을 보내왔을때 토큰이 존재한다면 토큰에 대해 인증을 시도하는 필터이다.
만약 토큰이 유효하다면 그 다음 필터로 넘기고, 유효하지 않다면 인증 실패와 같이 클라이언트에 응답한다.

JWT인가 필터
인가 필터는 앞에 인증 필터에서 토큰이 없을 경우 + 지정한 경로일 경우 오게되는 필터로
클라이언트에서 보낸 아이디, 비밀번호에서 아이디를 UserDetailsService의 구현체를 통해 DB에서 비밀번호를 가져와 클라이언트가 보낸 비밀번호DB에 저장된 비밀번호 를 비교하여 동일시 새로운 토큰을 인가 시킨다.
이때 비교하는 로직은 UsernamePasswordAuthenticationFilter 이 먼저 사용자 정의한 PasswordEncoder 를 통해 비교한다.

JwtAuthroizationFilter

public class JwtAuthorizationFilter extends OncePerRequestFilter {
    private final JwtUtils jwtUtils;
    private final MemberDetailServiceCustom memberDetailServiceCustom;
    public JwtAuthorizationFilter(JwtUtils jwtUtils, MemberDetailServiceCustom memberDetailServiceCustom) {
        this.jwtUtils = jwtUtils;
        this.memberDetailServiceCustom = memberDetailServiceCustom;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("Authorization");
        if (StringUtils.hasText(token)) {	//요청에 토큰이 존재하는지
            if (jwtUtils.authorizationJwt(token)) {//토큰이 유효한지
                Claims memberInfo = jwtUtils.getParsedToken(token);
                UserDetails userDetails = memberDetailServiceCustom.loadUserByUsername(memberInfo.getSubject());
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } else {//토큰이 유효하지 않을 경우
                return;
            }
        }
        //토큰이 유효하다면 다음 필터로 넘김
        filterChain.doFilter(request, response);
    }
}

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final JwtUtils jwtUtils;
    public JwtAuthenticationFilter(JwtUtils jwtUtils) {
        this.jwtUtils = jwtUtils;
    }
    //UsernamePasswordAuthenticationFilter의 메소드 오버라이딩
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        try {
            LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
            //Authentication매니저를 통해 비밀번호 검증 후 인증 객체 생성
            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(
                            requestDto.getEmail(),
                            requestDto.getPassword(),
                            null
                    )
            );
        } catch (Exception e) {
            log.error(e.getMessage());
            throw new RuntimeException(e.getMessage());
        }
    }

    //인증 성공시 인증객체에 담긴 principal을 가져와 새로운 토큰 생성 및 응답객체에 토큰 추가
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication result) throws IOException, ServletException {
        log.info("로그인 성공 및 JWT 생성");
        MemberDetailCustom authenticationMember = (MemberDetailCustom) result.getPrincipal();
        String email = authenticationMember.getEmail();
        Long id = authenticationMember.getId();
        String auth = authenticationMember.getAuth();

        String token = jwtUtils.createJWT(email, id, auth);
        jwtUtils.addJwtInHeader(token, response);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.info("로그인 실패");
        response.setStatus(401);
    }
}

UserDetails

UserDetailsService 구현체

Autentication매니저 가 요청한 아이디를 통해 DB에서 비밀번호를 가져올때
UserDetailsService 의 구현체를 사용하여 가져온다.

@Service
public class MemberDetailServiceCustom implements UserDetailsService {
    private final MemberRepository memberRepository;

    public MemberDetailServiceCustom(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email).orElseThrow(() ->
            new UsernameNotFoundException("해당 이메일이 존재하지 않습니다.")
        );
        return new MemberDetailCustom(member);
    }
}

오버라이딩된 loadUserByUsername메소드를 통해 해당 유저의 정보를 DB에서 가져오고
해당 유저의 상태가 잠겨있는지 등 정보를 UserDetails의 구현체에 담는다.

UserDetails 구현체

해당 유저의 상태 및 정보를 담은 구현체가 필요하다.
Authentication매니저는 해당 유저의 정보가 담긴 UserDetails 구현체를 통해 비밀번호를 조회하고 여러 상태를 확인하고 인증객체를 생성한다.

@Getter
public class MemberDetailCustom implements UserDetails {
    private final Member member;

    public MemberDetailCustom(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        String memberAuth = member.getAuth();

        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(memberAuth);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);
        return authorities;
    }


    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
    public Long getId() {
        return member.getId();
    }
    public String getEmail() {
        return member.getEmail();
    }
    public String getAuth() {
        return member.getAuth();
    }
}
profile
백엔드 개발자를 희망하는

0개의 댓글