Spring Security (1) - Login

2ㅣ2ㅣ·2024년 10월 10일

Spring

목록 보기
4/7
post-thumbnail

개요

Spring Security + JWT 를 이용하여 토큰 기반 로그인을 구현하려고 한다.

JWT를 선택한 이유

사용자 인증 방식으로는 크게 session 인증token 인증이 있다.
내가 진행하는 프로젝트의 경우, Vetordb로 Redis를 사용할 것이기 때문에 세션 기반 인증도 충분히 고려할 상황이었다.
레디스로 세션을 관리하면 MSA환경에서도 개발자가 세션을 동기화할 필요가 없기 때문이다.
하지만, 외부 저장소가 필요하고 서버에 부하가 간다는 문제는 여전히 존재한다.
따라서 외부 시스템 없이 동기화가 가능하고 서버 부하가 적은 Jwt를 선택하기로 결정했다.

  • Session + Redis : 인증 정보를 server에 저장 -> 외부 저장소 필요
  • Token : 인증 정보를 client에 저장 -> 외부 저장소 불필요

단, jwt를 사용할 경우 토큰 탈취의 위험이 있기 때문에 토큰 만료 시간을 두고 refreshToken이나 blackList를 이용한 관리 작업이 필요하다.


Security 기본 동작

스프링부트가 요청을 처리하는 과정

  • 클라이언트가 요청을 보내면, SpringBoot 내장 Tomcat이 이를 처리한다.
  • Tomcat은 ThreadPool을 이용해 요청을 Thread에 할당한다.
  • 요청이 Connector로 전달되고, 여기서 HTTP 관련 처리가 이루어진다.
  • 그 후 Host와 Context를 거쳐 애플리케이션의 Servlet에 도달하여 요청이 처리한다.
    • 나는 www.music-in-my-diary 라는 Host로 이동할 것이다.
    • www.music-in-my-diary = http://localhost:8080
  • Connector : 프로토콜에 따라 특정 Connnect로 client와의 통신을 처리하는 계층
  • Engine : 특정 서비스에 대한 요청 처리 파이프라인
  • Host : 네트워크 연결 주소
  • Context : 여러 개의 Servlet 집합, 1개의 Project라고 생각해도 무방함
    • Host와 Context의 차이를 쉽게 말하자면, Host서버가 외부와 통신할 때 사용하는 도메인 주소이고, Context그 도메인 안에서 애플리케이션이 시작되는 경로를 의미함.
    • 만약 Context가 /로 설정되어 있다면, Host와 Context가 같아져서 www.music-in-my-diary.com에서 애플리케이션이 바로 시작됨.
    • 하지만 Context가 /app이라면, www.music-in-my-diary.com/app에서 애플리케이션이 시작되고 그 안에서 /login, /dashboard 같은 uri가 사용됨.
  • Servlet : Controller, Service 등 각 MVC 구성요소

Spring Security를 이용한 인증 요청이 Servlet에서 처리되는 과정

마침내 요청이 Servlet까지 도달했다! 원래같으면 DispatcherServlet에 도달한 요청이 적절한 Controller로 매핑되고 메서드를 실행하겠지만, 인증 요청은 다르다. 인증 요청은 DispatcherServlet에 도달하기 전, 먼저 filterChain이라는 관문을 거쳐야 한다.

  • Servlet 내에 filterChain으로 요청이 이동한다.
  • filterChain 내의 DelegatingFilterProxy가 요청을 가로채서 FilterChainProxy로 요청을 전달한다.
  • FilterChainProxy는 요청과 매칭되는 SecurityFilterChain을 선택하고, 해당 체인의 여러 필터들이 요청을 순차적으로 처리한다.
    • 클라이언트가 /login으로 요청을 보내면, whiteListUris에 속한 uri이므로 인증 없이 통과시킨다.
    • 만약, /dashboard로 요청을 보내면, 인증이 필요한 경로임을 알아채고 Jwt필터(e.g. JwtAuthenticationFilter)를 적용시켜 토큰을 검증한다.
  • 인증이 완료되면, 요청은 DispatcherServlet으로 전달되어 Controller에서 처리한다.

Spring Security 내부 동작 과정

  • AuthenticationFilter가 요청을 가로챈다.
  • 가로 챈 정보(username, password)를 기반으로 UsernamePasswordAuthenticationToken 인증 객체를 생성한다.
    • 즉, 인증 객체는 토큰이다.
  • 이 토큰을 AuthenticationManager에게 전달하여 인증을 시도한다.
  • AuthenticationManager로 등록된 AuthenticaitonProvider에 전달한다.
  • UserDetailsService에서 사용자 정보를 조회하고, 사용자 정보가 포함된 UserDetails 객체를 생성하여 반환한다.
  • 인증이 성공하면 인증된 객체가 SecurityContext에 저장된다.
  • SecurityContext의 저장소인 SecurityContextHolder에 저장되고, 이로써 최종적으로 사용자 인증이 완료된다.

💡 더럽게 어렵고 복잡하다..🤮🤮🤮 초초초간단하게 말하자면,
로그인 정보를 필터 체인의 DelegatingFilterProxy에서 가로채고, 내부 동작을 통해서 인증 성공 시 요청이 DispatcherServlet에 전달됨으로써 로직이 실행된다고 이해해도 충분하다.


