JAVA/소박한그룹 프로젝트

[211023] 회원가입 승인/대기/거절 기능 추가하기 (Thymeleaf, Spring Boot) (1)

kth990303 2021. 10. 23. 13:54
반응형

이번 시간에는 프로젝트 이슈 #17에 해당되는 회원가입 승인 관련 내용을 포스팅하려한다.

 

참고로 아직 현재진행형 중인 이슈이므로, 참고만 해주길 바란다. 현재는 스프링시큐리티 권한 관련 약간의 버그가 존재하여 이후에 수정해주어야 한다.

github #21, #22 수정완료. 수정완료된 버전으로 포스팅하였음.

 

https://github.com/kth990303/BOJStudyList/issues/17

 

회원가입 시, 승인/대기/완료 창을 만들어주세요. · Issue #17 · kth990303/BOJStudyList

현재는 회원가입을 진행할 경우, 별다른 확인조치 없이 가입이 완료됩니다. 이는 그룹 인원파악 및 보안에 치명적일 수 있습니다. 10월 중순~말까지 이 기능을 완료해주세요. 회원가입을 진행할

github.com

그동안은 회원가입을 하면 바로 완료되어 글을 작성할 수 있었지만,

이제는 관리자의 승인을 받아야 정식 회원이 될 수 있도록 한다.


회원 ROLE Type을 분류하자

- Domain

그동안 Member Role은 두 가지로 분류됐다.

관리자를 나타내주는 ADMIN, 일반 회원을 나타내는 USER.

우리는 여기에 승인 대기중인 GUEST를 추가하도록 하자.

@AllArgsConstructor
@Getter
public enum MemberRole {
    USER("ROLE_USER"), ADMIN("ROLE_ADMIN"), GUEST("ROLE_GUEST");
    private final String value;
}

Member Entity에는 isMember 멤버변수를 추가해주었다.

isMember 멤버변수는 현재 회원이 정식회원(USER 이상) 인지, 승인대기중인 회원(GUEST)인지의 여부를 나타내준다.

Entity의 update 코드에 아래 메소드를 추가하였다.

public void updateMember(Boolean isMember){
    this.isMember=isMember;
}

- DTO

회원가입 시 사용하는 dto는 변화가 없다.

사용자가 자신이 일반회원인지 관리자인지, 대기를 기다리는 회원권한인지 입력하지 않기 때문이다.

 

대신, 회원 리스트를 출력할 때 사용하는 dto에 isMember 멤버변수를 추가해주었다.

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberIdDto {
    @NotNull
    private Long id;
    @NotNull
    private String name;
    @NotNull
    @Size(min = 2, max = 16)
    private String password;
    @Pattern(regexp = "[a-zA-z0-9]+@[a-zA-z]+[.]+[a-zA-z.]+")
    private String email;
    private String tier;
    private Boolean isMember;

    public MemberIdDto(Long id, String name, String password, String email, String tier, Boolean isMember) {
        this.id = id;
        this.name = name;
        this.password = password;
        this.email = email;
        this.tier = tier;
        this.isMember=isMember;
    }
}

AllArgsConstructor 어노테이션을 사용해주어도 될 듯한데, 이상하게 생성자를 만드는게 이게 버릇돼서 위처럼 코드를 작성한듯하다. (근데 대부분은 요즘 builder 쓰지 않나?)

 

DTO이기 때문에 @Setter가 있어도 크게 문제되지는 않는다. (사실 없는게 베스트이긴 하다.)

 

- Mapper

dto를 수정했으니, 당연히 mapper도 수정해주어야 한다.

근데 좀 웃긴게, 바꾼 dto의 mapper를 수정해야되느냐? 그건 아니다. 어차피 mapstruct가 멤버변수끼리는 자동으로 맞춤돼서 빌드할 때 변경되기 때문이다.

 

오히려 우리가 별다른 작업을 하지 않았던, 회원가입할 때 사용되는 dto의 mapper를 수정해주어야한다.

회원가입할 때 사용자가 권한을 직접 입력하진 않지만, 우리가 dto -> entity로 바꿔줄 때엔 권한을 설정한 채로 변환해주어야되기 때문이다.

@Mapper(componentModel = "spring")
public interface MemberMapper extends GenericMapper<MemberDto, Member> {
    @Override
    default Member toEntity(MemberDto memberDto){
        if(memberDto==null)
            return null;
        String name=memberDto.getName();
        String tier = memberDto.getTier();
        String email = memberDto.getEmail();
        String password = memberDto.getPassword();

        Member member=new Member(name,tier,email,password);
        if(name.equals("kth990303"))
            member.updateMember(true);
        else
            member.updateMember(false);

        return member;
    }
}

위에서 작성했던 entity의 update코드를 이용하도록 하자.

참고로 나 (kth990303)는 관리자이기 때문에 예외를 두었다.

더 간단하게 작업할 수 있을 듯한데... 일단은 저렇게 두었다.

- Security Config

이제 Security Config 부분에서 수정할 것이 있으면 수정해주자.

난 일단 GUEST 여도 게시판 목록에는 이동할 수 있도록 하되, 게시글 내용이나 제목은 볼 수 없도록 하고 싶었다.

