✨개요
🏃 목표
📢 포스트를 작성하는 기능을 구현하자.
📢 요구사항
- 회원만이 포스트를 작성할 수 있다.
- POST /posts
- 입력폼 (JSON 형식){
	"title" : "title1",
	"body" : "body1"
}
 
- 리턴 (JSON 형식){
	"resultCode":"SUCCESS",
	"result":{
		"message":"포스트 등록 완료",
		"postId":0
	}
}
 
📜 접근 방법
- 회원만이 포스트를 작성할 수 있기 때문에 시큐리티 필터체인에서 포스트 작성 API에 접근하려면 인증처리를 해주는 로직이 포함되어야 한다.
- 만약 토큰 없이 포스트 작성 API에 접근 시 시큐리티 필터 체인에 예외 핸들링 로직을 추가해야 한다.
- 시큐리티 인증 구현 과정 유튜브 강의
✅ TO-DO
🔧 구현
포스트 작성 테스트 작성
PostControllerTest
<@WebMvcTest(PostController.class)
@MockBean(JpaMetamodelMappingContext.class)
class PostControllerTest {
    @Autowired
    MockMvc mockMvc;
    @MockBean
    PostService postService;
    @Autowired
    ObjectMapper objectMapper;
    @Test
    @DisplayName("포스트 작성 성공")
    @WithMockUser
    void post_write_SUCCESS() throws Exception {
        String title = "테스트";
        String body = "테스트입니다.";
        String message = "포스트 등록 완료";
        Long id = 1l;
        when(postService.write(any(), any(), any()))
                .thenReturn(new PostWriteResponse(message, id));
        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(new PostWriteRequest(title, body))))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.message").exists())
                .andExpect(jsonPath("$.result.postId").exists());
    }
    @Test
    @DisplayName("포스트 작성 실패_인증")
    @WithMockUser
    void post_write_FAILED_authentication() throws Exception {
        String title = "테스트";
        String body = "테스트입니다.";
        when(postService.write(any(), any(), any()))
                .thenThrow(new AppException(ErrorCode.INVALID_TOKEN, ErrorCode.INVALID_PASSWORD.getMessage()));
        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(new PostWriteRequest(title, body))))
                .andDo(print())
                .andExpect(status().isUnauthorized());
    }
    @Test
    @DisplayName("포스트 작성 실패_토큰 만료")
    @WithAnonymousUser
    void post_write_FAILED() throws Exception {
        String title = "테스트";
        String body = "테스트입니다.";
        when(postService.write(any(), any(), any()))
                .thenThrow(new AppException(ErrorCode.INVALID_TOKEN, ErrorCode.INVALID_TOKEN.getMessage()));
        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(new PostWriteRequest(title, body))))
                .andDo(print())
                .andExpect(status().isUnauthorized());
    }
}
PostServiceTest
class PostServiceTest {
    PostService postService;
    PostRepository postRepository = mock(PostRepository.class);
    UserRepository userRepository = mock(UserRepository.class);
    @BeforeEach
    void setUp() {
        postService = new PostService(postRepository, userRepository);
    }
    @Test
    @DisplayName("포스트 등록 성공")
    void post_write_SUCCESS() {
        Post post = mock(Post.class);
        User user = mock(User.class);
        when(userRepository.findByUserName(any()))
                .thenReturn(Optional.of(user));
        when(postRepository.save(any()))
                .thenReturn(post);
        assertDoesNotThrow(() -> postService.write("아무개", "테스트", "테스트입니다."));
    }
    @Test
    @DisplayName("포스트 등록 실패_로그인을 하지않은 경우")
    void post_write_FAILED() {
        Post post = mock(Post.class);
        when(userRepository.findByUserName(any()))
                .thenReturn(Optional.empty());
        when(postRepository.save(any()))
                .thenReturn(post);
        AppException exception = assertThrows(AppException.class, () -> postService.write("아무개", "테스트", "테스트입니다."));
        assertEquals(ErrorCode.USERNAME_NOT_FOUND, exception.getErrorCode());
    }
}
포스트 컨트롤러 구현
PostController 구현
@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;
    @PostMapping("")
    public ResponseEntity<Response> writePost(@RequestBody PostWriteRequest postWriteRequest,Authentication authentication) {
        String userName = authentication.getName();
        PostWriteResponse postWriteResponse = postService.write(userName, postWriteRequest.getTitle(), postWriteRequest.getBody());
        return ResponseEntity.ok().body(Response.toResponse("SUCCESS", postWriteResponse));
    }
}
PostWriteDTO 구현
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
public class PostWriteRequest {
    private String title;
    private String body;
}
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
public class PostWriteResponse {
    private String message;
    private Long postId;
    public static PostWriteResponse of(String message, Long postId){
        return PostWriteResponse.builder()
                .message(message)
                .postId(postId)
                .build();
    }
}
포스트 서비스 구현
PostService
@Service
@RequiredArgsConstructor
@Slf4j
public class PostService {
    private final PostRepository postRepository;
    private final UserRepository userRepository;
    public PostWriteResponse write(String userName, String title, String body) {
        
        User findUser = userRepository.findByUserName(userName).orElseThrow(() -> {
            log.error("userName Not Found : {}", userName);
            throw new AppException(ErrorCode.USERNAME_NOT_FOUND, ErrorCode.DUPLICATED_USER_NAME.getMessage());
        });
        
        Post savedPost = Post.of(title, body, findUser);
        savedPost = postRepository.save(savedPost);
        
        PostWriteResponse postWriteResponse = PostWriteResponse.of("포스트 등록이 완료되었습니다.", savedPost.getId());
        return postWriteResponse;
    }
}
포스트 리포지토리 구현
PostRepository
public interface PostRepository extends JpaRepository<Post,Long> {
}
PostEntity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Setter
@Getter
public class Post extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String body;
    @ManyToOne
    @JoinColumn(name = "userId")
    private User user;
    public static Post of(String title, String body, User user) {
        return Post.builder()
                .title(title)
                .body(body)
                .user(user)
                .build();
    }
}
BaseEntity
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
@Setter
public abstract class BaseEntity {
    @CreatedDate
    @Column(name = "createDate", updatable = false)
    private LocalDateTime createdAt;
    @LastModifiedDate
    @Column(name = "modifiedDate")
    private LocalDateTime modifiedAt;
}
Main 수정
@SpringBootApplication
@EnableJpaAuditing
public class ProjectSnsApplication {
    public static void main(String[] args) {
        SpringApplication.run(ProjectSnsApplication.class, args);
    }
}
JWT 토큰 인증
JwtUtil 메서드 추가
public static boolean isValidToken(String token, String key) {
        String userName = null;
        try {
            Jwts.parser().setSigningKey(key).parseClaimsJws(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.error(e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.error(e.getMessage());
        } catch (MalformedJwtException e) {
            log.error(e.getMessage());
        } catch (SignatureException e) {
            log.error(e.getMessage());
        } catch (IllegalArgumentException e) {
            log.error(e.getMessage());
        }
        return false;
    }
    public static UsernamePasswordAuthenticationToken createAuthentication(String token, String key) {
        String userName = Jwts.parser().setSigningKey(key).parseClaimsJws(token)
                .getBody().get("userName", String.class);
        return new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));
    }