SecurityConfig

스프링 시큐리티를 수행하기 위한 환경을 설정하는 클래스이다.
Spring Security 5.7이 릴리즈 된 이후로는 각 config들을 Bean으로 등록하는 것을 권장한다고 한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtService jwtService;
    private final MemberService memberService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws
            Exception{
            return http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(((csrf)-> csrf.disable()))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(getWhiteListUris()).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterAt(
                        new LoginFilter(authenticationManager(authenticationConfiguration), jwtService, memberService),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .build();
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","PATCH","DELETE"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }

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

    private String[] getWhiteListUris() {
        return new String[]{"/swagger-ui/**", "/v3/**", "/login", "/swagger-ui.html", "/swagger-resources/**", "/signup"};
    }
}
  • SecurityFilterChain : 특정 http 요청에 대한 웹 기반 보안 구성
    • whiteListUris를 제외한 경로로의 접근을 제한한다.
    • 토큰을 사용할 것이기 때문에 세션을 stateless 설정한다.
  • CorsConfigurationSource : Cors 관련 설정
    • 모든 origin과 Method를 허용한다.
    • 이는, 배포 후 신뢰할 수 있는 origin으로 제한할 예정이다.
  • AuthenticationManager : 인증 관리자 관련 설정
  • getWhiteListUris 메서드 : 로그인 상태에 관계없이 접근할 수 있도록 설정함. 인증이 필요 없는 경로들.

CustomMemberDetailService

사용자 정보를 조회하는 UserDetailsSerivce의 구현체이다.

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

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

    public Member findMemberByMemberId(Long memberId){
        Member member = memberRepository.findById(memberId).orElseThrow(()->new EntityNotFoundException("존재하지 않는 회원아이디입니다 : "+memberId));
        return member;
    }
}
  • loadUserByUsername 메서드 : email로 member 정보를 조회한다.
    • 나는 로그인 시, id 대신 email로 로그인 요청을 보내기 때문이다.
    • loadUserByUsername은 개발자가 실행하지 않아도, 로그인을 요청하면 시큐리티 내부적으로 해당 메서드가 실행된다.

LoginFilter

로그인 요청을 하면 해당 필터에서 요청을 가로챈다. username, password 같은 정보를 추출해 로그인을 시도한다.
인증을 성공하면 인증 토큰을 반환하고, 실패하면 401 에러를 던진다.

@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;
    private final MemberService memberService;

    /**
     * Login 시도 메서드
     * */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
            AuthenticationException {
            String email = (obtainUsername(request) != null) ? obtainUsername(request) : "";
            String password = (obtainPassword(request) != null) ? obtainPassword(request) : "";

            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email,password,null);
            return authenticationManager.authenticate(authToken);
    }

    /**
     * Login 성공(인증 성공) 시 메서드
     * authResult : 인증 성공 후 만들어지는 인증 객체
     * */
    @Override
    protected void successfulAuthentication(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain,
            Authentication authResult) throws IOException, ServletException
    {
        Member member = (Member) authResult.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();

        Jwt token = jwtService.createTokens(member.getId());
        addJwtToCookie(response, token.getAccessToken(), "accessToken");

        sendMemberLoginResponse(response, HttpStatus.OK);
    }

    /**
     * Login 실패 시(인증 실패) 메서드
     * failed : 인증 실패 후 만들어지는 인증 실패 객체
     * */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException
    {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding("utf-8");
        response.getWriter().write("인증 실패 : " + failed.getMessage());
    }

    /**
     * Login 성공/실패 시 메세지 보내는 메서드
     * */
    private void sendMemberLoginResponse(HttpServletResponse response, HttpStatus status) throws IOException{
        Map<String, String> messageMap = new HashMap<>();
        response.setContentType("application/json;charet=UTF-8");
        response.setStatus(status.value());
        PrintWriter out = response.getWriter();
        out.print(new ObjectMapper().writeValueAsString(messageMap));
        out.flush();
    }

    /**
     * 쿠키 굽는 메서드
     * */
    public void addJwtToCookie(HttpServletResponse response, String jwtToken, String cookieName){
        Cookie cookie = new Cookie(cookieName, jwtToken);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setMaxAge(60*120);
        response.addCookie(cookie);
    }
}
  • attemptAuthentication 메서드 : 임시 토큰을 생성하고, AuthenticationManager에 전달해 인증 처리함
  • successfulAuthentication 메서드 : 인증에 성공하면, jwt 토큰 생성쿠키에 저장하고 성공 메세지 반환함
  • unsuccessfulAuthentication 메서드 : 인증에 실패하면, 401 에러와 함께 실패 메세지 반환함
  • addJwtToCookie 메서드 : HttpOnlySecure 속성으로 브라우저 보안 강화함


UsernamePasswordAuthenticationFilter를 뜯어보지 않아 로그인 컨트롤러를 만들었었는데, 알고보니 /login 이라는 엔드포인트로 POST 요청을 보내고 있었다.
즉, 별도의 로그인 컨트롤러 없이 해당 필터에서 사용자 인증 및 로그인 시도가 모두 가능하다.

0개의 댓글