백엔드

[그리고 시큐리티를 곁들인] #4 : Spring Security 를 이용한 JWT 인증/인가

0woodev 2024. 8. 13. 01:39

인트로

이전 글(3편)에서는 Jwt 토큰에 관련된 Util 클래스와 로그인, 회원가입 기능을 구현했다.

 

 

 

[그리고 시큐리티를 곁들인] #3 : ID/PW 방식의 회원가입과 로그인과 JWT 토큰 발급

인트로이전 글(2편)에서는 이번 시리즈의 주제와 글을 쓰게 된 배경을 소개했다. [그리고 시큐리티를 곁들인] #2 : 프로젝트 설계 + 유저 도메인 ERD 설계요구사항과 그에 따른 프로젝트 설계, 그

0woodev.tistory.com

 

 

이번 글에서는 발급된 JWT 토큰에 대해서 인증/인가 하기 위해 `Spring Security` 를 이용해보려고 한다.

필자는 단순히 따라하면서 하다가 어느 순간 내가 뭘 하고 있는지도 모르면서 복붙싸개가 되버리는건 아닌지, 이런 생각을 가진 적이 몇 번 있다. `Spring Security` 는 이런 생각을 가지게 했던 친구 중 하나였다.

 

이 번 포스트에서는 시큐리티에 대해서 완벽히 공부해서 쓰는 것은 아니지만, 다른 분들에게 좀 더 이해가 되기를 바라면서 적어본다.

과거의 필자처럼 낙오되지 않게 꽉 붙잡으시죠.

 

Spring Security (이하 시큐리티)

시큐리티는 프레임워크이다. 그것도 아주 덩치가 큰 녀석이다. 프레임워크? 라이브러리? 무슨 차이일까?

쉽게 표현하면,

용어 설명
프레임워크 특정한 목적을 달성하기 위해 프레임워크가 지정한 규칙에 맞춰 사용자가 코드를 작성했을 때 다양한 기능을 제공해주는 것
라이브러리 특정한 목적을 위한 다양한 기능을 제공해주고 사용자가 이를 적절히 활용하도록 도와주는 것

 

조금 더 디테일하게 얘기하면, 제어권이 사용자에게 있는지, 프레임워크에 있는지가 다르다.

 

시큐리티는 보안에 관련된 설정을 개발자가 손쉽게 할 수 있도록 도와준다. (손쉽게?)

Security 를 처음 써보면, 뭔놈의 설정이 이렇게 많은지, 또 뭘 넣어야하는지, 인터넷의 코드를 복사해서 넣었더니, 버젼차이로 메서드의 인자가 달라진다든가 하는 등 굉장히 어려워 보인다. 그래서 인터넷을 아무리 뒤져도 뭔가 속시원한 정보들이 잘 안보이다보니 금방 포기하거나, 대충 설정해두고 마무리하게 된다. (진짜, 인터넷을 찾아보면, 다 MVC 에 대한 개발, 즉 SpringBoot 에서 탬플릿엔진을 이용한 예시들만 차고 넘친다)

 

하지만, 이제는 우리에게 AI 가 있다. ChatGPT 가 알려준다. 물론, ChatGPT 를 쓴다고 해서 그냥 주는대로 쓰라는 것은 아니다.

그리고 AI 보단 못하지만, 필자도 어려움을 겪는 개발자들에게 알려주고 싶다. 이번 기회로, 필자가 쓴 이 글을 통해 조금은 각 설정들이 왜 필요한지, 어떤 설정들을 해줘야 하는지, 어떤 이유로 이렇게 설정하는지 완벽하게는 아니지만, 조금이라도 알고 넘어가보자.

 

 

시큐리티의 `HttpSecurity` 은 웹 애플리케이션의 HTTP 요청을 보호하는 데 사용하는 클래스이다. 이를 통해 애플리케이션에 적용할 보안 설정을 정의할 수 있는데, 다음과 같은 설정을 할 수 있다.

 

1. 인증 (Authentication)

더보기

formLogin( ) - 사용자에게 로그인 페이지를 제공하고, 로그인 폼을 통한 인증을 처리할 수 있다.

httpBasic( ) - 사용자의 Password 를 HTTP 헤더를 통해 서버에 전송(Authorization: Basic {EncodedPassword})하여 자격 증명에 대한 인증을 받기 위한 설정

