Spring/괴발개발

[괴발개발] TODO 웹 개발 노트 - 회원가입과 비밀번호 암호화

오잎 클로버 2022. 2. 18. 09:00
728x90

원래는 그냥 평문으로 저장을 하고자하였지만, 최소한의 암호화는 해야한다고 생각이 들어

암호화를 하도록 하였습니다.

일단, 먼저 기존 엔티티 중 회원(Member) 엔티티 코드를 조금 수정하였습니다.

UserDetails 이라는 인터페이스를 구현을 합니다.

일종의 권한 및 핵심 사용자 정보를 편하게 저장시켜주는 인터페이스이기에 사용합니다.

자세한 내용은 이 글을 참조해주세요. (잘 정리되어 있어 도움이 많이된 글입니다.)

@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getPassword() {
    return this.password;
}

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getUsername() {
    return this.email;
}

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
    return true;
}

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
    return true;
}

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
    return true;
}

@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
    return true;
}

위 인터페이스를 사용하여 구현해야하는 메소드들 전부 구현해줍니다.

@Getter @Setter
@AllArgsConstructor @NoArgsConstructor
@Builder
public class MemberSignUpDto {

    @NotBlank(message = "이메일은 빈칸이 될 수 없습니다!")
    @Size(min = 8, max = 100, message = "이메일은 최소 8자리에서 100자리 입니다.")
    private String email;

    @NotBlank(message = "닉네임은 빈칸이 될 수 없습니다!")
    @Size(min = 4, max = 100, message = "유저 이름은 최소 4자리에서 최대 100자리 입니다.")
    private String username;

    @NotBlank(message = "비밀번호는 빈칸이 될 수 없습니다!")
    @Size(min = 4, max = 100, message = "비밀번호는 최소 4자리에서 최대 100자리 입니다.")
    private String password;

    public Member toEntity(PasswordEncoder passwordEncoder) {
        return Member.builder()
                .email(email)
                .username(username)
                .password(passwordEncoder.encode(password))
                .build();
    }

}

회원가입을 구현하기 위해 DTO를 구현합니다. DTO를 구현하는 이유는 이 글을 참고해주세요.

회원의 회원가입을 할때 사용할 DTO이기에 DTO명은 MemberSignUpDto로 작명하였습니다.

 

@NotBlank는 @NotEmpty 어노테이션의 변형 어노테이션입니다.

@NotNull은 null값이 들어오면 에러 메시지와 함께 에러를 뱉는 어노테이션이고,

@NotEmpty는 @NotNull 효과와 함께 ""값이 들어오면 에러 메시지와 함께 에러를 뱉는 어노테이션이고,

@NotBlank는 @NotEmpty 효과와 함께 " "값 역시 들어오지 않게 하는 어노테이션입니다.

 

@Size는 엔티티가 아닌 클래스의 필드에 넣을 수 있는 어노테이션으로 최솟값, 최댓값을 정할 수 있는 어노테이션입니다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByUsername(String username);

    Optional<Member> findByEmail(String email);

    boolean existsByEmail(String email);

    boolean existsByUsername(String username);

}

회원의 리포지토리를 생성하여 Member 테이블에 접근 및 사용할 메소드(쿼리)들을 추가합니다.

 

그리고 보다 response에 일관성을 두기 위해 3가지 정도의 response 모델을 구성했습니다.

결과가 1개일때 사용하기 위한 SingleResult, 결과가 2개이상일 때 사용하기 위한 ListResult,

그리고 결과가 0, 오류 등 전부 사용가능한 CommonResult로 구성했습니다.

@Getter @Setter
public class CommonResult {

    private boolean success;

    private int code;

    private String msg;

}
@Getter @Setter
public class SingleResult<T> extends CommonResult {

    private T data;

}

위와같은 response 모델들을 구성한 후, 이를 service로 관리를 하기 위해 service 클래스를 생성합니다.

@Slf4j
@Service
public class ResponseService {

    @Getter
    @RequiredArgsConstructor
    @AllArgsConstructor
    public enum CommonResponse {
        SUCCESS(1, "성공"),
        FAIL(-1, "실패");

        private int code;
        private String msg;
    }

    public <T> SingleResult<T> getSingleResult(T data) {
        SingleResult<T> result = new SingleResult<>();
        result.setData(data);
        setSuccessResult(result);
        return result;
    }

    public <T> SingleResult<T> getSingleResult(T data, int code) {
        SingleResult<T> result = new SingleResult<>();
        result.setData(data);
        result.setSuccess(true);
        result.setCode(code);
        result.setMsg(CommonResponse.SUCCESS.getMsg());
        return result;
    }

    public <T> SingleResult<T> getSingleResult(T data, int code, String msg) {
        SingleResult<T> result = new SingleResult<>();
        result.setData(data);
        result.setSuccess(true);
        result.setCode(code);
        result.setMsg(msg);
        return result;
    }

