본문 바로가기

Backend/Spring

[SpringBoot] PasswordEncoder 사용하기

안녕하세요. 이번 포스팅에서는 Password Encoder를 활용하여 Database에 raw값이 아닌 Hashing이 완료된 비밀번호 값을 저장하는 방법에 대해 알아보겠습니다.

 

기본적으로 DB에 비밀번호를 바로 저장하게 되면 굉장히 위험합니다. 물론 현재 제공 중인 서비스가 개인정보를 많이 포함하고 있지 않다고 하더라도, 보통 사람들은 여러 사이트의 비밀번호를 동시에 사용하기 때문에 굉장히 위험한 상황이 발생됩니다.

 

https://www.boannews.com/media/view.asp?idx=78058&page=1&kind=1 

 

페이스북, 내부 서버에 사용자 비밀번호를 평문으로 저장해왔다

페이스북이 수천만 개가 넘는 사용자 비밀번호를 평문으로 보관해왔던 사실을 인정했습니다. 페이스북 라이트, 공식 페이스북 애플리케이션, 인스타그램을 사용해온 사람들이 이로 인해 피해

www.boannews.com

(아니...)

 

이러한 이유 때문에 DB에 비밀번호를 평문으로 저장하지 않고 Hash를 진행한 뒤, 저장하는 방법을 사용해야 합니다.

 

기본적으로 SpringBoot는 단방향 해슁 알고리즘에 Salt를 추가하여 Encoding을 해주는 PasswordEncoder를 사용할 수 있습니다. 

해당 코드는

org.springframework.security.crypto.factory.PasswordEncoderFactories

 

다음 패키지에 포함되어 있습니다. (버전 5.0부터 사용이 가능합니다.)

 

PasswordEncoderFactories의 구현부에 createDelegatingPasswordEncoder를 활용하면, PasswordEncoder를 반환받을 수 있습니다.

 

	public static PasswordEncoder createDelegatingPasswordEncoder() {
		String encodingId = "bcrypt";
		Map<String, PasswordEncoder> encoders = new HashMap<>();
		encoders.put(encodingId, new BCryptPasswordEncoder());
		encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
		encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
		encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
		encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
		encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
		encoders.put("scrypt", new SCryptPasswordEncoder());
		encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
		encoders.put("SHA-256",
				new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
		encoders.put("argon2", new Argon2PasswordEncoder());
		return new DelegatingPasswordEncoder(encodingId, encoders);
	}

 

encoders에 여러 메소드가 포함되어 있지만, 저희는 기본적으로 SpringBoot에서 권장하는 bcrypt전략을 사용하겠습니다.

 

bcrypt의 구현부도 살펴보겠습니다.

 

	public BCryptPasswordEncoder(BCryptVersion version, int strength, SecureRandom random) {
		if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) {
			throw new IllegalArgumentException("Bad strength");
		}
		this.version = version;
		this.strength = (strength == -1) ? 10 : strength;
		this.random = random;
	}

	@Override
	public String encode(CharSequence rawPassword) {
		if (rawPassword == null) {
			throw new IllegalArgumentException("rawPassword cannot be null");
		}
		String salt = getSalt();
		return BCrypt.hashpw(rawPassword.toString(), salt);
	}

	private String getSalt() {
		if (this.random != null) {
			return BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
		}
		return BCrypt.gensalt(this.version.getVersion(), this.strength);
	}

 

디비에 동일한 비밀번호를 사용하는 계정들의 정보를 보호하기 위해, 그리고 RainbowTable Attack과 같은 공격을 막기 위해 기본적으로 random Salt를 사용하여 같이 Encoding을 진행합니다.

 

간단하게 설명드리면,

 

단방향 해쉬의 기본적인 사용 이유는 역산이 안된다는 점입니다.

 

즉 만약에 sha256이라는 해슁알고리즘을 사용했다고 생각해보면, 

123456이라는 비밀번호를 해당 알고리즘을 사용하여 얻은 결과는

 

8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92

 

