SpringSecurity
는 스프링 기반 웹 애펄리케이션의 인증(Authentication)
과 인가(Authorization)
기능을 가진 프레임워크 입니다.
스프링 기반 애플리케이션의 보안 표준 이며 현재 웹 애플리케이션에서 많이 사용중입니다.
확장성에 유리하며 추가와 변경을 다양하게 수행할 수 있습니다.
Spring Security를 사용하기 위해선 다른 하위 프레임 워크와 마찬가지로 build.gradle
외부 의존 라이브러리를 추가합니다.
dependencies {
annotationProcessor 'org.projecctlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-security
implementation 'org.springframework.boot:spring-boot-starter-mustache'
...
}
security
와 mustache
를 추가하여 해당 라이브러리를 사용할 수 있게 되었습니다.
이외에도 h2
, Spring Data JPA
, web
, lombok
을 추가합니다.
h2
와 jpa
사용시 그래왔던 것 처럼 application.yml
에 설정을 추가합니다.
spring:
h2:
console:
enabled: true
path: /h2
datasource:
url: jdbc:h2:mem:test
jpa:
hibernate:
ddl-auto: create
show-sql: true
스프링 시큐리티 라이브러리를 추가하고 웹 애플리케이션을 실행시키면
애프리케이션으로 로그인과 로그아웃이 가능합니다. Username
은 user
, Password
는 콘솔창의 비밀번호를 입력시 로그인 가능합니다.
@Controller
public class 컨트롤러{
@GetMapping("/")
public @ResponseBody String index() {
return "index";
}
@GetMapping("/join")
public @ResponseBody String join(){
return "join";
}
..
}
@ResponseBody
는 핸들러메서드의 반환값을 단순히 resposneBody에 포함시킵니다.
@Configuration
@EnableWebSecurity
public class 시큐리티컨픽{
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
http.authorizeRequests()
.antMatchers("/경로1/**").authenticated()
.antMatchers("/경로2/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
.anyRequest().permitAll()
.and()
.formLogin()
.loginPage("/login");
return http.build();
}
}
http.csrf().disable()
: HTML의 form태그로만 요청이 가능합니다. 다른 경로의 요청은 거절하게 됩니다.
http.headers().frameOptions().disable()
: h2를 연결할 때 필요합니다.
HttpSecurity.authorizeRequest()
: 요청에 의한 보안검사를 시작합니다.
HttpSecurity.antMatchers("/경로1/**").authenticated()
: 인증 한 사용자들은 경로1의 모든 하위 리소스에 접근 가능합니다.
HttpSecurity.antMatchers("/경로2/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
: 메니저 권한과 어드민 권한을 가진 사용자는 경로2의 모든 하위 리소스에 접근이 가능합니다. 간혹 ROLE_ADMIN
이 아닌 ADMIN
만 사용 하는 경우도 존재하지만 스프링이 자동으로 ROLE_
을 추가해줘서 상관 없습니다.
anyRequest()
: .antMatchers()
에서 정의한 요청외 나머지 요청들을 지정합니다.
permitAll()
: 인증여부와 상관없이 모두 허가합니다.
만약 anyRequest().permitAll()로 나머지 요청들에 대한 접근 허가가 없다면 기본적으로 나머지 요청들은 모두 거부됩니다.
.and().formLogin().loginPage("/login")
: 권한이 없는 페이지에 접근하려 하면 자동으로 로그인페이지로 redirect 시켜 줍니다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer{
public void configureViewResolvers(ViewResolverRegistry registry){
MustacheViewResolver resolver = new MustacheViewResolver();
resolver.setCharset("UTF-8");
resolver.setContentType("text/html; charset=UTF-8");
resolver.setPrefix("classpath:/templates/");
resolver.setSuffix(".html");
registry.viewResolver(resolver);
}
}
먼저 ViewResolver
의 역할을알아야 합니다. 컨트롤러의 핸들러메서드는 ResopnseEntity
와같은 객체를 반환하거나 일반적인 String
을 반환합니다. 별도의 지정이 없다면 String을 반환시 해당 String 문자열에 해당하는 View객체를 찾아 뷰를 열어줍니다. 만일 보안이나 다른 옵션들을 직접 지정하고 싶다면 Viewresolver
를 개발자가 WebMvcConfigurer
를 implements하고 ConfigureViewResolvers(ViewResolverRegistry registry)
메서드를 오버라이딩 하여 정의합니다.
ViewResolverRegistry.setCharset("UTF-8")
: utf-8로 인코딩을 수행합니다.ViewResolverRegistry.setContentType("text/html; charset=UTF-8")
: 내용물을 HTML 형식으로 읽어들일 것인가를 결정합니다.ViewResolverRegistry.setPrefix()
: 헨들러 메서드가 반환하는 문자열 앞에 붙이는 문자열 입니다.ViewResolverRegistry.setSuffix()
: 핸들러 메서드가 반환하는 문자열 뒤에 붙히는 문자열 입니다..setPrefix("classpath:/templates/")
+ 핸들러 메서드 반환 문자열("login") + setSuffix(".html")
을 조합하여 스프링에서 뷰 객체를 찾게 됩니다.
classpath:/templates/login.html
과 같이 앞 뒤로 바로 붙어 해당 경로의 HTML문서를 반환합니다.(없으면 에러)
@Data
: @Getter
+ @Setter
+ @RequiredArgsConstructor
+ @ToString
+ @EqualsAndHashCode
입니다. 엔티티 클래스 작성시 유용합니다.회원 가입시 보안을 위하여 2차, 3차 피해를 막기 위해 서버에서 사용자의 패스워드를 저장할 시 해싱하여 만약 해커에 의해 DB가 탈취 당하더라도 원본 비밀번호를 알 수 없도록 해싱하여 비밀번호를 저장합니다.
스프링 시큐리티에서는 비밀번호를 고차원의 해시값으로 변환해주는 다양한 클래스들을 지원합니다.
public class 시큐리티컨픽{
...
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
}
@Configuration 존재하는 config클래스에 BCryptPasswordEncoder
클래스의 빈 객체를 스프링 컨테이너에 추가합니다.
public class 컨트롤러{
...
private final BcryptPasswordEncoder bCryptPasswordEncoder;
private final 레포지토리 레포지토리
public 컨트롤러(BcryptPasswordEncoder bCryptPasswordEncoder,
레포지토리 레포지토리){
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
this.레포지토리 = 레포지토리;
}
@PostMapping("/회원가입")
public class 회원가입(엔티티 엔티티){
String 원본비밀번호 = 엔티티.getPassword();
String 변환비밀번호 = bCryptPasswordEncoder.enocde(rawPassword);
엔티티.setPassword(변환비밀번호);
레포지토리.save(엔티티);
return "redirect:/로그인";
}
}
BCryptPasswordEncoder
빈 객체를 이용하여 엔티티의 원래 비밀번호를 해싱된 비밀번호로 변경하고 DB에 저장하였습니다.
bCryptPasswordEncoder.encode(비밀번호)
: 비밀번호를 암호화 합니다.return "redircet:/로그인"
: 현재 회원가입 페이지에 POST HTTP메서드를 보내면 처리 후 로그인 페이지로 돌아갑니다.UserDetails 인터페이스는 Spring Security에서 구현한 클래스를 사용자 정보로 인식하고 인증하는 작업을 합니다. UserDetails 인터페이스는 VO 역할을 합니다. 단순히 추상메서드들을 오버라이딩하여 필요한 정보를 넘겨 줄 수 있도록 합니다.
public class PrincipalDetails implements UserDetails{
private 엔티티 엔티티;
public PrincipalDetails(엔티티 엔티티){
this.엔티티 = 엔티티;
}
//오버라이딩
public Collection<? extends GrantedAuthority> getAuthorities(){
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority(){
public String getAuthority(){
return 엔티티.getRole();
}
});
}
public String getPassword(){
return 엔티티.getPassword();
}
public String getUsername(){
return 엔티티.getUsername();
}
public boolean isAccountNotExpired(){
return true;
}
public boolean isAccountNonLocked(){
return true;
}
public boolean isCredentialsNonExpried(){
return true;
}
public boolean isEnabled(){
return true;
}
}
메서드 | 설명 |
---|---|
getAuthorities() | 계정이 가진 권한 목록을 반환합니다. |
getPassword() | 계정의 비밀번호를 반환합니다. |
getUsername() | 계정의 이름을 반환합니다. |
isAccountNonExpired() | 계정이 만료되지 않았는 지 반환합니다.(true: 만료x) |
isAccountNonLocked() | 계정이 잠겨 있지 않았는 지 반환합니다.(true: 잠김x) |
isCredentialNonExpired() | 비밀번호가 만료되지 않았는 지 반환합니다.(true: 만료x) |
isEnabled() | 계정이 활성화 인지반환합니다.(true:활성화) |
@Service
public class Principal서비스 implements UserDetailsService{
@Autowired
private 레포지토리 레포지토리;
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
엔티티 엔티티 = 레포지토리.findByUsername(username);
if(엔티티 != null)
return new PrincipalDetails(엔티티);
return null;
}
}
public interface 레포지토리 implements JpaRepository<엔티티,ID타입>{
public 엔티티 findByUsername(String username);
}
DB에서 유저 정보를 불러오는 비즈니스 로직을 가지는 서비스를 UserDetailsService
인터페이스를 구현한 서비스 입니다.
loadUserByUsername()
메서드를 사용하여 DB로 부터 데이터를 읽어와 엔티티 객체를 만들고 엔티티 객체를 UserDetails
를 구현한 클래스로 다시한번 덫 데어 리턴합니다.
굳이 UserDetails
를 구현한 클래스, UserDetailsService
를 구현한 클래스로 따로 구현하여 리턴하는 이유는 SpringSecurity
의 보안
을 적용하기 위함입니다.
시큐리티컨픽에 에너테이션을 적용하여 설정정보 클래스의 SecurityFilterChain filterChain(HttpSecurity http)
빈 객체에서 뿐만 아니라 컨트롤러에서도 접근권한을 처리할 수 있도록 할 수있는 에너테이션을 지원합니다.
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class 시큐리티컨픽{
...
}
여기서 @EnableGlobalMethodSecurity
에너테이션의 securedEnabled = true
애트리뷰트는 @Secured
에너테이션을 prePostEnabled = true
애트리뷰트는 @PreAuthroize
, @PostAuthorize
애테너이션을 컨트롤러에서 사용 가능하게 설정합니다.
@Secured("ROLE_ADMIN")
@GetMapping("/리소스1")
public String 리소스1(){
return "리소스1";
}
@PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
@GetMapping("/리소스2")
public String 리소스2(){
return "리소스2";
}
@Secured("ROLE_ADMIN")
: .antMatchers("/리소스/**").access("hasRole('ROLE_ADMIN')")
를 SecurityFilterChain 빈 클래스에 추가하는 것과 동일합니다.@PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
: .antMatchers("/리소스2/**").access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
를 SecurityFilterChain 빈 클래스에 추가하는 것과 동일합니다.보통 @Secured
에너테이션은 1개의 권한을, @PreAuthorize
는 1개 이상의 권한을 부여할 때 사용합니다.
Spring Security는 servlet Filter
를 기반으로 서블릿을 지원합니다.
클라이언트가 요청을 하게 되면 Servlet Filter를 제일 먼저 거치고 인증
과 인가
에 대한 처리를 Filter에서 수행하고 난 후 DispatcherServlet에서 요청이 처리됩니다.
FilterChain
은 사슬처럼 여러개의 Filter
들이 연결되어 있고 서로 연결되어 동작합니다.
서블릿에는 하나의 단일 요청을 처리하지만, 필터는 체인을 형성하여 실제 요청을 순서대로 수행합니다.
순서는 2가지로 지정가능합니다. 첫번재로 @Order
에너테이션이나 Ordered
를 구현하는 것이고 다른 하나는 FilterRegistrationBean
의 일부가 되어 순서를 가집니다.
FilterChain은 서블릿 컨테이너에서 작동합니다. 만일 빈 객체로 만들어 스프링 컨테이너에서 관리되게 만들 수도 있습니다.
public class FirstFilter implements Filter{
public void init(FilterConfig filterConfig) throws ServletException{
Filter.super.init(filterConfig);
System.out.println("FirstFilter 생성됨");
}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("========First 필터 시작========");
chain.doFilter(request,response);
System.out.println("========First 필터 종료========");
}
public void destroy(){
System.out.println("FirstFilter 사라짐");
Filter.super.destroy();
}
}
public class SecondFilter implements Filter{
public void init(FilterConfig filterConfig) throws ServletException{
Filter.super.init(filterConfig);
System.out.println("SecondFilter가 생성됨");
}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("==========Second 필터 시작==========");
chain.doFilter(request, response);
System.out.println("==========Second 필터 종료==========");
}
public void destroy(){
System.out.println("SecondFilter가 사라집니다.")
Filter.super.destroy();
}
}
Filter
인터페이스를 구현한 클래스는 필터는 3개의 메서드를 오버라이딩 하여 사용합니다.
void init(FilterConfig)
: 서블릿 컨테이너에 필터가 적재되면서 실행됩니다.void doFilter(ServletRequest, ServletResponse, FilterChain)
: url로 요청이 오면 doFilter를 실행시킨 후 다음 필터를 실행합니다.void destroy()
: 서블릿 컨테이너에서 제거되면서 실행됩니다.@Configuration
public class 설정정보클래스{
@Bean
public FilterRegistrationBean<FirstFilter> firstFilterRegister() {
FilterRegistrationBean<FirstFilter> registrationBean = new FilterRegistrationBean<>(new FirstFilter());
registrationBean.setOrder(1);
return registrationBean;
}
@Baen
public FilterRegistrationBean<SecondFilter> secondFilterRegister(){
FilterRegistrationBean<SecondFilter> registrationBean = new FilterRegistrationBean<>(new SecondFilter());
registrationBean.setOrder(2);
return registrationBean;
}
}
FilterRegistrationBean<필터>
빈을 만들어 FilterChain에 Filter를 추가할 수 있습니다. 이때 FilterRegistrationBean<필터>.setOrder(순서)
를 지정하여 필터에서 몇번째로 실행 시킬 것인지 순서를 지정할 수도 있습니다.
스프링 시큐리티가 모든 애플리케이션 요청을 감싸게 해서 보안이 적용되게 하는 서블릿 필터입니다. 스프링 프레임워크의 스프링 컨테이너와 서블릿 컨테이너를 연계해 스프링 컨테이너의 빈 객체로 등록할 수 있게 합니다.
스프링 부트는 DelegatingFilterProxy라는 Filter구현체로 서블릿 컨테이너의 생명주기와 스프링의 스프링컨테이너를 연결합니다.
연결함으로써 서블릿 컨테이너 자체적 뿐만 아니라 모든 처리를 스프링 컨테이너의 Filter를 구현한 스프링 빈으로 위임해줍니다.
스프링 시큐리티가 제공하는 특별한 Filter로 SecurityFilterChain을 통해 여러 Filter인스턴스로 위임할 수 있습니다.
이해가 많이 어려운 부분입니다. 서블릿 컨테이너에 존재하는 FilterChainProxy
를 DelegatingFilterProxy
가 스프링 컨테이너의 SecurityFilterChain
에 등록하여 빈 객체로 사용될 수 있도록 합니다.
어떻게 가능하게 하면 FilterChainProxy
에 filterChain
이라는 변수에 SecurityFilterChain
의 리스트를 가지고 있으면 리스트마다 다시 필터들의 리스트를 가집니다.
이름에서 알 수 있듯이 패스워드엔코더는 비밀번호를 그대로 데이터베이스에 저장하기 보다 한번 해싱을 거쳐 데이터베이스에 저장하기 위해 사용합니다.
PasswordEncoder
들 중 DelegatingPasswordEncoder
를 사용하는 이유는 3가지가 있습니다.
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoder.put("pbkdf2",new Pbkdf2PasswordEncoder());
encoder.put("scrypt", new SCryptPasswordEncoder());
encoder.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
인스턴스를 반환 받을 수도 있고 아래와 같이 여러 인코딩 방식의 패스워드 인코더들중 선택하여 사용하는 객체를 생성할 수도 있습니다.
아래의 경우 BCryptPasswordEncoder
, NoOpPasswordEncoder
, Pbkdf2PasswordEncoder
, SCryptPasswordEncoder
, StandardPasswordEncoder
등 여러 비밀번호 인코더들을 해시맵에 저장하여 선택 할 수 있도록 합니다.
Spring은 범위가 매우 넓고 체득 하는데 역시 만만한 프레임워크가 아니구나 다시 한번 느끼고 있습니다. 특히나 보안 같은 경우는 엄격히 다루어져야 하니 더더욱 그런 것 같습니다. 이제 기초를 배우는데 열심히 따라가도록 하겠습니다.
https://github.com/ds02168/CodeStates_Spring/tree/main/section4-week1-FRI