SpringPlus- 개인과제(10)

ChoRong0824·2025년 3월 17일
0

Web

목록 보기
45/51
post-thumbnail

기존 방식 변경할 방식 (Spring Security)
Filter를 사용하여 JWT 검증 JwtAuthenticationFilter를 Spring Security FilterChain에 추가
Argument Resolver를 사용하여 인증된 사용자 정보 가져오기 @AuthenticationPrincipal 또는 SecurityContext 사용
직접 권한 검증 (if문으로 Role 체크) @PreAuthorize, @Secured, hasRole() 사용

기존

인증 관련

JwtUtil : jwt 생성 및 검증
jwtFilter : jwt기반 필터
FilterConfig : 필터 설정
AuthUser~~~Resolver : 유저 정보를 컨트롤러에 주입

서비스 및 예외 관련

AuthService : 로그인, 회원가입
userAdminService : 유저 역할 변경
InvalidRequestException, ServerException : 예외 처리

컨트롤러 관련 클래스

AuthController, UserController, UserAdminController, TodoController, ManagerController, CommentController

대충 이렇게 있습니다.

어떻게 수정 할 것인가?

  1. 스프링 시큐리티 기반 인증/인가 적용
    • jwt 필터 -> OncePerRequestFilter를 상속받아 시큐리티와 연동 해서 자동으로 검증 예정
    • 기존의 Filter 랑 Resolver 제거
    • 시큐리티의 필터체인 문법을 활용해서 접근 권한 설정 할 예정
    • PreAuthorize 활용해서 역할 기반 접근 제어 적용(고민중)
  2. 유저 디테일 구현
    • 시큐리티에서 UserDetailsService를 구현하여 사용자 정보를 UserDetails 객체로 변환하려고 구현함.
    • 즉, UserDetailsService를 사용해 유저 정보를 로드하고, Spring Security가 인증을 처리하도록 변경
    • AuthUserArgumentResolver를 제거하고 Spring Security의 SecurityContext를 활용
  3. 시큐리티 컨피그 추가
    • URL 별 접근 권한 설정 (@PreAuthorize, HttpSecurity)
    • @EnableMethodSecurity를 사용해 메서드 단위 접근 제한 설정

JwtFilter를 OncePerRequestFilter 상속받아서 활용

@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        if (url.startsWith("/auth")) {
            chain.doFilter(request, response);
            return;
        }

        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
                return;
            }

            /*
            닉네임 가져오고 검증
            */
            String nickname = jwtUtil.getNicknameFromToken(jwt);
            if (nickname == null) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "jwt에 닉네임 정보가 없습니다.");
                return;
            }

            UserRole userRole = UserRole.of(claims.get("userRole", String.class));

            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email"));
            httpRequest.setAttribute("nickname", claims.get("nickname"));
            httpRequest.setAttribute("userRole", claims.get("userRole"));

            if (url.startsWith("/admin")) {
                // 관리자 권한이 없는 경우 403을 반환합니다.
                if (!UserRole.ADMIN.equals(userRole)) {
                    httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다.");
                    return;
                }
                chain.doFilter(request, response);
                return;
            }

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
        } catch (Exception e) {
            log.error("Internal server error", e);
            httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

메서드 명도 security에 맞게끔 수정
JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String bearerJwt = getJwtFromRequest(request);

        if (bearerJwt != null) {
            try {
                String jwt = substringToken(bearerJwt);
                Claims claims = extractClaims(jwt);

                validateNickname(claims, response);
                setAuthentication(request, claims);

            } catch (ExpiredJwtException e) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
                return;
            } catch (MalformedJwtException | UnsupportedJwtException e) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 형식입니다.");
                return;
            } catch (Exception e) {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "서버 오류 발생");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken;
        }
        return null;
    }

    private String substringToken(String tokenValue) {
        return jwtUtil.substringToken(tokenValue);
    }

    private Claims extractClaims(String token) {
        return jwtUtil.extractClaims(token);
    }

    private void validateNickname(Claims claims, HttpServletResponse response) throws IOException {
        String nickname = claims.get("nickname", String.class);
        if (nickname == null) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "jwt에 닉네임 정보가 없습니다.");
        }
    }

    private void setAuthentication(HttpServletRequest request, Claims claims) {
        Long userId = Long.parseLong(claims.getSubject());
        String email = claims.get("email", String.class);
        String nickname = claims.get("nickname", String.class);
        UserRole userRole = UserRole.of(claims.get("userRole", String.class));

        request.setAttribute("userId", userId);
        request.setAttribute("email", email);
        request.setAttribute("nickname", nickname);
        request.setAttribute("userRole", userRole.name());

        AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(authUser, null, List.of(new SimpleGrantedAuthority(userRole.name())));

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

FIlterConfig && WebConfig


@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final JwtUtil jwtUtil;

    @Bean
    public FilterRegistrationBean<JwtFilter> jwtFilter() {
        FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new JwtFilter(jwtUtil));
        registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다.

        return registrationBean;
    }
}

