주의
이 책은 Spring Security 5 버전을 기준으로 작성되었으므로, Spring Boot 3.X 버전에서는 일부 클래스가 더 이상 사용되지(deprecated) 않을 수 있습니다.
1. PasswordEncoder 계약의 이해

- 일반적으로 시스템은 암호를 일반 텍스트로 관리하지 않고 공격자가 암호를 읽고 훔치기 어렵게 하기 위한 일종의 변환 과정을 거침
- 스프링 시큐리티에는 이 책임을 위해 정의된 별도의 계약이 있음
PasswordEncoder 계약의 정의
- PasswordEncoder는 인증 프로세스에서 암호가 유효한지를 확인함
- 모든 시스템은 어떤 방식으로든 인코딩 된 암호를 저장하며 아무도 암호를 읽을 수 없도록 해싱 후 저장하는 것을 권장
- PasswordEncoder도 암호를 인코딩할 수 있으며 계약에 선언된 encode() 및 matches() 메서드는 사실상 계약의 책임을 정의함
- 이 둘은 서로 강력하게 연결되어 있으며 같은 계약의 일부
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public interface PasswordEncoder { | |
String encode(CharSequence var1); | |
boolean matches(CharSequence var1, String var2); | |
default boolean upgradeEncoding(String encodedPassword) { | |
return false; | |
} | |
} |
- PasswordEncoder 인터페이스는 두 개의 추상 메서드와 기본 구현이 있는 메서드를 하나를 정의함
- 추상 encode() 및 matches() 메서드는 또한 PasswordEncoder 구현을 다룰 때 가장 자주 접하는 메서드
- encode(CharSequence rawPassword) 메서드는 주어진 문자열을 변환해 반환하며 스프링 시큐리티 기능의 관점에서 해당 메서드는 주어진 암호의 해시를 제공하거나 암호화를 수행하는 일을 함
- 인코딩 된 문자열이 원시 암호와 일치하는지 나중에 확인하는 데는 matches(CharSequence rawPassword, String encodedPassword) 메서드를 이용할 수 있음, matches() 메서드는 지정된 암호를 인증 프로세스에서 알려진 자격 증명의 집합을 대상으로 비교함
- upgradeEncoding(CharSequence encodedPassword)은 계약에서 기본값 false를 반환하는데 true를 반환하도록 메서드를 재정의하면 인코딩 된 암호를 보안 향상을 위해 다시 인코딩함
PasswordEncoder 계약의 구현
- encode() 메서드에서 반환된 문자열은 항상 같은 PasswordEncoder의 match() 메서드로 검증할 수 있어야 함
- PasswordEncoder를 구현할 수 있으면 애플리케이션이 인증 프로세스에서 암호를 관리하는 방법을 선택할 수 있음
- 가장 직관적인 암호 인코더 구현은 암호를 인코딩하지 않고 일반 텍스트로 간주하는 것
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class PlainTextPasswordEncoder implements PasswordEncoder { | |
@Override | |
public String encode(CharSequence rawPassword) { | |
return rawPassword.toString(); // 암호를 변경하지 않고 그대로 반환 | |
} | |
@Override | |
public boolean matches(CharSequence rawPassword, String encodedPassword) { | |
return rawPassword.equals(encodedPassword); // 두 문자열이 같은지 확인 | |
} | |
} |
- 해싱 알고리즘 SHA-512를 이용하는 PasswordEncoder의 구현 예제는 아래와 같음
- encode() 메서드에서 호출되고 주어진 입력의 해시 값을 반환
- matches() 메서드는 입력된 원시 암호를 해시하고 주어진 해시와 비교해 검증
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Sha512PasswordEncoder implements PasswordEncoder { | |
@Override | |
public String encode(CharSequence rawPassword) { | |
return hashWithSHA512(rawPassword.toString()); | |
} | |
@Override | |
public boolean matches(CharSequence rawPassword, String encodedPassword) { | |
String hashedPassword = encode(rawPassword); | |
return encodedPassword.equals(hashedPassword); | |
} | |
private String hashWithSHA512(String input) { | |
StringBuilder result = new StringBuilder(); | |
try { | |
MessageDigest md = MessageDigest.getInstance("SHA-512"); | |
byte[] digested = md.digest(input.getBytes()); | |
for (int i = 0; i < digested.length; i++) { | |
result.append(Integer.toHexString(0xFF & digested[i])); | |
} | |
} catch (NoSuchAlgorithmException e) { | |
throw new RuntimeException("Bad Algorithm"); | |
} | |
return result.toString(); | |
} | |
} |
PasswordEncoder의 제공된 구현 선택
- PasswordEncoder를 구현하는 방법을 알면 도움이 되지만 스프링 시큐리티에 이미 몇 가지 유용한 구현이 있다는 것도 알아야 함
- 이러한 구현 중 애플리케이션에 적합한 구현이 있으면 직접 작성할 필요가 없음
- 이 절에서는 스프링 시큐리티에 있는 PasswordEncoder 구현 옵션을 다룸
- NoOpPasswordEncoder: 암호를 인코딩하지 않고 일반 텍스트로 유지, 암호를 해싱하지 않기 때문에 실제 시나리오에는 절대 쓰지 말아야 함
- StandardPasswordEncoder: SHA-256를 이용해 암호를 해시, 해당 구현은 이제는 구식이기 때문에 새 구현에는 쓰지 않는 것을 권장
- Pbkdf2PasswordEncoder: PBKDF2를 이용
- BCryptPasswordEncoder: bcrypt 강력 해싱 함수로 암호를 인코딩
- SCryptPasswordEncoder: scrypt 해싱 함수로 암호를 인코딩
NoOpPasswordEncoder
- 암호를 인코딩하지 않기 때문에 이론적인 예제에만 이용
- NoOpPasswordEncoder 클래스는 싱글톤으로 설계돼서 클래스 바깥에서는 생성자를 직접 호출할 수 없지만, 다음과 같이 NoOpPasswordEncoder.getInstance() 메서드로 클래스의 인스턴스를 얻을 수 있음
PasswordEncoder p = NoOpPasswordEncoder.getInstance();
StandardPasswordEncoder
- StandardPasswordEncoder 구현은 SHA-256으로 암호를 해싱함
- StandardPasswordEncoder를 이용하면 해싱 프로세스에 적용할 비밀을 지정할 수 있으며 비밀의 값은 생성자의 매개변수로 전달되고 인수가 없는 생성자를 호출하면 빈 문자열이 키 값으로 이용됨
- 앞서 언급했다시피 이제 구식이므로 새 구현에는 쓰지 않는 것을 권장
PasswordEncoder p = new StandardPasswordEncoder();
PasswordEncoder p = new StandardPasswordEncoder("secret");
Pbkdf2PasswordEncoder
- 스프링 시큐리티에는 PBKDF2로 암호를 인코딩
- Pbkdf2PasswordEncoder의 인스턴스를 만들 때는 다음과 같은 옵션이 있음
PasswordEncoder p = new Pbkdf2PasswordEncoder();
PasswordEncoder p = new Pbkdf2PasswordEncoder("secret");
PasswordEncoder p = new Pbkdf2PasswordEncoder("secret", 185000, 256);
- PBKDF2는 반복 횟수 인수만큼 HMAC를 수행하는 아주 단순하고 느린 해싱 함수
- 마지막 호출의 세 매개 변수는 각각 인코딩 프로세스에 이용되는 키의 값, 암호 인코딩의 반복 횟수, 해시의 크기
- 두 번째와 세 번째 매개변수는 결과의 강도에 영향을 주고 반복 횟수와 결과 길이를 늘이거나 줄일 수 있으며 해시가 길수록 암호는 더 강력해짐
- 그러나 이러한 값은 성능에 영향을 주며 반복 횟수를 늘리면 애플리케이션이 소비하는 리소스가 증가함
- 따라서 해시 생성에 사용되는 리소스와 필요한 인코딩 강도 사이에서 신중하게 절충해야 함
BCryptPasswordEncoder
- bcrypt 강력 해싱 함수로 암호를 인코딩
- BCryptPasswordEncoder의 인스턴스를 만들려면 인수가 없는 생성자를 호출해도 되지만, 인코딩 프로세스에 이용되는 로그 라운드를 나타내는 강도 계수를 지정할 수도 있음
- 또한 인코딩에 이용되는 SecureRandom 인스턴스를 변경할 수도 있음
PasswordEncoder p = new BCryptPasswordEncoder();
PasswordEncoder p = new BCryptPasswordEncoder(4);
SecureRandom s = SecureRandom.getInstanceStrong();
PasswordEncoder p = new BCryptPasswordEncoder(4, s);
- 지정하는 로그 라운드 값은 해싱 작업이 이용하는 반복 횟수에 영향을 줌
- 반복 횟수는 2 로그 라운드로 계산됨
- 반복 횟수를 계산하기 위한 로그 라운드 값은 4 ~ 31 사이여야 하며 해당 값을 지정하려면 이전 코드에 나온 것처럼 두 번째나 세 번째 오버로딩되니 생성자를 호출하면 됨
SCryptPasswordEncoder
- 해당 암호 인코더는 scrypt 해싱 함수를 이용
- ScryptPasswordEncoder의 인스턴스를 만드는 데는 두 가지 옵션이 있음
PasswordEncoder p = new SCryptPasswordEncoder();
PasswordEncoder p = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);