입니다.

 

123456 -> HASH -> "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"는 가능하지만, "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92" -> HASH -> 123456은 불가능합니다.

 

하지만 만약에 123456과 같은 간단한 비밀번호를 사용하여 저장을 하게 되면 어떻게 될까요?

 

 

놀랍게도 해당 해쉬 결괏값은 123456으로 추론됩니다. 아니 방금은 분명 해쉬 알고리즘이 역산이 안된다고 하셨잖아요...?라고 물어볼 수 있겠지만, 이미 수많은 해커들이 모아놓은 자료 중에 "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"라는 데이터가 존재하고, 해당 문장의 역산의 결괏값을 저장해 놓은 것입니다.

 

 이 저장해놓은 테이블을 RainbowTable이라고 하고, 이를 활용하여 미리 저장된 역산의 결괏값을 가지고 원래 비밀번호를 찾아내는 공격을 RainbowTable Attack이라고 합니다.

 

따라서 저희는 123456 -> HASH -> "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"를 사용하지 않고

 

(123456 + salt) -> HASH -> "xxxxxxxx" 를 사용하게 됩니다. 이렇게 되면 RainbowAttack의 확률을 현저히 낮출 수 있고, 만약에 A라는 유저와 B라는 유저가 같은 비밀번호를 공유한다 하더라도 저장된 결괏값이 다르기 때문에 보안성을 높일 수 있습니다.

 

실제로 Bcrypt알고리즘을 보면 encode구현부에 

String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);

 

Random 한 Salt값을 받아서 같이 hashing에 활용하는 것을 볼 수 있습니다.

 

그러면 이제 PasswordEncoder를 활용해보겠습니다.

 

package com.BlogWithSpringBoot.Config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class AppConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

 

프로젝트 내에서 해당 PasswordEncoder를 주입받아 사용하려면 당연히 PasswordEncoder도 Bean으로 등록되어 있어야 합니다. 

 

@Configuration : 해당 클래스가 1개 이상의 Bean을 제공한다면 @Configuration을 사용합니다.

@Bean : 사용자가 직접 정의한 것이 아닌 외부 라이브러리 또는 설정을 위한 클래스를 Bean으로 등록하기 위해 사용합니다.

 

 자 그러면 이제 UserService에서 해당 인코더를 활용해 보겠습니다.

 

package com.BlogWithSpringBoot.User;


import com.BlogWithSpringBoot.RoleType;
import com.BlogWithSpringBoot.User.Dto.SignupRequestDto;
import com.BlogWithSpringBoot.User.Dto.UserResponseDto;
import com.BlogWithSpringBoot.User.Dto.UserUpdateRequestDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class UserService {
    /*
    * Autowired, Bean Injection
    * user repository to CRUD user
    * password encoder to encode password
    * */

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    /*
    * Controller should not know SignUpDto to User method
    * Controller should not directly Connect to Repository
    *
    * */

    private User createNewUser(SignupRequestDto signupRequestDto) {
        return User.builder().username(signupRequestDto.getUsername())
                .email(signupRequestDto.getEmail())
                .password(passwordEncoder.encode(signupRequestDto.getPassword()))
                .role(RoleType.ROLE_USER).build();
    }

    private User SaveUserToRepo(User user) {
        return userRepository.save(user);
    }

    ...


    /*
    * Public method
    *
    * */

    public User UserSignup(SignupRequestDto signupRequestDto) {
        User user = createNewUser(signupRequestDto);
        return SaveUserToRepo(user);
    }
    
    ...
}

 

UserService에서 @Autowired를 통해 PasswordEncoder를 주입받습니다.

 

DB에 저장하는 User 객체를 만들기 위한 User.build에서 해당 객체의 Password를 passwordEncoder.encode(password)를 통해 객체를 만들고, DB에 저장해줍니다.

 

 

 

SignUp api

 

response

 

DB에서 조회 요청을 보냈을 때 password에 bcrypt로 인코딩 된 결괏값을 확인할 수 있습니다.

 

*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.