oauth2Login( ) - 소셜 로그인을 위한 설정

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            
            /* 만약, formLogin() 을 적용하고 싶다면, 아래와 같이 설정할 수 있고, logout() 과 같이 적용할 수 있다.
            .formLogin()
                .loginPage("/login")  // 커스텀 로그인 페이지의 URL
                .defaultSuccessUrl("/home", true)  // 로그인 성공 후 이동할 URL
                .permitAll()  // 로그인 페이지 접근은 누구나 가능
            .and()
            .logout()
                .logoutUrl("/logout")  // 로그아웃 요청을 처리할 URL
                .logoutSuccessUrl("/login?logout")  // 로그아웃 성공 후 이동할 URL
                .permitAll();
            */
            
            .oauth2Login(oauth -> oauth
                    .userInfoEndpoint(c -> c.userService(oAuth2UserService))
                    .successHandler(oAuth2SuccessHandler));
                    
    return http.build();
}

2. 인가 (Authorization)

더보기

addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter) - UsernamePasswordAuthenticationFilter.class 이전에 JwtAuthFilter 를 추가해주면 된다.

authorizeRequests(Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> authorizationHttpRequestsCustomizer) - 특정 URL 에 대한 접근 권한을 설정할 수 있다.

 

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .addFilterBefore(new JwtAuthFilter(customUserDetailsService, jwtUtil), UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(request -> request
                    .requestMatchers(AUTH_WHITELIST).permitAll()
                    .anyRequest().authenticated());

    return http.build();
}

 

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtUtil jwtUtil;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader("Authorization");

        if (authorization != null && authorization.startsWith("Bearer ")) {
            String token = authorization.substring(7);

            if (jwtUtil.validateToken(token)) {
                Long userId = jwtUtil.getUserId(token);
                UserDetails userDetail = customUserDetailsService.loadUserById(userId);

                if (userDetail != null) {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(userDetail, null, userDetail.getAuthorities());

                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}

 

package org.imgame.imgame.service.impl;

import lombok.RequiredArgsConstructor;
import org.imgame.imgame.common.ErrorCode;
import org.imgame.imgame.dto.CustomUserDetails;
import org.imgame.imgame.exception.CustomException;
import org.imgame.imgame.model.User;
import org.imgame.imgame.repository.UserRepository;
import org.modelmapper.ModelMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;


@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    private final ModelMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

        return mapper.map(user, CustomUserDetails.class);
    }

    public UserDetails loadUserById(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
        return mapper.map(user, CustomUserDetails.class);
    }
}

3. 세션 관리

더보기

sessionManagement( ).sessionFixation( ) - 세션 고정 공격을 방지하는 설정

sessionManagement( ).maximumSessions( ) - 동일 사용자의 동시 세션 수 제한

sessionManagement( ).sessionCreatePolicy(SessionCreationPolicy.STATELESS) - API 서버에서 세션 사용하지 않도록 설정

 

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    return http.build();
}

4. CSRF 보호 (Cross-Site Request Forgery Protection)

더보기

csrf( ) - CSRF 공격을 방지한다. 주로 웹 애플리케이션에서 폼 제출을 통해 발생할 수 있는 공격이다.

 

CSRF 란, 무엇이냐 도대체 왜 필요하냐. 뭔가 얼핏보면, cors( ) 로 해결할 수 있을 것 같은데, 뭘까?

우선, cors 부터 설명 하겠다. cors 란, Cross-Orgin-Resource-Sharing 으로, 브라우저 차원에서 javascript 를 통해 http 요청을 보낼 때, 동일한 오리진이 아닌, 다른 오리진에 대해서 허용이 되어있는지에 대한 설정을 의미한다.

즉, imgame.com 이라는 클라이언트 주소가 있다고 하자. api.imgame.com 이 서버 주소라고 했을 때, imgame.com 에서 api.imgame.com 에서 준 응답값을 브라우저가 믿고 신뢰할 수 있을지에 대해서 서버가 imgame.com 에 대해서 설정을 열어주었다면, 성장적으로 요청을 보낼 수 있다. 자, 그럼 여기서 설정을 열어주었는지를 어떻게 확인하는지 보자. 즉, preflight 를 요청해서 요청 결과가 허용 x 가 나오면, 브라우저에서 더 이상 백엔드에 요청하지 않도록 막는다. 

CSRF 공격은 단순 요청인 form 요청을 이용한다. 그렇기에 Preflight 없이 서버에 바로 전송이 된다. 따라서, CORS 설정과 상관없이 요청이 동작한다.

 

