Programming/Spring

Spring - Signup 비밀번호 암호화 (crypto, SCryptoPasswordEncoder)

잇(IT) 2023. 10. 9. 21:37
- Crypto

- 암호화는 정보나 데이터를 안전하게 보호하고 전송하기 위한 기술적인 방법이다.

1. 암호화는 데이터를 암호화(암호키를 사용하여 원본 데이터를 불가독하게 만드는 과정)하고, 필요할 때 이를 복호화(암호화된 데이터를 원래 상태로 복원하는 과정)하는 과정이 있다.

2. 암호화 알고리즘 : AES, RSA, DES, SHA 알고리즘이 대표적이다.

3. 대칭 암호화, 비대칭 암호화 : 대칭 암호화에서는 동일한 키를 암호화와 복호화에 모두 사용한다. 반면, 비대칭 암호화에서는 공개 키와 비밀 키라는 서로 다른 두 키를 사용한다. 

4. 해시 함수 :고정된 길이의 해시 코드(또는 해시 값)를 생성하는 함수로, 입력 데이터의 일부나 전체에 대한 고유한 값을 생성한다.

 

- SCryptPasswordEncoder

1. 스프링 시큐리티 프레임워크에서 제공하는 비밀번호 인코딩(암호화) 클래스 중 하나다. 스프링 시큐리티는 웹 애플리케이션에서 보안을 관리하고 사용자 인증 및 권한 부여를 처리하는 데 사용되며, 비밀번호 인코딩은 사용자의 비밀번호를 안전하게 저장하고 인증 과정에서 사용된다.

2. SCryptPasswordEncoder는 비밀번호를 안전하게 저장하기 위해 스크립트(scrypt) 알고리즘을 사용하는 비밀번호 인코딩 클래스이다.

 

- build.gradle

//---------------- crypto
    implementation 'org.springframework.security:spring-security-crypto'

    //---------------- bouncycastle
    implementation 'org.bouncycastle:bcprov-jdk15on:1.70'

1. SCryptPasswordEncoder를 사용하기 위해선 gradle에 의존성을 추가해주어야 한다.


- AuthController.java

@PostMapping("/auth/signup")
    public void signup(@RequestBody Signup signup) {
        authService.signup(signup);
    }

1. 회원가입을 위한 /auth/signup 경로로 post 요청을 보낸다. body에는 회원가입에 필요한 데이터들이 전달된다.

2. body를 통해 전달된 데이터들은 Signup 클래스의 필드에 매핑된다.

3. authService의 signup 메서드를 통해 DB에 새롭게 등록된 사용자가 저장된다.


- AuthService.java

public void signup(Signup signup) {

        Optional<Member> memberOptional = memberRepository.findByEmail(signup.getEmail());
        if(memberOptional.isPresent()){
            throw new AlreadyExistsEmailException();
        }

        String eneryptedPassword = passwordEncoder.encrypt(signup.getPassword());

        Member member = Member.builder()
                .name(signup.getName())
                .password(eneryptedPassword)
                .email(signup.getEmail())
                .build();

        memberRepository.save(member);
    }

1. 회원가입을 위해서는 기존에 해당 ID가 필요한지 검사를 해야한다. -> 회원가입하려는 동일 계정이 있다면 예외를 발생 시킨다.

2. 비밀번호의 경우 DB에 저장할 때 별도의 암호화를 진행하는 것이 좋다. -> DB가 해킹 당했을 때 암호의 원본이 유출되면 직관적으로 암호를 알 수 있기 때문에 보안에 큰 위협을 줄 수 있다.

3. DB에 저장하기 위한 엔티티인 Member 객체에 전달받은 데이터를 builder를 통해 생성한다. -> 비밀번호의 경우 encrypt를 통해 암호화된 비밀번호를 전달한다.


- PasswordEncoder.java

public interface PasswordEncoder {

    String encrypt(String rawPassword);

    boolean matches(String rawPassword, String encryptedPassword);
}