/*
WebConfig
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserArgumentResolver());
    }
}

기존에 있던,

  1. WebConfig 삭제 가능
    기존 WebConfig는 ArgumentResolver(예: AuthUserArgumentResolver)를 등록하는 역할을 합니다.
    Spring Security를 적용하면 ArgumentResolver가 불필요해지므로 WebConfig는 삭제 가능합니다.
  2. FilterConfig 삭제 가능
    기존 FilterConfig는 JwtFilter를 등록하는 역할을 했었습니다.
    Spring Security에서 JwtAuthenticationFilter를 SecurityFilterChain에 등록하므로 FilterConfig는 삭제 가능.

그러나, PersistenceConfig는 유지해야합니다.

  • @EnableJpaAuditing을 설정하는 역할이므로 삭제하면 안됩니다.
    JPA Auditing(자동 시간 저장 등) 기능을 사용하려면 꼮 유지해야 함.

SecurityConfig

@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                ).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

SecurityFilterChain이 JwtFilter를 등록하던 역할을 대신 수행합니다.

FilterConfig SecurityConfig
FilterRegistrationBean로 필터 등록 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
서블릿 필터로 동작 Spring Security 필터로 동작
doFilter() 메서드 사용 doFilterInternal() 메서드 사용

Argument Resolver (AuthUserArgumentResolver)

@Slf4j
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
        }

        return hasAuthAnnotation;
    }

    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴
        Long userId = (Long) request.getAttribute("userId");
        String email = (String) request.getAttribute("email");
        String nickname = (String) request.getAttribute("nickname");
        UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
        log.info("AuthUser: userId={}, email={}, nickname={}, role={}", userId, email, nickname, userRole);

        if (nickname == null) {
            throw new AuthException("닉네임이 누락되었습니다.");
        }
        return new AuthUser(userId, email, nickname, userRole);
    }
}

시큐리티 방식으로 변경 필요합니다.

  • 기존 AuthUserArgumentResolver는 Controller의 @Auth AuthUser authUser를 해석하는 역할입니다.
  • Spring Security에서는 @AuthenticationPrincipal을 활용하여 SecurityContext에서 유저 정보를 가져올 수 있습니다.
  • 기존 ArgumentResolver 제거 후, @AuthenticationPrincipal을 활용하도록 변경해야 함.

왜 변경해야 하는 것일까 ?

  • @AuthenticationPrincipal을 사용하면 Spring Security의 인증 객체에서 자동으로 유저 정보를 가져기 떄문에, 기존의 AuthUserArgumentResolver를 유지할 필요 없음.

따라서
컨트롤러 메서드에 전부 다 @AuthenticationPrincipal를 붙여주고, ArgumentResolver를 제거하면 됩니다.

더 정확히는 매개변수로 AuthUser authUser가 들어간 메서드만 변경하면 되는 것입니다.

WHY?

  1. 기존 코드에서는 AuthUser를 AuthUserArgumentResolver (커스텀 HandlerMethodArgumentResolver)를 통해 매개변수로 주입받았었습니다.
    즉, 스프링이 컨트롤러 메서드를 호출할 때 AuthUserArgumentResolver가 AuthUser를 만들어서 넣어줬던 거야.

  2. 그런데 이제 Security를 적용하면서, AuthUserArgumentResolver를 제거했습니다.
    → 이는, @AuthenticationPrincipal이 Spring Security의 SecurityContext에서 로그인한 사용자 정보를 가져와서 자동으로 주입해줍니다.
    → 그래서 기존 AuthUserArgumentResolver가 필요 없어지고, 해당 기능이 @AuthenticationPrincipal로 대체되는 것이라고 생각하시면 됩니다.

정리

Security로 인증받아야 하는 정보는 AuthUser뿐입니다.
다른 매개변수 (@PathVariable long todoId, @RequestBody ...) 같은 것들은 애초에 AuthUserArgumentResolver와 상관없었습니다.
따라서, 기존에 AuthUserArgumentResolver가 담당했던 부분만 @AuthenticationPrincipal로 변경하면 되고, 나머지는 그대로 두면 되는 것입니다.



수정

그렇다고 @Auth 어노테이션이 있는데, 이렇게 추가하면 안됩니다.
@Auth은 꼭 제거해야합니다.

WHY ?

@Auth이 더이상 필요 없는 이유는 ?

  1. Spring Security 적용으로 인해 AuthUserArgumentResolver가 삭제됨.
    - @Auth는 원래 AuthUserArgumentResolver를 통해 AuthUser를 컨트롤러에 주입하는 역할 수행했습니다.
    하지만 이제는 @AuthenticationPrincipal을 사용하면 SecurityContext에서 직접 AuthUser를 가져올 수 있어서 @Auth가 필요 없습니다.

  2. SecurityContext에서 @AuthenticationPrincipal을 통해 자동으로 AuthUser를 주입함.
    → Spring Security의 @AuthenticationPrincipal이 로그인한 유저 정보를 자동으로 가져오기 때문에, @Auth는 의미 없는 코드가 되기때문입니다.

  3. @Auth를 남겨두면 불필요한 코드가 추가되는 것뿐 아니라, 시큐리티는 동작하지 않을 수도 있기 때문입니다. 시큐리티는 조금만 다르거나 좀 이상하면 안돌아갑니다.. ㅋㅋㅋ 미칩니다..
    → 다시 말해, 이미 AuthUserArgumentResolver를 삭제했기 때문에 @Auth를 붙여도 아무 역할도 못하기 때문에 삭제.


profile
백엔드를 지향하며, 컴퓨터공학과를 졸업한 취준생입니다. 많이 부족하지만 열심히 노력해서 실력을 갈고 닦겠습니다. 부족하고 틀린 부분이 있을 수도 있지만 이쁘게 봐주시면 감사하겠습니다. 틀린 부분은 댓글 남겨주시면 제가 따로 학습 및 자료를 찾아봐서 제 것으로 만들도록 하겠습니다. 귀중한 시간 방문해주셔서 감사합니다.

0개의 댓글