spring/spring security

Spring Security 기본개념 공부 4.

대기업 가고 싶은 공돌이 2024. 3. 30. 17:57

Section 3.

UserDetailsService 커스텀

  • 이제 jdbcUserDetailsManager가 아닌 우리만의 로직으로 UserDetails를 정의해보겠다.!
  • 우선 LoadUserByUsername 메소드를 직접 작성해야한다.
  • 그러기 위해 데이터베이스에서 유저 정보를 가져와 UserDetails 객체로 만들어 반환해주는 로직을 작성해줘야한다.
public class EazyBankUserDetails implements UserDetailsService {
  • 다음과 같이 UserDetailsService를 구현하여 만든다.
@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String userName, password;
        List<GrantedAuthority> authorities;
        List<Customer> customer = customerRepository.findByEmail(username);
        if (customer.size() == 0) {
            throw new UsernameNotFoundException("User details not found for the user : " + username);
        } else{
            userName = customer.get(0).getEmail();
            password = customer.get(0).getPwd();
            authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority(customer.get(0).getRole()));
        }
        return new User(userName,password,authorities);
    }
  • 위는 구현한 코드다 findByEmail 메소드를 통해 유저 정보를 가져오고 추출한 유저의 정보를 바탕으로
  • User 객체를 생성한다. User 객체 생성자는 두 가지 유형이 있었는데 그 중 첫 번째를 사용했다.
  • 생성된 객체는 DaoAuthenticationProvider에게 넘어간다.
  • DaoAuthenticationProvider에선 유저에게 받은 비밀번호와 데이터베이스에서 가져온 비밀번호를 비교한다.
  • 이제 EazyBankUserDetails를 Bean으로 등록해놓으면 스프링 시큐리티는 인증을 진행할 때 UserDetails의 구현체로 EazyBankUserDetails를 선택할 것이다.

데이터베이스 유저 등록 API

  • 스프링 시큐리티의 기본 설정은 CSRF 공격을 막기위해 데이터베이스에 접근하는 모든 post 요청을 막아놨다.
    http.csrf((csrf) -> csrf.disable())
  • 다음과 같이 csrf를 해제해 놓아야 post 요청이 들어간다. 자세한 내용은 추후에 다룬다.

Section 4.

PasswordEncoder에 관하여

  • 비밀번호는 반드시 암호화하여 데이터베이스에 저장해야한다.
  • AuthenticationProvider에서 실질적인 비밀번호 검증이 일어난다.
  • 맨처음 DaoAuthenticationProvider에 User 객체가 넘어가면 preAuthenticationChecks 라는 메소드가 실행된다.
  • 이 메소드에선 유저의 계정이 만료됐는지, 비밀번호는 유효한지 등등의 모든 시나리오를 검색해보고 모든 시나리오가 통과됐다면 additionalAuthenticationsChecks 메소드를 호출한다.
  • additionalAuthenticationsChecks 메소드는 password검증이 일어나는 곳이다.
  • 유저로부터 받은 Authentication 객체에서 비밀번호를 읽어낸다.
  • 그리고 passwordEncoder에서 matches라는 메소드를 호출한다.
  • passwordEncoder의 mathches 메소드는 두 비밀번호가 일치하는지 확인해주는 메소드이며 만약 암호화가 돼있다면 복호화하여 두 비밀번호를 비교한뒤 일치하는지 아닌지를 반환해준다.

Encoding이란

  • 인코딩이란 데이터를 다른 형식으로 바꾸는 작업을 의미한다.
  • 인코딩은 보안과 관련된 작업이 아니다. 그저 한국어를 미국어로 바꾸는 정도의 작업을 의미한다.
  • 주로 음성 파일이나 영상 파일을 압축하여 저장할 때 사용된다.
  • EX) ASCII, BASE64,UNICODE 등등,,

Encryption이란

  • 일반 데이터를 암호화하여 저장하는 과정이다.
  • 특정 암호화 알고리즘을 따르며 그 알고리즘에 비밀 키를 제공하면, 비밀 키를 활용하여 아무도 모르는 형식으로 암호화 시키는 것이다.
  • 암호화된 비밀번호가 무엇인지 알고싶다면 다시 Decryption을 진행해야 한다.
  • 그러한 경우엔 동일한 알고리즘과 동일한 비밀키가 필요하다.
  • 어떠한 알고리즘인지, 어떠한 비밀키인지는 백엔드 내부에 기밀 데이터로 관리된다.
  • 만약 내부 기밀 데이터에 접근할 수 있는 사람이라면 이러한 비밀번호를 복호화 시키는 것은 어려운 작업이 아닐 것이다.
  • Encryption은 암호화에 사용된 알고리즘과 비밀키 값만 알고있다면 비밀번호를 Decryption 할 수 있기에 문제가 있다.

