[더 알아보기] 💡Salt는 어떤 값을 기준으로 채번이 될까? - 암호학적 보안 난수생성기(CSPRNG)로 만들어집니다. 스프링의 BCryptPasswordEncoder는 내부에서 SecureRandom을 사용하며 그냥 Random이 아니라 예측 불가능성이 보장된 난수입니다. - 안 그러면 salt를 미리 알아내 레인보우 테이블을 만들 여지가 생깁니다.
encode("1234") → {bcrypt}$2a$10$dXJ3SW6...
encode("1234") → {bcrypt}$2a$10$9pKz2Lm... // 같은 비번인데 salt가 달라 다른 해시
[더 알아보기] 💡 레인보우 테이블 - 해시값을 역으로 원본(주로 비밀번호)으로 되돌리기 위해 미리 계산해둔 조회용 테이블입니다. - 해시는 단방향이라 원본→해시는 쉽지만 해시→원본은 불가능합니다. 무차별 대입(Brute Force)은 매번 다시 계산해야 해서 느리기에 "해시 → 리덕션 함수로 다시 평문 후보 생성 → 다시 해시" 를 수천 번 반복하는 체인을 만들고, 각 체인의 시작값과 끝값만 저장하며 중간값은 버리는 방식을 이용합니다. - 크래킹 시엔 목표 해시에 리덕션/해시를 반복해 저장된 끝값과 매칭되는 체인을 찾고, 그 체인의 시작값부터 다시 돌려 원본을 복원합니다.
- Spring의 BCryptPasswordEncoder 기본 cost는 10입니다.cost가 1 오를 때마다 연산량이 2배로 늘어나며, 하드웨어가 빨라지면 cost만 올려서 방어력 유지 가능합니다. - 단, cost 비용이 높아질때마다, 사용자의 입장에서는 로그인 시 속도가 느려지는 문제점이 있습니다. 그래서 암호를 검증하는데 약 1초가 걸리도록 cost를 조정하는것이 좋다고합니다.
💡 권장: 로그인 응답이 체감상 너무 느리지 않은 선에서 최대한 높게하며, 보통 10~12으로 구성을 합니다.
new BCryptPasswordEncoder(); // cost 10 (기본)
new BCryptPasswordEncoder(12); // cost 12 (더 강하고 느림)
3. SHA-256과의 차이점
💡 SHA-256과의 차이점 - SHA-256은 원래 데이터 무결성 검증용(파일 체크섬 등)으로 설계되어서 최대한 빠르게 계산되도록 만들어졌습니다. 그런데 비밀번호 저장에선 이 "빠름"이 약점이 될수 있습니다. 공격자가 해시가 유출된 상황에서 GPU로 초당 수십억 개를 대입하여 비밀번호를 알 수 있습니다. - 게다가 SHA-256 자체엔 Salt나 반복 개념이 없어서 salt를 수동으로 붙이고 그마저 모든 계정이 같은 고정 salt(userUseKey) 되어 있기에, 같은 비번은 같은 해시가 나오고, 레인보우 테이블·병렬 크래킹에 취약합니다.
- 반대로 BCrypt는 비밀번호 저장 전용으로 설계됐습니다. 계정마다 다른 랜덤 salt 자동 생성·저장. 같은 비번이라도 해시가 매번 다르고, salt가 해시 문자열 안에 함께 들어갑니다.work factore(cost)를 통하여서 반복 강도를 높이며, 하드웨어가 빨라지면 이 숫자만 올려서 계산 비용을 키울 수 있습니다.
- Spring Security 5부터 기본 인코더로 채택이 되었으며 여러 인코딩 방식을 {id} 접두사로 구분해 상황에 맞는 인코더에게 위임하는 PasswordEncoder 구현체입니다. 저장값에 알고리즘 식별자를 함께 저장해 여러 알고리즘의 공존과 점진적 마이그레이션을 가능하게 한다라는 장점이 있습니다.
💡 왜 필요한가 - 과거에는 애플리케이션마다 비밀번호 인코딩은 하나로 고정이 되어있습니다. 이러한 인코딩은 시간이 지나면 알고리즘이 취약하다라는 단점을 가지게 되었습니다. (예: MD5 → bcrypt → argon2) - 그렇다고 기존에 DB에 저장된 수만 건의 해시들은 평문을 모르기에 한번에 재 인코딩을 할 수 없었습니다. 이러한 인코더를 바꾸게 되면 기존의 저장데이터에 대한 검증이 깨집니다.그래서 {bcrypt}$2a$10$dXJ3SW6 와 같이 접두사로 {id}를 명시하여서 어떤 알고리즘을 암호화를 구성했는지 명시를 합니다.
1. 공격자는 접두어가 없어도 해시 길이/포멧을 통해서 어떤 알고리즘인지 추측이 가능 - $2a$로 시작하는 60자 문자열은 접두어를 지워도 누가 봐도 bcrypt로 추측이 가능합니다.즉,{bcrypt}를 넣지 않는다고 해도 공격자가 얻는 정보는 사실상 없습니다. 2. 알고리즘을 알아도 크래킹 비용은 그대로입니다. - 공격자가 bcrypt인 걸 알든 모르든, 원문을 찾으려면 후보 비번을 하나씩 bcrypt로 돌려 대조하는 수밖에 없습니다.bcrypt는 일부러 느리고(cost 10 = 후보 하나당 수십 ms), 계정마다 salt가 달라서 레인보우 테이블도 못 사용합니다. "알고리즘을 안다"는 게 이 느린 대입 비용을 전혀 줄여주지 않습니다.
3. 최종적으로 비밀번호 누출의 방어선은 salt와 cost입니다. - 그 값 안의 salt 덕에 같은 비번도 계정마다 해시가 다르고, cost 덕에 무차별 대입이 느립니다.salt는 원래 숨기는 값이 아니라 "재사용을 막는" 값으로 사용이 됩니다.
2. 구조
💡 구조
- DelegatingPasswordEncoder를 통해 생성한 암호화는 아래와 같은 구조를 가집니다. - 구조 중에서 {id}는 알고리즘의 아이디를 의미합니다. - DelegatingPasswordEncoder를 사용하였을때 ‘{id}’ 접두사를 사용하며, 접두사를 사용하지 않을때 UnmappedIdPasswordEncoder와 같은 Exception이 발생합니다.
// 구조
{id}encodedPassword
// 암호화 예시
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4e...
{argon2}$argon2id$v=19$m=16384,t=2,p=1$...
{noop}myPassword
💡 예시 - 사용자는 BCrypy를 통해서 암호화를 진행하고자 합니다. 해당 인코딩은 시간이 지나면 알고리즘이 취약하다라는 단점을 가지고 있습니다. 그렇기에 DelegatingPasswordEncoder를 통해서 인코딩을 수행하고자 합니다.
3.1. 컨테이너에 Bean 등록
💡 컨테이너에 Bean 등록
- Spring 컨테이너에 PasswordEncoder 타입 빈을 등록하며, 실제 구현체로 DelegatingPasswordEncoder를 사용합니다. - 이후 PasswordEncoder를 주입받아 사용하면 내부적으로 DelegatingPasswordEncoder가 동작합니다.
- 사전에 구성한 passwordEncoder bean의 encode()를 통해서 인코딩을 수행합니다.
@Service
@RequiredArgsConstructor
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public void signup(String username, String rawPassword) {
String encoded = passwordEncoder.encode(rawPassword);
// encoded → {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe...
userRepository.save(new UserEntity(username, encoded));
}
}
3.3. 로그인 수행
💡 로그인 수행
- passwordEncoder의 matches() 함수를 통해서 검증을 합니다
public boolean login(String username, String rawPassword) {
UserEntity user = userRepository.findByUsername(username);
return passwordEncoder.matches(rawPassword, user.getPassword()); // true or false
// {bcrypt} 라벨 읽고 → bcrypt 인코더로 검증
}
4. DelegatingPasswordEncoder 사용 목적
💡 DelegatingPasswordEncoder 사용 목적 1. 알고리즘의 교체가 자유롭다. - BCrypt를 직접 쓰면 저장값이 $2a$10$...처럼 접두어가 없어서, 암호화 알고리즘에 대해서 알수 없었습니다. - 접두어만 확인하더라도 알고리즘이 적용되어서 bcrypt로 검증 할 수 있다
2. 여러 알고리즘이 DB에 공존해도 각각 맞게 검증돼 - 여러 알고리즘이 존재할때 {bcrypt}, {argon2}, {sha256} 해시가 한 테이블에 섞여 있어도 각 접두어대로 알아서 골라 검증가능합니다. 직접 인코더 하나만 쓰면 이런 혼재 상황을 코드로 분기해야 한다는 점이 있습니다.
3. 로그인 시 자동 업그레이드가 가능해 - matches()가 성공했을 때 upgradeEncoding()이 true면(예: cost가 낡았거나 옛 알고리즘), 그 타이밍에 최신 방식으로 다시 해시해서 DB를 갱신할 수 있습니다. - 사용자는 평소처럼 로그인하는데 뒤에서 조용히 해시가 최신화됩니다.
4. 스프링 공식 표준입니다. - createDelegatingPasswordEncoder()가 스프링이 미는 기본값이고, 스프링 시큐리티 내부(예: 사용자 등록·기본 인코딩)도 이 접두어 규약을 전제로 동작을합니다. 접두어 없는 순수 해시를 넣으면 오히려 "Encoded password does not look like BCrypt/…" 예외가 날 수 있습니다.
3) Spring Boot 적용 예시
1. PasswordEncoder 인터페이스의 구현체 구현
💡 PasswordEncoder 인터페이스의 구현체 구현 - PasswordEncoder 인터페이스의 구현체로 PasswordEncoderFactories.createDelegatingPasswordEncoder() 함수를 호출하여 Default 값인 BCrypt를 사용하여 암호화를 수행합니다.
- Spring Security 내에서 사용자의 아이디/비밀번호를 통하여 인증을 수행합니다. - PasswordEncoder를 호출하며, passwordEncoder.matches() 함수를 이용하여 true/false를 통해서 평문 → BCrypt를 통해서 매칭되는 비밀번호를 확인합니다.
package xxxx.config.handler;
import xxxx.UserDetailsDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 전달받은 사용자의 아이디와 비밀번호를 기반으로 비즈니스 로직을 처리하여 사용자의 ‘인증’에 대해서 검증을 수행하는 클래스입니다.
* CustomAuthenticationFilter로 부터 생성한 토큰을 통하여 ‘UserDetailsService’를 통해 데이터베이스 내에서 정보를 조회합니다.
*
* @author : jonghoon
* @fileName : CustomAuthenticationFilter
* @since : 10/1/24
*/
@Slf4j
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public CustomAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
log.debug("2.CustomAuthenticationProvider");
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
String userId = token.getName();
String userPw = (String) token.getCredentials();
UserDetailsDto userDetailsDto;
try {
log.debug("입력 평문 bcrypt 인코딩: {}", passwordEncoder.encode(userPw));
userDetailsDto = (UserDetailsDto) userDetailsService.loadUserByUsername(userId);
} catch (UsernameNotFoundException e) {
log.error("사용자를 찾을 수 없음 - userId: {}, message: {}", userId, e.getMessage());
throw e;
} catch (RuntimeException e) {
log.error("사용자 조회 중 예측하지 못한 예외 발생 - userId: {}, message: {}", userId, e.getMessage(), e);
throw new AuthenticationServiceException("사용자 인증 처리 중 오류가 발생했습니다.", e);
}
if (!passwordEncoder.matches(userPw, userDetailsDto.getUserPw())) {
log.error("비밀번호 불일치 - userId: {}", userDetailsDto.getUserId());
throw new BadCredentialsException(userDetailsDto.getUserId() + " Invalid password");
}
return new UsernamePasswordAuthenticationToken(userDetailsDto, userPw, userDetailsDto.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
3. 중간 인코딩 결과 확인
💡 중간 인코딩 결과 확인
- 아래와 같이 평문 1234를 입력했을때 ‘{bcrypt}$2a$10$w9qsRfjFctxooJEhssv0hOKfh/cRHBmjFbiU5aUPtLwL2FhIZq0s2’ 인코딩이 되었습니다.
💡 해당 값을 pw에 임의로 db에 갱신하였습니다.
💡 해당 값으로 로그인을 했을때 아래와 같이 토큰 발급이 완료되었습니다.
4. Salt Key가 랜덤인데, 동일한 평문을 암호화 하면 해시 값이 달라질까?
💡 궁금한 사항 : Salt Key가 랜덤인데, 동일한 평문을 암호화 하면 해시 값이 달라질까?
- 궁금증과 같이 동일하게 평문 1234를 입력했을때, {bcrypt}$2a$10$w9qsRfjFctxooJEhssv0hOKfh/cRHBmjFbiU5aUPtLwL2FhIZq0s2 값으로 출력이 되었습니다. - 위에와 비교해보았을때, 같은 평문 1234였지만 해시값이 다름을 확인할 수 있었습니다.
// 이전에 1234 입력 해시값
{bcrypt}$2a$10$w9qsRfjFctxooJEhssv0hOKfh/cRHBmjFbiU5aUPtLwL2FhIZq0s2
// 서버 재실행 이후 해시값
{bcrypt}$2a$10$5D.hUSLauOZ94h50cnoj9uT3qx8qe2QlUzLRV5RLten0StLCV0R0O
💡 동일한 평문 1234이지만, 다른 해시값을 가진것으로 변경했습니다.
💡 로그인 성공이 확인되었습니다
💡 결론적으로, 매번 동일한 1234라는 평문일지라도, 암호화를 수행하면 다른 해시값을 가집니다. - 그리고 평문 1234를 입력하여 암호화 로그인을 할때에 모두 성공함을 확인하였습니다.