1. PasswordEncoder 인터페이스를 생성하는 이유는 여러 구현체를 사용하기 위함이다.

 1.1. 첫번째 용도로는 DB에 저장할 때 암호화에 필요한 encrypt() 메서드를 사용할 것이고, 원본 비밀번호와 암호화된 암호가 암,복호화 관계에 있는지 확인하기 위한 matches() 메서드를 사용할 것이다.

 1.2. 두번째 용도로는 내부 테스트가 이루어질 때 암호화된 암호를 계속해서 사용한다면 테스트에 있어서 불편함이 많기 때문에 원본 암호를 사용하기 위한 encrypt() 메서드를 사용할 것이고, matches 메서드는 똑같이 암호 일치 확인에 있어서 사용할 것이다.


- ScryptPasswordEncoder.java

@Profile("default")
@Component
public class ScryptPasswordEncoder implements PasswordEncoder {

    private static final SCryptPasswordEncoder encoder = new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64);

    @Override
    public String encrypt(String rawPassword){
        return encoder.encode(rawPassword);
    }

    @Override
    public boolean matches(String rawPassword, String encrpytedPassword) {
        return encoder.matches(rawPassword, encrpytedPassword);
    }


}

1. SCryptPasswordEncoder 클래스는 원본 비밀번호를 암호화하기 위한 클래스이다.

 1.1 SCryptPasswordEncoder의 파라미터는 16,8,1,32,64 - cpuCost, memoryCost, parallelization, keyLength, saltLength이다.

2. SCryptPasswordEncoder의 클래스의 메서드인 encode() 메서드의 파라미터로 rawPassword 즉, 원본 비밀번호를 넣으면 암호화된 비밀번호를 얻을 수 있다.

3. SCryptPasswordEncoder의 클래스의 메서드인 matches(원본 비밀번호, 암호화된 비밀번호)를 통해 실제 해당 비밀번호와 암호화된 비밀번호가 일치하는지 확인 할 수 있다. -> 이 확인 작업을 통해 DB에 암호화된 암호를 저장하고, 확인 시 해당 원본과 비교가 가능하다.

 

- PlainPasswordEncoder.java

@Profile("test")
@Component
public class PlainPasswordEncoder implements PasswordEncoder {

    @Override
    public String encrypt(String rawPassword) {
        return rawPassword;
    }

    @Override
    public boolean matches(String rawPassword, String encryptedPassword) {
        return rawPassword.equals(encryptedPassword);
    }
}

1. 두번째 인터페이스 구현체는 테스트 코드에서 사용할 DB에 원본 비밀번호를 그대로 저장하고 비교 또한 원본과 DB에 저장된 원본 비밀번호가 일치하는지 구분하는 클래스에 해당한다.

 

- @Profile(), @ActiveProfiles()

- @Profile() : Spring 프레임워크에서 프로파일(환경)을 지정하는 데 사용되는 어노테이션이다. Spring 프로파일은 특정 환경 또는 상황에 맞게 애플리케이션의 구성을 다르게 설정하고 사용하는 데 도움을 준다.

- @ActiveProfiles() : Spring 프레임워크 기반의 테스트에서 사용되며, 지정된 환경 프로파일을 활성화하여 테스트 환경을 구성하는 데 사용된다.

 

* 각 구현체를 보게 되면 Profile("default"), Profile("test")가 추가된 것을 확인할 수 있다. 

@Service
@RequiredArgsConstructor
public class AuthService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

1. 위와 같이 PasswordEncoder 클래스가 스프링에 의해 생성자 파라미터에 주입할 빈을 자동으로 정하지만 현재 PasswordEncoder의 구현체가 2개이기 때문에 어떤 구현체를 주입해야하는지 스프링 입장에선 알 수 없다.

