SNS 제작 (포스트 작성)

개발연습생log·2022년 12월 26일
0

SNS 제작

목록 보기
4/15
post-thumbnail

✨개요

🏃 목표

📢 포스트를 작성하는 기능을 구현하자.

📢 요구사항

  • 회원만이 포스트를 작성할 수 있다.
  • POST /posts
  • 입력폼 (JSON 형식)
    {
    	"title" : "title1",
    	"body" : "body1"
    }
  • 리턴 (JSON 형식)
    {
    	"resultCode":"SUCCESS",
    	"result":{
    		"message":"포스트 등록 완료",
    		"postId":0
    	}
    }

📜 접근 방법

  • 회원만이 포스트를 작성할 수 있기 때문에 시큐리티 필터체인에서 포스트 작성 API에 접근하려면 인증처리를 해주는 로직이 포함되어야 한다.
  • 만약 토큰 없이 포스트 작성 API에 접근 시 시큐리티 필터 체인에 예외 핸들링 로직을 추가해야 한다.
  • 시큐리티 인증 구현 과정 유튜브 강의

✅ TO-DO

  • 포스트 작성 테스트 작성
  • JWT 토큰 인증
  • 포스트 컨트롤러 구현
  • 포스트 서비스 구현
  • 포스트 리포지토리 구현

🔧 구현

포스트 작성 테스트 작성

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 구현

  • PostWriteRequest
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
public class PostWriteRequest {
    private String title;
    private String body;
}
  • PostWriteResponse
@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) {
        //userName 찾기
        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);
        //포스트 DTO 반환
        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 = {
            /* swagger v2 */
            "/v2/api-docs",
            "/swagger-resources",
            "/swagger-resources/**",
            "/configuration/ui",
            "/configuration/security",
            "/swagger-ui.html",
            "/webjars/**",
            /* swagger v3 */
            "/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를 추가하고 난 뒤 컨트롤러 테스트를 작성하는 것이 어려웠다. 이 부분에 대해서 좀 공부가 필요한 것 같다.
profile
주니어 개발자를 향해서..

0개의 댓글