따라서 게시판까지는 이동할 수 있도록 하되, 제목은 조건문을 통해 blind처리해주었다.

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    //.csrf().disable()// 나중에 없애기
    .authorizeRequests()
    .antMatchers("/", "/createMemberForm", "/login", "/errorPage", "/index").permitAll()
    .antMatchers("/memberInfo", "/user/", "/post/**").hasAnyRole("ADMIN", "USER")
    .antMatchers("/admin/**").hasRole("ADMIN")
    .antMatchers("/editMember").authenticated()
    .anyRequest().authenticated()

    // ...
}

.permitAll()은 모든 회원이 열람가능이다.

.hasRole(), .hasAnyRole() 로 열람권한을 설정할 수 있다.

.authenticated() 로 인증된 회원만 접근할 수 있도록 할 수 있다.

 

- Repository

그동안 전체회원 목록만 findAll 해주는 메소드만 있었는데,

이제 isMember 변수 여부에 따라 Member을 전부 찾는 메소드, preMember를 전부 찾는 메소드로 분리해주었다.

CrudRepository 기능을 사용하였다.

public interface MemberRepository extends CrudRepository<Member, Long> {
    Member findByName(String name);
    Boolean existsByName(String name);
    List<Member> findAllByIsMemberTrue();
    // 회원가입 대기자
    List<Member> findAllByIsMemberFalse();
}

- Service

@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
  Member findMember = memberRepository.findByName(name);
  List<GrantedAuthority> roles=new ArrayList<>();
  if(findMember.getName().equals("kth990303")){
    roles.add(new SimpleGrantedAuthority(MemberRole.ADMIN.getValue()));
  }
  else if(!findMember.getIsMember()){
    roles.add(new SimpleGrantedAuthority(MemberRole.GUEST.getValue()));
  }
  else
    roles.add(new SimpleGrantedAuthority(MemberRole.USER.getValue()));
  return new User(findMember.getName(), findMember.getPassword(), roles);
}

나를 제외한 모두가 관리자가 아니다. (ㅎㅎ...)

그리고 아직 isMember가 아닌 회원들은 승인 대기중인 것이나 마찬가지이기 때문에 GUEST 권한을 부여해주었다.

 

preMember가 승인 대기 전 회원, Member가 정식회원이라고 하자.

preMember -> Member로 바꿔주는 메소드 또한 당연히 필요할 것이므로 아래 코드를 추가하였다.

public void changePreMemberToMember(Long id){
  Optional<Member> member = memberRepository.findById(id);
  if(member.isEmpty()){
    throw new NoSuchElementException("회원이 존재하지 않습니다.");
  }
  member.get().updateMember(true);
}

 

- Controller

이제 model에 정보를 넘겨주어 View쪽과 연결해주는 Controller 작업만 하면 된다.

@GetMapping("/admin/member/preMemberList")
public String memberAdmin(Model model){
  try{
    Collection<? extends GrantedAuthority> authorities
      = SecurityContextHolder.getContext().getAuthentication().getAuthorities();
      // 관리자가 아닌데 접근할 수 있었다면 권한없음 에러페이지를 띄워준다.
    if(!authorities.contains(new SimpleGrantedAuthority("ROLE_ADMIN"))){
      return "error/notFound404Page";
    }

    List<MemberIdDto> preMembers = memberService.findAllPreMembers();
    model.addAttribute("preMembers", preMembers);
  } catch(Exception e){
    e.printStackTrace();
    return "error/notFound404Page";
  }
  return "memberPreRegister/memberAdmin";
}

 승인/거절을 하는 관리자 페이지가 필요하기 때문에 view 페이지와 매핑해주는 코드를 작성해준다.

 

- Service Test Code

    @Test
    void 모든회원조회() {
        createMember();
        // premember를 member로 변환해준다.
        service.changePreMemberToMember(1L);
	// member 명단만 찾음.
        List<MemberIdDto> members = service.findAllMembers();

        Assertions.assertThat(members.size()).isEqualTo(1);
    }


    @Test
    void findAllPreMember(){
    	// member로 변환해주진 않음.
        createMember();
        List<MemberIdDto> allPreMembers = service.findAllPreMembers();
        Assertions.assertThat(allPreMembers.size()).isEqualTo(3);
        Assertions.assertThat(allPreMembers.get(0).getName()).isEqualTo("test");
        Assertions.assertThat(allPreMembers.get(1).getName()).isEqualTo("test2");
        Assertions.assertThat(allPreMembers.get(2).getName()).isEqualTo("test3");
    }

Thymeleaf View 코드

전체코드는 아래 깃헙주소를 참고하자.

https://github.com/kth990303/BOJStudyList

 

GitHub - kth990303/BOJStudyList: BOJ 그룹스터디 회원들의 공간을 만들자~

BOJ 그룹스터디 회원들의 공간을 만들자~. Contribute to kth990303/BOJStudyList development by creating an account on GitHub.

github.com

포스팅에선 새로운 기능들만 쓸 것이다.

 

- 회원정보를 확인할 수 있는 백준 프로필링크