    public <T> ListResult<T> getListResult(List<T> list) {
        ListResult<T> result = new ListResult<>();
        result.setList(list);
        setSuccessResult(result);
        return result;
    }

    public CommonResult getSuccessResult() {
        CommonResult result = new CommonResult();
        setSuccessResult(result);
        return result;
    }

    public CommonResult getSuccessResult(int code) {
        CommonResult result = new CommonResult();
        result.setSuccess(true);
        result.setCode(code);
        result.setMsg(CommonResponse.SUCCESS.getMsg());
        return result;
    }

    public CommonResult getFailResult(int code, String msg) {
        CommonResult result = new CommonResult();
        result.setSuccess(false);
        result.setCode(code);
        result.setMsg(msg);
        return result;
    }

    private void setFailResult(CommonResult result, String msg) {
        result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMsg(msg);
    }

    private void setSuccessResult(CommonResult result) {
        result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMsg(CommonResponse.SUCCESS.getMsg());
    }
    
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests().antMatchers("/member/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler());
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/webjars/**", "/h2-console/**");
    }
}

그 후 이 포스팅에 가장 중요한 PasswordEncoder를 Config 클래스에 추가하여 Bean에 넣어둡니다.

 

이제 실제 service 기능과 controller의 기능들을 추가합니다.

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    /*
    회원 가입
     */
    @Transactional
    public Long signUp(MemberSignUpDto memberSignUpDto) {
        if (memberRepository.existsByEmail(memberSignUpDto.getEmail())) {
            throw new AlreadyEmailExistedException("이미 존재하는 이메일입니다.");
        }
        if (memberRepository.existsByUsername(memberSignUpDto.getUsername())) {
            throw new AlreadyUsernameExistedException("이미 존재하는 닉네임입니다.");
        }
        return memberRepository.save(memberSignUpDto.toEntity(passwordEncoder)).getId();
    }
}

AlreadyEmailExistedException과 AlreadyUsernameExistedException은 커스텀 익셉션으로 RuntimeException를 상속한 클래스입니다.

ExceptionAdvice 클래스는 다음에 다루도록 하겠습니다. (내용이 너무 길어지고 서론이 너무 길어지기때문)

 

이제 마지막으로 컨트롤러를 구현해줍니다.

@RequiredArgsConstructor
@RequestMapping("/member")
@RestController
public class MemberController {

    private final MemberService memberService;
    private final ResponseService responseService;

    @PostMapping("/signup")
    public SingleResult<Long> signUp(@RequestBody MemberSignUpDto memberSignUpDto) {
        Long memberId = memberService.signUp(memberSignUpDto);
        return responseService.getSingleResult(memberId, 201);
    }

}

회원가입을 성공적으로 완료를 하였을 때, 해당 회원의 ID를 반환하여, 추후 ID로 쉽게 찾기 위해 반환을 해줍니다.

 

포스트맨으로 값을 보내본 결과

 

값을 확인해보았을 때에는 아무런 변화가 딱히 없어보이나

DB 상에서는 이미 암호화가 진행되어 저장되었습니다.

 

테스트 코드

회원가입 테스트를 해보기 위해 테스트 코드를 작성하였습니다.

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class MemberTest {

    @Autowired
    MemberService memberService;

    @Autowired
    MemberRepository memberRepository;

    // 회원 가입 테스트
    @Test
    @Order(1)
    public void signupTest() {
        // given
        MemberSignUpDto memberSignUpDto = MemberSignUpDto.builder()
                .email("test@gmail.com")
                .username("test")
                .password("1234")
                .build();

        // when
        Long id = memberService.signUp(memberSignUpDto);

        // then
        Member findMember = memberRepository.findById(id)
                .orElseThrow();
        Assert.assertEquals(memberSignUpDto.getEmail(), findMember.getEmail());
    }

}

리포지토리 테스트가 아니기에 @DataJPATest 어노테이션은 굳이 하지않았습니다.

@DataJPATest 내부에도 @Transactional 어노테이션이 있기에 @Transactional 어노테이션만 사용하였습니다.

@Test 어노테이션이 있으면 트랜잭션을 하더라도 해당 메소드가 종료되면 

자동으로 롤백을 합니다. (물론, auto_increment 같은 경우에는 롤백을 하더라도 다시 값으로 돌아오지는 않습니다.)

(1, 2를 롤백하더라도 다시 1, 2로 PK를 가질 수 없고 3부터 PK로 가질 수 있게됩니다.)

 

다음에는...

이로써 간단한 회원의 비밀번호를 PasswordEncoder를 사용하여 암호화를 하였습니다.

다음 포스팅에서는 JWT(Json Web Token)를 사용하여 로그인 시, 액세스 토큰 및 리프래시 토큰을 발행하도록 하겠습니다.

 

 

이상입니다.