위의 링크에서 비밀번호를 암호화해서 저장하는 BCryptPasswordEncoder를 사용해 회원가입 API를 구현해 봤다. 정확하게 이해하고 구현한 건 아니라서 아래의 공식 문서를 읽어보면서 관련된 내용을 정리해보려고 한다.
Password Storage
1. PasswordEncoder
Spring Security의 PasswordEncoder 인터페이스는 암호를 단방향으로 변환하여 안전하게 보호하기 위해 사용된다.
암호가 양방향으로 변환돼야 하는 경우엔 유용하지 않다. (ex. DB에 인증하기 위해 사용된 자격증명을 저장해야 하는 경우) 대부분은 인증 시 유저가 제공한 암호와 비교해야 하는 암호를 저장해야 할 때 사용된다.
2. Password Storage History
암호를 저장하기 위한 표준 메커니즘은 계속해서 발전해 왔다. 놀랍게도 초기엔 암호를 plaintext로 저장했는데, 악성 유저들이 점차 늘어나면서 일반 유저들의 암호를 보호할 방법이 필요하게 됐다.
암호를 보호할 방법으로 떠오른 것들 중 하나는 SHA-256 같은 단방향 해시를 사용해 암호를 저장하는 것이었다. SHA-256을 사용하면 유저가 입력한 간단한 암호가 256-bit의 복잡한 값으로 변환된다.
유저가 암호(1234)를 입력해 인증을 시도하는 경우 해당 암호(1234)를 해시한 값과 기존에 단방향 해시 이후 시스템에 저장된 값을 비교한다. 시스템엔 암호가 단방향 해시된 값만 저장되기 때문에 이 값을 통해 유저가 입력한 암호를 알아내기가 쉽지 않았다.
그럼에도 악성 유저들은 이 단방향 해시 시스템을 공격하기 위해 Rainbow Tables(lookup table)에 해시된 암호를 계산해 모두 저장하고, 유저가 입력한 텍스트를 찾아내는 방식을 사용했다. 이런 공격을 막기 위해 등장한 것이 salt다.
salt는 유저의 암호마다 함께 생성되는 무작위 바이트로, salt와 유저의 암호를 해시 함수에 같이 넣으면 고유한 해시를 만들 수 있다. 예를 들어 유저1과 유저2가 같은 암호를 사용하더라도, 유저마다 salt 값이 고유하기 때문에 해시되어 시스템에 저장된 값도 고유하게 생성된다. salt는 유저의 암호와 함께 일반 text로 저장된다.
현대 컴퓨터는 해시 계산을 초당 수십억 번 할 수 있기 때문에 SHA-256 같은 암호화 해시는 더 이상 안전하지 않다. 이 문제를 해결하기 위해 개발자들은 적응형 단방향 함수를 통해 암호를 저장한다.
적응형 단방향 함수를 통해 암호를 검증하려면, 의도적으로 많은 CPU나 메모리 등의 리소스를 사용하게 된다. 함수엔 하드웨어의 성능에 따라 증가할 수 있는 'work factor'를 설정할 수 있는데, 이 work factor를 조절해 암호 검증이 1초 정도 걸리게 하면 암호를 쉽게 해독할 수 없고, 동시에 시스템에 과도한 부담을 주지 않으며 유저의 암호 검증이 너무 오래 걸리지 않도록 할 수 있다는 장점이 있다.
ex. bcrypt, PBKDF2, scrypt, argon2
유저 이름과 암호를 사용해 장기 자격 증명을 하는 것에서 세션이나 OAuth 토큰 등으로 단기 자격 증명을 하도록 바꾸면, 보안상으로 더 좋은 애플리케이션을 만들 수 있다.
DelegatingPasswordEncoder
Spring Security 5.0 이전엔, 암호를 plaintext로 저장하는 NoOpPasswordEncoder를 PasswordEncoder의 기본값으로 사용했다. 이 방식은 위에 설명한 대로 보안상 많은 결함이 있기 때문에 지금은 다른 PasswordEncoder를 기본값으로 사용한다. BCryptPasswordEncoder 같은 적응형 단방향 함수를 기본값으로 사용할 수도 있지만 아래와 같은 몇 가지 문제를 갖고 있다.
- 많은 애플리케이션들이 BCryptPasswordEncoder로 쉽게 바꿀 수 없는 오래된 암호화 방식을 사용한다.
- 가장 좋은 암호 저장 방식은 계속해서 바뀔 거지만, Spring Security는 프레임워크기 때문에 큰 변화를 자주 만들 수 없다.
대신 Spring Security는 DelegatingPasswordEncoder를 기본값으로 사용해 위에 적힌 문제를 해결했다.
- 암호가 현재의 암호 저장 권장사항(ex. bcrypt)을 통해 암호화됐는지 보장할 수 있다.
- 과거에 썼거나 지금도 쓰는 형식으로 암호를 검증할 수 있다.
- 나중에 개선된 암호화 방식이 나오더라도 쉽게 적용할 수 있다.
// Create Default DelegatingPasswordEncoder
PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
// Create Custom DelegatingPasswordEncoder
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder =
new DelegatingPasswordEncoder(idForEncode, encoders);
1. Password Storage Format
암호 저장 형식은 아래와 같다.
// DelegatingPasswordEncoder Storage Format
{id}encodedPassword
// ex
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
여기서 id는어떤 PasswordEncoder가 사용됐는지 찾을 수 있는 식별자다. 만약 id를 찾을 수 없다면 null로 설정된다. 암호가 매칭에 성공하면 id에 해당하는 PasswordEncoder로 위임된다.
2. Password Encoding
생성자에 어떤 id를 넣어 DelegatingPasswordEncoder 객체를 만드느냐에 따라 암호화 방식이 달라진다. 위에 적은 예시를 보면 암호화한 결과가 BCryptPasswordEncoder로 위임되고, 암호 앞에 {bcrypt}가 붙어 저장된다.
3. Password Matching
암호는 {id}와 생성자에 제공된 PasswordEncoder에 매핑된 id를 기반으로 매칭된다. 이때, id가 매핑돼있지 않거나 null 값을 가지면 matches(CharSequence, String) 메서드 호출 시 IllegalArgumentException 에러가 발생한다.
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233)
at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)
이런 에러가 발생한다면 아래 코드를 작성해 매칭할 때 쓸 PasswordEncoder의 기본값을 설정할 수 있다.
DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder);
Other PasswordEncoder
1. BCryptPasswordEncoder
BCryptPasswordEncoder는 암호를 해시하기 위해 널리 지원되는 bcrypt 알고리즘을 사용한다. 다른 적응형 단방향 함수와는 다르게 4부터 31까지의 strength를 설정해 암호화 강도를 조절할 수 있다. 암호를 검증하는 데 1초 정도 걸리도록 시스템에 맞게 조절하면 된다. 또, Bcrypt 버전이 2a, 2b, 2y로 3가지가 존재하기 때문에 암호를 다양한 방식으로 해시할 수 있다.
// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
// Result ex: $2a$16$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
나머지 적응형 단방향 함수들은 BCryptPasswordEncoder보다 특별할 게 없어서 간단한 설명만 작성했다.
2. Argon2PasswordEncoder
Argon2PasswordEncoder는 암호를 해시하기 위해 Argon2 알고리즘을 사용한다. Argon2 알고리즘은 Password Hashing Competition에서 우승한 적이 있는 알고리즘이며, 많은 양의 메모리를 필요로 한다. 현재 Argon2PasswordEncoder를 구현하려면 BouncyCastle(암호화 API) 라이브러리가 필요하다.
// Create an encoder with all the defaults
Argon2PasswordEncoder encoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
3. Pbkdf2PasswordEncoder
Pbkdf2PasswordEncoder는 암호를 해시하기 위해 PBKDF2 알고리즘을 사용한다. PBDKF2 알고리즘은 FIPS(Federal Information Processing Standard; 연방 정보 처리 표준) 인증이 필요할 때 쓰면 좋다.
// Create an encoder with all the defaults
Pbkdf2PasswordEncoder encoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
4. SCryptPasswordEncoder
SCryptPasswordEncoder는 암호를 해시하기 위해 scrypt 알고리즘을 사용한다. scrypt 알고리즘은 많은 양의 메모리를 필요로 한다.
// Create an encoder with all the defaults
SCryptPasswordEncoder encoder = SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));