Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Test/169] 시큐리티 도입으로 인한 테스트코드 수정 및 SecurityConfig 리팩토링 #171

Merged
merged 6 commits into from
Jan 18, 2025
9 changes: 7 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-mail'

// jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
Expand All @@ -44,13 +45,17 @@ dependencies {
// rds ssh 접속
implementation 'com.github.mwiede:jsch:0.2.16'

// mail
implementation 'org.springframework.boot:spring-boot-starter-mail'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'org.awaitility:awaitility:4.2.0'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import com.gamegoo.gamegoo_v2.account.auth.annotation.AuthMember;
import com.gamegoo.gamegoo_v2.account.auth.security.SecurityUtil;
import com.gamegoo.gamegoo_v2.core.exception.MemberException;
import com.gamegoo.gamegoo_v2.core.exception.common.ErrorCode;
import com.gamegoo.gamegoo_v2.account.member.domain.Member;
import com.gamegoo.gamegoo_v2.account.member.repository.MemberRepository;
import com.gamegoo.gamegoo_v2.core.exception.MemberException;
import com.gamegoo.gamegoo_v2.core.exception.common.ErrorCode;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
Expand Down Expand Up @@ -39,7 +39,6 @@ public Object resolveArgument(@NonNull MethodParameter parameter, ModelAndViewCo
Long currentMemberId = SecurityUtil.getCurrentMemberId();
return memberRepository.findById(currentMemberId)
.orElseThrow(() -> new MemberException(ErrorCode.MEMBER_NOT_FOUND));

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,30 @@
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.List;

import lombok.NonNull;
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.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
import java.io.IOException;
import java.util.List;

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final CustomUserDetailsService customUserDetailsService;
private final List<String> excludedPaths;
private final List<RequestMatcher> excludedRequestMatchers;
private final JwtProvider jwtProvider;

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

String requestURI = request.getRequestURI();

// JWT Filter를 사용하지 않는 Path는 제외
if (excludedPaths.stream().anyMatch(requestURI::startsWith)) {
if (excludedRequestMatchers.stream().anyMatch(m -> m.matches(request))) {
filterChain.doFilter(request, response);
return;
}
Expand All @@ -48,12 +43,12 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht

// UserDetails, Password, Role -> 접근권한 인증 Token 생성
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null,
new UsernamePasswordAuthenticationToken(
userDetails, null,
userDetails.getAuthorities());

//현재 Request의 Security Context에 접근권한 설정
SecurityContextHolder.getContext()
.setAuthentication(usernamePasswordAuthenticationToken);
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
} else {
SecurityContextHolder.getContext().setAuthentication(null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import static org.springframework.security.config.Customizer.withDefaults;

Expand All @@ -34,11 +36,10 @@ public class SecurityConfig {

private final JwtProvider jwtProvider;
private final CustomUserDetailsService customUserDetailsService;
private final CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler();
private final EntryPointUnauthorizedHandler unauthorizedHandler = new EntryPointUnauthorizedHandler();
private final JwtAuthenticationExceptionHandler jwtAuthenticationExceptionHandler =
new JwtAuthenticationExceptionHandler();

private final CustomAccessDeniedHandler accessDeniedHandler;
private final EntryPointUnauthorizedHandler unauthorizedHandler;
private final JwtAuthenticationExceptionHandler jwtAuthenticationExceptionHandler;
private final SecurityJwtProperties securityJwtProperties;

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
Expand All @@ -47,14 +48,11 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration c

@Bean
public JwtAuthFilter jwtAuthFilter() {
List<String> excludedPaths = Arrays.asList(
"/swagger-ui", "/v3/api-docs",
"/api/v2/auth/token/**", "/api/v2/email/send",
"/api/v2/internal/**", "/api/v2/email/verify", "/api/v2/riot/verify", "/api/v2/auth/join",
"/api/v2/auth/login", "/api/v2/password/reset", "/api/v2/auth/refresh", "/api/v2/posts/list"
);

return new JwtAuthFilter(customUserDetailsService, excludedPaths, jwtProvider);
List<RequestMatcher> excludedRequestMatchers = securityJwtProperties.getExcludedMatchers().stream()
.map(e -> new AntPathRequestMatcher(e.getPattern(), e.getMethod()))
.collect(Collectors.toList());

return new JwtAuthFilter(customUserDetailsService, excludedRequestMatchers, jwtProvider);
}

@Bean
Expand All @@ -73,31 +71,39 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthF
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

// 인증하지 않는 API 설정
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.requestMatchers("/api/v2/internal/**").permitAll()
.requestMatchers("/api/v2/auth/token/**", "/api/v2/email/send/**", "/api/v2/email/verify",
"/api/v2/riot/verify", "/api/v2/auth/join", "/api/v2/auth/login", "/api/v2/auth" +
"/refresh").permitAll() // Auth 관련
.requestMatchers("/api/v2/password/reset").permitAll() // Member 관련
.requestMatchers("/api/v2/posts/list/**").permitAll() // 게시판
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated());

// 필터 순서
http.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.requestMatchers("/api/v2/internal/**").permitAll() // internal
.requestMatchers("/api/v2/password/reset").permitAll() // password
.requestMatchers("/api/v2/riot/verify").permitAll() // riot
.requestMatchers("/api/v2/posts/list/**").permitAll() // board
.requestMatchers(
"/api/v2/auth/token/**",
"/api/v2/auth/join",
"/api/v2/auth/login",
"/api/v2/auth/refresh").permitAll()
.requestMatchers(
"/api/v2/email/send/**",
"/api/v2/email/verify").permitAll()
.requestMatchers(
"/api/v2/manner/keyword/*",
"/api/v2/manner/level/*").permitAll()
.requestMatchers(
"/swagger-ui/**",
"/v3/api-docs/**").permitAll()
.anyRequest().authenticated());

// 커스텀 필터 추가
http
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationExceptionHandler, jwtAuthFilter.getClass());

// 예외처리
http
.exceptionHandling(
(exceptionHandling) ->
exceptionHandling
.accessDeniedHandler(accessDeniedHandler) // access deny 되었을 때 커스텀 응답 주기 위한 핸들러
.authenticationEntryPoint(unauthorizedHandler)); // 로그인되지 않은 요청에 대해 커스텀 응답 주기
// 위한 핸들러
.exceptionHandling((exceptionHandling) -> exceptionHandling
.accessDeniedHandler(accessDeniedHandler) // access deny 되었을 때 커스텀 응답 핸들러
.authenticationEntryPoint(unauthorizedHandler)); // 로그인되지 않은 요청에 대해 커스텀 응답 핸들러

return http.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.gamegoo.gamegoo_v2.core.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

@Data
@Configuration
@ConfigurationProperties(prefix = "security.jwt")
public class SecurityJwtProperties {

private List<ExcludedMatcher> excludedMatchers = new ArrayList<>();

@Data
public static class ExcludedMatcher {

private String method;
private String pattern;

}

}
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ spring:
profiles:
default: local

# excluded-paths.yml 파일 import
config:
import: "optional:classpath:excluded-paths.yml"

# Gmail 설정
mail:
host: smtp.gmail.com
Expand Down
31 changes: 31 additions & 0 deletions src/main/resources/excluded-paths.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
security:
jwt:
excluded-matchers:
- pattern: /swagger-ui/*
- pattern: /v3/api-docs/*
- pattern: /v3/api-docs
- pattern: /api/v2/internal/** # 모든 internal 엔드포인트
- method: GET
pattern: /api/v2/auth/token/* # 임시 access token 발급
- method: POST
pattern: /api/v2/auth/join # 회원 가입
- method: POST
pattern: /api/v2/auth/login # 로그인
- method: POST
pattern: /api/v2/auth/refresh # 토큰 재발급
- method: POST
pattern: /api/v2/password/reset # 비밀번호 재설정
- method: POST
pattern: /api/v2/email/send/** # 인증 메일 전송
- method: POST
pattern: /api/v2/email/verify # 이메일 인증코드 검증
- method: POST
pattern: /api/v2/riot/verify # riot 계정 검증
- method: GET
pattern: /api/v2/posts/list # 게시글 목록 조회
- method: GET
pattern: /api/v2/posts/list/* # 비회원용 특정 게시글 조회
- method: GET
pattern: /api/v2/manner/keyword/* # 매너 키워드 정보 조회
- method: GET
pattern: /api/v2/manner/level/* # 매너 레벨 정보 조회
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,48 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import com.gamegoo.gamegoo_v2.account.auth.annotation.resolver.AuthMemberArgumentResolver;
import com.gamegoo.gamegoo_v2.account.auth.jwt.JwtInterceptor;
import com.gamegoo.gamegoo_v2.account.auth.jwt.JwtProvider;
import com.gamegoo.gamegoo_v2.account.auth.security.CustomUserDetailsService;
import com.gamegoo.gamegoo_v2.account.member.domain.LoginType;
import com.gamegoo.gamegoo_v2.account.member.domain.Member;
import com.gamegoo.gamegoo_v2.account.member.domain.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

@ActiveProfiles("test")
@WebMvcTest
@AutoConfigureMockMvc
public abstract class ControllerTestSupport {

@Autowired
protected WebApplicationContext context;

@Autowired
protected MockMvc mockMvc;

@MockitoBean
protected JwtInterceptor jwtInterceptor;
protected AuthMemberArgumentResolver authMemberArgumentResolver;

@MockitoBean
protected AuthMemberArgumentResolver authMemberArgumentResolver;
protected JwtProvider jwtProvider;

@MockitoBean
protected CustomUserDetailsService customUserDetailsService;

@Autowired
protected ObjectMapper objectMapper;
Expand All @@ -48,8 +64,14 @@ public abstract class ControllerTestSupport {

@BeforeEach
public void setUp() throws Exception {
// 인터셉터가 항상 true를 반환하도록 Mock 설정
given(jwtInterceptor.preHandle(any(), any(), any())).willReturn(true);
// csrf 설정
this.mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.defaultRequest(post("/**").with(csrf()))
.defaultRequest(patch("/**").with(csrf()))
.defaultRequest(delete("/**").with(csrf()))
.build();

// authMemberArgumentResolver가 mockMember를 반환하도록 Mock 설정
mockMember = Member.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.gamegoo.gamegoo_v2.controller;

import org.springframework.security.test.context.support.WithSecurityContext;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithCustomMockAdminSecurityContextFactory.class)
public @interface WithCustomMockAdmin {

Long memberId = 0L;

}
Loading
Loading