이번에는 Member API가 추가되었다.
회원 전체조회, 회원단건조회, 회원 정보 수정, 회원탈퇴 기능이 추가되었다.
저번 포스팅에서 바뀐 점과 중요한 점 위주로 설명할 것이다.
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers( "/v3/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests()
.antMatchers("/swagger-ui/**", "/v3/**").permitAll() // swagger
.antMatchers("/api/sign-up", "/api/sign-in", "/api/reissue").permitAll()
.antMatchers(HttpMethod.GET, "/api/members").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.PUT, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
마지막 쪽에. hasAnyAuthority를 추가해 주었다. 해당 권한이 있는 사람만 해당 페이지에 접속할 수 있도록 설정해 주었다.
Member
@Getter
@NoArgsConstructor
@Entity
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(nullable = false, unique = true)
private String username;
@JsonIgnore
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
private Authority authority;
@Builder
public Member(Long id, String username, String password, String name, Authority authority) {
this.id = id;
this.username = username;
this.password = password;
this.name = name;
this.authority = authority;
}
public void modify(String password,String name) {
this.password = password;
this.name = name;
onPreUpdate();
}
}
아래 modify라는 메서드가 추가되었다.
현재 username을 로그인 시 id로 사용하기 때문에, username을 제외한 나머지를 수정할 수 있는 메서드이다.
BaseEntity
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public abstract class BaseEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime modifiedAt;
/* 해당 엔티티를 저장하기 이전에 실행 */
@PrePersist
public void onPrePersist(){
this.createdAt = LocalDateTime.now();
this.modifiedAt = this.createdAt;
}
/* 해당 엔티티를 업데이트 하기 이전에 실행*/
@PreUpdate
public void onPreUpdate(){
this.modifiedAt = LocalDateTime.now();
}
}
각 정보들이 언제 생성되었고, 언제 수정되었는지 컬럼을 추가하기위해 사용된다.
위의 BaseEntity는 컬럼을 내려주게 되는데, 이를 위해 1가지 어노테이션을 추가해주어야 한다.
@SpringBootApplication
@EnableJpaAuditing
public class CommunityApplication {
public static void main(String[] args) {
SpringApplication.run(CommunityApplication.class, args);
}
}
메인이 있는 곳에 @EnableJpaAuditing을 추가해주어야 한다.
MemberController
@Api(value = "Member Controller", tags = "Member")
@RequestMapping("/api")
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final MemberRepository memberRepository;
@ApiOperation(value = "회원 전체 조회", notes = "회원 전체 조회")
@ResponseStatus(HttpStatus.OK)
@GetMapping("/members")
public Response findAllMembers(){
return Response.success(memberService.findAllMembers());
}
@ApiOperation(value = "회원 개별 조회", notes = "회원 개별 조회")
@ResponseStatus(HttpStatus.OK)
@GetMapping("/members/{id}")
public Response findMember(@ApiParam(value = "Member Id",required = true)
@PathVariable("id") Long id){
return Response.success(memberService.findMember(id));
}
@ApiOperation(value = "회원 정보 수정", notes = "회원 정보 수정")
@ResponseStatus(HttpStatus.OK)
@PutMapping("/members")
public Response editMemberInfo(@RequestBody MemberEditRequestDto memberEditRequestDto){
memberService.editMemberInfo(getPrincipal(), memberEditRequestDto);
Authentication authentication = new UsernamePasswordAuthenticationToken(getPrincipal().getUsername(),
memberEditRequestDto.getPassword());
SecurityContextHolder.getContext().setAuthentication(authentication);
return Response.success();
}
@ApiOperation(value = "회원 탈퇴", notes = "회원 탈퇴")
@ResponseStatus(HttpStatus.OK)
@DeleteMapping("/members")
public Response deleteMemberInfo() {
memberService.deleteMember(getPrincipal());
return Response.success();
}
public Member getPrincipal(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return memberRepository.findByUsername(authentication.getName())
.orElseThrow(MemberNotFoundException::new);
}
}
getPrincipal 메서드를 따로 만들어서 로그인한 유저를 가져오도록 만들었다.
이번에도 Swagger를 이용할 것이기 때문에 Swagger어너테이션들이 추가된 모습이다.
회원수정 부분을 보면, 비밀번호를 바꾸면 그 비밀번호를 가지고 시큐리티 컨텍스트에 넣어준 모습이다.
MemberService
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Transactional(readOnly = true)
public List<MemberFindResponseDto> findAllMembers() {
List<Member> members = memberRepository.findAll();
return members.stream()
.map(MemberFindResponseDto::toDto)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public MemberFindResponseDto findMember(Long id) {
Member member = memberRepository.findById(id).orElseThrow(MemberNotFoundException::new);
return MemberFindResponseDto.toDto(member);
}
@Transactional
public void editMemberInfo(Member member,MemberEditRequestDto memberEditRequestDto) {
member.modify(
passwordEncoder.encode(memberEditRequestDto.getPassword()),
memberEditRequestDto.getName());
}
@Transactional
public void deleteMember(Member member) {
memberRepository.delete(member);
}
}
MemberFindResponseDto의 경우 보이는 정보들인데, 현재 아이디와 이름을 보여주도록 설정하였다.
엔티티 객체자체를 넘겨주면 안 되는지 궁금할 수 있는데, 엔티티 객체 자체를 넘기면 안 된다.
우선 첫 번째로 보안상의 이유가 있다.
실제 보여주면 안 되는 정보들이 있을 수 있기 때문이다.
둘째는 만약 컬럼이 추가된다면,
엔티티를 반환하는 API마다 전부 수정해주어야 하는 상황이 발생한다.
따라서 엔티티를 API 스펙에 맞추지 말고, DTO를 사용하여 반환해야 한다.
PasswordEncoder를 주입받은 이유는 단순하게 비밀번호를 저장할 때, 암호화해야 하기 때문이다.
테스트 코드
MemberControllerTest
@ExtendWith(MockitoExtension.class)
public class MemberControllerTest {
@InjectMocks
MemberController memberController;
@Mock
MemberRepository memberRepository;
@Mock
MemberService memberService;
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
public void beforeEach() {
mockMvc = MockMvcBuilders.standaloneSetup(memberController).build();
}
@Test
public void 회원전체조회() throws Exception {
mockMvc.perform(
get("/api/members"))
.andExpect(status().isOk());
verify(memberService).findAllMembers();
}
@Test
public void 회원단건조회() throws Exception {
//given
Long id = 1L;
//when, then
mockMvc.perform(
get("/api/members/{id}", id))
.andExpect(status().isOk());
verify(memberService).findMember(id);
}
@Test
public void 회원정보수정() throws Exception {
// given
MemberEditRequestDto req = new MemberEditRequestDto("비밀번호수정","이름 수정");
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when
mockMvc.perform(
put("/api/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
).andExpect(status().isOk());
// then
verify(memberService).editMemberInfo(refEq(member), refEq(req));
}
@Test
public void 회원탈퇴() throws Exception {
// given
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "",
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
// when then
mockMvc.perform(
delete("/api/members"))
.andExpect(status().isOk());
verify(memberService).deleteMember(member);
}
}
각 메서드마다 한글로 표기하였는데, 테스트의 경우 다른 곳에서 사용되지 않고 테스트의 용도로만 사용되기 때문에 가독성이 좋은 한글을 사용하였다.
then 부분을 보면 반환타입에 refEq가 포함되어 있다.
처음에는 저부분을 생략하고 진행하였는데,
다음과 같은 에러가 발생했다.
왜 안되는지 찾아보니 Object(객체)의 비교가 동작이 잘 안 될 때도 있다고 한다.
테스트코드 서비스의 메서드에 넣어준 MemberEditRequestDto와 실제 controller를 거치면서 가공된 MemberEditRequestDto가 동일한지 비교하는 것으로 보였다. (컨트롤러를 탈 때 컨트롤러 메서드 안에서 매개변수가 변형될 수도 있다.)
인자가 똑같도록 맞춰주었음에도 객체 비교가 잘 안 되는지 Argument(s) are different! Wanted: 에러가 발생했다.
해결방법은 mockito가 제공하는 Matchers 클래스의 refEq()를 사용하면 된다. javadoc에는 refEq()에 대해 이렇게 설명되어 있다.
* Object argument that is reflection-equal to the given value with support for excluding selected fields from a class.
주어진 값 자체를 비교하기 위해 사용하니 테스트를 통과할 수 있었다.
MemberServiceTest
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@InjectMocks
MemberService memberService;
@Mock
MemberRepository memberRepository;
@Mock
PasswordEncoder passwordEncoder;
@Test
void 회원전체조회테스트() {
// given
Member member = createMember();
Member member2 = createMember();
List<Member> list = new LinkedList<>();
list.add(member);
list.add(member2);
given(memberRepository.findAll()).willReturn(list);
// when
List<MemberFindResponseDto> result = memberService.findAllMembers();
// then
assertThat(result.size()).isEqualTo(2);
}
@Test
void 회원단건조회테스트() {
// given
Long id = 1L;
Member member = Member.builder().username("username")
.password("1234")
.name("name")
.build();
given(memberRepository.findById(id)).willReturn(Optional.of(member));
// when
MemberFindResponseDto result = memberService.findMember(id);
// then
assertThat(result.getUsername()).isEqualTo("username");
}
@Test
public void 회원정보수정테스트() {
//given
Member member = Member.builder().username("username")
.password("1234")
.name("name")
.build();
MemberEditRequestDto req = new MemberEditRequestDto("0000", "new");
//when
memberService.editMemberInfo(member,req);
//then
assertThat(member.getName()).isEqualTo("new");
}
@Test
public void 회원삭제테스트() {
//given
Member member = Member.builder().username("username")
.password("1234")
.name("name")
.build();
//when
memberService.deleteMember(member);
//then
verify(memberRepository).delete(any());
}
}
테스트는 무사히 잘 통과하게 된다.
이제 포스트맨을 사용하여 실험해 보자..
우선 멤버회원가입을 해두자.
1번부터 5번까지 등록시켜 두었다.
회원 전체조회부터 해보자.
권한 설정이 USER부터 가능하기 때문에 로그인이 되어있어야 접근가능하다.
회원 단건조회를 해보자.
만약 회원이 없다면?
위와 같이 에러가 발생한다.
회원 5로 로그인 후 개인정보를 수정해 보자.
회원 5를 보면, 수정된 시간과 이름이 변경된 것을 확인할 수 있다.
당연하게도 이전 비밀번호로는 로그인이 안된다.
이제 회원 탈퇴를 해보자.
잘 삭제된 것을 볼 수 있다.
스웨거는 테스트는 따로 진행하지 않고,
추가된 모습만 살펴보자.
자세한 코드는 깃허브를 통해 볼 수 있다.
https://github.com/kimtaesoo99/community
'프로젝트 > 커뮤니티' 카테고리의 다른 글
[프로젝트] 커뮤니티 REST API 서버만들기 #6 Comment API만들기 (0) | 2023.01.25 |
---|---|
[프로젝트] 커뮤니티 REST API 서버만들기 #5 게시판 부가기능 추가 (0) | 2023.01.24 |
[프로젝트] 커뮤니티 REST API 서버만들기 #4 Board API만들기 (0) | 2023.01.23 |
[프로젝트] 커뮤니티 REST API 서버만들기 #3 - Message API 만들기 (6) | 2023.01.21 |
[프로젝트] 커뮤니티 REST API 서버 만들기 #1 로그인기능 구현 + Swagger (2) | 2023.01.18 |