Hashing이란

  • 해싱에서 우리의 데이터는 해시값으로 변환된다고 한다.
  • 해시값만을 아는걸로 비밀번호를 평문으로 되돌릴 수 없기에 해싱은 업계 표준으로 사용되고 있다.

그렇다면 엔드유저의 비밀번호는 어떻게 검증할까?

  • 해싱을 통한 비밀번호 저장은 저장된 값을 다시 평문으로 되돌릴 수 없기에 엔드유저의 비밀번호를 해싱하는 과정에서 생성된 해시값과 데이터베이스에 저장된 해시값을 비교하여 검증한다.
  • 데이터베이스를 해킹해서 해시값을 알아낸다고 하더라도, 평문으로 만들 수 없어 해킹해도 아무런 소용이 없다.
  • 해싱 알고리즘은 같은 값을 해싱할때마다 다른 문자열을 반환한다 하지만 내부적으로는 같은 해시값을 갖고있게 만들어져있기 때문에 내부적으로 저장된 해시값을 통해 검증을 수행할 수 있다.
    • 각각의 다른 문자열들의 해시값이 모두 동일하다는 의미이다.
  • 그렇게 해싱할 때마다 나오는 다른 문자열들과 데이터베이스에 저장된 문자열을 비교하여 해시값이 동일한지 비교하면 검증에 성공할 수 있다.

  1. 유저가 비밀번호를 입력한다.
  2. 비밀번호를 해싱하여 문자열을 얻어낸다
  3. UserDetails 객체에 저장된 해싱 문자열을 찾는다.
  4. 문자열들의 해시값을 알아내어 두 해시값이 일치한다면 검증에 성공한다.

PasswordEncoder 인터페이스

  • PasswordEncoder는 인터페이스이며 두 개의 추상 메소드와 한 개의 기본 메소드가 있다.
  • encode 라는 메소드는 엔드유저가 회워가입 할 때 활용되는 메소드다.
    String encode(CharSequence rawPassword);
  • 엔드유저가 입력한 평문 비밀번호를 우리가 Configuration에 등록한 passwordEncoder를 기반으로 암호화하하는 역할을 한다.
  • matches 라는 메소드는 로그인 작업에서 유저가 입력한 비밀번호와 저장된 비밀번호가 일치하는지 작업을 수행할 때 사용된다.
    boolean matches(CharSequence rawPassword, String encodedPassword);
  • upgradeEncoding 메소드는 언제나 false를 반환하는 메소드다.
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
  • 보안을 훨씬 좋게 만들고 싶을 때 upgradeEncoding을 true를 반환하게 만들어 두 번의 암호화를 거치게 하는 역할을 한다.
  • NoOpPasswordEncoder를 비롯한 모든 암호화 관련 클래스들은 PasswordEncoder 인터페이스를 구현한다.

추천하지 않는 PasswordEncoder의 구현 클래스

    1. NoopPasswordEncoder 암호화를 하지 않고 평문으로 비밀번호를 저장하는 Encoder다.
    1. standardPAsswordEncoder 레거시 어플리케이션에 암호화를 지원하기 위해 만들어진 PasswordEncoder로서 요즘 어플리케이션에는 사용하지 않는 것을 추천한다고 나와있다.
    1. pbkdf2PasswordEncoder 5~6년전에는 안전하다 여겨진 PasswordEncoder다. 고성능 GPU만 있다면 무차별 대입공격을 통해 손쉽게 해킹할 수 있으니 요즘엔 추천하지 않는다고 한다.

무차별 대입공격이란?

  • 무차별적으로 사람들이 많이 사용하는 비밀번호를 대입하여 해시 문자열 값을 얻어내는 방식이다.
  • 강력한 비밀번호를 강제함으로서 무차별 대입공격을 방어할 수 있다.

추천하는 PasswordEncoder의 구현 클래스

    1. BCryptPAsswordEncoder, Bcrypt 해싱 알고리즘을 사용한다.
    • encode나 matches등의 메소드를 호출하면 cpu연산이 일어난다. 즉 가벼운 코드가 아니다.
    • 해킹을 시도하기 위해 많은 연산을 요구하기 때문에 충분히 안전한 구현체다.
    1. SCryptPasswordEncoder, BcryptPasswordEncoder의 고급버전이다.
    • SCryptPasswordEncoder는 cpu에 더불어 메모리도 인자로 받는다. 즉 메모리 공간도 인자로 받기 때문에 더 높은 수준의 보안을 구현할 수 있다.
    1. Argon2PasswordEncoder, 이 Encoder는 cpu, 메모리에 추가적으로 다중 스레드까지 인자로 받는다. 세가지의 인자를 받기 때문에 무차별 대입공격은 거의 불가능하다고 볼 수 있다.
  • 높은 수준을 사용할수록 성능문제가 발생하기 때문에 우리에게 적합한 것은 BCryptPasswordEncoder이다. 만약 비밀번호를 여러가지 문자를 조합해 만들도록 강제했다면 충분한 보안을 유지할 수 있을것이다.

