✨개요
🏃 목표
📢 포스트를 작성하는 기능을 구현하자.
📢 요구사항
- 회원만이 포스트를 작성할 수 있다.
- 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를 추가하고 난 뒤 컨트롤러 테스트를 작성하는 것이 어려웠다. 이 부분에 대해서 좀 공부가 필요한 것 같다.