Development/Spring

Spring Password Encoder

반응형

Spring에서는 인증/권한인가 등의 처리가 필요할 때 사용하라고 만든 Spring Security 패키지가 존재한다.

그 중 유저가 입력하는 Password를 암호화해서 저장하는 방법에 대해서 알아보자

아, 그 전에 패스워드를 저장할 때 사용하는 알고리즘을 먼저 봐야 하는데 일단 패스워드는 무조건 단방향 암호화/해싱을 사용해야 한다. 한번 encode된 패스워드는 다시 복호화를 할 수 없도록 해야 하고(AES,RSA,DES… 등의 양방향 암호화를 사용하면 안된다는 뜻이다) 이를 비교하는 로직만 같은지 아닌지만 판단할 수 있게 만들어야 한다.

이를 지키지 않을 경우 최악은 DB에 저장된 유저의 패스워드가 다 복호화 되어 개인정보가 털리던.. 혹은 결제와 관련된 경우 직접적인 타격을 받게될 수도 있다.

혹여나, 지금이라도 패스워드를 AES 등으로 저장하여 사용하고 있다면 당장 해싱하는 방향으로 바꾸도록 하자

그럼 유저가 패스워드를 잃어버렸을때 찾아주는 방법이 없지않냐! 할 수 있는데, 잘 생각해보면 우리 주위의 포털사이트나 웹사이트에서는 이미 "패스워드 변경 메일" 혹은 SMS등으로 인증을 해서 "새로운 비밀번호" 를 입력받도록 유도를 하고 있음을 알 수 있다.

한번 encode된 패스워드를 다시 복호화할 이유가 없다는건 이런 해결책이 있기 때문이기도 하고, 완벽한 보안은 없으면서도 그나마 강력한 보안성을 유지하기 위해서는 위와 같은 방법을 취하는게 옳은 방법이다.

PasswordEncoder Interface

public interface PasswordEncoder {
    String encode(CharSequence var1);

    boolean matches(CharSequence var1, String var2);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

인터페이스를 먼저 보면, 간단하게 encode()matches()로 구성되어 있다.

encode 는 실제로 패스워드를 암호화 할 때 사용하고

matches 는 사용자에게 입력받은 패스워드를 비교하고자 할 때 사용한다

default 메서드로 구현된 upgradeEncoding 은 기본적으로 false를 리턴하나, Custom하게 구현할 경우 이를 기반으로 더 강력한 암호화를 실행할 것인지에 대한 로직처리를 해주면 될 것 같다.

upgradeEncoding() : true if the encoded password should be encoded again for better security, else false.

PasswordEncoder 구현체 종류

Spring에서 기본적으로 제공되는 PasswordEncoder의 종류는 위와 같은데

BCryptPasswordEncoder, DelegatingPasswordEncoder, SCryptPasswordEncoder, Pbkdf2PasswordEncoder 가 decrepated 되지 않은 PasswordEncoder 이므로 이들 클래스를 살펴보도록 하자.

 

BCryptPasswordEncoder

Implementation of PasswordEncoder that uses the BCrypt strong hashing function. Clients can optionally supply a "strength" (a.k.a. log rounds in BCrypt) and a SecureRandom instance. The larger the strength parameter the more work will have to be done (exponentially) to hash the passwords. The default value is 10.

BCrypt 라는 해시 함수를 사용한 구현체이다. 단순히 해시를 하는것 뿐만 아니라 Salt 를 넣는 작업까지 하므로, 입력값이 같음에도 불구하고 매번 다른 encoded된 값을 return 해주게 된다.

    public String encode(CharSequence rawPassword) {
        String salt;
        if (this.strength > 0) {
            if (this.random != null) {
                salt = BCrypt.gensalt(this.strength, this.random);
            } else {
                salt = BCrypt.gensalt(this.strength);
            }
        } else {
            salt = BCrypt.gensalt();
        }
        // salt를 generate하고 rawPassword와 salt를 이용해 hashing을 한다
        return BCrypt.hashpw(rawPassword.toString(), salt);
    }

그러므로 Bcrypt를 이용했을 경우에는 matches 함수를 잘 확인하고 사용하는게 좋다(입력값이 같아도 매번 출력물이 다르기 때문에 equal로 비교하려고 하면 패스워드가 계속 일치하지 않는 상황을 겪게될 수 있다)

        String input = "hello world";
        String encoded = passwordEncoder.encode(input);

        // true
        Assert.assertTrue(passwordEncoder.matches(input, encoded));