JwtFilter
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
    @Value("${jwt.token.key}")
    private String key;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("authrization : {}", authorization);
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        String token = authorization.split(" ")[1];
        JwtUtil.isValidToken(token, key);
        UsernamePasswordAuthenticationToken authenticationToken = JwtUtil.createAuthentication(token, key);
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}
ExceptionHandlerFilter
@Component
@Slf4j
public class AuthenticationManager implements AuthenticationEntryPoint {
    @Autowired
    private ObjectMapper objectMapper;
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        this.setResponse(ErrorCode.INVALID_TOKEN, response);
    }
    private void setResponse(ErrorCode errorCode, HttpServletResponse response) throws IOException {
        log.error(errorCode.getMessage());
        response.setStatus(errorCode.getHttpStatus().value());
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus().name(), errorCode.getMessage());
        Response resultResponse = Response.of("ERROR", errorResponse);
        String json = objectMapper.writeValueAsString(resultResponse);
        response.getWriter().write(json);
    }
}
SecurityConfig 수정
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final AccessDeniedManager accessDeniedManager;
    private final AuthenticationManager authenticationManager;
    private final JwtFilter jwtFilter;
    private String[] PERMIT_URL = {
            "/api/v1/hello",
            "/api/v1/users/**"
    };
    private String[] SWAGGER = {
            
            "/v2/api-docs",
            "/swagger-resources",
            "/swagger-resources/**",
            "/configuration/ui",
            "/configuration/security",
            "/swagger-ui.html",
            "/webjars/**",
            
            "/v3/api-docs/**",
            "/swagger-ui/**"
    };
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers(PERMIT_URL).permitAll()
                .antMatchers(SWAGGER).permitAll()
                .anyRequest().authenticated()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(authenticationManager)
                .and()
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}
🌉 회고
- 스프링 시큐리티 체인에서 발생한 예외는 @RestControllerAdvice에서 처리할 수 없고 시큐리티 필터 체인에 exceptionHandling을 추가해줘야 한다는 사실을 알게 되었다.
- JwtFilter를 추가하고 난 뒤 컨트롤러 테스트를 작성하는 것이 어려웠다. 이 부분에 대해서 좀 공부가 필요한 것 같다.