백준 프로필링크는 탭 내에서 열리는 것이 아닌, 또다른 탭으로 열렸으면 좋겠어서 아래와 같이 th:target="_blank"를 이용하였다. thymeleaf에서도 target 기능이 있으니 잘 이용하면 좋을 듯.

 <a th:href="@{|https://www.acmicpc.net/user/${member.name}|}"
                   th:target="_blank">백준 프로필</a></td>

- 회원 승인/거절

<td>
  <a th:href="@{|/edit/${member.id}|}">수정 및 승인</a>
  <a th:href="@{|/delete/${member.id}|}"
  	th:text="#{member.page.secession}">삭제</a>
</td>

th:text에 있는 # 기능은 messages.properties에서 메시지를 따로 설정한 것이다.

아래와 같이 properties에 설정된 내용이 text로 출력된다.

member.page.add=회원가입
member.page.edit=정보수정
member.page.secession=회원탈퇴
member.page.login=로그인
member.page.logout=로그아웃

참고로 승인을 왜 edit 메소드에서 하는지 의문이 들 수 있다.

아래와 같은 경우가 있기 때문에 따로 mapping을 하지 않고 edit 매핑에서 진행하였다.

 

  1. 회원이 백준 티어를 잘못 입력한 채 회원가입을 진행했을 가능성이 존재한다.
  2. ModelAttribute 기능을 통해 기존에 있었던 내용을 가져와서 수정할 수 있도록 한다.
  3. 수정 작업을 거쳐 승인한다는 것은 어차피 preMember -> member로 되는 것이 확실시되기 때문에 기존의 editMember 메소드를 가져와도 문제가 되지 않는다.

따라서 컨트롤러의 editMember의 postMapping에 아래 코드를 추가해주었다.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
if(authorities.contains(new SimpleGrantedAuthority("ROLE_ADMIN"))){
  // 관리자가 회원을 수정하는 경우는 두 가지.
  // 1. 회원정보 수정 2. 회원을 승인하기 전 정보를 설정하기 위한 수정 (거절 시엔 굳이 수정 필요 X)
  // 2번을 위하여 넣은 코드.
  memberService.changePreMemberToMember(id);
}

- 글쓰기 기능, 그리고 게시글 제목은 GUEST가 보이지 않도록

<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" 
	class="no-uline" href="/post/addPost" th:href="@{/post/enroll}">글쓰기</a>

sec:authorize 기능을 이용하면 된다.

<td><a class="no-uline" th:text="${posts[i].title}"
                   th:if="${#authorization.expression('hasAnyRole(''ROLE_USER'', ''ROLE_ADMIN'')')}"
                   th:href="@{|/post/${posts[i].id}|}">제목</a></td>

th:if 와 authorization 을 이용하면 된다.

이 작업은 아래 블로그 포스팅을 참고하였다.

https://fknd12.tistory.com/54

 

Thymeleaf spring security 사용하기

페이지에서 Spring Security를 사용하여 권한별 구조를 바꾸려고 하는 데 사용했던 소스를 적용을 시켰는데 동작을 안 하여 이것저것 살펴봤더니 Config 쪽에 적용이 안 된 부분이 있어 적용을 하였습

fknd12.tistory.com


결과

- 관리자 계정으로 로그인

자, 그럼 실행해보도록 하자.

참고로 백엔드에 집중하면서 만든 프로젝트라 디자인은 영 아니다.

관리자 계정으로 로그인

대기회원을 제외한 USER, ADMIN 계정 명단이 뜨는 것을 확인할 수 있다.

또한, 관리자 계정으로 로그인하니까, 승인대기회원이 1명 대기중임을 확인할 수 있으며, 이는 USER, GUEST 계정으로 로그인할 땐 보이지 않는다.

저기로 들어가면 우측 사진과 같은 화면이 뜬다.

 

오른쪽 사진은 승인/거절을 선택할 수 있는 관리자화면이다.

zi_hee님이 회원가입 승인을 대기중인 걸 확인할 수 있다.

백준 프로필 링크로 들어가 입력한 티어가 맞는지 확인해주고 수정/승인해주자.

이런, 회원이 입력한 티어와 실제 티어가 다르다.

이런 경우가 존재하기 때문에 바로 승인이 아닌, 수정 후 승인을 할 수 있도록 해주었다.

Silver II로 수정 후 승인해주었더니, 회원 정보가 수정된 상태로 가입됨을 확인할 수 있다.

 

 

- GUEST 계정으로 로그인

이번엔 test라는 계정으로 새로 회원가입을 해본 후, 로그인해보았다.

승인 대기중 멘트가 뜬다.

GUEST 계정은 승인 대기중이라는 글이 뜬다.

 

왼쪽은 일반계정, 오른쪽은 GUEST 계정

제목은 아예 보이지 않게 해주었으며,

들어갈 수도 없게 해주었다.


GUEST 계정이 생기니까 훨씬 재밌어졌다.

앞으로도 다양한 기능을 추가해야겠다.

 

그리고 기능이 많아질수록 test code는 정말 중요한듯하다.

나중에 mock을 이용한 controller 테스트도 좀 더 해봐야겠다.

반응형