        // false
        Assert.assertEquals(passwordEncoder.encode(input), passwordEncoder.encode(input));

"hello world" 라는 문자열이 있을 때 이를 encode한 값을 encoded에 넣고 테스트를 해보면, matches 로 확인을 했을 경우에는 예상대로 true 가 리턴되지만, equals 로 확인을 해보면 값이 false로 리턴이 되는 것을 알 수 있다.

참고 : matches(rawPassword, encoded) 이다. rawPassword parameter에 암호화된 값을 넣지 말고, 사용자가 입력한 패스워드를 그대로 넣어주도록 하자

 

DelegatingPasswordEncoder

A password encoder that delegates to another PasswordEncoder based upon a prefixed identifier.

PasswordEncoder를 여러개 선언한 뒤, 상황에 맞게 골라쓸 수 있도록 지원하는 Encoder이다.

 String idForEncode = "bcrypt";
 Map encoders = new HashMap<>();
 encoders.put(idForEncode, new BCryptPasswordEncoder());
 encoders.put("noop", NoOpPasswordEncoder.getInstance());
 encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
 encoders.put("scrypt", new SCryptPasswordEncoder());
 encoders.put("sha256", new StandardPasswordEncoder());
 PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);

위와 같이 선언을 하게 되면 PasswordEncoder에 선언된 bcrypt, noop, pbkdf2, scrypt, sha256 PasswordEncoder들이 들어가게 되고, 마지막에 선언했을 때 idForEncode를 bcrypt로 줬으니 최종으로 encoding이 되는 값은 bcrypt로 해싱이 된 패스워드가 return이 된다. 그리고 맨 앞에 prefix로 암호화를 한 방법이 붙는다

{bcrypt}$2a$10$UemKUf.cijGeJz6CJ/81auJKQVU0syWTJq2O.UGQXga9G.SCCKDR.

위와 같이 맨 앞에 prefix로 저렇게 붙게 되어서, 어떤 암호화를 사용해서 encode를 했는지 구분하는 목적인것 같다.

여러가지의 패스워드 암호화를 뒀을 때 매번 DI를 해서 안써도 되니 그럴때 사용하면 유용할 것으로 추측..

matches를 호출하는건 인터페이스가 동일하다. prefix가 들어간 password를 앞에서 알아서 짤라서 맞는 encode방식으로 변환을 해준다.

 

SCryptPasswordEncoder

Implementation of PasswordEncoder that uses the SCrypt hashing function. Clients can optionally supply a cpu cost parameter, a memory cost parameter and a parallelization parameter.

좀 신기한놈인데, Scrypt 해시 함수를 사용하고, 추가적으로 cpu와 memory의 cost parameter를 넘길수 있게 되어있다.

Scrypt는 실제로 Salsa20 라는 해싱방식을 사용한다고 한다.