예시를 보면, 좀 더 이해하기 쉬울것 같다.

만약, 공격자가 attacker.com 이라는 사이트를 만들고, 세션방식의 서버를 이용하고 있는 bank 서비스를 이용하고 있다고 가정하자. 이때, bank 서비스에 로그인을 하고, 새로운 탭을 켜서 (같은 세션내) 로그인을 한 후, 공격자가 의도한대로 attacker.com 에 접속을 했을 때, 다음과 같은 코드가 실행되는 상황을 CSRF 공격 이라고 한다. 이러한 상황은 요청주소에 따라 브라우저가 자동으로 쿠키의 값을 포함해서 요청하는 것 때문에 발생한다. 이를 해결하기 위한 방법으로 서버에서 csrf 에 관련된 토큰을 내려주거나, 쿠키에 httpOnly 설정을 키는 방법이 있다.

<form action="https://api.bank.com/transfer" method="POST">
    <input type="hidden" name="amount" value="1000">
    <input type="hidden" name="to_account" value="attacker_account">
</form>

<script>
    // 사용자가 이 페이지를 방문하면 폼이 자동으로 제출됩니다.
    document.forms[0].submit();
</script>

 

 

5. 예외 처리

더보기

exceptionHandling( ) -@Deprecated(since="6.1", forRemoval=true)

exceptionHandling(Customizer<ExceptionHandlingConfigurer<HttpSecurity>> exceptionHandlingCustomizer) throws Exception - ExceptionHandling 에 대한 Customizer 를 정의해야 한다.

 

 

ExceptionHandlingConfigurer

  • authenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint) - 인증이 안된 익명의 사용자가 인증이 필요한 엔드포인트에 접근하게 되는 경우 401 과 스프링의 기본 오류페이지를 보게 된다. 커스텀된 오류 페이지를 보여주거나, 특정 응답로 변경해야 하는 경우, 해당 인터페이스를 구현한 구현체를 시큐리티에 등록하여 사용할 수 있다.
  • accessDeniedPage(String accessDeniedUrl) - 에러상황에 보여줄 Error Page 를 빠르게 설정할 수 있는 기능
  • accessDeniedHandler(AccessDeniedHandler accessDeniedHandler) - 인증이 완료되었으나, 해당 엔트포인트에 접근할 권한이 없는 경우, 403 Forbidden 오류를 스프링의 기본 오류페이지와 함께 응답한다. 해당 응답형태나 보여줄 페이지를 변경하고자 할때, AccessDeniedHandler 를 구현한 구현체를 시큐리티에 등록하여 사용할 수 있다.
    • handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) 를 Override 해야한다.
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler))

        return http.build();
    }
}

 

@AllArgsConstructor
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper mapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        CommonResponse<Void> errorResponse = CommonResponse.error(ErrorCode.FORBIDDEN);

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

 

@AllArgsConstructor
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final ObjectMapper mapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        CommonResponse<Void> errorResponse = CommonResponse.error(ErrorCode.UNAUTHORIZED);

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

6. 로그아웃

더보기

사용자가 로그아웃을 요청했을 때, 다음과 같은 작업을 수행해야 한다.

  • 세션 무효화
  • 인증토큰 삭제
  • SecurityContext 삭제
  • 쿠키정보 삭제
  • 로그인 페이지로 리다이렉트

와 같은 작업을 일반적으로 수행한다.

 

필자는 JWT 토큰을 사용하여, `SessionCreationPolicy.STATELESS` 이기 때문에, logout 설정이 따로 필요없다.

 

logout( ) - @Deprecated(since="6.1", forRemoval=true)  in 7.0.

logout(LogoutConfigurer<HttpSecurity>> logoutCustomizer) - LogoutCustomizer 를 시큐리티에 등록할 수 있다.

public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .logout(AbstractHttpConfigurer::disable)

        return http.build();
    }
}

 

LogoutCustomizer

  • deleteCookies(String... cookieNamesToClear) - 로그아웃 후 삭제할 쿠키 지정
  • logoutSuccessUrl(String logoutSuccessUrl) - 로그아웃 성공 후 이동할 Url
  • logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler) - logoutSucessUrl 과 함께 사용못함
  • logoutUrl(String logoutUrl) - logoutRequestMatcher 와 중복 설정 x, 로그아웃 API Url 설정
  • invalidateHttpSession(boolean invalidateHttpSession) - HttpSession 을 무효화해야하는 경우 true(기본값) 로 해야하며, 그렇지 않은 경우 false 로 설정하면 된다.

