문제 상황
현재 Fixadate의 Signin 과정은 다음과 같습니다.
- Client(ios)에서 소셜 로그인을 수행한 뒤, 얻은 정보를 Server에게 보냅니다.
- Server는 받은 정보를 바탕으로 DB에서 해당 회원이 있는지 조회합니다.
- 해당 회원이 DB에 있으면 JWT 토큰을 발급해서 header로 보내고, 없으면 401 exception을 발생시킵니다.
이와 같이 진행을 하면 회원이 있는 것과 없는 것을 잘 판단할 수 있지만, 보안 관점에서 보았을 때 매우 위험한 방법입니다. 만약 무차별 대입 공격과 같이 무작위 문자열을 대입하다가 DB에 있는 회원의 oauthId와 일치하게 되어 jwt를 발급하게 되면 한 사람의 일정을 다른 사람이 전부 볼 수 있게 되기 때문에 매우 큰 문제가 될 수 있는 방법입니다.
토이 프로젝트였다면 실 사용자가 많지 않아 그대로 진행을 했을 것 같지만, Fixadate는 창업 프로젝트이기 때문에 보안성을 높이기로 결정했습니다.
단방향과 양방향
단방향 알고리즘
우선 암호화에 대한 기본적인 이해를 하기 위해서는 단방향과 양방향 암호화에 대한 이해가 필요합니다.
단방향 알고리즘 : 암호화 가능, 복호화 불가능 (해시 함수 사용)
양방향 알고리즘 : 암호화 가능, 복호화 가능 (공개키, 비공개키 사용)
해시함수란?
임의의 데이터를 고정된 길이의 데이터로 매핑하는 함수입니다.
MD5, SHA-1, SHA-2등 다양한 알고리즘이 존재합니다. (이 글에서는 자세하게 설명하지 않겠습니다)
위 그림과 같이 해시함수를 통해 데이터를 매핑하게 되면 비밀번호가 유출이 되어도 비밀번호를 알 수 없다는 장점이 있습니다.
만약 해커가 이미 qwer에 대한 해시값을 알고 있다면?
같은 알고리즘으로 매핑을 한 해시값은 항상 동일합니다.
그러므로 해커가 이미 자신만의 데이터베이스에 10개의 해시 값을 가지고 있고 당신의 비밀번호의 해시값이 그 중하나라면 해커가 비밀번호를 알고 있는 것과 다를 바 없습니다.
이때 해커가 가지고 있는 데이터베이스 즉 표는 레인보우 테이블이라고 불립니다.
레인보우 테이블 : 해시함수를 사용하여 만들어낼 수 있는 값들을 대량으로 저장한 표이다.
"그러면 단 방향 알고리즘은 위험하겠네요?"
레인보우 테이블에 해시값이 존재할 수 없게 비밀번호를 조금만 변경하면 되지 않을까요?라는 생각을 한 독자 여러분 대단해요.
키 스트레칭
키 스트레칭은 단방향 해시값을 계산한 후 그 값을 또 해시하고 또 해시하는 과정을 반복하는 방식입니다.
네이버 D2의 말을 인용하자면 최근에는 일반적인 장비로 1초에 50억 개 이상의 다이제스트(해시 값)를 비교할 수 있지만, 키 스트레칭을 적용하여 동일한 장비에서 1초에 5번 정도만 비교할 수 있게 한다.
2013년에 글을 작성하여 현재는 이보다 수치가 많이 올라갔겠지만 차이가 큰 모습을 볼 수 있습니다.
솔팅
솔트는 단방향 해시 함수에서 다이제스트를 생성할 때 추가되는 바이트 단위의 임의의 문자열이다. 그리고 이 원본 메시지에 문자열을 추가하여 다이제스트를 생성하는 것을 솔팅이라고 합니다.
이렇게 하면 공격자가 redfl0wer을 알게 된다 하더라도 솔팅된 다이제스트를 대상으로 패스워드 일치 여부를 확인하기 어렵다는 장점이 있습니다.
위 사이트에서 레인보우 테이블에 해시값이 존재하는지 테스트를 해봤습니다.
qwer의 다이제스트 값은 레인보우 테이블에 존재하는 것을 확인할 수 있었습니다.
qwer의 다이제스트 값을 한 번 더 해시한 결과 레인보우 테이블에 없는 것을 확인할 수 있습니다.
임의의 솔트(문자열)를 붙여서 확인해 본 결과 레인보우 테이블에 없는 것을 확인할 수 있습니다.
양방향 알고리즘
양방향 알고리즘은 단방향 알고리즘과는 달리 복호화를 할 수 있습니다.
이때 복호화를 할 때 키를 이용하여 복호화를 진행하는데 키에는 두 가지 종류가 있습니다.
대칭키 암호화
하나의 키로 암호화와 복호화를 모두 수행하는 것을 말합니다.
암호화와 복호화를 모두 수행하므로 키는 절대로 외부에 유출되어서는 안 됩니다. (유출되면 오픈소스나 마찬가지죠..)
대칭키 암호는 공개키 방식에 비하여 구현이 용이하고 매우 빠른 암/복호화 속도를 장점으로 가지고 있습니다.
DES, AES, SEED, ARIA등의 알고리즘이 있습니다.(이 글에서는 자세하게 설명하지 않겠습니다)
공개키 암호화
키가 하나밖에 없는 대칭키에 비해 공개키 암호화는 키가 두 개 존재합니다. 즉 암호화와 복호화에 사용하는 키가 서로 다릅니다.
암호화할 때의 키는 공개키로 하며 복호화 할 때의 키는 개인키로 합니다.
대표적인 알고리즘으로는 RSA 알고리즘이 있습니다. (JWT를 구현할 때 많이 사용합니다)
그러면 다시 Fixadate로 돌아와서 비밀번호를 암호화할 때는 단방향 알고리즘을 사용할까요? 양방향 알고리즘을 사용할까요?
두 글의 글자 차이가 너무 많이 나서 답이 쉬울 것 같기도 하네요...
우선 비밀번호의 특징을 생각해보면 다음과 같이 3개의 특징을 생각해 볼 수 있습니다.
- 비교를 통해 일치하면 통과된다.
- 비밀번호를 복호화해서 사용할 일이 없다.
- 원본이 유출되면 안된다.
이러한 특징들을 통해 단방향 알고리즘이 비밀번호의 암호화에 알맞은 암호화 알고리즘이라는 것을 알 수 있습니다.
만약 비밀번호를 복호화해서 사용할 일이 있는 프로젝트에서는 양방향 알고리즘도 고려해 볼 수 있습니다.
PasswordEncoder
Spring Boot에서는 비밀번호 암호화를 제공해 주는 인터페이스가 있습니다.
주요 함수
1. encode
파라미터로 받은 원본 비밀번호를 암호화합니다.
일반적으로 좋은 알고리즘은 SHA-1 이상의 해시 함수를 제공하거나 무작위로 솔트가 추가됩니다.
SHA-1, 솔트를 통해서 PasswordEncoder의 encode 함수는 단방향 알고리즘을 사용함을 알 수 있습니다.
2. matches
파라미터로 원본 비밀번호와 암호화된 비밀번호를 받은 뒤, 두 개의 비밀번호가 일치하는지 확인합니다.
일치하면 true를 반환하고, 일치하지 않으면 false를 반환합니다.
3. upgradeEncoding
더 나은 보안을 위해 인코딩 된 비밀번호를 다시 인코딩해야 하는 경우 true를 반환하고, 그렇지 않으면 false를 반환합니다. 본 값은 false이며 커스텀해서 사용할 수 있습니다.
밑의 사진은 PasswordEncoder를 구현한 ScryptPasswordEncoder 구현체입니다.
유지보수
유지보수하기 좋은 코드는 변경에 유연한 코드라고 생각합니다.
암호화 알고리즘이 발전하듯이 해커들의 능력도 계속해서 진화하고 있습니다.
만약 내가 사용 중인 알고리즘이 취약점이 발견되어 다른 암호화 알고리즘으로 전환해야 하는 상황이 발생하면 어떻게 대응할 것인가요? 기존 암호화 코드를 전부 변경하는 것은 매우 좋지 않은 방법입니다. 암호화 코드를 변경할 때 실수가 발생할 수 있고, 예상하지 못한 사이트 이펙트가 발생할 수 있기 때문입니다.
Spring Boot에서는 암호화 알고리즘을 편하게 사용할 수 있게 해주는 클래스를 제공하고 있습니다.
DelegatingPasswordEncoder
생성 방법
DelegatingPasswordEncoder은 PasswordEncoderFactories를 통해 생성할 수 있습니다.
public final class PasswordEncoderFactories {
private PasswordEncoderFactories() {
}
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
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("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
}
Creates a DelegatingPasswordEncoder with default mappings. Additional mappings may be added and the encoding will be updated to conform with best practices. However, due to the nature of DelegatingPasswordEncoder the updates should not impact users.
기본 매핑이 있는 DelegatingPasswordEncoder를 생성합니다. 추가 매핑을 추가할 수 있으며 인코딩은 최적의 방법을 준수하도록 업데이트됩니다. 그러나 DelegatingPasswordEncoder의 특성상 업데이트는 사용자에게 영향을 미치지 않아야 합니다.
조금 더 쉽게 설명을 하자면 위의 기본적인 매핑이 있는 DelegatingPasswordEncoder 객체를 생성하는 것입니다.
추가 매핑(암호화 함수)을 추가할 수 있으며, 인코딩은 최적의 방법을 준수하도록 업데이트됩니다.
또는 생성자를 통해 생성할 수 있습니다.
public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder, String idPrefix, String idSuffix) {
this.defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
} else if (idPrefix == null) {
throw new IllegalArgumentException("prefix cannot be null");
} else if (idSuffix != null && !idSuffix.isEmpty()) {
if (idPrefix.contains(idSuffix)) {
throw new IllegalArgumentException("idPrefix " + idPrefix + " cannot contain idSuffix " + idSuffix);
} else if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
} else {
Iterator var5 = idToPasswordEncoder.keySet().iterator();
while(var5.hasNext()) {
String id = (String)var5.next();
if (id != null) {
if (!idPrefix.isEmpty() && id.contains(idPrefix)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + idPrefix);
}
if (id.contains(idSuffix)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + idSuffix);
}
}
}
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = (PasswordEncoder)idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap(idToPasswordEncoder);
this.idPrefix = idPrefix;
this.idSuffix = idSuffix;
}
} else {
throw new IllegalArgumentException("suffix cannot be empty");
}
}
파라미터로 인코딩에 사용될 알고리즘의 ID, 알고리즘의 ID와 해당 알고리즘을 매핑한 맵, 접두사, 접미사를 전해주면 생성됩니다. 간단하게 입력값을 검증하고 저장하는 로직이어서 설명은 간단하게 하겠습니다.
클래스
1. 생성자
public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) {
this(idForEncode, idToPasswordEncoder, "{", "}");
}
PasswordEncoderFactories의 createdelegatingpasswordencoder 함수를 통해 DelegatingPasswordEncoder를 생성하면 위의 생성자를 통해 객체가 생성됩니다. 이때 idForCode는 bcrypt입니다.
2. setDefaultPasswordEncoderForMatches
public void setDefaultPasswordEncoderForMatches(PasswordEncoder defaultPasswordEncoderForMatches) {
if (defaultPasswordEncoderForMatches == null) {
throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
} else {
this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}
}
setDefaultPasswordEncoderForMatches 메서드를 통해서 여러 암호화 알고리즘 중 특정 알고리즘을 기본 암호화 알고리즘으로 설정할 수 있습니다.
3. encode
public String encode(CharSequence rawPassword) {
String var10000 = this.idPrefix;
return var10000 + this.idForEncode + this.idSuffix + this.passwordEncoderForEncode.encode(rawPassword);
}
파라미터로 주어진 원본 비밀번호를 암호화하여 문자열을 반환합니다.
이때 암호화에 사용될 알고리즘의 ID ex) bcrypt와 접두사, 접미사를 이용해 암호화된 문자열을 생성합니다.
4. matches
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
} else {
String id = this.extractId(prefixEncodedPassword);
PasswordEncoder delegate = (PasswordEncoder)this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
} else {
String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}
}
}
주어진 rawPassword와 암호화된 prefixEncodedPassword가 일치하는지 확인합니다.
만약 둘 다 null이라면 true를 반환하고 그렇지 않으면 추출된 알고리즘의 ID를 이용해 해당 알고리즘을 가져오고 암호화된 Password와 Password가 일치하는지 확인합니다.
5. extractId
private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
} else {
int start = prefixEncodedPassword.indexOf(this.idPrefix);
if (start != 0) {
return null;
} else {
int end = prefixEncodedPassword.indexOf(this.idSuffix, start);
return end < 0 ? null : prefixEncodedPassword.substring(start + this.idPrefix.length(), end);
}
}
}
위의 matches를 할 때 알고리즘의 ID를 가져오는 역할을 하는 함수입니다.
prefixEncodedPassword에서 idPrefix의 시작 인덱스를 찾고, 이후 idSuffix의 인덱스를 찾습니다. 두 인덱스 사이의 문자열을 반환합니다.
사용 예시
'org.springframework.boot' version '3.0.0'
'org.springframework.security', name: 'spring-security-crypto', version: '6.2.1'
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import java.util.HashMap;
import java.util.Map;
public final class PasswordEncoderFactories {
private PasswordEncoderFactories() {
}
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
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("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
}
PasswordEncoderFactories를 커스텀해서 원하는 알고리즘을 사용할 수 있게 합니다.
위 코드의 경우 encodingId를 "bcrypt"로 했기 때문에 해당 알고리즘을 사용합니다.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
이후 @Configuration 클래스에서 Bean을 생성합니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
PasswordEncoder를 사용하고 싶은 위치에서 의존성 주입을 해줍니다.
@Transactional
public void registMember(MemberRegistRequest memberRegistRequest) {
String encodedOauthId = passwordEncoder.encode(memberRegistRequest.oauthId());
log.info(encodedOauthId);
Member member = memberRegistRequest.of(encodedOauthId);
memberRepository.save(member);
}
public Member signIn(MemberOAuthRequest memberOAuthRequest) {
Member member = findMemberByOAuthProviderAndEmailAndName(memberOAuthRequest)
.orElseThrow(() -> new MemberSigninException("Member not found"));
authenticateMember(memberOAuthRequest, member);
return member;
}
private Optional<Member> findMemberByOAuthProviderAndEmailAndName(MemberOAuthRequest memberOAuthRequest) {
return memberRepository.findMemberByOauthPlatformAndEmailAndName(
memberOAuthRequest.getOAuthProvider(), memberOAuthRequest.email(), memberOAuthRequest.memberName()
);
}
private void authenticateMember(MemberOAuthRequest memberOAuthRequest, Member member) {
if (!passwordEncoder.matches(memberOAuthRequest.oauthId(), member.getOauthId())) {
throw new MemberSigninException("Invalid credentials");
}
}
마지막으로 사용하고 싶은 위치에서 사용을 하시면 됩니다.
예시 코드의 경우 oauthId를 encoding 하였고 match를 한 결과 true가 반환되었습니다.