글쓰기 기능을 추가해 보았다.
글쓰는 기능은 유효한 토큰을 가지고 있는 유저만 사용 가능하게 하였다.
1. Configuration
JwtTokenUtil
public class JwtTokenUtil {
private static Claims extractClaims(String token, String key) {
return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
}
public static boolean isExpired(String token, String key) {
Date expiredDate = extractClaims(token, key).getExpiration();
return expiredDate.before(new Date()); // 현재보다 전인지 check. true : 만료됨
}
public static String getUserName(String token, String key) {
return extractClaims(token, key).get("userName").toString();
}
}
토큰의 만료여부와, 토큰의 헤더에 저장되어 있는 userName을 불러 올 수 있는 method를 추가하였다.
2. Configuration
JwtTokenFilter
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); //헤더에 토큰을 넘긴 것을 가져옴.
//토큰이 없는 경우 or Bearer로 시작하는 토큰이 아닌 경우
if(authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
//bearer 이후 문자열 token 분리 성공 실패
String token;
try {
token = authorizationHeader.split(" ")[1];
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
//만료된 토큰일 경우
if(JwtTokenUtil.isExpired(token, secretKey)) {
filterChain.doFilter(request, response);
return;
};
// Token에서 UserName꺼내기 (JwtTokenUtil에서 Claim에서 꺼냄)
String userName = JwtTokenUtil.getUserName(token, secretKey);
User user = userService.tokenGetUserByUserName(userName);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), null
, List.of(new SimpleGrantedAuthority(user.getRole().name())));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); //권한 부여
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request,response);
}
}
밑의 경우를 제외한 경우를 제외한 경우 인가를 내렸다.
- 토큰을 보유하지 않은 경우
- Bearer로 시작하는 토큰이 아닌 경우
- Bearer이후의 토큰 내용 분리가 실패하는 경우
- 만료 시간이 지난 토큰인 경우
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
//WebSecurityConfigurerAdapter 는 이후 SpringBoot에서 잘 안쓰게 됨
public class SecurityConfig {
private final UserService userService;
@Value("${jwt.token.secret}")
private String secretKey;
private static final String[] PERMIT_URL_ARRAY = {
/* swagger v2 */
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
/* swagger v3 */
"/v3/api-docs/**",
"/swagger-ui/**",
/* login and join*/
"/api/v1/users/login",
"/api/v1/users/join"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeRequests()
.antMatchers(PERMIT_URL_ARRAY).permitAll()
.antMatchers(HttpMethod.POST,"/api/**").authenticated() // post는 인가자만 허용
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀
.and()
.addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class) //UserNamePasswordAuthenticationFilter적용하기 전에 JWTTokenFilter를 적용
.build();
}
}
모든 유저가 접근 가능한 URL을 PERMIT_URL_ARRAY에 담았다.
- swagger의 경우 filter를 걸면 접근 오류가 생겨 관련 URL을 모두 추가해 주었다.
- 로그인과 회원가입은 모든 유저가 가능해야 하므로 추가해 주었다.
그 후, Post와 관련된 모든 내용은 권한이 있는 자만 허락해 주었다.
<Reference>
https://devfunny.tistory.com/692
3. Domain
Post
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Table(name = "post")
@Entity
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Post extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String body;
private String title;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id")
private User user;
}
글에 대한 정보를 담고 있는 Entity를 생성해 주었다.
유저가 다수의 글을 쓸 수 있으므로 @ManyToOne 매칭을 해주었다. 이 때, Post table이 user 의 key(id)를 foreign key로 갖게 된다.
fetch를 EAGER로 설정하면 예기치 못한 오류가 자주 발생한다 하니, LAZY로 설정하였다.
<Reference>
https://ict-nroo.tistory.com/132
PostCreateRequest (domain - dto directiory)
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class PostCreateRequest {
private String title;
private String body;
}
글 작성시 사용자에게 제목과 본문을 받아온다.
PostCreateResponse (domain - dto directiory)
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class PostCreateResponse {
private String message;
private int id;
}
글 작성 성공 시, 성공 메세지와 post의 id가 출력된다.
PostDto (domain - dto directiory)
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class PostDto {
private int id;
private String userName;
private String title;
private String body;
private LocalDateTime lastModifiedAt;
private LocalDateTime createdAt;
}
PostService에서 PostController로 정보를 옮길 때 사용하는 dto이다.
4. Repository
PostRepository
public interface PostRepository extends JpaRepository<Post, Long> {
}
JpaRepository를 extends 해주었다.
5. Service
PostService
@Service
@RequiredArgsConstructor
@Slf4j
public class PostService {
private final PostRepository postRepository;
private final UserRepository userRepository;
public PostDto createPost(PostCreateRequest req, String userName) {
log.info("userName:{}", userName);
User user = userRepository.findByUserName(userName)
.orElseThrow(()-> new AppException(ErrorCode.NOT_FOUNDED_USER_NAME, ""));
log.info("user info:{}", user.getUserName());
Post post = Post.builder()
.user(user)
.title(req.getTitle())
.body(req.getBody())
.build();
postRepository.save(post);
return PostDto.builder()
.id(post.getId())
.title(post.getTitle())
.body(post.getBody())
.userName(post.getUser().getUserName())
.build();
}
}
로그인한 유저의 토큰에서 가져온 userName과 유저가 입력한 글 제목, 글 내용을 controller에게 받는다.
db에서 userName을 통해 검색한 결과 없을 때 exception을 발생한다. (후에 토큰을 발행받은 후 유저가 삭제되는 경우 이 경우에 걸릴 듯 하다.)
그 후, post Entity에 입력받은 정보를 저장하고, PostDto에 정보를 담아 controller에 return해준다.
6. Controller
PostController
@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping
public Response<PostCreateResponse> createPost(@RequestBody PostCreateRequest req, Authentication authentication) {
String userName = authentication.getName();
PostDto postDto = postService.createPost(req, userName);
return Response.success(new PostCreateResponse("포스트 등록 완료", postDto.getId()));
}
}
유저로부터 글 제목과 글 내용을 받고, 인증내용을 토대로 userName을 가져온다.
그 후 PostService에서 로직을 실행한 후 받은 postDto을 토대로 Response를 view에 return한다.
7. TestCode
PostControllerTest
@WebMvcTest(PostController.class)
class PostControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
PostService postService;
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("포스트 작성 성공")
@WithMockUser
void post_success() throws Exception{
PostCreateRequest postCreateRequest = PostCreateRequest.builder()
.title("title")
.body("body")
.build();
when(postService.createPost(any(),any())).thenReturn(mock(PostDto.class));
mockMvc.perform(post("/api/v1/posts")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(postCreateRequest)))
.andDo(print())
.andExpect(status().isOk());
}
@Test
@DisplayName("포스트 작성 실패 - 로그인 하지 않은 상태(토큰 x)")
@WithAnonymousUser
void post_fail() throws Exception{
PostCreateRequest postCreateRequest = PostCreateRequest.builder()
.title("title")
.body("body")
.build();
mockMvc.perform(post("/api/v1/posts")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(postCreateRequest)))
.andDo(print())
.andExpect(status().isUnauthorized());
}
}
포스트 작성 성공과 실패 총 두가지 경우를 나눠 테스트 코드를 작성하였다.
@WithMockUser(인증된 상태) , @WithAnonymousUser(인증되지 않은 상태)를 사용하였다.
PostServiceTest
public 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_success() {
Post mockPostEntity = mock(Post.class);
User mockUserEntity = mock(User.class);
when(userRepository.findByUserName(mockUserEntity.getUserName()))
.thenReturn(Optional.of(mockUserEntity));
when(postRepository.save(any()))
.thenReturn(mockPostEntity);
Assertions.assertDoesNotThrow(() -> postService.createPost(new PostCreateRequest(mockPostEntity.getTitle(), mockPostEntity.getBody()), mockUserEntity.getUserName()));
}
@Test
@DisplayName("포스트 작성 실패 - 유저가 존재하지 않을 때")
void post_fail() {
Post mockPostEntity = mock(Post.class);
User mockUserEntity = mock(User.class);
when(userRepository.findByUserName(mockUserEntity.getUserName()))
.thenReturn(Optional.empty());
when(postRepository.save(any()))
.thenReturn(mockPostEntity);
AppException exception = assertThrows(AppException.class,
()-> {
postService.createPost(new PostCreateRequest(mockPostEntity.getTitle(), mockPostEntity.getBody()), mockUserEntity.getUserName());
});
Assertions.assertEquals("NOT_FOUNDED_USER_NAME", exception.getErrorCode().name());
}
}
SpringBoot Dependency가 없이 Pojo만으로 테스트 가능하도록 작성하였다.
User와 Post에 대한 Mock 객체를 임의로 설정하였다.
포스트 실패의 경우, userName을 db에서 찾았을 때, 빈 객체를 Optional.empty()를 통해 return하도록 하였다.
그 경우 발생한 exception이 "NOT_FOUNDED_USER_NAME"과 동일한지 검증하였다.
<reference>
https://covenant.tistory.com/256
https://www.daleseo.com/java8-optional-after/
발생 이슈들
1. JwtTokenFilter에서의 예외 처리 시도
JwtTokenFilter에서 해당 토큰이 유효하지 않은 경우 throw new exception을 통해 예외 처리를 하고 싶었으나,
return하지 않을 시 error가 났다. 만약 인증/인가에서 exception 처리를 하고 싶다면 아래의 방법을 이용해야 할 듯 하다.
<Reference>
https://velog.io/@seongwon97/Spring-Security-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80%EC%9D%98-Exception
2. Docker images가 계속 누적됨
1일차에 설정한 CI/CD로 자동 배포를 실행하면서 container는 제거했지만, image는 제거하지 않아 계속 쌓이고 있었다.
deploy-docker.sh에서 이미지를 pull 받을 때, 새 image가 받아지면 기존 image를 제거하도록 코드를 추가하였다.
image의 구분은 tag를 이용하여 하였으며, tag가 latest가 아니면 삭제하도록 하였다.
docker rmi $(docker images -f "dangling=true" -q)
<Reference>
<전체 Reference>
https://devfunny.tistory.com/692
https://ict-nroo.tistory.com/132
https://covenant.tistory.com/256
https://www.daleseo.com/java8-optional-after/
https://velog.io/@seongwon97/Spring-Security-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80%EC%9D%98-Exception
'프로젝트 > 멋사 개인 프로젝트 (mutsa-SNS)' 카테고리의 다른 글
[09] mutsa-SNS 4일차 - Post 조회 기능 추가 (0) | 2022.12.24 |
---|---|
[08] mutsa-SNS 3일차 - (3) JwtTokenFilter Exception 추가 (0) | 2022.12.23 |
[06] mutsa-SNS 3일차 - (1) User 수정 (0) | 2022.12.22 |
[05] mutsa-SNS 2일차 - (2) 로그인 기능 (1) | 2022.12.21 |
[04] mutsa-SNS 2일차 - (1) 회원가입 test code (0) | 2022.12.21 |