DelegatingPasswordEncoder를 이용한 여러 인코딩 전략
- 일부 애플리케이션에는 다양한 암호 인코더를 갖추고 특정 구성에 따라 선택하는 방식이 유용할 수 있음
- 운영 단계 애플리케이션에 DelegatingPasswordEncoder가 사용되는 일반적인 시나리오로는 특정 애플리케이션 버전부터 인코딩 알고리즘이 변경되는 경우가 있음
- 현재 사용되는 알고리즘에서 취약점이 발견되어 신규 등록 사용자의 자격 증명을 변경하고 싶지만, 기존 자격 증명을 변경하고 싶을 때 DelegatingPasswordEncoder 객체가 좋은 해결 방법 중 하나
- DelegatingPasswordEncoder는 PasswordEncoder 인터페이스의 한 구현이며 자체 인코딩 알고리즘을 구현하는 대신 같은 계약의 다른 구현 인스턴스에 작업을 위임함
- 해시는 해당 해시를 의미하는 알고리즘의 이름을 나타내는 접두사로 시작하며 DelegatingPasswordEncoder는 암호의 접두사를 기준으로 올바른 PasswordEncoder 구현에 작업을 위임함
- i.g. 암호에 접두사 {noop}가 있으면 DelegatingPasswordEncoder가 작업을 NoOpPasswordEncoder 구현에 위임하며 {bcrypt}에 대해 BCryptPasswordEncoder, {scrypt}에 대해 SCryptPasswordEncoder를 등록하고 해당 PasswordEncoder에 위임함

