- 이제 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를 선택할 것이다.
- 스프링 시큐리티의 기본 설정은 CSRF 공격을 막기위해 데이터베이스에 접근하는 모든 post 요청을 막아놨다.
http.csrf((csrf) -> csrf.disable())
- 다음과 같이 csrf를 해제해 놓아야 post 요청이 들어간다. 자세한 내용은 추후에 다룬다.
- 비밀번호는 반드시 암호화하여 데이터베이스에 저장해야한다.
- AuthenticationProvider에서 실질적인 비밀번호 검증이 일어난다.
- 맨처음 DaoAuthenticationProvider에 User 객체가 넘어가면 preAuthenticationChecks 라는 메소드가 실행된다.
- 이 메소드에선 유저의 계정이 만료됐는지, 비밀번호는 유효한지 등등의 모든 시나리오를 검색해보고 모든 시나리오가 통과됐다면 additionalAuthenticationsChecks 메소드를 호출한다.
- additionalAuthenticationsChecks 메소드는 password검증이 일어나는 곳이다.
- 유저로부터 받은 Authentication 객체에서 비밀번호를 읽어낸다.
- 그리고 passwordEncoder에서 matches라는 메소드를 호출한다.
- passwordEncoder의 mathches 메소드는 두 비밀번호가 일치하는지 확인해주는 메소드이며 만약 암호화가 돼있다면 복호화하여 두 비밀번호를 비교한뒤 일치하는지 아닌지를 반환해준다.
- 인코딩이란 데이터를 다른 형식으로 바꾸는 작업을 의미한다.
- 인코딩은 보안과 관련된 작업이 아니다. 그저 한국어를 미국어로 바꾸는 정도의 작업을 의미한다.
- 주로 음성 파일이나 영상 파일을 압축하여 저장할 때 사용된다.
- EX) ASCII, BASE64,UNICODE 등등,,
- 일반 데이터를 암호화하여 저장하는 과정이다.
- 특정 암호화 알고리즘을 따르며 그 알고리즘에 비밀 키를 제공하면, 비밀 키를 활용하여 아무도 모르는 형식으로 암호화 시키는 것이다.
- 암호화된 비밀번호가 무엇인지 알고싶다면 다시 Decryption을 진행해야 한다.
- 그러한 경우엔 동일한 알고리즘과 동일한 비밀키가 필요하다.
- 어떠한 알고리즘인지, 어떠한 비밀키인지는 백엔드 내부에 기밀 데이터로 관리된다.
- 만약 내부 기밀 데이터에 접근할 수 있는 사람이라면 이러한 비밀번호를 복호화 시키는 것은 어려운 작업이 아닐 것이다.
- Encryption은 암호화에 사용된 알고리즘과 비밀키 값만 알고있다면 비밀번호를 Decryption 할 수 있기에 문제가 있다.
- 해싱에서 우리의 데이터는 해시값으로 변환된다고 한다.
- 해시값만을 아는걸로 비밀번호를 평문으로 되돌릴 수 없기에 해싱은 업계 표준으로 사용되고 있다.
그렇다면 엔드유저의 비밀번호는 어떻게 검증할까?
- 해싱을 통한 비밀번호 저장은 저장된 값을 다시 평문으로 되돌릴 수 없기에 엔드유저의 비밀번호를 해싱하는 과정에서 생성된 해시값과 데이터베이스에 저장된 해시값을 비교하여 검증한다.
- 데이터베이스를 해킹해서 해시값을 알아낸다고 하더라도, 평문으로 만들 수 없어 해킹해도 아무런 소용이 없다.
- 해싱 알고리즘은 같은 값을 해싱할때마다 다른 문자열을 반환한다 하지만 내부적으로는 같은 해시값을 갖고있게 만들어져있기 때문에 내부적으로 저장된 해시값을 통해 검증을 수행할 수 있다.
- 각각의 다른 문자열들의 해시값이 모두 동일하다는 의미이다.
- 그렇게 해싱할 때마다 나오는 다른 문자열들과 데이터베이스에 저장된 문자열을 비교하여 해시값이 동일한지 비교하면 검증에 성공할 수 있다.
- 유저가 비밀번호를 입력한다.
- 비밀번호를 해싱하여 문자열을 얻어낸다
- UserDetails 객체에 저장된 해싱 문자열을 찾는다.
- 문자열들의 해시값을 알아내어 두 해시값이 일치한다면 검증에 성공한다.
- 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의 구현 클래스
-
- NoopPasswordEncoder 암호화를 하지 않고 평문으로 비밀번호를 저장하는 Encoder다.
- standardPAsswordEncoder 레거시 어플리케이션에 암호화를 지원하기 위해 만들어진 PasswordEncoder로서 요즘 어플리케이션에는 사용하지 않는 것을 추천한다고 나와있다.
- pbkdf2PasswordEncoder 5~6년전에는 안전하다 여겨진 PasswordEncoder다. 고성능 GPU만 있다면 무차별 대입공격을 통해 손쉽게 해킹할 수 있으니 요즘엔 추천하지 않는다고 한다.
- 무차별적으로 사람들이 많이 사용하는 비밀번호를 대입하여 해시 문자열 값을 얻어내는 방식이다.
- 강력한 비밀번호를 강제함으로서 무차별 대입공격을 방어할 수 있다.
추천하는 PasswordEncoder의 구현 클래스
-
- BCryptPAsswordEncoder, Bcrypt 해싱 알고리즘을 사용한다.
- encode나 matches등의 메소드를 호출하면 cpu연산이 일어난다. 즉 가벼운 코드가 아니다.
- 해킹을 시도하기 위해 많은 연산을 요구하기 때문에 충분히 안전한 구현체다.
- SCryptPasswordEncoder, BcryptPasswordEncoder의 고급버전이다.
- SCryptPasswordEncoder는 cpu에 더불어 메모리도 인자로 받는다. 즉 메모리 공간도 인자로 받기 때문에 더 높은 수준의 보안을 구현할 수 있다.
- 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값이 높을수록 해싱을 하는데 많은 시간이 소요된다고 한다.
AuthenticationProvider에 관하여
- AuthenticationProvider는 여러개를 정의해놓고 사용할 수 있다 그렇다면 다양한 AuthenticationProvider를 정의하는 것은 어떠한 상황일까?
- 다음과 같은 예시를 살펴볼 수 있다.
-
- 아이디, 비밀번호로 로그인 하는 경우
- OAUTH2.0 와 같이 소셜 아이디로 로그인하는 경우
- 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 즉 비밀번호를 객체에서 삭제한다.
- 불필요하게 내부에서 비밀번호가 돌아다는것을 막기 위함이다.
- 이후 인증이 성공했다는 이벤트를 발생시킨다.