그리고 왠지모르게 Javadoc에 이 패스워드 해싱방식을 추천하지 않는다고 쓰여있다(..?). 해당 글을 읽다보니 보안문제가 존재하지만 그래도 여전히 쓸만하다… 라는 느낌의 분석이 있었다(https://blog.ircmaxell.com/2014/03/why-i-dont-recommend-scrypt.html)

 

Pbkdf2PasswordEncoder

A PasswordEncoder implementation that uses PBKDF2 with a configurable number of iterations and a random 8-byte random salt value

이름이 매우 특이한데, PBKDF2 라는 해싱을 사용하는 이놈의 이름은(Password-Based Key Derivation Function 2) 의 뜻을 가지고 있다. 네이버 D2 포스팅에도 한번 소개가 되었었다

이 encoder는 해싱+salt를 동시에 진행한다(Bcrypt와 동일) 다만 차이점은 아래와 같다

  • Pbkdf2PasswordEncoder : salt가 8bytes 랜덤 값이다
  • BCryptPasswordEncoder : salt가 72bytes 랜덤값이다

그렇기 때문에 bcrypt가 좀더 안전(하다고 말하기엔 좀 그렇지만, collision이 발생할 수학적 확률이 더 낮다고 볼 수 있다) 하기 때문에 Spring의 기본 PasswordEncoder는 bcrypt를 권장하는 것으로 보인다

 

Conclusion

BCryptPasswordEncoder 혹은 Pbkdf2PasswordEncoder 를 사용하자 인 것 같은데,

JMH Benchmark를 돌려본 결과 Pbkdf2의 성능이 상대적으로 낮게 측정이 되었다.

Pbkdf2가 좀더 심플하고, Random으로 받는 key bytes도 작아서 좋은 성능이 나올줄 알았는데 의외였다.

Throughput : scrypt > bcrypt >>> pbkdf2

평균 소모되는 operation 별 avg time또한 위와 같다.

단일 쓰레드에서 암호화 되는 encode() 만 테스트해본 결과이다.

테스트 코드

@State(Scope.Benchmark)
public class EncodeTest {


    private BCryptPasswordEncoder bCryptPasswordEncoder;
    private SCryptPasswordEncoder sCryptPasswordEncoder;
    private Pbkdf2PasswordEncoder pbkdf2PasswordEncoder;
    private final String targetString = "HELLO WORLD ENCODE TEST";

    @Setup(Level.Invocation)
    public void setUp() {
        bCryptPasswordEncoder= new BCryptPasswordEncoder();
        sCryptPasswordEncoder = new SCryptPasswordEncoder();
        pbkdf2PasswordEncoder = new Pbkdf2PasswordEncoder();
    }

    @Benchmark
    @Fork(value = 1, warmups = 1)
    @BenchmarkMode(Mode.All)
    public void bcrypt(){
        bCryptPasswordEncoder.encode(targetString);
    }

    @Benchmark
    @Fork(value = 1, warmups = 1)
    @BenchmarkMode(Mode.All)
    public void scrypt(){
        sCryptPasswordEncoder.encode(targetString);
    }

    @Benchmark
    @Fork(value = 1, warmups = 1)
    @BenchmarkMode(Mode.All)
    public void pbkdf2(){
        pbkdf2PasswordEncoder.encode(targetString);
    }

    public static void main(String[] args) throws IOException, RunnerException {
        org.openjdk.jmh.Main.main(args);
    }
}

테스트 결과

# Run complete. Total time: 00:14:06

Benchmark                           Mode  Cnt   Score    Error  Units
EncodeTest.bcrypt                  thrpt   20  12.021 ±  0.038  ops/s
EncodeTest.pbkdf2                  thrpt   20   2.279 ±  0.015  ops/s
EncodeTest.scrypt                  thrpt   20  16.036 ±  0.143  ops/s
EncodeTest.bcrypt                   avgt   20   0.083 ±  0.001   s/op
EncodeTest.pbkdf2                   avgt   20   0.440 ±  0.002   s/op
EncodeTest.scrypt                   avgt   20   0.066 ±  0.002   s/op
EncodeTest.bcrypt                 sample  241   0.085 ±  0.001   s/op
EncodeTest.bcrypt:bcrypt·p0.00    sample        0.082            s/op
EncodeTest.bcrypt:bcrypt·p0.50    sample        0.083            s/op
EncodeTest.bcrypt:bcrypt·p0.90    sample        0.087            s/op
EncodeTest.bcrypt:bcrypt·p0.95    sample        0.091            s/op
EncodeTest.bcrypt:bcrypt·p0.99    sample        0.117            s/op
EncodeTest.bcrypt:bcrypt·p0.999   sample        0.168            s/op
EncodeTest.bcrypt:bcrypt·p0.9999  sample        0.168            s/op
EncodeTest.bcrypt:bcrypt·p1.00    sample        0.168            s/op
EncodeTest.pbkdf2                 sample   60   0.439 ±  0.004   s/op
EncodeTest.pbkdf2:pbkdf2·p0.00    sample        0.430            s/op
EncodeTest.pbkdf2:pbkdf2·p0.50    sample        0.434            s/op
EncodeTest.pbkdf2:pbkdf2·p0.90    sample        0.452            s/op
EncodeTest.pbkdf2:pbkdf2·p0.95    sample        0.455            s/op
EncodeTest.pbkdf2:pbkdf2·p0.99    sample        0.462            s/op
EncodeTest.pbkdf2:pbkdf2·p0.999   sample        0.462            s/op
EncodeTest.pbkdf2:pbkdf2·p0.9999  sample        0.462            s/op
EncodeTest.pbkdf2:pbkdf2·p1.00    sample        0.462            s/op
EncodeTest.scrypt                 sample  328   0.063 ±  0.001   s/op
EncodeTest.scrypt:scrypt·p0.00    sample        0.059            s/op
EncodeTest.scrypt:scrypt·p0.50    sample        0.061            s/op
EncodeTest.scrypt:scrypt·p0.90    sample        0.071            s/op
EncodeTest.scrypt:scrypt·p0.95    sample        0.073            s/op
EncodeTest.scrypt:scrypt·p0.99    sample        0.081            s/op
EncodeTest.scrypt:scrypt·p0.999   sample        0.090            s/op
EncodeTest.scrypt:scrypt·p0.9999  sample        0.090            s/op
EncodeTest.scrypt:scrypt·p1.00    sample        0.090            s/op
EncodeTest.bcrypt                     ss        0.090            s/op
EncodeTest.pbkdf2                     ss        0.824            s/op
EncodeTest.scrypt                     ss        0.355            s/op
반응형

'Development > Spring' 카테고리의 다른 글

Spring Redis Template Transaction  (2) 2021.09.09
Spring RequestContextHolder  (8) 2020.07.05
Dispatcher Servlet  (0) 2019.04.06
Spring Boot Prometheus Converter 406 Not Acceptable  (0) 2019.04.06
[JAVA/Spring] BeanUtils 관련  (2) 2018.07.20