- DelegatingPasswordEncoder를 정의하기 위해서는 원하는 PasswordEncoder 구현의 인스턴스 컬렉션을 만들고, 이를 DelegatingPasswordEncoder에 넣으면 됨
- 아래 예제 코드는 BCryptPasswordEncoder를 기본 구현으로 정의했기 때문에 접두사가 없을 때 애플리케이션이 작업을 BCryptPasswordEncoder에 위임함
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Configuration | |
public class ProjectConfig { | |
// 중략 | |
@Bean | |
public PasswordEncoder passwordEncoder() { | |
Map<String, PasswordEncoder> encoders = new HashMap<>(); | |
encoders.put("noop", NoOpPasswordEncoder.getInstance()); | |
encoders.put("bcrypt", new BCryptPasswordEncoder()); | |
encoders.put("scrypt", new SCryptPasswordEncoder()); | |
return new DelegatingPasswordEncoder("bcrypt", encoders); | |
} | |
} |
- 스프링 시큐리티는 편의를 위해 모든 표준 제공 PasswordEncoder의 구현에 대한 맵을 가진 DelegatingPasswordEncoder를 생성하는 방법을 제공
- PasswordEncoderFactories 클래스에는 bcrypt가 기본 인코더인 DelegatingPasswordEncoder의 구현을 반환하는 정적 메서드 createDelegatingPasswordEncoder()가 있음
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
2. 스프링 시큐리티 암호화 모듈에 관한 추가 정보
- SSCM의 두 가지 필수 기능을 이용하는 방법은 다음과 같음
- 키 생성기: 해싱 및 암호화 알고리즘을 위한 키를 생성하는 객체
- 암호기: 데이터를 암호화 및 복호화하는 객체
키 생성기 이용
- 키 생성기는 특정한 종류의 키를 생성하는 객체로서 일반적으로 암호화나 해싱 알고리즘에 필요함
- 스프링 시큐리티의 키 생성기 구현은 아주 훌륭한 유틸리티 툴
- BytesKeyGenerator 및 StringKeyGenerator는 키 생성기의 두 가지 주요 유형을 나타내는 인터페이스이며 팩토리 클래스 KeyGenerators로 직접 만들 수 있음
- StringKeyGenerator 계약으로 나타내는 문자열 키 생성기를 이용해 문자열 키를 얻을 수 있음
- 일반적으로 해당 키는 해싱 또는 암호화 알고리즘의 솔트 값으로 이용
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public interface StringKeyGenerator { | |
String generateKey(); | |
} |
- 생성기에는 키 값을 나타내는 문자열 하나를 반환하는 generate() 메서드 하나만 있음
- 해당 생성기는 8바이트 키를 생성하고 이를 16진수 문자열로 인코딩하며 메서드는 이러한 작업의 결과를 문자열로 반환함
StringKeyGenerator keyGenerator = KeyGenerators.string();
String salt = keyGenerator.generateKey();
- 두 번째 키 생성기 인터페이스 BytesKeyGenerator는 다음과 같이 정의됨
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public interface BytesKeyGenerator { | |
int getKeyLength(); | |
byte[] generateKey(); | |
} |
- 위 인터페이스에는 byte [] 키를 반환하는 generateKey() 메서드 외에도 바이트 수를 반환하는 메서드가 있음
- 기본 ByteKeyGenerator는 8바이트 길이의 키를 생성
- 다른 키 길이를 지정하려면 키 생성기 인스턴스를 얻을 때 KeyGenerators.secureRandom() 메서드에 원하는 값을 전달하면 됨
- KeyGenerators.secureRandom() 메서드로 생성한 BytesKeyGenerator는 generateKey() 메서드가 호출될 때마다 고유한 키를 생성함
BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom();
byte[] key = keyGenerator.generateKey();
int keyLength = keyGenerator.getKeyLength();
BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom(10);
- 같은 키 생성기를 호출하면 같은 키 값을 반환하는 구현이 적합할 때가 있음
- 이때는 KeyGenerators.shared(int length) 메서드로 BytesKeyGenerator를 생성할 수 있음
- 다음 코드에 key1 및 key2는 값이 같음
BytesKeyGenerator keyGenerator = KeyGenerators.shared(10);
byte[] key1 = keyGenerator.generateKey();
byte[] key2 = keyGenerator.generateKey();
암호화와 복호화 작업에 암호기 이용
- 암호기는 암호화 알고리즘을 구현하는 객체
- 암호화와 복호화는 보안을 위한 공통적인 기능이므로 애플리케이션에 이러한 기능이 필요할 가능성이 큼
- 시스템의 구성 요소 간에 데이터를 전송하거나 데이터를 저장할 때 암호화가 필요할 때가 많음
- 암호기는 암호화와 복호화 작업을 지원하며 SSCM에는 이를 위해 BytesEncryptor 및 TextEncryptor라는 두 유형의 암호기가 정의되어 있음
- 이들 암호기는 역할은 비슷하지만 다른 데이터 형식을 처리함
- TextEncryptor는 데이터를 문자열로 관리함
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public interface TextEncryptor { | |
String encrypt(String var1); | |
String decrypt(String var1); | |
} | |
public interface BytesEncryptor { | |
byte[] encrypt(byte[] var1); | |
byte[] decrypt(byte[] var1); | |
} |
- 암호기를 구축하고 이용하는 옵션은 다음과 같음
- 팩토리 클래스 Encryptors는 여러 가능성을 제공하며, BytesEncryptor의 경우 아래와 같이 Encryptors.standard() 또는 Encryptors.stronger() 메서드를 이용할 수 있음
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
String salt = KeyGenerators.string().generateKey(); | |
String password = "secret"; | |
String valueToEncrypt = "HELLO"; | |
BytesEncryptor e = Encryptors.standard(password, salt); | |
byte[] encrypted = e.encrypt(valueToEncrypt.getBytes()); | |
byte[] decrypted = e.decrypt(encrypted); |
- 내부적으로 표준 바이트 암호기는 256 바이트 AES 암호화를 이용해 입력을 암호화함
- 더 강력한 바이트 암호기 인스턴스를 만들려면 Encryptors.stronger() 메서드를 호출하면 됨
BytesEncryptor e = Encryptors.stronger(password, salt);
- TextEncryptors는 세 가지 주요 형식이 있으며 Encryptors.text(), Encryptors.delux(), Encryptors.queryableText() 메서드를 호출해 이러한 형식을 생성할 수 있음
- 암호기를 생성하는 이러한 메서드 외에도 값을 암호화하지 않는 더미 TextEncryptor를 반환하는 메서드도 있음
- 암호화에 시간을 소비하지 않고 애플리케이션의 성능을 테스트하기를 원할 때나 데모 예제에는 더미 TextEncryptor를 이용할 수 있음
String valueToEncrypt = "HELLO";
TextEncryptor e = Encryptors.noOpText(); // 더미 암호기
String encrypted = e.encrypt(valueToEncrypt);
- Encryptors.text() 암호기는 Encryptors.standard() 메서드로 암호화 작업을 관리하고 Encryptors.delux() 메서드는 다음과 같이 Encryptors.stronger() 인스턴스를 이용함
String salt = KeyGenerators.string().generateKey();
String password = "secret";
String valueToEncrypt = "HELLO";
TextEncryptor e = Encryptors.text(password, salt);
String encrypted = e.encrypt(valueToEncrypt);
String decrypted = e.decrypt(encrypted);
- Encryptors.text() 및 Encryptors.delux()의 경우 같은 입력으로 encrypt() 메서드를 반복 호출해도 다른 출력이 반환되는데 그 이유는 암호화 프로세스에 임의의 초기화 벡터가 생성되기 때문
- 만약 같은 출력이 반환되길 원한다면 쿼리 기능 텍스트를 사용하면 되고 이 상황에서는 Encryptors.queryableText() 인스턴스를 이용
String salt = KeyGenerators.string().generateKey();
String password = "secret";
String valueToEncrypt = "HELLO";
// 쿼리 기능 텍스트 암호기 생성
TextEncryptor e = Encryptors.queryableText(password, salt);
String encrypted1 = e.encrypt(valueToEncrypt);
String encrypted2 = e.encrypt(valueToEncrypt);
참고
스프링 시큐리티 인 액션
반응형
'Spring > 스프링 시큐리티 인 액션' 카테고리의 다른 글
[8장] 권한 부여 구성: 제한 적용 (0) | 2025.05.30 |
---|---|
[7장] 권한 부여 구성: 액세스 제한 (0) | 2025.05.30 |
[5장] 인증 구현 (0) | 2025.05.21 |
[3장] 사용자 관리 (0) | 2025.05.18 |
[2장] 스프링 시큐리티 기본 구성 (0) | 2025.05.18 |