세션 무효화 처리는 

@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {

	@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .logout(logout -> logout
                        .logoutUrl("/logout")   // 로그아웃 처리 URL (= form action url)
                        .addLogoutHandler((request, response, authentication) -> { 
                            // 로그아웃 핸들러 추가
                        })  
                        .logoutSuccessHandler((request, response, authentication) -> {
                            // 로그아웃 성공 핸들러
                            response.sendRedirect("/login");
                        }) 
                        .deleteCookies("remember-me"); // 로그아웃 후 삭제할 쿠키 지정
                )


        return http.build();
    }
}

 

7. 소셜로그인

더보기

oauth2Login(Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer) 를 통해 설정할 수 있습니다. 자세한 내용에 대해서는 이후 시리즈 글에서 다뤄보고자 한다.

 

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .oauth2Login(oauth -> oauth
                    .userInfoEndpoint(c -> c.userService(oAuth2UserService))
                    .successHandler(oAuth2SuccessHandler));

    return http.build();
}

 

최종 코드

더보기

SecurityConfig.java

package org.imgame.imgame.config;

import lombok.RequiredArgsConstructor;
import org.imgame.imgame.service.impl.CustomOAuth2UserService;
import org.imgame.imgame.service.impl.CustomUserDetailsService;
import org.imgame.imgame.util.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtUtil jwtUtil;
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final CustomOAuth2UserService oAuth2UserService;

    private static final String[] AUTH_WHITELIST = {"/auth/**"};

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


    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().
                requestMatchers("/error", "/favicon.ico");
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)

                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(new JwtAuthFilter(customUserDetailsService, jwtUtil), UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler))
                .authorizeHttpRequests(request -> request
                        .requestMatchers(AUTH_WHITELIST).permitAll()
                        .anyRequest().authenticated());

        // 다음 블로그 포스트에서 다룰 예정
        http
                .oauth2Login(oauth -> oauth
                        .userInfoEndpoint(c -> c.userService(oAuth2UserService))
                        .successHandler(oAuth2SuccessHandler));

        return http.build();
    }
}

 

JwtAuthFilter.java

package org.imgame.imgame.config;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.imgame.imgame.service.impl.CustomUserDetailsService;
import org.imgame.imgame.util.JwtUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtUtil jwtUtil;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader("Authorization");

        if (authorization != null && authorization.startsWith("Bearer ")) {
            String token = authorization.substring(7);

            if (jwtUtil.validateToken(token)) {
                Long userId = jwtUtil.getUserId(token);
                UserDetails userDetail = customUserDetailsService.loadUserById(userId);

                if (userDetail != null) {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(userDetail, null, userDetail.getAuthorities());

                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}

 

CustomUserDetailsService.java

package org.imgame.imgame.service.impl;

import lombok.RequiredArgsConstructor;
import org.imgame.imgame.common.ErrorCode;
import org.imgame.imgame.dto.CustomUserDetails;
import org.imgame.imgame.exception.CustomException;
import org.imgame.imgame.model.User;
import org.imgame.imgame.repository.UserRepository;
import org.modelmapper.ModelMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;


@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    private final ModelMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

        return mapper.map(user, CustomUserDetails.class);
    }

    public UserDetails loadUserById(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
        return mapper.map(user, CustomUserDetails.class);
    }
}

 

CustomAccessDeniedHandler.java

package org.imgame.imgame.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.imgame.imgame.common.ErrorCode;
import org.imgame.imgame.util.CommonResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;


@AllArgsConstructor
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper mapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        CommonResponse<Void> errorResponse = CommonResponse.error(ErrorCode.FORBIDDEN);

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

 

CustomAuthenticationEntryPoint.java

package org.imgame.imgame.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.imgame.imgame.common.ErrorCode;
import org.imgame.imgame.util.CommonResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@AllArgsConstructor
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final ObjectMapper mapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        CommonResponse<Void> errorResponse = CommonResponse.error(ErrorCode.UNAUTHORIZED);

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(mapper.writeValueAsString(errorResponse));
    }
}

 

마침

 

다음 시리즈는 첫 스프링부트 프로젝트(2022)부터 지금(2024)까지의 소셜로그인 방식에 대한 삽질 과정을 다뤄보고자 한다.

 

 

[그리고 시큐리티를 곁들인] #5 : 소셜로그인 방식에 대한 삽질기

// TODO

0woodev.tistory.com