@Profile("default")
@Component
public class ScryptPasswordEncoder implements PasswordEncoder {

2. 구현체 중 ScryptPasswordEncoder 클래스의 @Profile()에 "default"가 붙었는데, default가 붙을 경우 빈을 주입할 때 기본은 ScryptPasswordEncoder의 구현체가 생성자로 주입된다는 것을 뜻한다.

@Profile("test")
@Component
public class PlainPasswordEncoder implements PasswordEncoder {
@ActiveProfiles("test")
@SpringBootTest
class AuthServiceTest {
..............

3. @Profile("test")를 붙이게 되면 "test"의 환경에서 해당 클래스를 생성자 구현체로 사용하겠다는 의미이다.

4. 아래 Test 코드를 보게 되면 @ActiveProfiles("test")가 클래스에 붙어 있기 때문에 Profile("test") 해당 환경을 영향 받아 아래 클래스에서는 PlainPasswordEncoder가 생성자 주입을 통해 구현체로 사용된다.


- TEST SERVICE
- TEST 1
@Test
    @DisplayName("회원가입시 중복 이메일")
    void test2(){

        Member member = Member.builder()
                .email("saymay10@naver.com")
                .name("백인수")
                .password("1234")
                .build();

        memberRepository.save(member);

        //given
        Signup signup = Signup.builder()
                .email("saymay10@naver.com")
                .name("백인수")
                .password("1234")
                .build();

        //expected
        assertThrows(AlreadyExistsEmailException.class, () -> authService.signup(signup));
    }

1. 위의 테스트 코드는 Member 즉, 사용자 정보를 DB에 저장하고, Signup 클래스(회원가입 정보) 객체를 생성하여, authService의 signup메서드를 통해 해당 정보가 DB에 존재하는지 확인하고, 없다면 Member 객체를 builder를 통해 생성하여 DB에 저장한다.

public void signup(Signup signup) {

        Optional<Member> memberOptional = memberRepository.findByEmail(signup.getEmail());
        if(memberOptional.isPresent()){
            throw new AlreadyExistsEmailException();
        }

        String eneryptedPassword = passwordEncoder.encrypt(signup.getPassword());

        Member member = Member.builder()
                .name(signup.getName())
                .password(eneryptedPassword)
                .email(signup.getEmail())
                .build();

        memberRepository.save(member);
    }

 

- TEST 2
@ActiveProfiles("test")
@SpringBootTest
class AuthServiceTest {

@Test
    @DisplayName("로그인 성공")
    void test3(){

        //given
//        ScryptPasswordEncoder encoder = new ScryptPasswordEncoder();
//        String encrpytedPassword = encoder.encrypt("1234");

        Member member = Member.builder()
                .email("saymay10@naver.com")
                .name("백인수")
                .password("1234")
                .build();
        memberRepository.save(member);

        Login login = Login.builder()
                .email("saymay10@naver.com")
                .password("1234")
                .build();

        //when
        Long memberId = authService.signin(login);

        //then
        Assertions.assertNotNull(memberId);
    }

1. 위의 코드는 사용자 정보를 DB에 저장하고, login 객체를 이용하여 signin() 메서드를 통해 로그인 정보를 확인하는 코드에 해당한다.

2. 일반적으로 DB에 저장될 때 입력한 1234는 암호화되여 DB에는 1234가 아닌 암호화된 비밀번호가 저장될 것이다.

2. Profile("test")가 클래스에 적용되지 않았더라면, 위에 주석으로 처리된 기존의 원본 비밀번호를 암호화 한 뒤 암호화된 비밀번호를 login 정보에 넣어 확인을 하여야 통과할 것이다. -> 하지만 TEST 코드의 경우 일반 비밀번호를 암호화 하지 않는 구현체를 사용하기 때문에 위의 테스트 코드가 통과하는 것이다.

 


- TEST CONTROLLER
- TEST 1 
@Test
    @DisplayName("회원가입")
    void test6() throws Exception {
        //given
        Signup signup = Signup.builder()
                .email("saymay10@naver.com")
                .name("백인수")
                .password("1234")
                .build();

        //expected
        mockMvc.perform(post("/auth/signup")
                        .contentType(APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(signup)))
                .andExpect(status().isOk())
                .andDo(print());
    }
728x90