본문 바로가기
Spring/Spring Security

비밀번호 암호화 적용하기(Spring Security Crypto)

by 제우제우 2024. 8. 29.

https://20240228.tistory.com/153

 

비밀번호 암호화

목차암호화 종류단방향 암호화 방법단방향 암호화 문제점 개선 (+ Salt)단방향 암호화 문제점 개선 (+ Key Stretching)양방향 암호화 특징 참고 자료암호화 종류 암호화를 하는 이유는 해당

20240228.tistory.com

 

최근에 비밀번호 암호화에 대해서 공부했었다. 

그중에 단방향 암호화 라이브러리인 Spring Security Crypto를 사용해서 프로젝트에 적용해 보겠다. 

 

라이브러리 Dependency 찾기 

Maven Repository : Spring Security Crypto 검색 

 

 

build.gradle 

 

특정 버전 명시 O

dependencies {
	implementation group: 'org.springframework.security', name: 'spring-security-crypto', version: '6.3.3'
}

 

특정 버전 명시 X

dependencies {
	implementation 'org.springframework.security:spring-security-crypto'
}

 

회원가입 서비스에 적용하기 

public void signUp(SignUp signup) {
    Optional<User> findDuplicateUser = userRepository.findByEmail(signup.getEmail());
    if(findDuplicateUser.isPresent()){
        throw new AlreadyExistsEmailException();
    }
    SCryptPasswordEncoder encoder = new SCryptPasswordEncoder(
            16,
            8,
            1,
            32,
            64);

    String encryptedPassword = encoder.encode(signup.getPassword());
    log.info("encryptedPassword:{}", encryptedPassword);

    User user = User.builder()
            .name(signup.getName())
            .password(encryptedPassword)
            .email(signup.getEmail())
            .build();
    userRepository.save(user);
}

 

내가 적용한 암호화 부분 

SCryptPasswordEncoder encoder = new SCryptPasswordEncoder(
        16, // cpuCost
        8,  // memoryCost
        1,  // parallelization
        32, // keyLength
        64);// saltLength

String encryptedPassword = encoder.encode(signup.getPassword());
log.info("encryptedPassword:{}", encryptedPassword);

 

SCryptPasswordEncoder 분석

SCryptPasswordEncoder 공식 문서

 

SCryptPasswordEncoder (spring-security-docs 6.3.3 API)

Constructs a SCrypt password encoder with cpu cost of 65,536, memory cost of 8, parallelization of 1, a key length of 32 and a salt length of 16 bytes.

docs.spring.io

 

SCryptPasswordEncoder 생성 

SHA-1 이상 해시와 8바이트 이상 랜덤 생성 솔트를 결합

 

당연히 솔트 부분은 내가 랜덤하게 넣어줘야 할 줄 알았는데 알아서 랜덤으로 넣어주는 게 신기하다.

이러면 어떻게 나중에 DB에 저장된(암호화된) 비밀번호와 입력한 비밀번호가 동일한지 비교를 어떻게 하는지 궁금하다. 

 

SCryptPasswordEncoder 

private final BytesKeyGenerator saltGenerator;

public SCryptPasswordEncoder(int cpuCost, int memoryCost, int parallelization, int keyLength, int saltLength) {
	// 생략 ...
    this.saltGenerator = KeyGenerators.secureRandom(saltLength);
}

SCryptPasswordEncoder 생성자이다. 

해당 코드를 보면 생성자에서 객체 생성시점에  BytesKeyGenerator 값을 할당을 하는데

이때 KeyGenerators.secureRandom(saltLength);를 통해서 할당한다. 

 

KeyGenerators

public static BytesKeyGenerator secureRandom(int keyLength) {
    return new SecureRandomBytesKeyGenerator(keyLength);
}

여기서 다시 SecureRandomBytesKeyGenerator(keyLength); (생성자 호출)

 

SecureRandomBytesKeyGenerator

final class SecureRandomBytesKeyGenerator implements BytesKeyGenerator {

	private static final int DEFAULT_KEY_LENGTH = 8;

	private final SecureRandom random;

	private final int keyLength;

	SecureRandomBytesKeyGenerator(int keyLength) {
		this.random = new SecureRandom();
		this.keyLength = keyLength;
	}
    
    // 생략 ...
}

SecureRandomBytesKeyGenerator 클래스에 SecureRandom 생성하고 salt 길이 또한 저장을 한다. 

SecureRandom은 랜덤한 바이트 salt 생성에 사용 

 

SecureRandomBytesKeyGenerator  

@Override
public int getKeyLength() {
    return this.keyLength;
}

@Override
public byte[] generateKey() {
    byte[] bytes = new byte[this.keyLength];
    this.random.nextBytes(bytes);
    return bytes;
}

이부분을 암호화할 때 사용한다. 

this.random.nextBytes(bytes) 이부분은 랜덤한 salt 생성 

org.bouncycastle.crypto.generators.SCrypt

브레이크 포인트를 찍고 디버깅 모드로 분석하던중 예외가 발생했다. 

java.lang.ClassNotFoundException: org.bouncycastle.crypto.generators.SCrypt

 

build.gradle 추가로 해결 

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

 

SCryptPasswordEncoder.matches() - 비교 메서드 

 

SCryptPasswordEncoder.matches() 

@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
    if (encodedPassword == null || encodedPassword.length() < this.keyLength) {
        this.logger.warn("Empty encoded password");
        return false;
    }
    return decodeAndCheckMatches(rawPassword, encodedPassword);
}

 

SCryptPasswordEncoder.decodeAndCheckMatches() 

private boolean decodeAndCheckMatches(CharSequence rawPassword, String encodedPassword) {
    String[] parts = encodedPassword.split("\\$");
    if (parts.length != 4) {
        return false;
    }
    long params = Long.parseLong(parts[1], 16);
    byte[] salt = decodePart(parts[2]);
    byte[] derived = decodePart(parts[3]);
    int cpuCost = (int) Math.pow(2, params >> 16 & 0xffff);
    int memoryCost = (int) params >> 8 & 0xff;
    int parallelization = (int) params & 0xff;
    byte[] generated = SCrypt.generate(Utf8.encode(rawPassword), salt, cpuCost, memoryCost, parallelization,
            this.keyLength);
    return MessageDigest.isEqual(derived, generated);
}

 

암호화되지 않은 비밀번호와 암호화된 비밀번호를 메서드를 매개변수로 받고 

내부에서 암호화되지 않은 비밀번호를 암호화했던 비밀번호와 동일한 방식(해시 알고리즘, 키 길이, salt, ...)으로

암호화해서 같은지 비교하고 같으면 true, 다르면 false를 반환한다.