[기본] Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)
이전 챕터에서 InMemory User를 이용해 애플리케이션 실행 시, 메모리에 두 개의 사용자 정보를 미리 등록(kevin@gmail.com, admin@gmail.com)한 후 사용자의 권한 별로 request URL의 접근이 제한되는지 여부 등을 살펴보았습니다.
그런데 지난 챕터까지 학습한 내용만으로는 정상적으로 동작하지 않는 기능이 하나 있습니다.
그것은 바로 회원 가입입니다.
여러분들이 일반적으로 생각하기로 회원 가입 후, 가입한 회원 정보로 당연히 로그인을 할 수 있어야 할 텐데 현재 상태로는 회원 가입이 에러 없이 진행되는 것 같지만 가입한 회원 정보로 로그인해 보면 로그인 인증에 실패합니다.
왜 실패할까요?
우리는 아직 Hello Spring Security 샘플 애플리케이션에서 회원 가입 요청으로 전달받은 회원 정보를 Spring Security가 알 수 있는 어떠한 처리도 하지 않았기 때문입니다.
우리가 아직 H2나 MySQL 같은 데이터베이스를 사용하지 않고, 단순히 메모리에 미리 등록한 InMemory User를 사용하고 있지만 애플리케이션이 실행되고 난 이후에도 InMemory User를 추가적으로 등록할 수 있습니다.
이제 무늬만 있는 기능인 회원 가입 기능을 제대로 동작하도록 코드를 수정해 보도록 하겠습니다.
데이터베이스 연동 없는 로그인 인증
package com.springboot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
@Configuration
public class SecurityConfiguration {
...
...
@Bean
public UserDetailsManager userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
UserDetails admin =
User.withDefaultPasswordEncoder()
.username("admin@gmail.com")
.password("2222")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}
[코드 4-15] 등록된 두 개의 InMemory User
코드 4-15는 이전 챕터에서 설정한 InMemory User 등록을 위한 코드입니다.
애플리케이션이 실행되면, 코드 4-15의 userDetailsService() 메서드에서 설정한 두 개의 User가 InMemory User로 등록됩니다.
이제 새로운 User를 등록하는 작업을 추가해 봅시다.
⭐ 잊지 마세요❗
여기에서 User 등록은 데이터베이스에 회원 정보를 등록하는 게 아닙니다.
우리는 아직 데이터베이스를 연동하지 않고, Spring Security에서 지원하는 InMemory User를 사용하고 있다는 사실을 기억하세요! 그렇다면 여기서 의미하는 User 등록은? 맞습니다. 바로 메모리에 등록하는 InMemory User입니다.
✅ 회원 가입 폼을 통한 InMemory User 등록
회원 가입 폼을 통해 InMemory User를 등록하기 위한 작업 순서는 다음과 같습니다.
- PasswordEncoder Bean 등록
- MemberService Bean 등록을 위한 JavaConfiguration 구성
- InMemoryMemberService 클래스 구현
1️⃣ PasswordEncoder Bean 등록
PasswordEncoder는 Spring Security에서 제공하는 패스워드 암호화 기능을 제공하는 컴포넌트입니다.
우리가 회원 가입 폼을 통해 애플리케이션에 전달되는 패스워드는 암호화되지 않은 플레인 텍스트(Plain Text)입니다.
따라서 회원 가입 폼에서 전달받은 패스워드는 InMemory User로 등록하기 전에 암호화되어야 합니다.
PasswordEncoder는 다양한 암호화 방식을 제공하며, Spring Security에서 지원하는 PasswordEncoder의 디폴트 암호화 알고리즘은 bcrypt입니다.
PasswordEncoder와 bcrypt 알고리즘에 대해서 더 알아보고 싶다면 아래의 \[심화 학습]을 참고하세요.
package com.springboot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
@Configuration
public class SecurityConfiguration {
...
...
@Bean
public UserDetailsManager userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
UserDetails admin =
User.withDefaultPasswordEncoder()
.username("admin@gmail.com")
.password("2222")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
// (1)
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // (1-1)
}
}
[코드 4-16] PasswordEncoder Bean 등록
코드 4-16에서는 (1)과 같이 SecurityConfiguration 클래스에서 PasswordEncoder를 Bean으로 등록하고 있습니다.
(1-1)의 PasswordEncoderFactories.createDelegatingPasswordEncoder();를 통해 DelegatingPasswordEncoder를 먼저 생성하는데, 이 DelegatingPasswordEncoder가 실질적으로 PasswordEncoder 구현 객체를 생성해 줍니다.
우리가 userDetailsService() 메서드에서 미리 생성하는 InMemoryUser의 패스워드는 내부적으로 디폴트 PasswordEncoder를 통해 암호화된다는 사실을 기억하세요!
2️⃣ MemberService Bean 등록을 위한 JavaConfiguration 구성
여러분들이 섹션 3에서 익숙하게 사용한 MemberService는 클래스이지만 여기서는 학습 목적을 위해 편의상 코드 4-17과 같이 클래스가 아닌 MemberService 인터페이스로 구현합니다.
package com.springboot.member;
public interface MemberService {
Member createMember(Member member);
}
[4-17] MemberService 인터페이스
Hello Spring Security 샘플 애플리케이션은 회원 가입 폼에서 전달받은 정보를 이용해 새로운 사용자를 추가하는 기능만 있으면 되므로 createMember() 하나만 구현하는 구현체가 있으면 됩니다.
이제 이 MemberService 인터페이스를 구현하는 구현 클래스가 있어야 하겠죠?
✔ InMemory User 등록을 위한 InMemoryMemberService 클래스
package com.springboot.member;
public class InMemoryMemberService implements MemberService {
public Member createMember(Member member) {
return null;
}
}
[코드 4-18] InMemory User 등록을 위한 InMemoryMemberService 클래스
코드 4-18은 InMemory User를 등록하기 위한 MemberService 인터페이스의 구현 클래스인 InMemoryMemberService 클래스입니다.
InMemoryMemberService 클래스의 createMember()는 잠시 뒤에 구현하도록 하겠습니다.
✔ 데이터베이스에 User를 등록하기 위한 DBMemberService 클래스
package com.springboot.member;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class DBMemberService implements MemberService {
public Member createMember(Member member) {
return null;
}
}
[코드 4-19] 데이터베이스에 User를 등록하기 위한 DBMemberService 클래스
코드 4-19는 데이터베이스에 User를 등록하기 위한 MemberService 인터페이스의 구현 클래스인 DBMemberService 클래스입니다.
DBMemberService 클래스는 InMemory User 등록 학습이 끝난 다음 이어서 바로 구현해 볼 예정이니 조금만 기다려주세요!😊
✔ JavaConfiguration 구성
package com.springboot.config;
import com.springboot.member.InMemoryMemberService;
import com.springboot.member.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;
@Configuration
public class JavaConfiguration {
// (1)
@Bean
public MemberService inMemoryMemberService(UserDetailsManager userDetailsManager,
PasswordEncoder passwordEncoder) {
return new InMemoryMemberService(userDetailsManager, passwordEncoder);
}
}
[코드 4-20] MemberService Bean 등록을 위한 JavaConfiguration 구성
코드 4-20 JavaConfiguration 클래스에서는 MemberService 인터페이스의 구현 클래스인 InMemoryMemberService를 Spring Bean으로 등록합니다.
- (1)에서는 MemberService 인터페이스의 구현체인 InMemoryMemberService 클래스의 Bean 객체를 생성합니다.또한 User 등록 시, 패스워드를 암호화한 후에 등록해야 하므로 Spring Security에서 제공하는 PasswordEncoder 객체가 필요합니다.
- 따라서 이 두 객체를 InMemoryMemberService 객체 생성 시, DI 해 줍니다.
- InMemoryMemberService 클래스는 데이터베이스 연동 없이 메모리에 Spring Security의 User를 등록해야 하므로 UserDetailsManager 객체가 필요합니다.
3️⃣ InMemoryMemberService 구현
package com.springboot.member;
import com.springboot.auth.utils.AuthorityUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;
import java.util.List;
public class InMemoryMemberService implements MemberService { // (1)
private final UserDetailsManager userDetailsManager;
private final PasswordEncoder passwordEncoder;
// (2)
public InMemoryMemberService(UserDetailsManager userDetailsManager, PasswordEncoder passwordEncoder) {
this.userDetailsManager = userDetailsManager;
this.passwordEncoder = passwordEncoder;
}
public Member createMember(Member member) {
// (3)
List<GrantedAuthority> authorities = createAuthorities(Member.MemberRole.ROLE_USER.name());
// (4)
String encryptedPassword = passwordEncoder.encode(member.getPassword());
// (5)
UserDetails userDetails = new User(member.getEmail(), encryptedPassword, authorities);
// (6)
userDetailsManager.createUser(userDetails);
return member;
}
private List<GrantedAuthority> createAuthorities(String... roles) {
// (3-1)
return Arrays.stream(roles)
.map(role -> new SimpleGrantedAuthority(role))
.collect(Collectors.toList());
}
}
[코드 4-21] 메모리에 Spring Security User를 등록해 주는 InMemoryMemberService 클래스
코드 4-21은 회원 가입 정보를 전달받아 Spring Security의 User를 메모리에 등록해 주는 InMemoryMemberService 클래스의 코드입니다.
코드의 설명은 다음과 같습니다.
- InMemoryMemberService 클래스는 MemberService 인터페이스를 구현하는 구현 클래스임으로 (1)과 같이 implements MemberService를 지정합니다.
- 우리가 여태껏 @Service 애너테이션을 사용해 특정 서비스 클래스를 Bean으로 등록하는 방법을 사용해 왔지만 여기서는 @Service을 사용하지 않고, JavaConfiguration을 이용해 Bean을 등록하고 있다는 사실을 기억하기를 바랍니다.
- (2)에서는 UserDetailsManager와 PasswordEncoder를 DI 받습니다.
- UserDetailsManager는 Spring Security의 User를 관리하는 관리자 역할을 합니다. 우리가 SecurityConfiguration에서 Bean으로 등록한 UserDetailsManager는 InMemoryUserDetailsManager이므로 여기서 DI 받은 UserDetailsManager 인터페이스의 하위 타입은InMemoryUserDetailsManager라는 사실을 기억하기를 바랍니다.
- PasswordEncoder는 Spring Security User를 등록할 때 패스워드를 암호화해 주는 클래스입니다.
- Spring Security에서 User를 등록하기 위해서는 해당 User의 권한(Authority)을 지정해 주어야 합니다.Member 클래스에는 MemberRole이라는 enum이 정의되어 있고, ROLE_USER와 ROLE_ADMIN이라는 enum 타입이 정의되어 있습니다.
- (3-1)에서는 Java의 Stream API를 이용해 생성자 파라미터로 해당 User의 Role을 전달하면서 SimpleGrantedAuthority 객체를 생성한 후, List<SimpleGrantedAuthority> 형태로 리턴해 줍니다.
- ⭐ Spring Security에서는 SimpleGrantedAuthority를 사용해 Role 베이스 형태의 권한을 지정할 때 ‘ROLE_’ + 권한 명 형태로 지정해 주어야 합니다. 그렇지 않을 경우 적절한 권한 매핑이 이루어지지 않는다는 사실을 기억하기를 바랍니다.
- 따라서 (3)의 createAuthorities(Member.MemberRole.ROLE_USER.name());를 이용해 User의 권한 목록을 List<GrantedAuthority>로 생성하고 있습니다.
- (4)에서는 PasswordEncoder를 이용해 등록할 User의 패스워드를 암호화하고 있습니다.java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null”
- 만약, 패스워드를 암호화하지 않고 User를 등록한다면 User 등록은 되지만 로그인 인증 시, 다음과 같은 에러를 만나게 되므로 User의 패스워드는 반드시 암호화해야 합니다.
- (5)에서는 Spring Security User로 등록하기 위해 UserDetails를 생성합니다.
- ⭐ Spring Security에서는 Spring Security에서 관리하는 User 정보를 UserDetails로 관리한다는 사실을 꼭 기억하기를 바랍니다.
- (6)에서는 UserDetailsManager의 createUser() 메서드를 이용해서 User를 등록합니다.
이제 애플리케이션을 다시 실행하고 [회원 가입] 메뉴에서 회원 정보를 등록한 후, 등록한 회원 정보(이메일 주소, 패스워드)로 로그인을 수행하면 정상적으로 로그인이 되는 것을 확인할 수 있습니다.
데이터베이스 연동을 통한 로그인 인증
앞에서 살펴본 로그인 인증 방식은 오로지 Spring Security에서 제공하는 InMemory User를 사용하는 인증 방식입니다.
InMemory User는 애플리케이션을 다시 시작하면 메모리에 등록했던 User 정보가 모두 사라지기 때문에 실무에서는 당연히 사용하기 힘들겠죠?
이제 데이터베이스 같은 영구 저장소를 이용해 사용자의 인증 정보를 관리해야 될 시점인 것 같군요.
우리가 여태껏 학습을 위해 만들어보았던 커피 주문 애플리케이션에서 Member 클래스 같은 엔티티 클래스로 회원 정보를 MEMBER 테이블에 저장했듯이 Hello Spring Security 샘플 애플리케이션에서도 간단한 Member 엔티티 클래스를 이용해서 회원의 인증 정보를 포함한 회원 정보를 데이터베이스 테이블에서 관리해 보도록 하겠습니다.
InMemory User를 사용하는 방식은 테스트 환경이나 데모 환경에서 사용할 수 있는 방법입니다.
💡 실무에서 사용하지도 않는 방식을 굳이 왜 설명하느냐고 할 수도 있겠지만 InMemory User를 이용한 로그인 인증에 대해 학습하면서 우리가 Spring Security의 기본 구조와 사용법 등을 단계적으로 익히고 있다는 사실을 꼭 기억하기를 바랍니다.
✅ Custom UserDetailsService를 사용하는 방법
Spring Security에서는 User의 인증 정보를 테이블에 저장하고, 테이블에 저장된 인증 정보를 이용해 인증 프로세스를 진행할 수 있는 몇 가지 방법이 존재하는데 그중 한 가지 방법이 바로 Custom UserDetailsService를 이용하는 방법입니다.
Custom UserDetailsService를 이용해 User의 로그인 인증을 어떻게 처리하는지 지금부터 살펴보도록 하겠습니다.
일반적으로 Spring Security에서는 인증을 시도하는 주체를 User(비슷한 의미로 Principal도 있음)라고 부릅니다.
Principal은 User의 더 구체적인 정보를 의미하며, 일반적으로 Spring Security에서의 Username을 의미합니다.
샘플 애플리케이션에서는 Member 엔티티 클래스가 로그인 인증 정보를 포함할 텐데 이 Member 엔티티가 Spring Security의 User 정보를 포함한다고 보면 됩니다.
1️⃣ SecurityConfiguration의 설정 변경 및 추가
지금부터는 로그인 인증을 위해 데이터베이스에 저장되어 있는 인증 정보를 사용할 것입니다.
따라서 InMemory User를 위한 설정들은 더 이상 필요 없으므로 제거해야 합니다.
package com.springboot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin() // (1)
.and()
.csrf().disable()
.formLogin()
.loginPage("/auths/login-form")
.loginProcessingUrl("/process_login")
.failureUrl("/auths/login-form?error")
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.and()
.exceptionHandling().accessDeniedPage("/auths/access-denied")
.and()
.authorizeHttpRequests(authorize -> authorize
.antMatchers("/orders/**").hasRole("ADMIN")
.antMatchers("/members/my-page").hasRole("USER")
.antMatchers("/**").permitAll()
);
return http.build();
}
// ======================================================== 여기부터
/**
* InMemory User를 위한 설정이므로, 제거 대상.
*/
@Bean
public UserDetailsManager userDetailsService() { // (2)
UserDetails user =
User.withDefaultPasswordEncoder()
.username("kevin@gmail.com")
.password("1111")
.roles("USER")
.build();
UserDetails admin =
User.withDefaultPasswordEncoder()
.username("admin@gmail.com")
.password("2222")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
// ======================================================== 여기까지 제거
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
[코드 4-22] 현재 시점까지 사용하고 있는 SecurityConfiguration(V1) 클래스
- 코드 4-22의 SecurityConfiguration 클래스에서 (1)은 여러분들이 웹 브라우저에서 H2 웹 콘솔을 정상적으로 사용하기 위한 설정입니다.
frameOptions()는 HTML 태그 중에서 <frame>이나 <iframe>, <object> 태그에서 페이지를 렌더링 할지의 여부를 결정하는 기능을 합니다.
Spring Security에서는 Clickjacking 공격을 막기 위해 기본적으로 frameOptions() 기능이 활성화되어 있으며 디폴트 값은 DENY입니다. 즉, 위에서 언급한 HTML 태그를 이용한 페이지 렌더링을 허용하지 않겠다는 의미입니다.
(1)과 같이 .frameOptions().sameOrigin()을 호출하면 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용합니다.
H2 웹 콘솔의 화면 자체가 내부적으로 <frame> 태그를 사용하고 있으므로 개발 환경에서는 H2 웹 콘솔을 정상적으로 사용할 수 있도록 (1)과 같이 설정하면 됩니다.
Clickjacking 공격에 대해서 더 알아보고 싶은 분은 아래의 \[심화 학습]을 참고하세요.
- (2)의 userDetailsService() 메서드는 InMemory User를 등록하는 역할을 하지만 이제 데이터베이스에서 User를 등록하고, 데이터베이스에 저장된 User의 인증 정보를 사용할 것이므로 userDetailsService() 메서드를 제거합니다.
2️⃣ JavaConfiguration의 Bean 등록 변경
@Configuration
public class JavaConfiguration {
// (1)
@Bean
public MemberService dbMemberService(MemberRepository memberRepository,
PasswordEncoder passwordEncoder) {
return new DBMemberService(memberRepository, passwordEncoder); (1-1)
}
}
[코드 4-23] 데이터베이스를 사용하기 위해 변경된 JavaConfiguration 클래스
코드 4-23의 (1)과 같이 데이터베이스에 User의 정보를 저장하기 위해 MemberService 인터페이스의 구현 클래스를 DBMemberService로 변경합니다.
DBMemberService는 내부에서 데이터를 데이터베이스에 저장하고, 패스워드를 암호화해야 하므로 (1-1)과 같이 MemberRepository와 PasswordEncoder 객체를 DI 해줍니다.
3️⃣ DBMemberService 구현
이제 DBMemberService를 구현해 보겠습니다.
DBMemberService는 User의 인증 정보를 데이터베이스에 저장하는 역할을 하는데, 앞에서도 언급했지만 Spring Security 입장에서 User라고 부르는 정보는 우리가 회원 가입 시 등록하는 회원 정보 안에 포함이 되어 있다고 보면 됩니다.
여러분들이 Spring MVC 섹션에서 사용했던 Member 엔티티 클래스를 생각해 보세요. 이 Member 엔티티 클래스의 필드에 인증 정보를 담는 password 필드가 포함된다고 생각하면 되겠습니다.
package com.springboot.member;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Transactional
public class DBMemberService implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
// (1)
public DBMemberService(MemberRepository memberRepository,
PasswordEncoder passwordEncoder) {
this.memberRepository = memberRepository;
this.passwordEncoder = passwordEncoder;
}
public Member createMember(Member member) {
verifyExistsEmail(member.getEmail());
String encryptedPassword = passwordEncoder.encode(member.getPassword()); // (2)
member.setPassword(encryptedPassword); // (3)
Member savedMember = memberRepository.save(member);
System.out.println("# Create Member in DB");
return savedMember;
}
...
...
}
[코드 4-24] 회원 정보를 데이터베이스에 등록하는 DBMemberService 클래스
코드 4-24는 회원 정보를 데이터베이스에 저장하는 DBMemberService 클래스의 코드입니다.
코드 설명은 다음과 같습니다.
- (1)의 생성자를 통해 MemberRepository와 PasswordEncoder Bean 객체를 DI 받습니다.
- (2)에서 PasswordEncoder를 이용해 패스워드를 암호화합니다.
- (3)에서 암호화된 패스워드를 password 필드에 다시 할당합니다.
이 외에 나머지 코드는 여러분들이 Spring Data JPA 시간에 학습했던 코드들이기 때문에 설명은 생략하겠습니다.
⭐ 패스워드의 암호화
회원의 패스워드를 암호화해서 데이터베이스에 저장하는 건 개발자 입장에서는 정말 당연한 이야기인데도 불구하고, 회원 등록 로직을 구현할 때 패스워드를 암호화하지 않고 평문(Plain Text) 그대로 저장하는 경우는 실무에서도 종종 볼 수 있는 일입니다.
패스워드 같은 민감한(sensitive) 정보는 반드시 암호화되어 저장되어야 합니다.
꼭 잊지 말아 주세요!
그리고 패스워드는 암호화된 상태에서 복호화할 이유가 없으므로 단방향 암호화 방식으로 암호화되어야 한다는 사실을 꼭 기억하세요!
단방향 암호화에 대해서 더 알아보고 싶다면 아래의 [심화 학습]을 참고하세요.
4️⃣ Custom UserDetailsService 구현
다음으로 데이터베이스에서 조회한 User의 인증 정보를 기반으로 인증을 처리하는 Custom UserDetailsService를 구현해 봅시다.
⭐ UserDetailsService
Spring Security에서 제공하는 컴포넌트 중 하나인 UserDetailsService는 User 정보를 로드(load)하는 핵심 인터페이스입니다.
여기서 로드(load)의 의미는 인증에 필요한 User 정보를 어딘가에서 가지고 온다는 의미이며, 여기서 말하는 ‘어딘가’는 메모리가 될 수도 있고, DB 등의 영구 저장소가 될 수도 있습니다.
우리가 InMemory User를 등록하는 데 사용했던 InMemoryUserDetailsManager는 UserDetailsManager 인터페이스의 구현체이고, UserDetailsManager는 UserDetailsService를 상속하는 확장 인터페이스라는 점 기억하기를 바랍니다.
✔ HelloUserDetailsService
package com.springboot.auth;
import com.springboot.auth.utils.HelloAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import com.springboot.member.Member;
import com.springboot.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class HelloUserDetailsServiceV1 implements UserDetailsService { // (1)
private final MemberRepository memberRepository;
private final HelloAuthorityUtils authorityUtils;
// (2)
public HelloUserDetailsServiceV1(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.authorityUtils = authorityUtils;
}
// (3)
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
// (4)
Collection<? extends GrantedAuthority> authorities = authorityUtils.createAuthorities(findMember.getEmail());
// (5)
return new User(findMember.getEmail(), findMember.getPassword(), authorities);
}
}
[코드 4-25] 데이터베이스의 인증 정보로 인증을 처리하는 Custom UserDetailsService
코드 4-25는 데이터베이스에서 조회한 인증 정보를 기반으로 인증을 처리하는 Custom UserDetailsService인 HelloUserDetailsService 클래스의 코드입니다.
코드의 설명은 다음과 같습니다.
- HelloUserDetailsService와 같은 Custom UserDetailsService를 구현하기 위해서는 (1)과 같이 UserDetailsService 인터페이스를 구현해야 합니다.
- HelloUserDetailsService는 V1 ~ V3까지 만들어지기 때문에 각각 클래스를 만들어야 합니다.
- HelloUserDetailsService는 데이터베이스에서 User를 조회하고, 조회한 User의 권한(Role) 정보를 생성하기 위해 (2)와 같이 MemberRepository와 HelloAuthorityUtils 클래스를 DI 받습니다.
- UserDetailsService 인터페이스를 implements 하는 구현 클래스는 (3)과 같이 loadUserByUsername(String username)이라는 추상 메서드를 구현해야 합니다.
- (4)에서는 HelloAuthorityUtils를 이용해 데이터베이스에서 조회한 회원의 이메일 정보를 이용해 Role 기반의 권한 정보(GrantedAuthority) 컬렉션을 생성합니다. (HelloAuthorityUtils 코드는 바로 아래에서 설명합니다)
- 데이터베이스에서 조회한 인증 정보와 (4)에서 생성한 권한 정보를 Spring Security에서는 아직 알지 못하기 때문에 Spring Security에 이 정보들을 제공해 주어야 하며, (5)에서는 UserDetails 인터페이스의 구현체인 User 클래스의 객체를 통해 제공하고 있습니다.⭐ 즉, 데이터베이스에서 User의 인증 정보만 Spring Security에 넘겨주고, 인증 처리는 Spring Security가 대신해 줍니다.
- (5)와 같이 데이터베이스에서 조회한 User 클래스의 객체를 리턴하면 Spring Security가 이 정보를 이용해 인증 절차를 수행합니다.
⭐ UserDetails
UserDetails는 UserDetailsService에 의해 로드(load)되어 인증을 위해 사용되는 핵심 User 정보를 표현하는 인터페이스입니다.
UserDetails 인터페이스의 구현체는 Spring Security에서 보안 정보 제공을 목적으로 직접 사용되지는 않고, Authentication 객체로 캡슐화되어 제공됩니다.
✔ HelloAuthorityUtils
package com.springboot.auth.utils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class HelloAuthorityUtils {
// (1)
@Value("${mail.address.admin}")
private String adminMailAddress;
// (2)
private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");
// (3)
private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
public List<GrantedAuthority> createAuthorities(String email) {
// (4)
if (email.equals(adminMailAddress)) {
return ADMIN_ROLES;
}
return USER_ROLES;
}
}
[코드 4-26] User의 권한을 매핑, 생성하는 HelloAuthorityUtils
코드의 설명은 다음과 같습니다.
- (1)은 application.yml에 추가한 프로퍼티를 가져오는 표현식입니다.(1)에서는 application.yml에 미리 정의한 관리자 권한을 가질 수 있는 이메일 주소를 불러오고 있습니다.💡 application.yml 파일에는 다음과 같이 관리자 이메일 주소를 정의해야 합니다.
- application.yml 파일에 정의한 관리자용 이메일 주소는 회원 등록 시, 특정 이메일 주소에 관리자 권한을 부여할 수 있는지를 결정하기 위해 사용됩니다. (4)에서 다시 설명하겠습니다.
- (1)과 같이 @Value("${프로퍼티 경로}")의 표현식 형태로 작성하면 application.yml에 정의되어 있는 프로퍼티의 값을 클래스 내에서 사용할 수 있습니다.
...
...
mail:
address:
admin: admin@gmail.com
- (2)에서는 Spring Security에서 지원하는 AuthorityUtils 클래스를 이용해서 관리자용 권한 목록을 List<GrantedAuthority> 객체로 미리 생성합니다.
- 관리자 권한의 경우, 일반 사용자의 권한까지 추가로 포함되어 있습니다.
- (3)에서는 Spring Security에서 지원하는 AuthorityUtils 클래스를 이용해서 일반 사용 권한 목록을 List<GrantedAuthority> 객체로 미리 생성합니다.
- (4)에서는 파라미터로 전달받은 이메일 주소가 application.yml 파일에서 가져온 관리자용 이메일 주소와 동일하다면 관리자용 권한인 List<GrantedAuthority> ADMIN_ROLES를 리턴합니다.
⭐ 실무에서는 당연히 회원 가입 시, 아무런 인증 장치도 없이 이메일 주소만 입력해서 관리자 권한을 부여하지는 않습니다.
관리자 권한은 아무리 강조해도 지나치지 않을 정도로 중요하니까요. 😊
아마도 관리자로서 등록하기 위한 추가적인 인증 절차가 있을 것입니다.
우리는 학습 목적이므로 편의상 이렇게 관리자 권한을 부여한다는 사실을 잊지 마세요!
5️⃣ H2 웹 콘솔에서 등록한 회원 정보 확인 및 로그인 인증 테스트
여러분들이 여기까지 타이핑을 잘했다면 애플리케이션 실행 후, 회원 가입 메뉴에서 회원을 등록한 뒤에 H2 웹 콘솔(http://localhost:8080/h2)에 접속해서 여러분이 등록한 회원 정보를 확인해 보세요.
아래의 [그림 4-10]과 비슷한 화면을 볼 수 있을 겁니다.
[그림 4-10] 인증 정보가 포함된 등록된 회원 정보
우리가 여태껏 사용해 봤던 MEMBER와 큰 차이점은 없습니다.
그런데 [그림 4-10]에서 주목할 부분은 맨 마지막 열인 PASSWORD 열을 보면 회원 가입 메뉴에서 입력했던 패스워드 정보가 암호화 되어있다는 것입니다.
회원의 인증 정보도 잘 등록되어 있으니 이제 로그인 화면에서 등록한 회원의 이메일 주소와 패스워드를 이용해 로그인 해보면 정상적으로 로그인이 되는 걸 확인할 수 있습니다.
application.yml 파일에 정의되어 있는 관리자용 이메일 주소(admin@gmail.com)로도 회원 가입을 한 후, 로그인이 잘 되는지 확인해 보세요! 😊
6️⃣ Custom UserDetails 구현
지금껏 작성한 코드만으로도 데이터베이스에 회원의 인증 정보를 저장하고, 저장된 인증 정보를 기반으로 로그인 인증을 하는 데 큰 문제는 없습니다.
하지만 우리는 조금 더 유연하고 깔끔한 코드의 구성을 위해 앞에서 작성한 HelloUserDetailsService 클래스를 살짝 개선해 보도록 하겠습니다.
✔ 현재까지 작성된 HelloUserDetailsService(V1)
package com.springboot.auth;
import com.springboot.auth.utils.HelloAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import com.springboot.member.Member;
import com.springboot.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class HelloUserDetailsServiceV1 implements UserDetailsService {
private final MemberRepository memberRepository;
private final HelloAuthorityUtils authorityUtils;
public HelloUserDetailsServiceV1(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.authorityUtils = authorityUtils;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
Collection<? extends GrantedAuthority> authorities = authorityUtils.createAuthorities(findMember);
// (1) 개선하면 좋은 포인트
return new User(findMember.getEmail(), findMember.getPassword(), authorities);
}
}
[코드 4-27] 현재까지 작성된 HelloUserDetailsService(V1)
코드 4-27은 현재까지 작성된 HelloUserDetailsService의 코드입니다.
여기서 개선하면 좋은 부분은 바로 (1)과 같이 UserDetails의 구현 클래스인 User의 객체를 직접적으로 생성해서 리턴하는 부분입니다.
이 부분을 조금 더 유연하고 깔끔하게 작성해 보도록 합시다!
✔ 개선된 HelloUserDetailsService(V2)
package com.springboot.auth;
import com.springboot.auth.utils.HelloAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import com.springboot.member.Member;
import com.springboot.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class HelloUserDetailsServiceV2 implements UserDetailsService {
private final MemberRepository memberRepository;
private final HelloAuthorityUtils authorityUtils;
public HelloUserDetailsServiceV2(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.authorityUtils = authorityUtils;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
return new HelloUserDetails(findMember); // (1) 개선된 부분
}
// (2) HelloUserDetails 클래스 추가
private final class HelloUserDetails extends Member implements UserDetails { // (2-1)
// (2-2)
HelloUserDetails(Member member) {
setMemberId(member.getMemberId());
setFullName(member.getFullName());
setEmail(member.getEmail());
setPassword(member.getPassword());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorityUtils.createAuthorities(this.getEmail()); // (2-3) 리팩토링 포인트
}
// (2-4)
@Override
public String getUsername() {
return getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
}
[코드 4-28] 유연하게 개선된 HelloUserDetailsService(V2)
코드 4-28은 코드 4-27의 코드를 개선한 HelloUserDetailsService(V2)의 코드입니다.
- 기존에는 loadUserByUsername() 메서드의 리턴 값으로 new User(findMember.getEmail(), findMember.getPassword(), authorities);을 리턴했지만 개선된 코드에서는 (1)과 같이 new HelloUserDetails(findMember);라는 Custom UserDetails 클래스의 생성자로 findMember를 전달하면서 코드가 조금 더 깔끔해졌습니다.이 코드는 어디로 갔을까요?
- 바로 (2)에서 정의한 HelloUserDetails 클래스 내부로 포함되었습니다.
- 그리고 코드를 유심히 보면 기존에는 loadUserByUsername() 메서드 내부에서 User의 권한 정보를 생성하는 Collection<? extends GrantedAuthority> authorities = authorityUtils.createAuthorities(findMember); 코드가 사라졌습니다.
- (2)의 HelloUserDetails 클래스는 UserDetails 인터페이스를 구현하고 있고 또한 Member 엔티티 클래스를 상속하고 있습니다.💡 또한 HelloUserDetails 클래스는 Member 엔티티 클래스를 상속하고 있으므로 HelloUserDetails를 리턴 받아 사용하는 측에서는 두 개 클래스의 객체를 모두 다 손쉽게 캐스팅해서 사용 가능하다는 장점이 있습니다.
- 💡 이렇게 구성하면 데이터베이스에서 조회한 회원 정보를 Spring Security의 User 정보로 변환하는 과정과 User의 권한 정보를 생성하는 과정을 캡슐화할 수 있습니다.
- (2-3)에서는 HelloAuthorityUtils의 createAuthorities() 메서드를 이용해 User의 권한 정보를 생성하고 있습니다.
- 이 코드는 기존에는 loadUserByUsername() 메서드 내부에 있었지만 지금은 HelloUserDetails 클래스 내부에서 사용되도록 캡슐화되었습니다
- (2-4)에서는 Spring Security에서 인식할 수 있는 username을 Member 클래스의 email 주소로 채우고 있습니다. getUsername()의 리턴 값은 null일 수 없습니다.
- 기타 UserDetails 인터페이스의 추상 메서드를 구현한 부분은 지금은 크게 중요하지 않은 부분이므로 모두 true값을 리턴하고 있습니다.
7️⃣ User의 Role을 DB에서 관리하기
자, 여러분 이제 Custom UserDetailsService를 이용한 로그인 인증의 마지막 단계입니다.
일반적으로 User의 인증 정보 같은 보안과 관련된 정보는 데이터베이스 같은 영구 저장소에 안전하게 보관합니다.
그런데 현재까지 User의 권한 정보는 데이터베이스에서 관리하는 것이 아니라 데이터베이스에서 조회한 User 정보를 기준으로 코드상에서 조건에 맞게 생성하고 있습니다.
이제 User의 권한 정보를 데이터베이스에서 관리하도록 코드를 수정해 보도록 하겠습니다.
User의 권한 정보를 데이터베이스에서 관리하기 위해서는 다음과 같은 과정이 필요합니다.
- User의 권한 정보를 저장하기 위한 테이블 생성
- 회원 가입 시, User의 권한 정보(Role)를 데이터베이스에 저장하는 작업
- 로그인 인증 시, User의 권한 정보를 데이터베이스에서 조회하는 작업
이 세 가지 과정을 하나씩 적용해 보도록 하겠습니다.
✔ User의 권한 정보 테이블 생성
User의 권한 정보 테이블을 생성하기 전에 User와 User의 권한 정보 간에 관계를 먼저 생각해야 합니다
여기서 의미하는 ‘관계’는 테이블 간의 연관 관계를 의미하며, 이 테이블 간의 연관 관계는 우리가 샘플 애플리케이션에서 사용하고 있는 ORM 기술인 JPA를 통해 손쉽게 연관 관계를 맺을 수 있습니다.
이제 JPA를 이용해서 User와 User의 권한 정보 간에 연관 관계를 맺어보겠습니다.
Member
package com.springboot.member;
import com.springboot.audit.Auditable;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;
@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable implements Principal{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
@Column(length = 100, nullable = false)
private String fullName;
@Column(nullable = false, updatable = false, unique = true)
private String email;
@Column(length = 100, nullable = false)
private String password;
@Enumerated(value = EnumType.STRING)
@Column(length = 20, nullable = false)
private MemberStatus memberStatus = MemberStatus.MEMBER_ACTIVE;
// (1) User의 권한 정보 테이블과 매핑되는 정보
@ElementCollection(fetch = FetchType.EAGER)
private List<String> roles = new ArrayList<>();
public Member(String email) {
this.email = email;
}
public Member(String email, String fullName, String password) {
this.email = email;
this.fullName= fullName;
this.password = password;
}
@Override
public String getName() {
return getEmail();
}
public enum MemberStatus {
MEMBER_ACTIVE("활동중"),
MEMBER_SLEEP("휴면 상태"),
MEMBER_QUIT("탈퇴 상태");
@Getter
private String status;
MemberStatus(String status) {
this.status = status;
}
}
public enum MemberRole {
ROLE_USER,
ROLE_ADMIN
}
}
[코드 4-29] Spring Security의 User 역할을 하는 Member 엔티티 클래스에 User 권한 정보 매핑
코드 4-29는 Member 엔티티 클래스와 User의 권한 정보를 매핑한 코드입니다.
Member 엔티티 클래스와 User의 권한 정보를 매핑하는 것은 (1)과 같이 간단하게 처리할 수 있습니다.
(1)과 같이 List, Set 같은 컬렉션 타입의 필드는 @ElementCollection 애너테이션을 추가하면 User 권한 정보와 관련된 별도의 엔티티 클래스를 생성하지 않아도 간단하게 매핑 처리가 됩니다.
즉, 이 상태에서 애플리케이션을 실행하면 아래의 [그림 4-11]과 같은 테이블이 생성됩니다.
[그림 4-11] User의 권한 정보를 저장하는 MEMBER_ROLES 테이블
[그림 4-11]을 보면 Member 엔티티 클래스와 연관 관계 매핑에 대한 테이블이 생성되었습니다.
한 명의 회원이 한 개 이상의 Role을 가질 수 있으므로, MEMBER 테이블과 MEMBER_ROLES 테이블은 1대 N의 관계입니다.
회원 가입을 통해 회원 정보가 MEMBER 테이블에 저장될 때, MEMBER_ROLES 테이블의 MEMBER_MEMBER_ID 열에는 MEMBER 테이블의 기본키 값이 그리고 ROLES 열에는 권한 정보가 저장될 것입니다.
✔ 회원 가입 시, User의 권한 정보(Role)를 데이터베이스에 저장
User의 권한 정보를 관리하는 테이블도 만들어졌으니 이제 회원 가입 시, 해당 회원의 권한 정보를 MEMBER_ROLES 테이블에 저장해 봅시다.
DBMemberService
package com.springboot.member;
import com.springboot.auth.utils.HelloAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Transactional
public class DBMemberService implements MemberService {
...
...
private final HelloAuthorityUtils authorityUtils;
...
...
public Member createMember(Member member) {
verifyExistsEmail(member.getEmail());
String encryptedPassword = passwordEncoder.encode(member.getPassword());
member.setPassword(encryptedPassword);
// (1) Role을 DB에 저장
List<String> roles = authorityUtils.createRoles(member.getEmail());
member.setRoles(roles);
Member savedMember = memberRepository.save(member);
return savedMember;
}
...
...
}
[코드 4-30] 회원 등록 시, 권한 정보를 DB에 저장
코드 4-30에서는 DBMemberService에서 회원 등록 시, 회원의 권한 정보를 데이터베이스에 저장하는 코드가 추가되었습니다.
(1)에서는 authorityUtils.createRoles(member.getEmail());를 통해 회원의 권한 정보(List<String> roles)를 생성한 뒤 member 객체에 넘겨주고 있습니다.
아래의 코드 4-31은 createRoles() 메서드가 추가된 HelloAuthorityUtils 클래스의 코드입니다.
package com.springboot.auth.utils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class HelloAuthorityUtils {
@Value("${mail.address.admin}")
private String adminMailAddress;
...
...
private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
private final List<String> USER_ROLES_STRING = List.of("USER");
...
...
// (1) DB 저장용
public List<String> createRoles(String email) {
if (email.equals(adminMailAddress)) {
return ADMIN_ROLES_STRING;
}
return USER_ROLES_STRING;
}
}
[코드 4-31] 회원의 Role 정보를 생성하는 createRoles() 메서드가 추가된 HelloAuthorityUtils
(1)에서는 파라미터로 전달된 이메일 주소가 application.yml 파일의 mail.address.admin 프로퍼티에 정의된 이메일 주소와 동일하면 관리자 Role 목록(ADMIN_ROLES_STRING)을 리턴하고, 그 외에는 일반 사용자 Role 목록(USER_ROLES_STRING)을 리턴합니다.
✔ 로그인 인증 시, User의 권한 정보를 데이터베이스에서 조회하는 작업
자, 이제 마지막 작업은 로그인 인증에 성공 시, 제공하는 User의 권한 정보를 데이터베이스의 테이블에서 관리되는 Role을 기반으로 생성하는 것입니다.
개선된 HelloUserDetailsService(V3)
package com.springboot.auth;
import com.springboot.auth.utils.HelloAuthorityUtils;
import com.springboot.exception.BusinessLogicException;
import com.springboot.exception.ExceptionCode;
import com.springboot.member.Member;
import com.springboot.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class HelloUserDetailsServiceV3 implements UserDetailsService {
private final MemberRepository memberRepository;
private final HelloAuthorityUtils authorityUtils;
public HelloUserDetailsServiceV3(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
this.memberRepository = memberRepository;
this.authorityUtils = authorityUtils;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> optionalMember = memberRepository.findByEmail(username);
Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
return new HelloUserDetails(findMember);
}
private final class HelloUserDetails extends Member implements UserDetails {
HelloUserDetails(Member member) {
setMemberId(member.getMemberId());
setFullName(member.getFullName());
setEmail(member.getEmail());
setPassword(member.getPassword());
setRoles(member.getRoles()); // (1)
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// (2) DB에 저장된 Role 정보로 User 권한 목록 생성
return authorityUtils.createAuthorities(this.getRoles());
}
...
...
}
}
[코드 4-32] DB에서 조회한 Role을 기반으로 User의 권한 정보 생성
코드 4-32는 데이터베이스의 MEMBER_ROLES 테이블에서 조회한 Role을 기반으로 User의 권한 목록(List<GrantedAuthority>)을 생성하는 로직이 추가된 HelloUserDetailsService 클래스입니다.
- 먼저 (1)에서는 HelloUserDetails가 상속하고 있는 Member(extends Member)에 데이터베이스에서 조회한 List<String> roles를 전달합니다.
- 그리고 (2)에서 다시 Member(extends Member)에 전달한 Role 정보를 authorityUtils.createAuthorities() 메서드의 파라미터로 전달해서 권한 목록(List<GrantedAuthority>)을 생성합니다.
데이터베이스에서 Role 정보를 가지고 오지 않았을 때는 authorityUtils.createAuthorities(this.getRoles());가 아니라 authorityUtils.createAuthorities(this.getEmail());이었습니다.
수정된 부분을 혼동하지 않길 바랍니다.
아래의 코드 4-33은 데이터베이스에서 조회한 Role 정보를 기반으로 User의 권한 목록 생성하는 createAuthorities(List<String> roles) 메서드가 추가된 HelloAuthorityUtils 클래스의 코드입니다.
HelloAuthorityUtils
package com.springboot.auth.utils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class HelloAuthorityUtils {
@Value("${mail.address.admin}")
private String adminMailAddress;
private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");
private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
private final List<String> USER_ROLES_STRING = List.of("USER");
// 메모리 상의 Role을 기반으로 권한 정보 생성.
public List<GrantedAuthority> createAuthorities(String email) {
if (email.equals(adminMailAddress)) {
return ADMIN_ROLES;
}
return USER_ROLES;
}
// (1) DB에 저장된 Role을 기반으로 권한 정보 생성
public List<GrantedAuthority> createAuthorities(List<String> roles) {
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // (2)
.collect(Collectors.toList());
return authorities;
}
...
...
}
[코드 4-33] 데이터베이스에서 조회한 Role 정보를 기반으로 User의 권한 목록 생성
코드 4-33의 (1)을 보면 기존에는 application.yml 파일의 mail.address.admin 프로퍼티에 정의된 관리자용 이메일 주소를 기준으로 관리자 Role을 추가했지만 이제는 그럴 필요가 없습니다.
단순히 데이터베이스에서 가지고 온 Role 목록(List<String> roles)을 그대로 이용해서 권한 목록(authorities)을 만들면 되니까요.
💡 주의해야 할 것은 (2)와 같이 SimpleGrantedAuthority 객체를 생성할 때 생성자 파라미터로 넘겨주는 값이 “ USER" 또는 “ADMIN"으로 넘겨주면 안 되고 “ROLE_USER" 또는 “ROLE_ADMIN" 형태로 넘겨주어야 한다는 것입니다.
이제 애플리케이션을 실행한 뒤 회원 가입 후, 로그인을 해보세요. 잘 동작하나요?
구현에 성공했길 바랍니다.
✅ Custom AuthenticationProvider를 사용하는 방법
앞에서 Custom UserDetailsService를 사용해 로그인 인증을 처리하는 방식은 Spring Security가 내부적으로 인증을 대신 처리해 주는 방식입니다.
이번에는 마지막으로 Custom AuthenticationProvider를 이용해 우리가 직접 로그인 인증을 처리하는 방법을 살펴보도록 하겠습니다.
그냥 Spring Security가 로그인 인증을 대신해 주는 방식을 쓰면 되지 뭣하러 직접 로그인 인증 처리 로직을 만들어야 해?라고 할 수도 있습니다.
물론 Spring Security가 친절하게 대신 인증해 주는 방식을 사용해도 됩니다.
하지만 여러분이 Custom AuthenticationProvider 방식을 사용해 보는 것은 Spring Security의 핵심 컴포넌트인 AuthenticationProvider를 이해하는 데 도움이 되고 또한 보안 요구 사항에 부합하는 적절한 인증 방식(예를 들어 2 Factor 인증 등)을 직접 구현해야 할 경우, Custom AuthenticationProvider가 필요할 수 있습니다.
따라서 이러한 관점으로 아래의 Custom AuthenticationProvider 구현 코드를 살펴보기 바랍니다.
HelloUserAuthenticationProvider(V1)
package com.springboot.auth;
import org.springframework.security.authentication.AuthenticationProvider;
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.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class HelloUserAuthenticationProvider implements AuthenticationProvider { // (1)
private final HelloUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public HelloUserAuthenticationProvider(HelloUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
// (3)
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication; // (3-1)
// (3-2)
String username = authToken.getName();
Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));
// (3-3)
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
String password = userDetails.getPassword();
verifyCredentials(authToken.getCredentials(), password); // (3-4)
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities(); // (3-5)
// (3-6)
return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
}
// (2) HelloUserAuthenticationProvider가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려준다.
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.equals(authentication);
}
private void verifyCredentials(Object credentials, String password) {
if (!passwordEncoder.matches((String)credentials, password)) {
throw new BadCredentialsException("Invalid User name or User Password");
}
}
}
[코드 4-34] Custom AuthenticationProvider인 HelloUserAuthenticationProvider
코드 4-34는 AuthenticationProvider를 HelloUserAuthenticationProvider의 코드입니다.
코드 4-34의 설명은 다음과 같습니다.
- (1)과 같이 AuthenticationProvider 인터페이스의 구현 클래스로 정의합니다.Spring Security는 코드 4-34와 같이 AuthenticationProvider를 구현한 구현 클래스가 Spring Bean으로 등록되어 있다면 해당 AuthenticationProvider를 이용해서 인증을 진행합니다.
- 따라서 클라이언트 쪽에서 로그인 인증을 시도하면 우리가 구현한 HelloUserAuthenticationProvider가 직접 인증을 처리하게 됩니다.
- 따라서 우리는 AuthenticationProvider의 구현 클래스로써의 HelloUserAuthenticationProvider를 구현해야 합니다.
- AuthenticationProvider 인터페이스의 구현 클래스는 authenticate(Authentication authentication) 메서드와 supports(Class<?> authentication) 메서드를 구현해야 합니다.supports() 메서드의 리턴값이 true일 경우, Spring Security는 해당 AuthenticationProvider의 authenticate() 메서드를 호출해서 인증을 진행합니다.
- 그중에서 (2)의 supports(Class<?> authentication) 메서드는 우리가 구현하는 Custom AuthenticationProvider(HelloUserAuthenticationProvider)가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려주는 역할을 합니다.
- (3)의 authenticate(Authentication authentication)에서 우리가 직접 작성한 인증 처리 로직을 이용해 사용자의 인증 여부를 결정합니다.
- (3-1)에서 authentication을 캐스팅하여 UsernamePasswordAuthenticationToken을 얻습니다.
- 이 UsernamePasswordAuthenticationToken 객체에서 (3-2)와 같이 해당 사용자의 Username을 얻은 후, 존재하는지 체크합니다.
- Username이 존재한다면 (3-3)과 같이 userDetailsService를 이용해 데이터베이스에서 해당 사용자를 조회합니다.
- (3-4)에서 로그인 정보에 포함된 패스워드(authToken.getCredentials())와 데이터베이스에 저장된 사용자의 패스워드 정보가 일치하는지를 검증합니다.
- (3-4)의 검증 과정을 통과했다면 로그인 인증에 성공한 사용자이므로 (3-5)와 같이 해당 사용자의 권한을 생성합니다.
- 마지막으로 (3-6)과 같이 인증된 사용자의 인증 정보를 리턴값으로 전달합니다.
이제 애플리케이션을 다시 실행하고 회원 가입을 한 후, 로그인을 해 보세요.
회원 가입 시 등록한 이메일 주소/패스워드를 올바르게 입력하면 정상적으로 Hello, Spring Security의 메인 화면이 브라우저에 표시할 것입니다.
그런데 만약 회원 가입을 하지 않고 로그인을 시도할 경우(회원 가입 이후에는 상관없습니다) 인증에 실패하고, 아래의 [그림 4-11-1]과 같은 하얀 에러 화면을 만나게 됩니다.
[그림 4-11-1] HelloAuthenticationProvider를 통한 인증 실패 시 화면
우리가 앞에서 HelloUserDetailsService를 이용해 인증을 처리할 경우에는 인증 실패 시, Spring Security 내부에서 인증 실패에 대한 전용 Exception인 AuthenticationException을 throw 하게 되고 이 AuthenticationException이 throw 되면 결과적으로 SecurityConfiguration에서 설정한 .failureUrl("/auths/login-form?error") 을 통해 로그인 폼으로 리다이렉트 하면서 아래의 [그림 4-11-2]와 같이 “로그인 인증에 실패했습니다.”라는 인증 실패 메시지를 표시합니다.
[그림 4-11-2] HelloUserDetailsService를 이용해 인증 처리 시, 인증 실패 화면
그런데 Custom AuthenticationProvider를 이용할 경우에는 회원가입 전 인증 실패 시, 왜 [그림 4-11-2]와 같은 화면이 표시되지 않고
[그림 4-11-1]과 같은 “Whitelebel Error Page”가 표시되는 걸까요?
⭐ 이유는 MemberService에서 등록된 회원 정보가 없으면, BusinessLogicException을 throw 하는데 이 BusinessLogicException이 Cusotm AuthenticationProvider를 거쳐 그대로 Spring Security 내부 영역으로 throw 되기 때문입니다.
Spring Security에서는 인증 실패 시, AuthenticationException이 throw 되지 않으면 Exception에 대한 별도의 처리를 하지 않고, 서블릿 컨테이너인 톰캣 쪽으로 이 처리를 넘깁니다.
결국 서블릿 컨테이너 영역에서 해당 Exception에 대해 “/error” URL로 포워딩하는데 우리가 특별히 “/error” URL로 포워딩되었을 때 보여줄 뷰 페이지를 별도로 구성하지 않았기 때문에 디폴트 페이지인 “Whitelebel Error Page”를 브라우저에 표시하는 것입니다.
⭐ 해결책은 간단합니다. Cusotm AuthenticationProvider에서 Exception이 발생할 경우, 이 Exception을 catch 해서 AuthenticationException으로 rethrow를 해주면 됩니다.
개선된 HelloUserAuthenticationProvider(V2)
package com.springboot.auth;
import org.springframework.security.authentication.AuthenticationProvider;
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.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Optional;
@Component
public class HelloUserAuthenticationProvider implements AuthenticationProvider {
private final HelloUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
public HelloUserAuthenticationProvider(HelloUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
// V2: AuthenticationException을 rethrow 하는 개선 코드
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;
String username = authToken.getName();
Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));
try {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
String password = userDetails.getPassword();
verifyCredentials(authToken.getCredentials(), password);
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
} catch (Exception ex) {
throw new UsernameNotFoundException(ex.getMessage()); // (1) AuthenticationException으로 다시 throw 한다.
}
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.equals(authentication);
}
private void verifyCredentials(Object credentials, String password) {
if (!passwordEncoder.matches((String)credentials, password)) {
throw new BadCredentialsException("Invalid User name or User Password");
}
}
}
코드 4-34에서는 AuthenticationException이 아닌 다른 Exception이 발생할 경우 AuthenticationException으로 다시 rethrow 하도록 개선된 HelloUserAuthenticationProvider 코드입니다.
(1)에서 UsernameNotFoundException을 throw 하도록 수정되었는데, UsernameNotFoundException은 AuthenticationException을 상속하는 하위 Exception이기 때문에 이 UsernameNotFoundException이 throw되면 Spring Security 쪽에서 정상적으로 catch해서 [그림 4-11-2]와 같이 정상적인 인증 실패 화면으로 리다이렉트 시켜줍니다.
⭐ Custom AuthenticationProvider에서 AuthenticationException이 아닌 Exception이 발생할 경우에는 꼭
AuthenticationException을 rethrow 하도록 코드를 구성해야 한다는 사실을 기억하기를 바랍니다.
⭐ AuthenticationProvider AuthenticationProvider는 Spring Security에서 클라이언트로부터 전달받은 인증 정보를 바탕으로 인증된 사용자인지에 대한 인증 처리를 수행하는 Spring Security 컴포넌트입니다.
AuthenticationProvider는 인터페이스 형태로 정의되어 있으며, Spring Security에서는 AnonymousAuthenticationProvider, DaoAuthenticationProvider, JwtAuthenticationProvider, RememberMeAuthenticationProvider, OAuth2LoginAuthenticationProvider 등 다양한 유형의 AuthenticationProvider 구현체를 제공합니다.
핵심 포인트
- Spring Security에서 지원하는 InMemory User는 말 그대로 메모리에 등록되어 사용되는 User이므로 애플리케이션 실행이 종료되면 InMember User 역시 메모리에서 사라진다.
- InMemory User를 사용하는 방식은 테스트 환경이나 데모 환경에서 사용할 수 있는 방법이다.
- Spring Security는 **사용자의 크리덴셜(Credential, 자격증명을 위한 구체적인 수단)**을 암호화하기 위한 PasswordEncoder를 제공하며, PasswordEncoder는 다양한 암호화 방식을 제공하며, Spring Security에서 지원하는 PasswordEncoder의 디폴트 암호화 알고리즘은 bcrypt이다.
- 패스워드 같은 민감한(sensitive) 정보는 반드시 암호화되어 저장되어야 합니다. 패스워드는 복호화할 이유가 없으므로 단방향 암호화 방식으로 암호화되어야 한다.
- Spring Security에서 SimpleGrantedAuthority를 사용해 Role 베이스 형태의 권한을 지정할 때 ‘ROLE_’ + 권한명 형태로 지정해 주어야 한다.
- Spring Security에서는 Spring Security에서 관리하는 User 정보를 UserDetails로 관리한다.
- UserDetails는 UserDetailsService에 의해 로드(load)되는 핵심 User 정보를 표현하는 인터페이스입니다.
- UserDetailsService는 User 정보를 로드(load)하는 핵심 인터페이스이다.
- 일반적으로 Spring Security에서는 인증을 시도하는 주체를 User(비슷한 의미로 Principal도 있음)라고 부른다. Principal은 User의 더 구체적인 정보를 의미하며, 일반적으로 Username을 의미한다.
- Custom UserDetailsService를 사용해 로그인 인증을 처리하는 방식은 Spring Security가 내부적으로 인증을 대신 처리해 주는 방식이다.
- AuthenticationProvider는 Spring Security에서 클라이언트로부터 전달받은 인증 정보를 바탕으로 인증된 사용자인지를 처리하는 Spring Security의 컴포넌트이다.
심화 학습
- Spring Security에서 제공하는 PasswordEncoder에서 더 알아보고 싶다면 아래 링크를 참고하세요.
- bcrypt 알고리즘에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- 단방향 암호화에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- Clickjacking 공격에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
[기본] Spring Security의 웹 요청 처리 흐름
우리가 Hello, Spring Security 샘플 애플리케이션을 구현해 보면서 알 수 있었던 사실은 우리가 구현한 코드상으로는 잘 드러나지 않지만, 내부적으로는 Spring Security에서 제공하는 컴포넌트들이 애플리케이션 내부에서 User의 인증과 권한에 대한 처리를 알아서 진행해 준다는 사실을 대략적으로나마 확인해 볼 수 있었다는 것입니다
하지만 이처럼 Spring Security 내부에서 일어나는 과정들을 조금 더 구체적으로 알지 못한 상태에서는 Spring Security라는 기술을 이해하는 데 있어 한계에 부딪힐 수 있습니다.
Spring Security를 애플리케이션에 적용하는 데 어려움을 겪는 큰 이유 중의 하나는 Spring Security의 아키텍처와 Spring Security의 컴포넌트들이 어떻게 인터랙션 해서 인증, 권한 등의 보안 작업을 처리하는지 이해하지 못하기 때문입니다.
그리고 이러한 Spring Security의 동작 방식을 조금 더 잘 이해하기 위해서는 보호된 웹 요청을 처리하는 일반적인 처리 흐름과 Spring Security에서 지원하는 Filter의 역할을 이해하는 것이 선행되어야 합니다.
따라서 이번 시간에는 Spring Security의 웹 요청 처리 과정 중에서 가장 기본이 되는 웹 요청의 일반적인 흐름과 Spring Security에서 지원하는 Filter의 역할에 대해서 살펴보는 시간을 가져보도록 하겠습니다.
보안이 적용된 웹 요청의 일반적인 처리 흐름
Spring Security의 웹 요청 처리를 이해하기 위해서 알아두어야 할 부분은 먼저 보안이 적용된 웹 요청의 일반적인 흐름입니다.
[그림 4-12] 보안이 적용된 웹 요청의 일반적인 처리 흐름
그림 4-12는 보안이 적용된 사용자의 웹 요청에 대한 일반적인 처리 흐름을 그림으로 표현한 것입니다.
그림 4-12의 요청 처리 흐름은 다음과 같습니다.
- (1)에서 사용자가 보호된 리소스를 요청합니다.
- (2)에서 인증 관리자 역할을 하는 컴포넌트가 사용자의 크리덴셜(Credential)을 요청합니다.
- **사용자의 크리덴셜(Credential)**이란 해당 사용자를 증명하기 위한 구체적인 수단을 의미합니다. 일반적으로는 사용자의 패스워드가 크리덴셜에 해당합니다.
- (3)에서 사용자는 인증 관리자에게 크리덴셜(Credential)을 제공합니다.
- (4)에서 인증 관리자는 크리덴셜 저장소에서 사용자의 크리덴셜을 조회합니다.
- (5)에서 인증 관리자는 사용자가 제공한 크리덴셜과 크리덴셜 저장소에 저장된 크리덴셜을 비교해 검증 작업을 수행합니다.
- (6) 유효한 크리덴셜이 아니라면 Exception을 throw 합니다.
- (7) 유효한 크리덴셜이라면 (8)에서 접근 결정 관리자 역할을 하는 컴포넌트는 사용자가 적절한 권한을 부여받았는지 검증합니다.
- (9) 적절한 권한을 부여받지 못한 사용자라면 Exception을 throw합니다.
- (10) 적절한 권한을 부여받은 사용자라면 보호된 리소스의 접근을 허용합니다.
보안이 적용된 웹 요청은 일반적으로 위와 같은 처리 흐름을 가진다는 사실을 기억하기를 바랍니다.
웹 요청에서의 서블릿 필터와 필터 체인의 역할
[그림 4-12]에서 사용자의 웹 요청이 Controller 같은 엔드포인트를 거쳐 접근하려는 리소스에 도달하기 전에 인증 관리자나 접근 결정 관리자 같은 컴포넌트가 중간에 웹 요청을 가로채 사용자의 크리덴셜과 접근 권한을 검증하는 것을 볼 수 있습니다.
이처럼 서블릿 기반 애플리케이션의 경우, 애플리케이션의 엔드포인트에 요청이 도달하기 전에 중간에서 요청을 가로챈 후 어떤 처리를 할 수 있는 적절한 포인트를 제공하는데 그것은 바로 서블릿 필터(Servlet Filter)입니다.
서블릿 필터는 자바에서 제공하는 API이며, javax.servlet 패키지에 인터페이스 형태로 정의되어 있습니다.
javax.servlet.Filter 인터페이스를 구현한 서블릿 필터는 웹 요청(request)을 가로채어 어떤 처리(전처리)를 할 수 있으며, 또한 엔드포인트에서 요청 처리가 끝난 후 전달되는 응답(reponse)을 클라이언트에게 전달하기 전에 어떤 처리(후처리)를 할 수 있습니다.
서블릿 필터는 하나 이상의 필터들을 연결해 필터 체인(Filter Chain)을 구성할 수 있습니다.
[그림 4-13] Servlet Filter Chain의 구성도
그림 4-13은 Spring Framework의 DispatcherServlet에 클라이언트의 요청이 전달되기 전에 필터 체인(Filter Chain)을 구성한 예입니다.
서블릿 필터는 각각의 필터들이 doFilter()라는 메서드를 구현해야 하며, doFilter() 메서드 호출을 통해 필터 체인을 형성하게 됩니다.
만약 Filter 인터페이스를 구현한 다수의 Filter 클래스를 그림 4-13과 같이 구현했다면 여러분들이 생성한 서블릿 필터에서 여러분들이 작성한 특별한 작업을 수행한 뒤, HttpServlet을 거쳐 DispatcherServlet에 요청이 전달되며, 반대로 DispatcherServlet에서 전달한 응답에 대해 역시 특별한 작업을 수행할 수 있습니다.
그렇다면 Spring Security에서 필터가 어떤 역할을 하길래 우리가 제대로 배운 적도 없는 서블릿 필터에 대해서 알아보고 있는 걸까요? 🙄
Spring Security에서의 필터 역할
서블릿 필터는 클라이언트의 요청 중간에 끼어들어 무언가 추가적인 작업을 할 수 있다고 했습니다.
그렇다면 "Spring Security에서도 분명 이 필터를 이용해 클라이언트의 요청을 중간에 가로챈 뒤, 추가로 어떤 작업을 하는 거 아닐까?"라고 예상할 수 있습니다.
Spring Security에서 사용하는 필터는 어떤 작업을 추가하는 걸까요?
⭐ ‘보안’과 관련된 작업이겠죠?
[그림 4-14] Servlet Filter Chain에 Spring Seucrity Filter가 추가된 모습
그림 4-14는 서블릿 필터에 Spring Security Filter가 추가된 모습입니다.
빨간색 점선으로 된 박스 영역이 바로 Spring Security Filter 영역인데 뭔가 우리가 앞에서 살펴봤던 서블릿 필터와는 조금 다른 xxxxProxy라고 붙은 이름이 보이죠?
이름만 조금 다를 뿐이지 DelegatingFilterProxy와 FilterChainProxy 클래스는 Filter 인터페이스를 구현하기 때문에 엄연히 서블릿 필터로써의 역할을 합니다.
그런데 이 DelegatingFilterProxy와 FilterChainProxy는 조금 특별한 필터라고 보면 됩니다.
⭐ DelegatingFilterProxy
Spring에서 DI의 핵심은 바로 Spring 컨테이너인 ApplicationContext라는 사실은 여러분들도 잘 알고 있습니다.
Spring Security 역시 Spring의 핵심인 ApplicationContext를 이용합니다.
서블릿 필터와 연결되는 Spring Security만의 필터를 ApplicationContext에 Bean으로 등록한 후에 이 Bean들을 이용해서 보안과 관련된 여러 가지 작업을 처리하게 되는데 DelegatingFilterProxy 가 Bean으로 등록된 Spring Security의 필터를 사용하는 시작점이라고 생각하면 되겠습니다.
그런데 DelegatingFilterProxy라는 이름에서 알 수 있듯이 보안과 관련된 어떤 작업을 처리하는 것이 아니라 서블릿 컨테이너 영역의 필터
와 ApplicationContext에 Bean으로 등록된 필터들을 연결해 주는 브리지 역할을 합니다.
그렇다면 FilterChainProxy의 역할은 무엇일까요?
[그림 4-14]의 FilterChainProxy에서 끊어진 Next Step을 다시 그림으로 확인해 보도록 하겠습니다.
[그림 4-15] FilterChainProxy에 Spring Seucrity Filter Chain이 추가된 모습
[그림 4-15]는 [그림 4-14]에서 끊어진 FilterChainProxy에 Spring Security에서 지원하는 Filter Chain을 연결한 모습입니다.
⭐ FilterChainProxy
Spring Security의 Filter Chain은 말 그대로 Spring Security에서 보안을 위한 작업을 처리하는 필터의 모음입니다.
이 Spring Security의 Filter를 사용하기 위한 진입점이 바로 FilterChainProxy입니다.
한마디로 FilterChainProxy부터 Spring Security에서 제공하는 보안 필터들이 필요한 작업을 수행한다고 생각하면 되겠습니다.
Spring Security의 Filter Chain은 URL 별로 여러 개 등록할 수 있으며, Filter Chain이 있을 때 어떤 Filter Chain을 사용할지는 FilterChainProxy가 결정하며, 가장 먼저 매칭된 Filter Chain을 실행합니다.
예)
- /api/** 패턴의 Filter Chain이 있고, /api/message URL 요청이 전송하는 경우
- /api/** 패턴과 제일 먼저 매칭되므로, 디폴트 패턴인 /**도 일치하지만 가장 먼저 매칭되는 /api/** 패턴과 일치하는 Filter Chain만 실행합니다.
- /message/** 패턴의 Filter Chain이 없는데 /message/ URL 요청을 전송하는 경우
- 매칭되는 Filter Chain이 없으므로 디폴트 패턴인 /** 패턴의 Filter Chain을 실행합니다.
여러분들이 Spring Security를 통해 처리되는 웹 요청의 일반적인 흐름을 이해하고, Servlet Filter Chain과 Spring Security Filter Chain의 관계, Spring Security Filter의 역할 등을 이해했다면 뒤이어 계속되는 Spring Security의 인증 처리, 권한 부여 등의 학습을 조금 더 수월하게 진행할 수 있을 것으로 생각합니다.
Spring Security에서 지원하는 Filter 종류
Spring Security는 보안을 위한 특정 작업을 수행하기 위한 다양한 Filter를 지원하는데 그 수가 굉장히 많습니다.
따라서 Spring Security의 Filter가 각각 어떤 역할을 수행하는지는 전부 다 알 필요는 없으며, 필요한 상황이 되었을 때 그때그때 적용해도 상관은 없습니다.
그리고 Spring Security의 Filter 항상 모든 Filter가 수행되는 것이 아니라 프로젝트 구성 및 설정에 따라 일부의 Filter만 활성화되어 있기 때문에 직접적으로 개발자가 핸들링할 필요가 없는 Filter들이 대부분입니다.
따라서 개발자가 Custom Filter를 작성하고 등록할 경우 기존 필터들 사이에서 우선순위를 적용해 수행되어야 할 필요가 있는 경우에 참고해서 적용하면 됩니다.
Spring Security에서 지원하는 Filter 목록에 대해서 더 알아보고 싶다면 아래 [심화 학습]을 참고하세요.
핵심 포인트
- Spring Security를 애플리케이션에 적용하는 데 어려움을 겪는 큰 이유 중에 하나는 Spring Security의 아키텍처와 Spring Security의 컴포넌트들이 어떻게 인터랙션 해서 인증, 권한 등의 보안 작업을 처리하는지 이해하지 못하기 때문이다.
- 서블릿 필터(Servlet Filter)는 서블릿 기반 애플리케이션의 엔드포인트에 요청이 도달하기 전에 중간에서 요청을 가로챈 후 어떤 처리를 할 수 있도록 해주는 Java의 컴포넌트이다.
- Spring Security의 필터는 클라이언트의 요청을 중간에서 가로챈 뒤, 보안에 특화된 작업을 처리하는 역할을 한다.
- DelegatingFilterProxy라는 이름에서 알 수 있듯이 서블릿 컨테이너 영역의 필터와 ApplicationContext에 Bean으로 등록된 필터들을 연결해 주는 브리지 역할을 합니다.
- Spring Security의 Filter Chain은 Spring Security에서 보안을 위한 작업을 처리하는 필터의 모음이며, Spring Security의 Filter를 사용하기 위한 진입점이 바로 FilterChainProxy입니다.
심화 학습
- 서블릿 필터에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- Spring Security에서 지원하는 Filter 목록에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
[심화] Filter와 FilterChain 구현
이번 시간에는 앞에서 살펴보았던 Servlet Filter를 여러분이 직접 간단하게 구현해 보는 시간을 가져보면서 Servlet Filter의 동작 방식을 코드로 이해해 보는 시간을 가져보도록 하겠습니다.
Servlet Filter 구현에 앞서 Servlet Filter와 Filter Chain에 대해서 다시 한번 간단하게 정리해 봅시다.
Filter
우리가 앞에서 살펴보았던 것처럼 서블릿 필터(Servlet Filter)는 서블릿 기반 애플리케이션의 엔드포인트에 요청이 도달하기 전에 중간에서 요청을 가로챈 후 어떤 처리를 할 수 있도록 해주는 Java의 컴포넌트입니다.
[4-15] 서블릿 기반 애플리케이션에서의 Filter 위치
그림 4-15는 서블릿 기반 애플리케이션에서 Servlet Filter의 위치를 보여주고 있습니다.
그림 4-15에서처럼 클라이언트가 서버 측 애플리케이션으로 요청을 전송하면 제일 먼저 Servlet Filter를 거치게 됩니다.
그리고 Filter에서의 처리가 모두 완료되면 DispatcherServlet에서 클라이언트의 요청을 핸들러에 매핑하기 위한 다음 작업을 진행합니다.
Filter Chain
Filter Chain은 우리가 앞에서 살펴보았듯이 여러 개의 Filter가 체인을 형성하고 있는 Filter의 묶음을 의합니다.
Filter와 Filter Chain의 특성
Filter 구현 실습을 해보기 전에 알아야 하는 Filter와 Filter Chain의 특성은 다음과 같습니다.
- Servlet FilterChain은 요청 URI path를 기반으로 HttpServletRequest를 처리합니다. 따라서 클라이언트가 서버 측 애플리케이션에 요청을 전송하면 서블릿 컨테이너는 요청 URI의 경로를 기반으로 어떤 Filter와 어떤 Servlet을 매핑할지 결정합니다.
- Filter는 Filter Chain 안에서 순서를 지정할 수 있으며 지정한 순서에 따라서 동작하게 할 수 있습니다.
- Filter Chain에서 Filter의 순서는 매우 중요하며 Spring Boot에서 여러 개의 Filter를 등록하고 순서를 지정하기 위해서는 다음과 같은 두 가지 방법을 적용할 수 있습니다.
- Spring Bean으로 등록되는 Filter에 @Order 애너테이션을 추가하거나 Orderd 인터페이스를 구현해서 Filter의 순서를 지정할 수 있습니다.
- FilterRegistrationBean을 이용해 Filter의 순서를 명시적으로 지정할 수 있습니다.
Filter 인터페이스
public class FirstFilter implements Filter {
// (1) 초기화 작업
public void init(FilterConfig filterConfig) throws ServletException {
}
// (2)
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain)
throws IOException, ServletException {
// (2-1) 이곳에서 request(ServletRequest)를 이용해 다음 Filter로 넘어가기 전처리 작업을 수행한다.
// (2-2)
chain.doFilter(request, response);
// (2-3) 이곳에서 response(ServletResponse)를 이용해 response에 대한 후처리 작업을 할 수 있다.
}
// (3)
public void destroy() {
// (5) Filter가 사용한 자원을 반납하는 처리
}
}
[코드 4-35] Servlet Filter의 기본 구조
코드 4-35는 Servlet Filter 인터페이스를 구현한 구현 클래스의 기본 구조입니다.
코드의 설명은 다음과 같습니다.
- (1)의 init() 메서드에서는 생성한 Filter에 대한 초기화 작업을 진행할 수 있습니다.
- (2)의 doFilter() 메서드에서는 해당 Filter가 처리하는 실질적인 로직을 구현합니다.
- (2-1)에는 request를 이용해 (2-2)의 chain.doFilter(request, response)가 호출되기 전에 할 수 있는 전처리 작업에 대한 코드를 구현할 수 있습니다.
- (2-3)에는 response를 이용해 (2-2)의 chain.doFilter(request, response)가 호출된 이후에 할 수 있는 후처리 작업에 대한 코드를 구현할 수 있습니다.
- (3)의 destroy() 메서드는 Filter가 컨테이너에서 종료될 때 호출되는데 주로 Filter가 사용한 자원을 반납하는 처리 등의 로직을 작성하고자 할 때 사용됩니다.
Filter 실습 예제
그러면 이제 이제 여러분들이 직접 Filter를 만들어서 애플리케이션을 실행시킨 후, Filter가 어떤 식으로 동작하는지 직접 확인해 보는 시간을 가져보도록 합시다.
여러분이 만든 Filter의 동작을 확인해 보기 위한 애플리케이션은 여러분들이 Spring Boot Initializr를 이용해서 직접 만들어보세요.
Filter의 동작 과정을 확인하려면 최소한 하나의 Controller를 만들고, 해당 컨트롤러의 REST API 엔드포인트를 호출해 보아야 합니다.
1️⃣ 첫 번째 Filter 구현
import javax.servlet.*;
import java.io.IOException;
public class FirstFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
System.out.println("FirstFilter 생성됨");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("========First 필터 시작========");
chain.doFilter(request, response);
System.out.println("========First 필터 종료========");
}
@Override
public void destroy() {
System.out.println("FirstFilter Destory");
Filter.super.destroy();
}
}
[코드 4-36] FirstFilter 구현 코드
코드 4-36과 같이 애플리케이션에 적용할 첫 번째 Filter를 작성합니다.
2️⃣ FirstFilter를 적용하기 위한 FilterConfiguration 구성
import book.study.security.FirstFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfiguration {
@Bean
public FilterRegistrationBean<FirstFilter> firstFilterRegister() {
FilterRegistrationBean<FirstFilter> registrationBean = new FilterRegistrationBean<>(new FirstFilter());
return registrationBean;
}
}
[코드 4-37] FirstFilter를 등록한 FilterConfiguration
Spring Boot에서 Servlet Filter는 코드 4-37과 같이 FilterRegistrationBean의 생성자로 Filter 인터페이스의 구현 객체를 넘겨주는 형태로 등록할 수 있습니다.
3️⃣ 애플리케이션 실행
애플리케이션을 실행하면 가장 먼저 init() 메서드가 실행되면서 아래와 같은 로그가 출력되는 것을 확인할 수 있습니다.
FirstFilter 생성됨
다음으로 여러분이 만든 Controller가 있다면 해당 컨트롤러의 핸들러 메서드로 요청을 보내보세요.
doFilter → controller 동작 → destroy 메서드의 형태로 Filter가 동작하면서 아마도 아래와 유사한 로그가 출력되는 것을 확인할 수 있습니다.
========First 필터 시작========
Hello
========First 필터 종료========
4️⃣ 두 번째 Filter 구현
이번에는 필터를 하나 더 구현해서 총 두 개의 Filter를 적용해 보도록 하겠습니다.
import javax.servlet.*;
import java.io.IOException;
public class SecondFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
System.out.println("SecondFilter가 생성되었습니다.");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("==========Second 필터 시작==========");
chain.doFilter(request, response);
System.out.println("==========Second 필터 종료==========");
}
@Override
public void destroy() {
System.out.println("SecondFilter가 사라집니다.");
Filter.super.destroy();
}
}
[코드 4-38] SecondFilter 구현 코드
코드 4-38과 같이 애플리케이션에 적용할 두 번째 Filter를 작성합니다.
5️⃣ FilterConfiguration에 두 번째 Filter 등록
import book.study.security.FirstFilter;
import book.study.security.SecondFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class Config {
@Bean
public FilterRegistrationBean<FirstFilter> firstFilterRegister() {
FilterRegistrationBean<FirstFilter> registrationBean = new FilterRegistrationBean<>(new FirstFilter());
registrationBean.setOrder(1); // (1)
return registrationBean;
}
@Bean
public FilterRegistrationBean<SecondFilter> secondFilterRegister() {
FilterRegistrationBean<SecondFilter> registrationBean = new FilterRegistrationBean<>(new SecondFilter());
registrationBean.setOrder(2); // (2)
return registrationBean;
}
}
[코드 4-39] SecondFilter를 등록한 FilterConfiguration
코드 4-39에서는 두 번째 Filter인 SecondFilter를 Spring Bean으로 등록했습니다.
두 개의 Filter가 지정된 순서로 실행되도록 (1), (2)와 같이 registrationBean.setOrder() 메서드로 순서를 지정할 수 있다는 사실을 기억하기를 바랍니다.
registrationBean.setOrder()의 파라미터로 지정한 숫자가 적은 숫자일수록 먼저 실행됩니다.
애플리케이션을 다시 실행하고 Controller의 핸들러 메서드에 request를 전송하면 아래와 같은 실행 결과를 확인할 수 있습니다.
========First 필터 시작========
==========Second 필터 시작==========
Hello
==========Second 필터 종료==========
========First 필터 종료========
Filter는 나머지 Filter와 Servlet에 영향을 주기 때문에 Filter의 실행 순서가 중요하다는 사실을 꼭 기억하길 바랍니다.
핵심 포인트
- Spring Boot에서는 FilterRegistrationBean을 이용해 Filter를 등록할 수 있다.
- Spring Boot에서 등록하는 Filter는 다음과 같은 방법으로 실행 순서를 지정할 수 있다.
- Spring Bean으로 등록되는 Filter에 @Order 애너테이션을 추가하거나 Orderd 인터페이스를 구현해서 Filter의 순서를 지정할 수 있다.
- FilterRegistrationBean의 setOrder() 메서드를 이용해 Filter의 순서를 지정할 수 있다.
[심화] DelegatingPasswordEncoder
이번 시간에는 DelegatingPasswordEncoder라는 Spring Security의 컴포넌트에 대해서 조금 더 구체적으로 알아보는 시간을 가져보겠습니다.
‘Hello, Spring Security로 알아보는 Spring Security의 기본 구조 (2)에서 DelegatingPasswordEncoder라는 Spring Security의 컴포넌트를 사용했었던 것, 기억이 날까요?
DelegatingPasswordEncoder는 Spring Security에서 지원하는 PasswordEncoder 구현 객체를 생성해 주는 컴포넌트로써 DelegatingPasswordEncoder를 통해 애플리케이션에서 사용할 PasswordEncoder를 결정하고, 결정된 PasswordEncoder로 사용자가 입력한 패스워드를 단방향으로 암호화해줍니다.
DelegatingPasswordEncoder 도입 전 문제점
스프링 시큐리티 5.0 이전 버전에서는 평문 텍스트(Plain text) 패스워드를 그대로 사용하는 NoOpPasswordEncoder 가 디폴트 PasswordEncoder로 고정이 되어 있었지만 아래와 같은 문제를 해결하기 위해 DelegatingPasswordEncoder를 도입해서 조금 더 유연한 구조로 PasswordEncoder를 사용할 수 있게 되었습니다.
- 패스워드 인코딩 방식을 마이그레이션 하기 쉽지 않은 오래된 방식을 사용하고 있는 경우
- 패스워드 단방향 암호화에 사용되는 hash 알고리즘은 시간이 지나면서 보다 더 안전한 hash 알고리즘이 지속적으로 고안되고 있기 때문에 항상 고정된 암호화 방식을 사용하는 것은 바람직한 사용 방식이 아닙니다.
- 스프링 시큐리티는 프레임워크이기 때문에 하위 호환성을 보장하지 않는 업데이트를 자주 할 수 없습니다.
- 오래된 하위 버전의 기술이 언젠가 Deprecated 되는 것처럼 보안에 취약한 오래된 방식의 암호화 알고리즘 역시 언제까지 관리 대상이 되지는 않습니다.
DelegatingPasswordEncoder의 장점
- DelegatingPasswordEncoder를 사용해 다양한 방식의 암호화 알고리즘을 적용할 수 있는데, 우리가 사용하고자 하는 암호화 알고리즘을 특별히 지정하지 않는다면 Spring Security에서 권장하는 최신 암호화 알고리즘을 사용하여 패스워드를 암호화할 수 있도록 해줍니다.
- 패스워드 검증에 있어서도 레거시 방식의 암호화 알고리즘으로 암호화된 패스워드의 검증을 지원합니다.
- Delegating이라는 표현에서도 DelegatingPasswordEncoder의 특징이 잘 드러나듯이 나중에 암호화 방식을 변경하고 싶다면 언제든지 암호화 방식을 변경할 수 있습니다.
- 단 이 경우, 기존에 암호화되어 저장된 패스워드에 대한 마이그레이션 작업이 진행되어야 합니다.
DelegatingPasswordEncoder를 이용한 PasswordEncoder 생성
DelegatingPasswordEncoder를 사용해 PasswordEncoder를 생성하는 방법은 아래의 코드와 같습니다.
// PasswordEncoderFactories로 만들 수 있습니다.
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
[코드 4-40] PasswordEncoderFactories 클래스를 이용한 PasswordEncoder 인스턴스 생성
코드 4-40을 보면 PasswordEncoderFactories.createDelegatingPasswordEncoder();를 통해 DelegatingPasswordEncoder의 객체를 생성하고, 내부적으로 DelegatingPasswordEncoder가 다시 적절한 PasswordEncoder 객체를 생성합니다.
Custom DelegatingPasswordEncoder 생성
Spring Security에서 지원하는 PasswordEncoderFactories 클래스를 이용하면 기본적으로 Spring Security에서 권장하는 PasswordEncoder를 사용할 수 있지만 필요한 경우, DelegatingPasswordEncoder로 직접 PasswordEncoder를 지정해서 Custom DelegatingPasswordEncoder를 사용할 수 있습니다.
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
[코드 4-41] Custom DelegatingPasswordEncoder 사용
코드 4-41과 같이 Map encoders에 원하는 유형의 PasswordEncoder를 추가해서 DelegatingPasswordEncoder의 생성자로 넘겨주면 디폴트로 지정(idForEncode)한 PasswordEncoder를 사용할 수 있습니다.
암호화된 Password Format
Spring Security 5에서는 패스워드를 암호화할 때, 암호화 알고리즘 유형을 prefix로 추가합니다.
즉 암호화된 패스워드의 포맷은 아래와 같습니다.
- {id}encodedPassword
아래는 코드 4-41에서 생성한 Custom DelegatingPasswordEncoder에서 지원하는 단방향 암호화 알고리즘 유형에 따른 암호화된 패스워드의 예입니다.
- BCryptPasswordEncoder로 암호화할 경우,
- {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
- PasswordEncoder id는 “bcrypt”
- encodedPassword는$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG” 이다.
- {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
- Pbkdf2PasswordEncoder로 암호화할 경우,
- {pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
- PasswordEncoder id는 “pbkdf2”
- encodedPassword는 “5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc”이다.
- {pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
- SCryptPasswordEncoder로 암호화할 경우,
- {scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
- PasswordEncoder id는 “scrypt”
- encodedPassword는 “$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=”이다.
- {scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
- StandardPasswordEncoder로 암호화할 경우,
- {sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
- PasswordEncoder id는 “sha256”
- encodedPassword는 “97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0”이다.
- {sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
패스워드 해킹 공격에 따라 성장하는 패스워드 암호화(Password Encryption) 기술
보안의 중요성을 아는 사람들이라면 패스워드는 개인 정보 중에서도 최고 등급으로 중요한 정보라는 것은 잘 알고 있을 텐데, 당연히 해커들에게도 사용자 계정의 패스워드는 최고의 먹잇감입니다.
해커들이 사용자 계정의 패스워드 정보를 탈취하기 위해 안간힘을 쓰면서 그와 동시에 패스워드 암호화 기술 역시 성장하고 있다는 것은 아이러니한 일이기도 합니다. ^^
이처럼 패스워드 암호화는 굉장히 중요한 작업이고, 따라서 Spring Security에서도 과거부터 현재까지 패스워드 암호화에 대한 기능을 제공하고 있습니다.
우리가 패스워드 암호화 알고리즘 자체를 구체적으로 이야기하는 데는 한계가 있지만 그래도 잘 보호되는 애플리케이션을 만들어야 하는 입장에서 패스워드를 암호화하는 기술에는 어떤 것이 있는지 정도는 대략적으로나마 알고 있어야 한다고 생각하기에 아래와 같이 간단하게 정리해 보았습니다. <br /> 1️⃣ Plain Text 저장 Plain Text는 말 그대로 암호화되지 않은 텍스트 그 자체를 의미합니다.
요즘은 거의 이런 일이 없을 거라 생각하지만(믿고 싶습니다. ^^) 과거에는 회원 정보 중에서 패스워드 정보를 암호화하지 않고, Plain Text 그대로 데이터베이스에 저장하는 사례가 종종 있었던 걸로 기억합니다.
회원의 패스워드가 Plain Text 그대로 저장이 되어 있는 상태에서 해커에 의해 회원 정보가 탈취되는 상상은 정말 하고 싶지 않습니다. ^^; <br /> 2️⃣ 해시(Hash) 알고리즘 해시 알고리즘은 단방향 암호화를 위한 핵심 알고리즘입니다. 단방향 암호화라는 용어에서도 그 특성이 잘 드러나듯이 한번 암호화되면 복호화되기 어려운 특성을 가지고 있습니다.
2️⃣ 해시(Hash) 알고리즘 해시 알고리즘은 단방향 암호화를 위한 핵심 알고리즘입니다. 단방향 암호화라는 용어에서도 그 특성이 잘 드러나듯이 한번 암호화되면 복호화되기 어려운 특성을 가지고 있습니다.
데이터베이스에 암호화되어 저장되는 패스워드 자체는 사용자가 입력한 패스워드와 비교해 올바른 패스워드를 입력했는지 검증하는 용도이기 때문에 다시 복호화될 필요가 없습니다.
따라서 해시 알고리즘을 이용해 패스워드를 암호화하는 것은 괜찮은 선택입니다. <br /> 3️⃣ MD5(Message Digest 5) MD5는 초창기에 사용하던 MD2, MD4 해시 알고리즘의 결함을 보완한 알고리즘입니다. 하지만 MD5 역시 단방향 알고리즘인데도 불구하고 복호화가 된 사례가 종종 발견되어 지금은 거의 사용하지 않는 알고리즘입니다.
MD5에서 여러분이 기억하면 좋은 것은 **다이제스트(Digest)**라는 용어입니다. **다이제스트(Digest)**는 원본 메시지를 암호화한 메시지를 의미하는데, 암호화 기술에 굉장히 자주 사용되는 용어이므로 기억하면 좋을 것 같습니다. <br /> 4️⃣ SHA(Secure Hash Algorithm) MD5의 결함을 보완하기 위해서 나온 대표적인 해시 알고리즘이 바로 SHA 알고리즘입니다. SHA 알고리즘은 해시된 문자열을 만들어내기 위해 비트 회전 연산이 추가된 방식입니다. 쉽게 말해서 해시된 문자열의 비트 값을 회전하면서 반복적으로 해시 처리를 하는 것입니다.
원본 메시지를 한번 해시 처리해도 우리가 눈으로는 알기 힘든 문자열이 생성되는데, 해시 처리된 문자열의 비트 값을 옮겨가면서 반복으로 해시 처리를 하면 아주 강력한 해시 문자열이 생성되겠죠?
그런데 SHA 알고리즘으로 해시 처리된 메시지는 사람의 눈이나 머리로는 도저히 원본 메시지를 알아내기 힘들 것 같지만 컴퓨터는 알아낼 수 있습니다. ^^
해커 입장에서는 사용자가 패스워드로 사용할만한 문자열들을 미리 목록(Rainbow Table)으로 만들어 놓고, 이 목록에 있는 문자열을 동일한 알고리즘으로 암호화한 후, 탈취한 암호화된 문자열과 서로 비교하는 작업을 통해 패스워드의 원본 문자열을 알 수 있게 되는데, 이러한 공격을 Rainbow Attack이라고 합니다. <br /> 5️⃣ Rainbow Attack에 대한 대응책 컴퓨터의 성능이 워낙에 좋아지다 보니 자동화된 Rainbow Attack을 통해 비교할 수 있는 다이제스트(Digest)의 양이 초당 50억 개 이상이라고 합니다. ^^
그런데 Rainbow Attack을 백 퍼센트 무력화할 순 없겠지만 컴퓨터가 다이제스트(Digest)를 비교하는 작업의 횟수를 줄일 방법은 있습니다.
가장 단순한 방법은 앞에서 살펴본 SHA 알고리즘처럼 해시된 다이제스트를 또 해시하고, 또 해시된 다이제스트를 반복적으로 해시하는 것입니다(이를 키 스트레칭이라고 합니다). 해시 처리가 반복되면 될수록 다이제스트(Digest)를 비교하는 횟수도 현저히 줄어듭니다.
또 한 가지 방법은 **솔트(Salt)**를 이용하는 방법입니다. 솔트(Salt)란 패스워드로 입력하는 원본 메시지에 임의의 어떤 문자열을 추가해서 해시 처리하는 것을 의미합니다. 솔트(Salt)를 추가하면 Rainbow Table을 이용해 비교해야 하는 경우의 수가 늘어나기 때문에 완벽하지는 않지만 Rainbow Attack에 대응할 수 있습니다. 물론 솔트(Salt)까지 원본 메시지와 함께 탈취당한다면 또 다른 문제가 발생하겠지만요. ^^ <br /> 6️⃣ Work Factor를 추가한 Hash 알고리즘 해시 알고리즘을 연구하는 사람들의 고민 중 하나는 공격자가 Rainbow Attack과 같은 공격을 통해 해시된 메시지를 알아내려고 시도하더라도 어떻게 하면 최대한 느리게 최대한 비용이 많이 들게 할 수 있을까입니다.
이런 고민을 통해서 탄생한 Hash 알고리즘이 PBKDF2, bcrypt, scrypt입니다. 여기서 말하는 Work Factor는 공격자가 해시된 메시지를 알아내는 데 더 느리게 더 비용이 많이 들게 해주는 특정 요소를 의미합니다.
PBKDF2나 bcrypt의 경우 Work Factor로 솔트와 키 스트레칭을 기본적으로 사용하지만 내부적으로 훨씬 복잡한 알고리즘을 이용해서 공격자의 공격을 느리게 만듭니다.
scrypt는 기본적으로 다이제스트 생성 시, 메모리 오버헤드를 갖도록 설계되어 있기 때문에 무차별 대입 공격(Brute Force Attack)을 시도하기 위해 병렬화 처리가 매우 어려운 특징이 있습니다.
핵심 포인트
- 스프링 시큐리티 5.0 이전 버전부터 DelegatingPasswordEncoder를 도입해 조금 더 유연한 구조로 PasswordEncoder를 사용할 수 있게 되었다.
- DelegatingPasswordEncoder의 장점
- 사용하고자 하는 암호화 알고리즘을 특별히 지정하지 않는다면 Spring Security에서 권장하는 최신 암호화 알고리즘을 사용하여 패스워드를 암호화할 수 있도록 해준다.
- 패스워드 검증에 있어서 레거시 방식의 암호화 알고리즘으로 암호화된 패스워드의 검증을 지원한다.
- 암호화 방식을 변경하고 싶다면 언제든지 암호화 방식을 변경할 수 있다.
- 단 이 경우, 기존에 암호화되어 저장된 패스워드에 대한 마이그레이션 작업이 진행되어야 한다.
스프링 시큐리티 전체 흐름 순서
- 클라이언트에 요청을 서블릿 컨테이너에서 처리를 하는데 필터를 통해 보안처리를 합니다.
필터의 구조
- 서블릿 필터에는 여러개의 필터들의 체인 형식으로 이어져 순서대로 동작하게 됩니다.
스프링 시큐리티의 필터들을 처리하기 위한 DelegatingFilterProxy
- 필터는 서블릿 컨테이너에서 서블릿 필터로서 동작하게 되어있습니다.
- 하지만 스프링 시큐리티의 필터들은 스프링 컨테이너의 스프링 빈 필터에서 동작하게 됩니다.
- 서로 다른 영역에 존재하기 때문에 필터에서는 스프링 시큐리티 필터 처리가 어려웠습니다.
- 필터 내에서 DelegatingFilterProxy의 FilterChainProxy를 이용해 SecurityFilterChain에 접근하여 문제를 해결할 수 있습니다.
DelegatingFilterProxy는
스프링 시큐리티는 DelegatingFilterProxy를 통해 서블릿 컨테이너에 필터로서 요청을 취득하게 되고 Spring SeucrityFilterChain이 스프링 컨테이너에서 빈으로 등록된 필터를 찾아 요청을 위임하게 됩니다.
- 실제적인 인증 및 인가 처리를 하지 않고 스프링 빈에 등록된 필터를 불러와 처리하는 역할만 합니다.
- DelegatingFilterProxy의 FilterChainProxy가 의존성 주입이 된 SecurityFilterChain과 연결되어 인증, 인가 등의 보안 처리를 합니다.
Security Filter Chain과 보안 처리 과정
보안에 필요한 여러 개의 필터를 설정을 통해 자동으로 적용합니다.
- 여러 필터 중에 UsernamePasswordAuthenticationFilter가 있는데 해당 필터를 통해서 인증 처리를 합니다.
- 인증 관련 처리(SecurityFilterChain에서 UsernamePasswordAuthenticationFilter를 적용)는 3가지 정도로 구분할 수 있습니다.
- 필터에서 UsernamePasswordAuthenticationToken을 발행합니다.
- 해당 토큰을 가지고 인증 작업 담당인 AuthenticationManager가 처리하게 됩니다.
- AuthenticationManager의 처리 결과를 통해 SecurityContextHolder에 인증 정보를 보관하게 됩니다.
- 인증 관련 처리(SecurityFilterChain에서 UsernamePasswordAuthenticationFilter를 적용)는 3가지 정도로 구분할 수 있습니다.
인증 처리를 하기 위한 AuthenticationManager
- AuthenticationManager는 스프링 시큐리티 필터들이 수행하는 방식들을 정의합니다.
- AuthenticationManager를 호출한 시큐리티 필터에서 Authentication 객체를 SecurityContextHolder에 설정하게 됩니다.
- ProvideManager가 가지고 있는 AuthenticationProvider들의 목록을 순회하면서 실행 가능한 provider의 authenticate 메서드를 호출하여 인증 절차를 수행합니다.
- 인증 성공, 실패, 결정을 내릴 수 없거나 다음 AuthenticationProvider가 결정하도록 전달할 수 있습니다.
인증 정보가 저장된 SecurityContextHolder
- SecurityContextHolder에는 Authentication이라는 인증과 인가에 해당하는 정보들을 가지고 있습니다.
- SecuritycontextHolder만 알면 인증 정보를 가져올 수 있게 됩니다.
- 인증 정보로는 주체에 해당하는 Principal과 인증 작업을 하기 위한 Credentials가 있습니다.
- 인가 정보로는 권한에 대한 정보인 Authorities가 있습니다.
Security Filter Chain과 보안 처리 과정
보안에 필요한 여러 개의 필터를 설정을 통해 자동으로 적용합니다.
- 여러 필터 중에 UsernamePasswordAuthenticationFilter가 있는데 해당 필터를 통해서 인증 처리를 합니다.
- 인증 관련 처리(SecurityFilterChain에서 UsernamePasswordAuthenticationFilter를 적용)는 3가지 정도로 구분할 수 있습니다.
- 필터에서 UsernamePasswordAuthenticationToken을 발행합니다.
- 해당 토큰을 가지고 인증 작업 담당인 AuthenticationManager가 처리하게 됩니다.
- AuthenticationManager의 처리 결과를 통해 SecurityContextHolder에 인증 정보를 보관하게 됩니다.
- 인증 관련 처리(SecurityFilterChain에서 UsernamePasswordAuthenticationFilter를 적용)는 3가지 정도로 구분할 수 있습니다.
인증 처리를 하기 위한 AuthenticationManager
- AuthenticationManager는 스프링 시큐리티 필터들이 수행하는 방식들을 정의합니다.
- AuthenticationManager를 호출한 시큐리티 필터에서 Authentication 객체를 SecurityContextHolder에 설정하게 됩니다.
- ProvideManager가 가지고 있는 AuthenticationProvider들의 목록을 순회하면서 실행 가능한 provider의 authenticate 메서드를 호출하여 인증 절차를 수행합니다.
- 인증 성공, 실패, 결정을 내릴 수 없거나 다음 AuthenticationProvider가 결정하도록 전달할 수 있습니다.
인증 정보가 저장된 SecurityContextHolder
- SecurityContextHolder에는 Authentication이라는 인증과 인가에 해당하는 정보들을 가지고 있습니다.
- SecuritycontextHolder만 알면 인증 정보를 가져올 수 있게 됩니다.
- 인증 정보로는 주체에 해당하는 Principal과 인증 작업을 하기 위한 Credentials가 있습니다.
- 인가 정보로는 권한에 대한 정보인 Authorities가 있습니다.
SecurityFilterChain 구조
Debug를 통해 순서 출력
- SecurityConfig.java에 코드 수정
@Configuration
@EnableWebSecurity(debug = true) // 수정
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
- @EnableWebSecurity(debug = true) 추가 후 어떤 url이라도 접속하게 되면 security filter chain이 적용되는 순서대로 로그가 남게 됩니다.
Security filter chain:
- DisableEncodeUrlFilter
- WebAsyncManagerIntegrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CsrfFilter
- LogoutFilter
- OAuth2AuthorizationRequestRedirectFilter
- OAuth2LoginAuthenticationFilter
- UsernamePasswordAuthenticationFilter
- RequestCacheAwareFilter
- SecurityContextHolderAwareRequestFilter
- AnonymousAuthenticationFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
Custom Filter 추가
- SecurityConfig.java에 코드 추가
@Configuration
@EnableWebSecurity(debug = true)
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.addFilterBefore(new FirstFilter(), LogoutFilter.class); // 추가
...
}
}
- http.addFilterBefore() or http.addFilterAfter() 메서드를 사용해서 SecurityFilterChain 실행 중에 Custom 필터를 추가해서 실행시킬 수 있습니다.
- addFilterBefore(적용할 필터, 적용될 SecurityFilterChain.class) → LogoutFilter를 실행하기 전(후)에 어떤 필터를 추가할지 설정합니다.
- 위 적용 예시
Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CsrfFilter
FirstFilter // 추가
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
UsernamePasswordAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
]
Chapter - Spring Security 인증 구성요소 이해
여러분들이 안전하게 잘 보호된 애플리케이션을 개발하기 위해서 반드시 익혀야 하는 중요한 보안 요소가 두 가지 있는데 그중 하나가 바로 인증(Authentication)입니다.
우리가 이전 챕터들을 통해 Spring Security가 적용된 Hello Spring Security도 만들어봤고, Spring Security가 적용된 애플리케이션의 웹 요청 흐름도 살펴보면서 인증(Authentication)이라는 용어를 꽤 많이 사용했으며 또한 폼 로그인 방식의 인증도 직접 구현해 보았습니다.
이제는 여러분들은 인증이 무엇이고, 애플리케이션을 잘 보호하기 위해서 인증이 왜 필요한지 잘 알고 있을 거라 생각합니다.
이번 챕터에서는 Spring Security의 인증 컴포넌트를 통해 Spring Security에서 이루어지는 인증(Authentication) 프로세스가 어떻게 구성이 되어 있는지 자세하게 살펴보도록 하겠습니다.
여러분들이 Spring Security의 인증 처리 흐름을 잘 이해하고 있다면 여러분들이 구현하는 애플리케이션에 다양한 인증 방식을 조금 더 효과적으로 적용할 수 있을 거라 생각합니다.
학습 목표
- Spring Security의 인증 처리 흐름을 이해할 수 있다.
- Spring Security의 핵심 컴포넌트인 인증 컴포넌트의 역할을 이해할 수 있다.
Spring Security의 인증 처리 흐름
Spring Security에서는 Spring Security Filter Chain을 통해 보안을 위한 특정 작업을 처리한다는 사실을 이제 알게 되었습니다.
그렇다면 Spring Security Filter Chain에 사용자의 인증 요청이 전달되었다면 그다음의 처리는 어떻게 될까요?
이번 시간에는 사용자의 인증 요청이 Spring Security Filter Chain의 특정 Filter에 도달했을 때, Spring Security의 컴포넌트들이 어떤 과정을 거쳐 사용자의 인증 요청을 처리하는지 그 흐름을 이해해 보도록 하겠습니다.
Spring Security의 컴포넌트로 보는 인증(Authentication) 처리 흐름
[그림 4-16] Spring Security의 컴포넌트로 보는 인증(Authentication) 처리 흐름
[그림 4-16]은 사용자가 로그인 인증을 위한 요청을 전송할 경우, Spring Security에서 해당 인증 요청을 어떻게 처리하는지를 한눈에 볼 수 있는 Spring Security의 핵심 컴포넌트로 구성된 인증 처리 흐름입니다.
우리가 가장 일반적으로 사용하는 인증 방식이 ID/Password(Spring Security에서의 Username/Password)를 이용한 로그인 인증 방식이기 때문에 [그림 4-16]의 인증 처리 흐름 역시 로그인 인증에 대한 처리 흐름으로 구성했습니다.
하지만 여러분들이 [그림 4-16]의 인증 처리 흐름을 이해하게 된다면 로그인 인증 방식이 아닌 다른 인증 방식을 여러분의 애플리케이션에 적용한다고 할지라도 손쉽게(또는 조금은 덜 힘들게) 적용할 수 있을 거라고 생각합니다.
[그림 4-16]의 인증 처리 흐름을 어느 정도 이해하는 것과 그렇지 못한 것의 차이는 굉장히 크다고 생각합니다.
지금부터 [그림 4-16]의 인증 처리 흐름을 천천히 이해해 보도록 합시다.
- 먼저 (1)에서 사용자가 로그인 폼 등을 이용해 Username(로그인 ID)과 Password를 포함한 request를 Spring Security가 적용된 애플리케이션에 전송합니다.Spring Security에서의 실질적인 인증 처리는 지금부터가 시작입니다.
- 사용자의 로그인 요청이 Spring Security의 Filter Chain까지 들어오면 여러 Filter들 중에서 UsernamePasswordAuthenticationFilter가 해당 요청을 전달받습니다.
- 사용자의 로그인 요청을 전달받은 UsernamePasswordAuthenticationFilter는 Username과 Password를 이용해 (2)와 같이 UsernamePasswordAuthenticationToken을 생성합니다.
- UsernamePasswordAuthenticationToken은 Authentication 인터페이스를 구현한 구현 클래스이며, 여기에서 Authentication은 ⭐ 아직 인증이 되지 않은 Authentication이라는 사실을 기억하기 바랍니다.
- 아직 인증되지 않은 Authentication을 가지고 있는 UsernamePasswordAuthenticationFilter는 (3)과 같이 해당 Authentication을 AuthenticationManager에게 전달합니다.즉, ProviderManager가 인증이라는 작업을 총괄하는 실질적인 매니저인 것입니다.레스토랑에서의 매니저(또는 지배인)는 손님에게서 주문받거나 주문받은 음식을 요리한다거나 요리된 음식을 손님에게 가져다주는 등의 구체적인 일을 하지는 않습니다.현실 세계에서의 매니저처럼 Spring Security의 ProviderManager 역시 직접 인증을 처리하는 것이 아니라 인증을 처리할 누군가를 찾은 뒤, 인증 처리를 대신 맡깁니다.
- 그 누군가가 바로 AuthenticationProvider입니다.
- 손님의 주문을 받고 요리된 음식을 손님에게 가져다주는 웨이터 또는 웨이트리스, 주문받은 음식을 요리하는 요리사 등이 구체적인 일을 하고, 매니저(또는 지배인) 또는 지배인은 레스토랑에서 일어나는 모든 일들을 총괄해서 관리하는 역할을 합니다.
- 💡 현실 세계에서의 매니저 또는 지배인이 하는 일을 잠시 떠올려 보세요.
- AuthenticationManager는 인증 처리를 총괄하는 매니저 역할을 하는 인터페이스이고, AuthenticationManager를 구현한 구현 클래스가 바로 ProviderManager입니다.
- (4)와 같이 ProviderManager로부터 Authentication을 전달받은 AuthenticationProvider는 (5)와 같이 UserDetailsService를 이용해 UserDetails를 조회합니다.그리고 이 UserDetails를 제공하는 컴포넌트가 바로 UserDetailsService입니다.
- UserDetails는 데이터베이스 등의 저장소에 저장된 사용자의 Username과 사용자의 자격을 증명해 주는 크리덴셜(Credential)인 Password, 그리고 사용자의 권한 정보를 포함하고 있는 컴포넌트입니다.
- UserDetailsService는 (5)에서 처럼 데이터베이스 등의 저장소에서 사용자의 크리덴셜(Credential)을 포함한 사용자의 정보를 조회합니다.
- 데이터베이스 등의 저장소에서 조회한 사용자의 크리덴셜(Credential)을 포함한 사용자의 정보를 기반으로 (7)과 같이 UserDetails를 생성한 후, 생성된 UserDetails를 다시 AuthenticationProvider에게 전달합니다(8).
- UserDetails를 전달받은 AuthenticationProvider는 PasswordEncoder를 이용해 UserDetails에 포함된 암호화된 Password와 인증을 위한 Authentication안에 포함된 Password가 일치하는지 검증합니다.
- 검증에 성공하면 UserDetails를 이용해 인증된 Authentication을 생성합니다(9).
- 만약 검증에 성공하지 못하면 Exception을 발생시키고 인증 처리를 중단합니다.
- AuthenticationProvider는 인증된 Authentication을 ProviderManager에게 전달합니다(10).
- (2)에서의 Authentication은 인증을 위해 필요한 사용자의 로그인 정보를 가지고 있지만, ⭐ 이 단계에서 ProviderManager에게 전달한 Authentication은 인증에 성공한 사용자의 정보(Principal, Credential, GrantedAuthorities)를 가지고 있다는 사실을 꼭 기억하기 바랍니다.
- 이제 ProviderManager는 (11)과 같이 인증된 Authentication을 다시 UsernamePasswordAuthenticationFilter에게 전달합니다.
- 인증된 Authentication을 전달받은 UsernamePasswordAuthenticationFilter는 마지막으로 (12)와 같이 SecurityContextHolder를 이용해 SecurityContext에 인증된 Authentication을 저장합니다. ⭐ 그리고 SecurityContext는 이후에 Spring Security의 세션 정책에 따라서 HttpSession에 저장되어 사용자의 인증 상태를 유지하기도 하고, HttpSession을 생성하지 않고 무상태를 유지하기도 합니다. Spring Security의 세션 정책에 대해서는 JWT 유닛에서 설명하니 조금만 기다려주세요.
핵심 포인트
- 사용자의 로그인 요청을 처리하는 Spring Security Filter는 UsernamePasswordAuthenticationFilter이다.
- UsernamePasswordAuthenticationToken은 Authentication 인터페이스를 구현한 구현 클래스이며, 여기에서 Authentication은 ⭐ **아직 인증이 되지 않은 Authentication**을 의미한다.
- AuthenticationManager는 인증 처리를 총괄하는 매니저 역할을 하는 인터페이스이고, AuthenticationManager를 구현한 구현 클래스가 ProviderManager이다.
- UserDetails는 데이터베이스 등의 저장소에 저장된 사용자의 Username과 사용자의 자격을 증명해 주는 크리덴셜(Credential)인 Password, 그리고 사용자의 권한 정보를 포함하고 있는 컴포넌트이다.
- UserDetails를 제공하는 컴포넌트가 바로 UserDetailsService입니다.
- UserDetailsService는 데이터베이스 등의 저장소에서 사용자의 크리덴셜(Credential)을 포함한 사용자의 정보를 조회하여 AuthenticationProvider에게 제공한다.
- UsernamePasswordAuthenticationFilter가 생성하는 Authentication은 인증을 위해 필요한 사용자의 로그인 정보를 가지고 있지만, ⭐ AuthenticationProvider가 생성한 Authentication은 인증에 성공한 사용자의 정보(Principal, Credential, GrantedAuthorities)를 가지고 있다.
- 인증된 Authentication을 전달받은 UsernamePasswordAuthenticationFilter는 SecurityContextHolder를 이용해 SecurityContext에 인증된 Authentication을 저장한다. SecurityContext는 이후에 HttpSession에 저장되어 사용자의 인증 상태를 유지한다.
심화 학습
- Spring Security의 인증 처리 흐름을 조금 더 구체적으로 알아보고 싶다면 아래 링크를 참고하세요.
Spring Security의 인증 컴포넌트
이전 챕터에서 우리는 Spring Security의 인증 컴포넌트들이 유기적으로 연결되어 사용자의 인증을 처리하는 과정을 그림으로 자세히 살펴보았습니다.
이제 여러분들의 머릿속에는 Spring Security의 인증 컴포넌트를 이용한 인증 처리에 대한 큰 그림이 대략적으로나마 자리를 잡았을 거라고 생각합니다.
이 흐름을 이어받아 [그림 4-16]에 등장하는 Spring Security에서 지원하는 인증 컴포넌트들의 내부 코드를 들여다보면서 Spring Security의 인증 처리 흐름을 확실히 여러분들 것으로 만들어 봅시다.
[그림 4-16] Spring Security의 컴포넌트로 보는 인증(Authentication) 처리 흐름
이전 챕터에서 살펴보았던 Spring Security의 인증 처리 흐름을 한 번 더 확인하세요.
이제 [그림 4-16]에서 사용된 Spring Security 인증 컴포넌트 간의 인터랙션을 코드를 통해 이해해 보겠습니다.
✅ UsernamePasswordAuthenticationFilter
[그림 4-16]에서 사용자의 로그인 request를 제일 먼저 만나는 컴포넌트는 바로 Spring Security Filter Chain의 UsernamePasswordAuthenticationFilter입니다.
UsernamePasswordAuthenticationFilter는 일반적으로 로그인 폼에서 제출되는 Username과 Password를 통한 인증을 처리하는 Filter입니다.
UsernamePasswordAuthenticationFilter는 클라이언트로부터 전달받은 Username과 Password를 Spring Security가 인증 프로세스에서 이용할 수 있도록 UsernamePasswordAuthenticationToken을 생성합니다.
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { // (1)
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; // (2)
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; // (3)
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login","POST"); // (4)
...
...
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager); // (5)
}
// (6)
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// (6-1)
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
...
String password = obtainPassword(request);
...
// (6-2)
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
...
return this.getAuthenticationManager().authenticate(authRequest); // (6-3)
}
...
...
}
[코드 4-42] UsernamePasswordAuthenticationFilter의 코드 일부
코드 4-42는 UsernamePasswordAuthenticationFilter 클래스의 코드 일부입니다.
UsernamePasswordAuthenticationFilter 클래스의 내부 코드를 분석해 보면서 UsernamePasswordAuthenticationFilter 클래스가 어떤 역할을 하는지 살펴보겠습니다.
- UsernamePasswordAuthenticationFilter는 (1)과 같이 AbstractAuthenticationProcessingFilter를 상속합니다.Filter가 Filter의 역할을 하기 위해서는 doFilter() 메서드가 있어야 할 텐데 없다는 것은 말이 안 됩니다. 분명 어딘가에 있을 텐데 우리 눈에 보이지 않을 뿐입니다.바로 상위 클래스인 AbstractAuthenticationProcessingFilter 클래스가 doFilter() 메서드를 포함하고 있습니다.
- 결과적으로 사용자의 로그인 request를 제일 먼저 전달받는 클래스는 UsernamePasswordAuthenticationFilter의 상위 클래스인 AbstractAuthenticationProcessingFilter 클래스인 것입니다. (AbstractAuthenticationProcessingFilter 클래스에 대해서는 잠시 뒤에 살펴보겠습니다.)
- doFilter() 메서드는 어디 있을까요?
- UsernamePasswordAuthenticationFilter 클래스의 이름이 Filter로 끝나지만 UsernamePasswordAuthenticationFilter 클래스에는 doFilter() 메서드가 존재하지 않습니다.
- (2)와 (3)을 통해 클라이언트의 로그인 폼을 통해 전송되는 request parameter의 디폴트 name은 username과 password라는 것을 알 수 있습니다.
- (4)의 AntPathRequestMatcher는 클라이언트의 URL에 매치되는 매처입니다.(4)에서 생성되는 AntPathRequestMatcher의 객체(DEFAULT_ANT_PATH_REQUEST_MATCHER)는 (5)에서 상위 클래스인 AbstractAuthenticationProcessingFilter 클래스에 전달되어 Filter가 구체적인 작업을 수행할지 특별한 작업 없이 다른 Filter를 호출할지 결정하는 데 사용됩니다.
- (4)를 통해 클라이언트의 URL이 "/login"이고, HTTP Method가 POST일 경우 매치될 거라는 사실을 예측할 수 있습니다.
- (5)에서 AntPathRequestMatcher의 객체(DEFAULT_ANT_PATH_REQUEST_MATCHER)와 AuthenticationManager를 상위 클래스인 AbstractAuthenticationProcessingFilter에 전달합니다.
- (6)의 attemptAuthentication() 메서드는 메서드 이름에서도 알 수 있듯이 클라이언트에서 전달한 username과 password 정보를 이용해 인증을 시도하는 메서드입니다.
- (6-1)에서 HTTP Method가 POST가 아니면 Exception을 throw한다는 사실을 알 수 있습니다.
- (6-2)에서는 클라이언트에서 전달한 username과 password 정보를 이용해 UsernamePasswordAuthenticationToken을 생성합니다.
- (6-3)에서 AuthenticationManager의 authenticate() 메서드를 호출해 인증 처리를 위임하는 것을 볼 수 있습니다.
- ⭐ attemptAuthentication() 메서드는 상위 클래스인 AbstractAuthenticationProcessingFilter의 doFilter() 메서드에서 호출되는데 Filter에서 어떤 처리를 하는 시작점은 doFilter()라는 사실을 명심하기 바랍니다.
✅ AbstractAuthenticationProcessingFilter
AbstractAuthenticationProcessingFilter 클래스는 UsernamePasswordAuthenticationFilter가 상속하는 상위 클래스로써 Spring Security에서 제공하는 Filter 중 하나입니다.
⭐ AbstractAuthenticationProcessingFilter는 HTTP 기반의 인증 요청을 처리하지만 실질적인 인증 시도는 하위 클래스에 맡기고, 인증에 성공하면 인증된 사용자의 정보를 SecurityContext에 저장하는 역할을 합니다.
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
...
...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
// (1)
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// (1-1)
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response); // (1-2)
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult); // (1-3)
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed); // (1-4)
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
// (2)
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (this.requiresAuthenticationRequestMatcher.matches(request)) {
return true;
}
if (this.logger.isTraceEnabled()) {
this.logger
.trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher));
}
return false;
}
...
...
// (3)
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
// (4)
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
this.rememberMeServices.loginFail(request, response);
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
...
...
}
[코드 4-43] AbstractAuthenticationProcessingFilter 클래스의 코드 일부
코드 4-43은 AbstractAuthenticationProcessingFilter 클래스의 코드 일부입니다.
코드의 설명은 다음과 같습니다.
- (1)을 통해 AbstractAuthenticationProcessingFilter 클래스가 Spring Security의 Filter임을 알 수 있습니다.
- (1-1)에서는 AbstractAuthenticationProcessingFilter 클래스가 인증 처리를 해야 하는지 아니면 다음 Filter를 호출할지 여부를 결정하고 있습니다.
- (1-2)에서는 하위 클래스에 인증을 시도해 줄 것을 요청하고 있습니다. 여기에서 하위 클래스는 코드 4-42의 UsernamePasswordAuthenticationFilter가 됩니다.
- (1-3)에서는 인증에 성공하면 처리할 동작을 수행하기 위해 successfulAuthentication() 메서드를 호출합니다.
- 만약 인증에 실패한다면 (1-4)와 같이 unsuccessfulAuthentication() 메서드를 호출해 인증 실패 시 처리할 동작을 수행합니다.
- 우리가 코드 4-42의 (4)에서 설명한 AntPathRequestMatcher("/login","POST")의 파라미터인 URL과 HTTP Method가 매칭 조건이 된다는 것을 기억하세요.
✅ UsernamePasswordAuthenticationToken
⭐ UsernamePasswordAuthenticationToken은 Spring Security에서 Username/Password로 인증을 수행하기 위해 필요한 토큰이며, 또한 인증 성공 후 인증에 성공한 사용자의 인증 정보가 UsernamePasswordAuthenticationToken에 포함되어 Authentication 객체 형태로 SecurityContext에 저장됩니다.
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
...
private final Object principal;
private Object credentials;
...
...
// (1)
public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
return new UsernamePasswordAuthenticationToken(principal, credentials);
}
// (2)
public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}
...
...
}
[코드 4-44] UsernamePasswordAuthenticationToken 클래스의 코드 일부
코드 4-44는 UsernamePasswordAuthenticationToken 클래스의 코드 일부입니다.
UsernamePasswordAuthenticationToken 클래스의 코드는 어렵지 않습니다.
UsernamePasswordAuthenticationToken은 두 개의 필드를 가지고 있는데 principal은 Username 등의 신원을 의미하고, credentials는 Password를 의미합니다.
⭐ (1)의 unauthenticated() 메서드는 인증에 필요한 용도의 UsernamePasswordAuthenticationToken 객체를 생성하고, (2)의 authenticated() 메서드는 인증에 성공한 이후 SecurityContext에 저장될 UsernamePasswordAuthenticationToken 객체를 생성합니다.
✅ Authentication
Authentication은 Spring Security에서의 인증 자체를 표현하는 인터페이스입니다.
우리가 앞에서 UsernamePasswordAuthenticationToken의 코드를 살펴보았는데, UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken 추상 클래스를 상속하는 확장 클래스이자 Authentication 인터페이스의 메서드 일부를 구현하는 구현 클래스이기도 합니다.
애플리케이션의 코드상에서 인증을 위해 생성되는 인증 토큰 또는 인증 성공 후 생성되는 토큰은 UsernamePasswordAuthenticationToken과 같은 하위 클래스의 형태로 생성되지만 생성된 토큰을 리턴 받거나 SecurityContext에 저장될 경우에 Authentication 형태로 리턴 받거나 저장됩니다.
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
[코드 4-44-1] Authentication 인터페이스의 코드
코드 4-44-1은 Authentication 인터페이스의 코드입니다.
코드 4-44-1에서 확인할 수 있다시피 Authentication 인터페이스를 구현하는 클래스는 다음과 같은 정보를 가지고 있습니다.
- Principal
- Principal은 사용자를 식별하는 고유 정보입니다.
- UserDetails에 대해서는 뒤에서 다시 알아보겠습니다.
- Credentials
- 사용자 인증에 필요한 Password를 의미하며 인증이 이루어지고 난 직후, ProviderManager가 해당 Credentials를 삭제합니다.
- Authorities
- AuthenticationProvider에 의해 부여된 사용자의 접근 권한 목록입니다. 일반적으로 GrantedAuthority 인터페이스의 구현 클래스는 SimpleGrantedAuthority입니다.
✅ AuthenticationManager
AuthenticationManager는 이름 그대로 인증 처리를 총괄하는 매니저 역할을 하는 인터페이스입니다.
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
[코드 4-45] AuthenticationManager 인터페이스
코드 4-45와 같이 AuthenticationManager에는 authenticate() 메서드 하나만 정의되어 있습니다.
인증을 위한 Filter는 AuthenticationManager를 통해 느슨한 결합을 유지하고 있으며, 인증을 위한 실질적인 관리는 AuthenticationManager를 구현하는 구현 클래스를 통해 이루어집니다.
✅ ProviderManager
AuthenticationManager를 구현하는 것은 어떤 클래스이든 가능하지만 Spring Security에서 AuthenticationManager 인터페이스의 구현 클래스라고 하면 일반적으로 ProviderManager를 가리킵니다.
ProviderManager는 이름에서 유추할 수 있듯이 AuthenticationProvider를 관리하고, AuthenticationProvider에게 인증 처리를 위임하는 역할을 합니다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
...
...
// (1)
public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) {
Assert.notNull(providers, "providers list cannot be null");
this.providers = providers;
this.parent = parent;
checkState();
}
...
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
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();
// (2)
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication); // (3)
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication);
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
...
...
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
((CredentialsContainer) result).eraseCredentials(); // (4)
}
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
...
...
}
...
...
}
[코드 4-46] ProviderManager 클래스 코드 일부
코드 4-46은 ProviderManager 클래스의 코드 일부입니다.
코드의 설명은 다음과 같습니다.
- (1)에서 ProviderManager 클래스가 Bean으로 등록 시, List<AuthenticationProvider> 객체를 DI 받는다는 것을 알 수 있습니다.
- DI 받은 List<AuthenticationProvider>를 이용해 (2)와 같이 for문으로 적절한 AuthenticationProvider를 찾습니다.
- 적절한 AuthenticationProvider를 찾았다면 (3)과 같이 해당 AuthenticationProvider에게 인증 처리를 위임합니다.
- 인증이 정상적으로 처리되었다면 (4)와 같이 인증에 사용된 Credentials를 제거합니다.
✅ AuthenticationProvider
AuthenticationProvider는 AuthenticationManager로부터 인증 처리를 위임받아 실질적인 인증 수행을 담당하는 컴포넌트입니다.
Username/Password 기반의 인증 처리는 DaoAuthenticationProvider가 담당하고 있으며, DaoAuthenticationProvider는 UserDetailsService로부터 전달받은 UserDetails를 이용해 인증을 처리합니다.
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { // (1)
...
...
private PasswordEncoder passwordEncoder;
...
...
// (2)
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); // (2-1)
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
// (3)
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { // (3-1)
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
...
...
}
[코드 4-47] DaoAuthenticationProvider 클래스 코드 일부
코드 4-47은 AuthenticationProvider 인터페이스의 구현 클래스를 확장하는 DaoAuthenticationProvider 클래스의 코드 일부입니다.
코드 설명은 다음과 같습니다.
- (1)을 보면 DaoAuthenticationProvider는 AbstractUserDetailsAuthenticationProvider를 상속하는 것을 확인할 수 있습니다.⭐ 따라서 AbstractUserDetailsAuthenticationProvider 추상 클래스의 authenticate() 메서드에서부터 실질적인 인증 처리가 시작된다는 사실을 기억하기 바랍니다.
- ⭐ AuthenticationProvider 인터페이스의 구현 클래스는 AbstractUserDetailsAuthenticationProvider이고, DaoAuthenticationProvider는 AbstractUserDetailsAuthenticationProvider를 상속한 확장 클래스입니다.
- (2)의 retrieveUser() 메서드는 UserDetailsService로부터 UserDetails를 조회하는 역할을 합니다. 조회된 UserDetails는 사용자를 인증하는 데 사용될 뿐만 아니라 인증에 성공할 경우, 인증된 Authentication 객체를 생성하는 데 사용됩니다.
- (2-1)의 this.getUserDetailsService().loadUserByUsername(username); 에서 UserDetails를 조회하는 것을 확인할 수 있습니다.
- (3)의 additionalAuthenticationChecks() 메서드에서 PasswordEncoder를 이용해 사용자의 패스워드를 검증하고 있습니다.
- (3-1)에서 클라이언트로부터 전달받은 패스워드와 데이터베이스에서 조회한 패스워드가 일치하는지 검증하고 있는 것을 확인할 수 있습니다.
DaoAuthenticationProvider와 AbstractUserDetailsAuthenticationProvider의 코드를 이해하기 위해서는 메서드가 호출되는 순서가 중요합니다.
두 클래스가 번갈아 가면서 호출되기 때문에 로직을 이해하기 쉽지 않을 수 있으므로 메서드가 호출되는 순서를 간략하게 정리했으니 참고하기 바랍니다.
- AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드 호출
- DaoAuthenticationProvider의 retrieveUser() 메서드 호출
- DaoAuthenticationProvider의 additionalAuthenticationChecks() 메서드 호출
- DaoAuthenticationProvider의 createSuccessAuthentication() 메서드 호출
- AbstractUserDetailsAuthenticationProvider의 createSuccessAuthentication() 메서드 호출
- 인증된 Authentication을 ProviderManager에게 리턴
✅ UserDetails
UserDetails는 데이터베이스 등의 저장소에 저장된 사용자의 Username과 사용자의 자격을 증명해 주는 크리덴셜(Credential)인 Password 그리고 사용자의 권한 정보를 포함하는 컴포넌트이며, AuthenticationProvider는 UserDetails를 이용해 자격 증명을 수행합니다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); // (1) 권한 정보
String getPassword(); // (2) 패스워드
String getUsername(); // (3) Username
boolean isAccountNonExpired(); // (4)
boolean isAccountNonLocked(); // (5)
boolean isCredentialsNonExpired(); // (6)
boolean isEnabled(); // (7)
}
[코드 4-48] UserDetails 인터페이스 코드
코드 4-48은 UserDetails 인터페이스의 코드입니다.
UserDetails 인터페이스는 사용자의 권한 정보(1), 패스워드(2), Username(3)을 포함하고 있으며, 사용자 계정의 만료 여부(4) 사용자 계정의 lock 여부(5), Credentials(Password)의 만료 여부(6), 사용자의 활성화 여부(7)에 대한 정보를 포함하고 있습니다.
✅ UserDetailsService
UserDetailsService는 UserDetails를 로드(load)하는 핵심 인터페이스입니다.
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
[코드 4-49] UserDetailsService 인터페이스 코드
코드 4-49에서 확인할 수 있는 것처럼 UserDetailsService는 loadUserByUsername(String username) 메서드 하나만 정의하고 있으며, UserDetailsService를 구현하는 클래스는 loadUserByUsername(String username)을 통해 사용자의 정보를 로드합니다.
이전 챕터에서도 언급했듯이 사용자의 정보를 어디에서 로드하는지는 애플리케이션에서 사용자의 정보를 어디에서 관리하고 있는지에 따라서 달라집니다.
⭐ 즉, 사용자의 정보를 메모리에서 로드하든 데이터베이스에서 로드하든 Spring Security가 이해할 수 있는 UserDetails로 리턴 해주기만 하면 된다는 사실을 기억하기 바랍니다.
✅ SecurityContext와 SecurityContextHolder
SecurityContext는 인증된 Authentication 객체를 저장하는 컴포넌트이고, SecurityContextHolder는 SecurityContext를 관리하는 역할을 담당합니다.
⭐ Spring Security 입장에서는 SecurityContextHolder에 의해 SecurityContext에 값이 채워져 있다면 인증된 사용자로 간주합니다.
[그림 4-17] SecurityContext와 SecurityContextHolder의 구조
[그림 4-17]은 SecurityContext와 SecurityContextHolder의 관계를 그림으로 표현한 것입니다.
그림을 보면 SecurityContext가 인증된 Authentication을 포함하고 있고, 이 SecurityContext를 다시 SecurityContextHolder가 포함하고 있는 것을 볼 수 있습니다.
⭐ [그림 4-17]과 같이 SecurityContextHolder가 SecurityContext를 포함하고 있는 것은 SecurityContextHolder를 통해 인증된 Authentication을 SecurityContext에 설정할 수 있고 또한 SecurityContextHolder를 통해 인증된 Authentication 객체에 접근할 수 있다는 것을 의미합니다.
public class SecurityContextHolder {
...
...
private static SecurityContextHolderStrategy strategy; // (1)
...
...
// (2)
public static SecurityContext getContext() {
return strategy.getContext();
}
...
...
// (3)
public static void setContext(SecurityContext context) {
strategy.setContext(context);
}
...
...
}
[코드 4-50] SecurityContextHolder 클래스의 코드 일부
코드 4-50은 SecurityContextHolder 클래스의 코드 일부이며, 코드의 설명은 다음과 같습니다.
- (1)은 SecurityContextHolder에서 사용하는 전략을 의미하며, SecurityContextHolder 기본 전략은 ThreadLocalSecurityContextHolderStrategy입니다.
- 이 전략은 현재 실행 스레드에 SecurityContext를 연결하기 위해 ThreadLocal을 사용하는 전략입니다.
- (2)의 getContext() 메서드를 통해 현재 실행 스레드에서 SecurityContext를 얻을 수 있습니다.
- (3)의 setContext() 메서드는 현재 실행 스레드에 SecurityContext를 연결합니다. setContext()는 대부분 인증된 Authentication을 포함한 SecurityContext를 현재 실행 스레드에 연결하는 데 사용됩니다.
ThreadLocal
ThreadLocal은 스레드 간에 공유되지 않는 스레드 고유의 로컬 변수 같은 영역을 말합니다. ThreadLocal에 대해서 더 알아보고 싶다면 아래의 [심화 학습]을 참고하세요.
핵심 포인트
- UsernamePasswordAuthenticationFilter는 클라이언트로부터 전달받은 Username과 Password를 Spring Security가 인증 프로세스에서 이용할 수 있도록 UsernamePasswordAuthenticationToken을 생성한다.
- AbstractAuthenticationProcessingFilter는 HTTP 기반의 인증 요청을 처리하지만 실질적인 인증 시도는 하위 클래스에 맡기고, 인증에 성공하면 인증된 사용자의 정보를 SecurityContext에 저장하는 역할을 한다.
- Authentication은 Spring Security에서의 인증 자체를 표현하는 인터페이스이다.
- AuthenticationManager는 이름 그대로 인증 처리를 총괄하는 매니저 역할을 하는 인터페이스이며, 인증을 위한 실질적인 관리는 AuthenticationManager를 구현하는 구현 클래스를 통해 이루어진다.
- ProviderManager는 이름에서 유추할 수 있듯이 AuthenticationProvider를 관리하고, AuthenticationProvider에게 인증 처리를 위임하는 역할을 한다.
- AuthenticationProvider는 AuthenticationManager로부터 인증 처리를 위임받아 실질적인 인증 수행을 담당하는 컴포넌트이다.
- UserDetails는 데이터베이스 등의 저장소에 저장된 사용자의 Username과 사용자의 자격을 증명해 주는 크리덴셜(Credential)인 Password 그리고 사용자의 권한 정보를 포함하는 컴포넌트이며, AuthenticationProvider는 UserDetails를 이용해 자격 증명을 수행한다.
- UserDetailsService는 UserDetails를 로드(load)하는 핵심 인터페이스이다.
- SecurityContext는 인증된 Authentication 객체를 저장하는 컴포넌트이고, SecurityContextHolder는 SecurityContext를 관리하는 역할을 담당한다.
심화 학습
- AbstractAuthenticationProcessingFilter에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- ProviderManager에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- AuthenticationProvider에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- SecurityContextHolder에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- ThreadLocal에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
Chapter - Spring Security 권한 부여 구성요소 이해
이전 챕터에서 우리는 잘 보호된 애플리케이션을 개발하기 위해서 반드시 익혀야 하는 중요한 보안 요소 두 가지 중 인증(Authentication)에 대해서 살펴보았습니다.
그런데 애플리케이션의 서비스를 이용하기 위한 사용자 인증에 성공했다 하더라도 우리가 체크해야 할 또 하나의 중요한 보안 요소가 있는데 그것은 바로 권한 부여(Authorization, 인가)입니다.
권한 부여는 한마디로 인증에 성공한 사용자라 할지라도 부여된 권한 범위 내에서 애플리케이션의 리소스에 접근할 수 있어야 함을 의미합니다.
이번 챕터에서는 Spring Security에서 사용자 인증 시 부여받은 권한을 기준으로 애플리케이션 리소스에 대한 접근을 어떻게 허가 또는 제한하는지 그 과정을 자세하게 살펴보도록 하겠습니다.
학습 목표
- Spring Security의 권한 부여(Authorization, 인가) 흐름을 이해할 수 있다.
- Spring Security에서 권한 부여와 관련된 컴포넌트의 역할을 이해할 수 있다.
Spring Security의 권한 부여 처리 흐름
Spring Security Filter Chain에 도달한 사용자의 인증 요청을 처리하기 위한 작업이 수행된 후, 인증된 사용자임을 확인했다고 가정합시다.
인증된 사용자는 이제 애플리케이션에서 제공하는 리소스를 마음대로 이용할 수 있는 걸까요?
단순히 인증에만 성공했다고 해서 모든 리소스에 접근할 수 있다면 권한 부여(인가, Authorization)라는 중요한 보안 요소를 무시하는 것입니다.
이번 시간에는 사용자의 인증 요청이 정상적으로 처리되어 인증된 사용자임이 확인된 이후, Spring Security에서 인증된 사용자에게 어떤 과정을 거쳐 애플리케이션 리소스에 대한 접근 권한을 부여하는지 그 흐름을 이해해 보도록 하겠습니다.
Spring Security의 컴포넌트로 보는 권한 부여(Authorization) 처리 흐름
[그림 4-18] Spring Security의 컴포넌트로 보는 권한 부여(Authorization) 처리 흐름
[그림 4-18]을 통해 사용자가 로그인 인증에 성공한 이후, Spring Security에서 인증된 사용자에게 어떻게 권한을 부여하는지 그 처리 흐름을 알 수 있습니다.
- Spring Security Filter Chain에서 URL을 통해 사용자의 액세스를 제한하는 권한 부여 Filter는 바로 AuthorizationFilter입니다.
- AuthorizationFilter는 먼저 (1)과 같이 SecurityContextHolder로부터 Authentication을 획득합니다.
- 그리고 (2)와 같이 SecurityContextHolder로부터 획득한 Authentication과 HttpServletRequest를 AuthorizationManager에게 전달합니다.
- AuthorizationManager는 권한 부여 처리를 총괄하는 매니저 역할을 하는 인터페이스이고, RequestMatcherDelegatingAuthorizationManager는 AuthorizationManager를 구현하는 구현체 중 하나입니다.⭐ RequestMatcherDelegatingAuthorizationManager가 직접 권한 부여 처리를 하는 것이 아니라 RequestMatcher를 통해 매치되는 AuthorizationManager 구현 클래스에게 위임만 한다는 사실을 기억하기 바랍니다.
- RequestMatcherDelegatingAuthorizationManager는 RequestMatcher 평가식을 기반으로 해당 평가식에 매치되는 AuthorizationManager에게 권한 부여 처리를 위임하는 역할을 합니다.
- RequestMatcherDelegatingAuthorizationManager 내부에서 매치되는 AuthorizationManager 구현 클래스가 있다면 해당 AuthorizationManager 구현 클래스가 사용자의 권한을 체크합니다(3).
- 적절한 권한이라면 (4)와 같이 다음 요청 프로세스를 계속 이어갑니다.
- 만약 적절한 권한이 아니라면 (5)와 같이 AccessDeniedException이 throw되고 ExceptionTranslationFilter가 AccessDeniedException을 처리하게 됩니다.
Spring Security 5.5 이전 버전까지는 FilterSecurityInterceptor를 통한 권한 부여 처리 과정이 굉장히 복잡해서 권한 부여가 어떻게 처리되는지 이해하기가 쉽지 않았지만 Spring Security 5.5 버전부터 AuthorizationFilter를 통해 간결한 AuthorizationManager API를 이용해서 권한 부여 처리를 할 수 있게 되었습니다.
따라서 우리 코스에서는 metadata sources, config attributes, decision managers, voters 등을 이용한 권한 부여 처리 과정에 대해서는 학습하지 않음을 참고하세요.
Spring Security의 권한 부여 컴포넌트
그렇다면 이번에는 [그림 4-18]에서 확인할 수 있는 Spring Security의 몇 가지 권한 부여 컴포넌트들의 내부 코드를 들여다보면서 Spring Security의 권한 부여 흐름을 조금 더 구체적으로 확인해 봅시다.
✅ AuthorizationFilter
AuthorizationFilter는 URL을 통해 사용자의 액세스를 제한하는 권한 부여 Filter이며, Spring Security 5.5 버전부터 FilterSecurityInterceptor를 대체합니다.
public class AuthorizationFilter extends OncePerRequestFilter {
private final AuthorizationManager<HttpServletRequest> authorizationManager;
...
...
// (1)
public AuthorizationFilter(AuthorizationManager<HttpServletRequest> authorizationManager) {
Assert.notNull(authorizationManager, "authorizationManager cannot be null");
this.authorizationManager = authorizationManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request); // (2)
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
filterChain.doFilter(request, response);
}
...
...
}
[코드 4-51] AuthorizationFilter 코드 일부
코드 4-51은 AuthorizationFilter의 코드 일부이며, 코드의 설명은 다음과 같습니다.
- (1)과 같이 AuthorizationFilter 객체가 생성될 때, AuthorizationManager를 DI 받는 것을 확인할 수 있습니다.
- DI 받은 AuthorizationManager를 통해 권한 부여 처리를 진행합니다.
- (2)와 같이 DI 받은 AuthorizationManager의 check() 메서드를 호출해 적절한 권한 부여 여부를 체크합니다.URL 기반으로 권한 부여 처리를 하는 AuthorizationFilter는 AuthorizationManager의 구현 클래스로 RequestMatcherDelegatingAuthorizationManager를 사용합니다.
- AuthorizationManager의 check() 메서드는 AuthorizationManager 구현 클래스에 따라 권한 체크 로직이 다릅니다.
✅ AuthorizationManager
AuthorizationManager는 이름 그대로 권한 부여 처리를 총괄하는 매니저 역할을 하는 인터페이스입니다.
@FunctionalInterface
public interface AuthorizationManager<T> {
...
...
@Nullable
AuthorizationDecision check(Supplier<Authentication> authentication, T object);
}
[코드 4-52] AuthorizationManager 코드 일부
AuthorizationManager 인터페이스는 check() 메서드 하나만 정의되어 있으며, Supplier<Authentication>와 제너릭 타입의 객체를 파라미터로 가집니다.
✅ RequestMatcherDelegatingAuthorizationManager
RequestMatcherDelegatingAuthorizationManager는 AuthorizationManager의 구현 클래스 중 하나이며, 직접 권한 부여 처리를 수행하지 않고 RequestMatcher를 통해 매치되는 AuthorizationManager 구현 클래스에게 권한 부여 처리를 위임합니다.
public final class RequestMatcherDelegatingAuthorizationManager implements AuthorizationManager<HttpServletRequest> {
...
...
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, HttpServletRequest request) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Authorizing %s", request));
}
// (1)
for (RequestMatcherEntry<AuthorizationManager<RequestAuthorizationContext>> mapping : this.mappings) {
RequestMatcher matcher = mapping.getRequestMatcher(); // (2)
MatchResult matchResult = matcher.matcher(request);
if (matchResult.isMatch()) { // (3)
AuthorizationManager<RequestAuthorizationContext> manager = mapping.getEntry();
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Checking authorization on %s using %s", request, manager));
}
return manager.check(authentication,
new RequestAuthorizationContext(request, matchResult.getVariables()));
}
}
this.logger.trace("Abstaining since did not find matching RequestMatcher");
return null;
}
}
[코드 4-53] RequestMatcherDelegatingAuthorizationManager 클래스의 코드 일부
코드 4-53은 RequestMatcherDelegatingAuthorizationManager 클래스의 코드 일부이며, 코드의 설명은 다음과 같습니다.
- check() 메서드의 내부에서 (1)과 같이 루프를 돌면서 RequestMatcherEntry 정보를 얻은 후에 (2)와 같이 RequestMatcher 객체를 얻습니다.
- (3)과 같이 MatchResult.isMatch()가 true이면 AuthorizationManager 객체를 얻은 뒤, 사용자의 권한을 체크합니다.
- ⭐ 여기서의 RequestMatcher는 SecurityConfiguration에서 .antMatchers("/orders/**").hasRole("ADMIN") 와 같은 메서드 체인 정보를 기반으로 생성된다는 사실을 기억하기 바랍니다.
핵심 포인트
- AuthorizationFilter는 URL을 통해 사용자의 액세스를 제한하는 권한 부여 Filter이며, Spring Security 5.5 버전부터 FilterSecurityInterceptor를 대체한다.
- AuthorizationManager는 이름 그대로 권한 부여 처리를 총괄하는 매니저 역할을 하는 인터페이스이다.
- RequestMatcherDelegatingAuthorizationManager는 AuthorizationManager의 구현 클래스 중 하나이며, 직접 권한 부여 처리를 수행하지 않고 RequestMatcher를 통해 매치되는 AuthorizationManager 구현 클래스에게 권한 부여 처리를 위임한다.
- RequestMatcher는 SecurityConfiguration에서 .antMatchers("/orders/**").hasRole("ADMIN") 와 같은 메서드 체인 정보를 기반으로 생성된다.
심화 학습
- AuthorizationFilter에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- AuthorizationManager에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- RequestMatcherDelegatingAuthorizationManager에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- [ExceptionTranslationFilter](<https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-exceptiontranslationfilter>)의 처리 흐름에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
[심화 학습] 접근 제어 표현식
Spring Security에서는 웹 및 메서드 보안을 위해 표현식(Spring EL)을 사용할 수 있으며, Spring Security에서 지원하는 표현식에 대한 설명은 표 4-1과 같습니다.
[표 4-1] Spring Security에서 지원하는 표현식(Spring EL)