BCryptPasswordEncoder 구현하기

  • 우선 configuration에서 Encoder 객체를 BCryptPasswordEncoder 객체를 반환하게 바꿔준다.
  • 이후엔 다음과 같이 저장하기 전에 encode를 통해 해시 문자열을 반환받아, 반환받은 값을 데이터베이스에 저장해주기만 하면 된다.
String hashpwd = passwordEncoder.encode(customer.getPwd());
            customer.setPwd(hashpwd);
            savedCustomer = customerRepository.save(customer);
  • 보안수준을 높이고 싶다면 BCryptPasswordEncoder 생성자에 높은 수의 strength값을 인자로 전해주면 된다.
  • strength값이 높을수록 해싱을 하는데 많은 시간이 소요된다고 한다.

Section 5.

AuthenticationProvider에 관하여

  • AuthenticationProvider는 여러개를 정의해놓고 사용할 수 있다 그렇다면 다양한 AuthenticationProvider를 정의하는 것은 어떠한 상황일까?
  • 다음과 같은 예시를 살펴볼 수 있다.
      1. 아이디, 비밀번호로 로그인 하는 경우
      2. OAUTH2.0 와 같이 소셜 아이디로 로그인하는 경우
      3. OTP를 사용하여 로그인하는 경우
  • 세가지 상황에 맞는 여러가지 AuthenticationProvider를 정의하여 활용할 수 있다.

AuthenticationProvider 메소드

public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    boolean supports(Class<?> authentication);
}
  • Authenticate 메소드, Authentication 객체를 인자로 받아 인증을 수행한 뒤에 Authentication 객체를 반환한다.
  • Authentication 객체안에 반드시 인증 성공과 관련된 정보를 담아주어야 AuthenticationManager에서 다음 AuthenticationProvider를 호출할지 말지를 결정할 수 있다.
  • supports 메소드, 어떠한 형식의 로그인 방식을 지원할 것인지 명시해주는 역할을 한다.
    • 어떤 AuthenticationProvider를 호출해야하는지 알려주는 역할을 한다.
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
  • 위의 코드를 보면 UsernamePasswordAuthenticationToken.class 형식의 객체를 받는다고 돼있다.
Class<? extends Authentication> toTest = authentication.getClass(); <--- 여기
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        int currentPosition = 0;
        int size = this.providers.size();
        Iterator var9 = this.getProviders().iterator();

        while(var9.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var9.next();
            if (provider.supports(toTest)) {  <--- 여기
                if (logger.isTraceEnabled()) {
  • 위의 코드를 보면 supports 메소드의 활용 방법을 알 수 있다.
  • Authentication 객체의 구현 클래스가 무엇인지 알아내고, 해당 클래스와 provider의 클래스가 일치하는지 판단을 하는 코드가 작성돼있다.
  • 한 마디로 supports 메소드에 정의된 Authentication 클래스와 다른 클래스 인증 객체가 들어오면 다 반환시킨다는 얘기다.

AuthenticationProvider 구현하기

@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        String pwd = authentication.getCredentials().toString();
        List<Customer> customer = customerRepository.findByEmail(username);
        if(customer.size() > 0) {
            if (passwordEncoder.matches(pwd, customer.get(0).getPwd())) {
                List<GrantedAuthority> authorities = new ArrayList<>();
                authorities.add(new SimpleGrantedAuthority(customer.get(0).getRole()));
                return new UsernamePasswordAuthenticationToken(username, pwd, authorities);

            } else {
                throw new BadCredentialsException("유효하지 않은 비밀번호");
            }
        }else{
            throw  new BadCredentialsException("데이터베이스에 저장된 유저가 없다.");
        }

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
  • 위의 코드와 같이구현하면 된다.

정리

  • 로그인을 시도한후 ProviderManager가 실행된다.
  • ProviderManager 내부에서 Authentication 객체의 클래스가 무엇인지 알아낸다.
  • 이후 supports 메소드에서 일차하는 클래스를 가진 AuthenticationProvider가 실행된다.
  • 우린 UserDetails를 정의하지 않았기 때문에 AuthenticationProvider에서 모든 작업이 수행되고 Authentication 객체가 반환된다.
  • ProviderManager에 Authentication 객체가 반환되면 Credential 즉 비밀번호를 객체에서 삭제한다.
  • 불필요하게 내부에서 비밀번호가 돌아다는것을 막기 위함이다.
  • 이후 인증이 성공했다는 이벤트를 발생시킨다.