계정의 정보에는 아이디와 비밀번호가 기본적으로 구성되어 있다.
사용자가 계정에 로그인할 때 아이디와 비밀번호를 서버로 전송하게 되는데, 이때 HTTPS를 사용한다면 기본적으로 암호화가 되므로, 중간에 유출이 되더라도 보안을 지킬 수 있다.
하지만 서버에 계정의 정보가 제공되고, 로그인할 때 DB에 저장된 계정의 정보를 비교하여 로그인하게 되는데 별다른 조치를 하지 않았다면 DB에 저장된 계정의 정보는 암호화가 되어 있지 않다.
이때 보안 취약점으로 인해 서버의 DB가 털리는 사고가 발생한다면 평문으로 저장된 회원의 계정 정보가 모두 유출되는 대형 사고가 발생할 수 있다.
따라서 DB에 저장된 민감한 개인 정보들은 모두 암호화하여 저장해야 한다.
여기서 Spring 프레임워크를 사용한다면 암호화를 매우 간단하게 적용할 수 있다.
암호화를 하는 방법은 크게 두 가지가 있다.
암호화를 한 뒤 복호화가 가능한 양방향 암호화
그리고 복호화가 불가능한 단방향 암호화
여기서 계정의 정보를 암호화할 때 굳이 다시 복호화할 필요가 없다.
따라서 단방향 암호화
를 적용하는 방법인 해시(Hash) 알고리즘을 사용한다.
해시를 사용하면 고유한 길이의 무작위 문자열로 변환시킬 수 있으므로, 해시로 변환된 비밀번호를 얻어도 기존의 원본 비밀번호를 알아낼 수 없으므로 보안을 지킬 수 있다.
해시 알고리즘에는 여러 종류가 있는데, 대표적으로 MD5, SHA가 있다.
하지만 해시 알고리즘을 그대로 사용하면 위험할 수 있는데, 바로 레인보우 테이블
공격에 취약하기 때문이다.
따라서 암호화가 된 비밀번호라도, 원본 비밀번호를 알아낼 수 있다.
따라서 솔트
기법을 적용해야 암호화된 비밀번호가 유출되더라도 안전할 수 있다.
Java의 MessageDigest
클래스를 사용하여, 간단하게 솔트가 적용된 해싱을 적용할 수 있다.
void 솔트가_적용된_비밀번호_검증() throws Exception {
String rawPassword = "1234";
String salt = getSalt();
String encodedPassword = getEncodedPassword(salt, rawPassword);
String withoutSaltPassword = getEncodedPassword("", rawPassword);
String comparePassword = getEncodedPassword(extractSalt(encodedPassword), rawPassword);
assertThat(encodedPassword)
.isNotEqualTo(withoutSaltPassword)
.isEqualTo(comparePassword);
}
private String getSalt() {
Random random = new SecureRandom();
byte[] salt = new byte[5];
random.nextBytes(salt);
StringBuilder sb = new StringBuilder();
for (byte b : salt) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private String getEncodedPassword(String salt, String rawPassword) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] encodedPassword = md.digest((salt + rawPassword).getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : encodedPassword) {
sb.append(String.format("%02x", b));
}
return salt + sb;
}
private String extractSalt(String encodedPassword) {
return encodedPassword.substring(0, 10);
}
이렇게 직접 구현해도 되지만, 보안에 관련된 요소는 잘 만들어진 라이브러리를 사용하는 것이 좋다.
왜냐하면 내가 직접 만든 기능에 미처 파악하지 못한 취약점이 있을 수 있기 때문이다.
Spring에서는 보안에 관련된 모듈을 제공하는데, 바로 Spring Security
이다.
하지만 Spring Secuity를 그대로 추가하면 기본적인 보안 기능이 바로 적용된다.
즉, Spring Security의 구성 요소를 제대로 알지 못하고 적용하면, 어플리케이션의 복잡도가 순식간에 오르게 되고, 불필요한 기능을 비활성화해야 하므로 Spring Security 모듈에 대한 학습이 필요하게 된다.
Spring은 암호화에 필요한 기능만 제공하는 모듈도 따로 제공하는데 바로 Spring Security Crypto 이다.
공식 문서의 설명에는 다음과 같이 나와 있다.
The Spring Security Crypto module provides support for symmetric encryption, key generation, and password encoding. The code is distributed as part of the core module but has no dependencies on any other Spring Security (or Spring) code.
Spring Security의 핵심 모듈로 제공되지만, Spring Security와 다른 Spring 코드에 관한 의존성이 없다는 것이다.
즉, 의존성에 구애받지 않는 유틸리티 클래스로 활용이 가능하다.
build.gradle
의 dependency
에 다음과 같이 추가하면 된다.
dependencies {
...
// 스프링 부트를 사용하면 버전을 명시할 필요가 없다.
implementation 'org.springframework.security:spring-security-crypto'
...
}
암호화를 적용할 때는 PasswordEncoder
인터페이스를 구현한 구현체를 사용하면 된다.
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
여기서 사용하는 해시 알고리즘은 bcrypt
를 사용했다.
bcrypt 알고리즘은 기본적으로 솔트와 키 스트레칭이 적용되어 있으므로 비밀번호와 같은 민감한 개인정보 암호화에 최적화되어 있다.
즉, 직접 솔트와 키 스트래칭을 구현할 필요가 없으므로 매우 편리하게 구현할 수 있는 것이다.
사용자가 가입하고 로그인하는 비즈니스 로직을 다음과 같이 구현할 수 있다.
@Configuration
public class AuthConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Service
@Transcational
public class AuthService {
private final PasswordEncoder passwordEncoder;
...
public SignupResponse signup(SignupRequest req) {
String username = req.username();
String password = passwordEncoder.encode(req.password);
Member member = new Member(req.username(), password);
...
}
public SigninResponse signin(SigninRequest req) {
Member member = findMemberWithAuthenticate(request.username(), request.password());
...
}
private Member findMemberWithAuthenticate(String username, String rawPassword) {
return memberRepository.findByUsername(username)
.filter(member -> passwordEncoder.matches(rawPassword, member.getPassword()))
.orElseThrow(IllegalArgumentException::new);
}
}
프로그래밍 서적에는 다음과 같은 말이 자주 나온다.
유일하게 변하지 않는 것은 모든 것이 변한다는 사실뿐이다. - 헤라클레이토스
위의 코드에서는 PasswordEncoder
인터페이스를 사용하여 변화에 유연하게 대응했다.
즉, 구현체가 변하더라도 기존의 코드는 변경할 필요가 없다는 뜻이다.
하지만 이것은 코드의 이야기이고, 구현체로 암호화되어 DB에 저장된 비밀번호는 적용되지 않는다.
기존 회원의 비밀번호가 bcrypt
로 암호화된 상태에서 서버의 해싱 알고리즘을 scrypt
로 바꾸게 된다면, 기존 회원들은 정확한 비밀번호로 로그인 하더라도 비밀번호가 틀렸다고 응답이 올 것이다.
이러한 상황을 방지하기 위해 Spring은 DelegatingPasswordEncoder
라는 구현체를 제공한다.
자세한 설명은 링크를 참고하자
생성은 new
키워드와 PasswordEncoderFactories
클래스를 통해 생성할 수 있다.
new
키워드를 통해 생성하려면 암호화할 때 사용할 알고리즘 문자열, 알고리즘 문자열을 Key로 가지고, PasswordEncoder를 Value로 가진 Map을 인자가 필요하다.
PasswordEncoderFactories
클래스의 createDelegatingPasswordEncoder()
정적 메서드를 사용하여 간편하게 인스턴스를 생성할 수 있다.
DelegatingPasswordEncoder
를 사용해도 비밀번호를 암호화할 때는 고정된 하나의 알고리즘만 사용할 수 있다.
해당 정적 메서드로 생성된 DelegatingPasswordEncoder는 암호화할 때 bcrypt
알고리즘을 사용한다. (Crypto 6.1.4 기준)
해당 구현체를 사용하여 비밀번호를 암호화하면 {bcrypt}$2a$10$dXJ3...
과 같이 "{알고리즘}" 접두사가 붙는다.
즉, 해당 접두사를 사용하여 서버에서 암호화 알고리즘을 교체하더라도, 기존 DB에 저장되있는 비밀번호의 호환성을 지킬 수 있다.
하지만, 이것은 기존에도 DelegatingPasswordEncoder
를 사용했을 때 이야기이고, 알고리즘 접두사가 붙지 않은 경우 다음과 같이 예외가 발생한다.
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
String id = extractId(prefixEncodedPassword);
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
}
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}
...
private class UnmappedIdPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
throw new UnsupportedOperationException("encode is not supported");
}
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
}
}
이 경우 기존 접두사가 붙지 않은 암호화된 비밀번호를 호환시키기 위해 다음과 같은 메서드를 사용할 수 있다.
public void setDefaultPasswordEncoderForMatches(PasswordEncoder defaultPasswordEncoderForMatches) {
if (defaultPasswordEncoderForMatches == null) {
throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
}
this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}
@Configuration
public class AuthConfig {
@Bean
public PasswordEncoder passwordEncoder() {
DelegatingPasswordEncoder passwordEncoder = (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
passwordEncoder.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
return passwordEncoder;
}
}
비밀번호와 같은 민감한 정보를 DB 혹은 어딘가에 저장할 때는 암호화를 적용하여 유출되더라도 문제가 없도록 해야 한다.
여기서 단순히 암호화를 하는 데 그치지 않고, 적용한 방법에 대해 취약점이 있는지 분석하고 보완해야 한다.
스프링은 잘 만들어진 보안 모듈을 제공하므로, 암호화 기능을 직접 만들지 않고 쉽게 사용할 수 있다.
또한, 인터페이스를 의존하게 만들어 변경에 유연하게 설계하여도 구체적으로 저장된 것들 때문에 변경에 취약점이 생길 수 있으므로 다른 방법을 고려해야 한다.