2022/2022-1

[SpringBoot] Spring Security 처리 과정

JWonK 2022. 7. 13. 16:33
728x90
반응형

1. Spring Security 처리 과정


[Fig 1.] Spring Security Architecture

Spring Security의 처리 과정은 기본적으로 Session을 활용한 구현 방식이다. 후에 Token 기반 구현 방식도 공부하고 업로드 할 예정이다.

 

1. 사용자가 로그인 정보와 함께 인증 요청 (HttpRequest)

2. AuthenticationFilter가 요청을 가로챈다. 이 때 가로챈 정보를 통해 UsernamePasswordAuthenticationToken 객체(사용자가 입력한 데이터를 기반으로 생성, 즉 현상태는 미검증 Authentication) 생성

3. ProviderManager(AuthenticationManager 구현체)에게 UsernamePasswordAuthenticationToken 객체를 전달한다.

4. AuthenticationProviderUsernamePasswordAuthenticationToken 객체를 전달한다.

5. 실제 DB로부터 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다.

6. 넘겨받은 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 생성한다.

7. AuthenticationProviderUserDetails를 넘겨받고 사용자 정보를 비교한다.

8. 인증이 완료되면, 사용자 정보를 담은 Authentication 객체를 반환한다.

9. 최초의 AuthenticationFilterAuthentication 객체가 반환된다.

10. Authentication 객체를 SecurityContext에 저장한다.

 

여기서 주의깊게 살펴봐야 할 부분은 UserDetailsServiceUserDetails이다. 실질적인 인증 과정은 사용자가 입력한 데이터(ID, PW 등)와 UserDetailsServiceloadUserByUsername() 메서드가 반환하는 UserDetails 객체를 비교함으로써 동작한다. 따라서 UserDetailsServiceUserDetails 구현을 어떻게 하느냐에 따라서 인증의 세부과정이 달라진다.

 


[ 사전 세팅 ]

 

프로젝트 진행에 필요한 Dependency들을 build.gradle에 등록해준다.

plugins {
	id 'org.springframework.boot' version '2.7.1'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.mariadb.jdbc:mariadb-java-client'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

그리고 정적 자원을 제공하는 클래스를 생성하여 아래와 같이 설정한다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
            "classpath:/static/", "classpath:/public",
            "classpath:/resources/", "classpath:/META_INF/resources/",
            "classpath:/META-INF/resources/webjars/"
    };

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {

        // /에 해당하는 url mapping을 /common/test로 forward한다.
        registry.addViewController("/").setViewName("forward:/index");

        // 우선순위를 가장 높게 잡는다.
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {WebMvcConfigurer.super.addResourceHandlers(registry);
        registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
    }

}

WebMvcConfigurer를 잘 모른다면 어떻게 진행되는지 모를 수 있다.

 

 

Spring MVC에서 특정 요청 url에 대해 컨트롤러로 뷰를 리턴하는 경우 이렇게 코드를 작성할 수 있다.

@GetMapping("/hello")
public String hello(){
    return "hello";
}

 

이렇게 ViewController를 사용하여 뷰를 매핑하는 방식 대신 사용자가 직접 뷰를 등록하는 방식이 WebMvcConfigurer를 implements하는 클래스에서 addViewControllers()를 override해서 요청에 대한 뷰를 등록해주면 된다.

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    
    @Override
    public void addViewControllers(ViewControllerRegistry registry){
    	registry.addViewController("/hello").setViewName("hello");
    }
}

@GetMapping을 통해 뷰를 반환하는 방식과 결과는 동일하다.

 

아래의 addResourceHandlers를 통해 정적 리소스를 설정한다.

 

 

그리고 SpringSecurity에 대한 기본적인 설정들을 추가한다. SpringSecurity에 대한 설정 클래스에서는

  • configure 메소드를 통해 정적 자원들에 대해서는 Security를 적용하지 않음을 추가한다.
  • configure 메소드를 통해 어떤 요청에 대해서는 로그인을 요구하고, 어떤 요청에 대해서 로그인을 요구하지 않을지 설정한다.
  • form 기반의 로그인을 활용하는 경우 로그인 URL, 로그인 성공시 전달한 URL, 로그인 실패 URL 등에 대해 설정한다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                // /about 요청에 대해서는 로그인을 요구함
                .antMatchers("/about").authenticated()
                // /admin 요청에 대해서는 ROLE_ADMIN 역할을 가지고 있어야 함
                .antMatchers("/admin").hasRole("ADMIN")
                // 나머지 요청에 대해서는 로그인을 요구하지 않음
                .anyRequest().permitAll()
                .and()
                // 로그인하는 경우에 대해 설정함
            .formLogin()
                // 로그인 페이지를 제공하는 URL을 설정함
                .loginPage("/user/loginView")
                // 로그인 성공 URL을 설정함
                .successForwardUrl("/index")
                // 로그인 실패 URL을 설정함
                .failureForwardUrl("/index")
                .permitAll()
                .and()
                .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

- /about 경로에 대해서는 로그인이 성공한 경우에만 접근 가능

- /admin 경로에 대해서는 로그인 후 ROLE_ADMIN 권한을 가진 경우에만 접근 가능

 

 

 

 

[ 1. 로그인 요청 ]

사용자는 로그인을 위해 ID와 PW를 입력하여 로그인 요청을 하게 된다. 본 예제는 Form기반 로그인 요청이다.

 

 

 

 

[ 2. UserPasswordAuthenticationToken 발급 ]

로그인 정보와 함께 전송이 오면 AuthenticationFilter로 요청이 먼저 오게 되고, 아이디와 비밀번호를 기반으로 UserPasswordAuthenticationToken을 발급해주어야 한다. 프론트에서 이미 유효성 검사를 실시하겠지만, 보안을 위해 다시 한 번 ID, PW 정보에 대한 유효성 검사를 해주는 것이 좋다. (Null 여부) 아래 코드에서는 생략하며 해당 Filter를 구현하면 아래와 같다.

@Log4j2
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(request.getParameter("userEmail"), request.getParameter("userPw"));
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

}

attempAuthentication 메소드를 통해 UsernamePasswordAuthenticationToken을 발급 받는다. 아직 미검증 상태이며 setDetails에서는 서브 클래스가 인증 요청의 details 속성에 넣을 내용을 구성할 수 있도록 제공된다.

 

파라미터 :

request - 인증 요청이 생성되고 있음을 의미

authRequest - 세부 정보가 설정되어야 하는 인증 요청 객체를 나타낸다.

 

이렇게 직접 제작한 Filter를 이제 적용시켜야 하므로 UsernamePasswordAuthenticationFilter 필터 이전에 적용시켜야 한다.

그리고 해당 CustomAuthenticationFilter가 수행된 후에 처리될 Handler 역시 Bean으로 등록하고 CustomAuthenticationFilter의 핸들러로 추가해주어야 하는데, 해당 코드들은 WebSeurityConfig에 추가된다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 정적 자원에 대해서는 Security 설정을 적용하지 않음.
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                // /about 요청에 대해서는 로그인을 요구함
                .antMatchers("/about").authenticated()
                // /admin 요청에 대해서는 ROLE_ADMIN 역할을 가지고 있어야 함
                .antMatchers("/admin").hasRole("ADMIN")
                // 나머지 요청에 대해서는 로그인을 요구하지 않음
                .anyRequest().permitAll()
                .and()
                // 로그인하는 경우에 대해 설정함
            .formLogin()
                // 로그인 페이지를 제공하는 URL을 설정함
                .loginPage("/user/loginView")
                // 로그인 성공 URL을 설정함
                .successForwardUrl("/index")
                // 로그인 실패 URL을 설정함
                .failureForwardUrl("/index")
                .permitAll()
                .and()
                .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }
    
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        customAuthenticationFilter.setFilterProcessesUrl("/user/login");
        customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    @Bean
    public CustomLoginSuccessHandler customLoginSuccessHandler() {
        return new CustomLoginSuccessHandler();
    }
    
}

 

public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws ServletException, IOException {
        SecurityContextHolder.getContext().setAuthentication(authentication);
        response.sendRedirect("/about");
    }
}

CustomLoginSuccessHandler는 AuthenticationProvider를 통해 인증이 성공될 경우 처리되는데, 본 예제에서는 /about 요청으로 redirect되도록 하였다. 그리고 토큰을 사용하지 않고 세션을 사용하는 지금 예제 같은 경우에는 성공하여 반환된 Authentication 객체를 SecurityContextHolder의 Context에 저장해주어야 한다. 후에 사용자의 정보를 꺼내는 경우에도 SecurityContextHolder의 Context에서 조회하면 된다.

 

 

 

 

[ 3. UsernamePasswordToken을 Authentication Manager에게 전달 ]

AuthenticationFilter는 생성한 UsernamePasswordToken을 AuthenticationManager에게 전달한다. AuthenticationManager은 실제로 인증을 처리할 여러 개의 AuthenticationProvider를 가지고 있다.

 

 

 

 

[ 4. UsernamePasswordToken을 Authentication Provider에게 전달 ]

Authentication Manager는 전달 받은 UsernamePasswordToken을 순차적으로 AuthenticationProvider들에게 전달하여 실제 인증의 과정을 수행해야 하며, 실제 인증에 대한 부분은 authenticate 함수에 작성을 한다. SpringSecurity에서는 Username으로 DB에서 데이터를 조회한 다음에, 비밀번호의 일치 여부를 검사하는 방식으로 동작한다. 그렇기 때문에 먼저 UsernamePasswordToken으로부터 아이디를 조회해야한다.

@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
        // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
        String userEmail = token.getName();
        
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

 

 

 

 

[ 5. UserDetailsService로 조회할 아이디를 전달 ]

AuthenticationProvider에서 아이디를 조회하였으면, UserDetailsService로부터 아이디를 기반으로 데이터를 조회해야한다. UserDetailsService는 인터페이스이기 때문에 이를 implements한 클래스를 작성해주어야한다. 실제 반환값을 작성하는 부분은 7번에서 다룬다.

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetailsVO loadUserByUsername(String userEmail) {
    
    }
    
}

 

 

 

 

[ 6. 아이디를 기반으로 DB에서 데이터 조회 ]

전달받은 아이디를 기반으로 DB에서 조회하는 구현체는 우리가 직접 개발한 UserVO일 것이고, UserDetailsService의 반환값은 UserDetails 인터페이스이기 때문에 이를 implements하여 구현한 UserDetailsVO를 아래와 같이 작성한다.

@MappedSuperclass
@Getter
@NoArgsConstructor
public class CommonVO implements Serializable {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @CreationTimestamp
    @Column(nullable = false, length = 20, updatable = false)
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(length = 20)
    private LocalDateTime updatedAt;

    @Getter
    @Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT true")
    private Boolean isEnable = true;
}

 

@NoArgsConstructor
@Entity
@Table(name = "USER")
@Getter
public class UserVO extends CommonVO implements Serializable {

    @Setter
    @Column(nullable = false, unique = true, length = 50)
    private String userEmail;

    @Setter
    @Column(nullable = false)
    private String userPw;

    @Setter
    @Column(nullable = false, length = 50)
    @Enumerated(EnumType.STRING)
    private UserRole role;

    @Builder
    public UserVO(String userEmail, String userPw){
        this.userEmail = userEmail;
        this.userPw = userPw;
    }
}

 

 

@RequiredArgsConstructor
@Getter
public class UserDetailsVO implements UserDetails {

    @Delegate
    private final UserVO userVO;
    private final Collection<? extends GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return userVO.getUserPw();
    }

    @Override
    public String getUsername() {
        return userVO.getUserEmail();
    }

    @Override
    public boolean isAccountNonExpired() {
        return userVO.getIsEnable();
    }

    @Override
    public boolean isAccountNonLocked() {
        return userVO.getIsEnable();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return userVO.getIsEnable();
    }

    @Override
    public boolean isEnabled() {
        return userVO.getIsEnable();
    }
}

 

 

 

 

[ 7. 아이디를 기반으로 조회한 결과를 반환 ]

아이디를 기반으로 조회한 결과를 반환하기 위해서는 위에서 작성하던 UserDetailsServiceImpl을 마무리 해주어야 하는데, 그 전에 사용자의 아이디를 기반으로 데이터가 조회하지 않았을 경우 처리해주기 위한 Exception 클래스를 추가해준다.

public class UserNotFoundException extends RuntimeException{

    public UserNotFoundException(String userEmail){
        super(userEmail + " NotFoundException");
    }

}

 

그리고 조회한 결과를 CustomAuthenticationProvider로 반환하는 UserDetailsServiceImpl을 마무리해주면 아래와 같다.

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetailsVO loadUserByUsername(String userEmail) {
        return userRepository.findByUserEmail(userEmail).map(u -> new UserDetailsVO(u, Collections.singleton(new SimpleGrantedAuthority(u.getRole().getValue())))).orElseThrow(() -> new UserNotFoundException(userEmail));
    }
    
}

위의 예제에서는 UserRepository로부터 조회한 결과를 Optional로 반환하고 있기 때문에 map 함수를 이용해서 새로운 UserDetailsVO 객체로 생성하여 반환하고 있다.

 

 

 

 

[ 8. 인증 처리 후 인증된 토큰을 AuthenticationManager에게 반환 ]

이제 CustomAuthenticationProvider에서 UserDetailsService를 통해 조회한 정보와 입력받은 비밀번호가 일치하는지 확인하여, 일치한다면 인증된 토큰을 생성하여 반환해주어야 한다. DB에 저장된 사용자 비밀 번호는 암호화가 되어있기 때문에, 입력으로부터 들어온 비밀번호를 PasswordEncoder를 통해 암호화하여 DB에서 조회한 사용자의 비밀번호와 매칭되는지 확인해주어야 한다. 만약 비밀번호가 매칭되지 않는 경우에는 BadCredentialsException을 발생시켜 처리해준다.

@RequiredArgsConstructor
@Log4j2
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
        // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 조회함
        String userEmail = token.getName();
        String userPw = (String) token.getCredentials();
        // UserDetailsService를 통해 DB에서 아이디로 사용자 조회
        UserDetailsVO userDetailsVO = (UserDetailsVO) userDetailsService.loadUserByUsername(userEmail);

        if (!passwordEncoder.matches(userPw, userDetailsVO.getPassword())) {
            throw new BadCredentialsException(userDetailsVO.getUsername() + "Invalid password");
        }

        return new UsernamePasswordAuthenticationToken(userDetailsVO, userPw, userDetailsVO.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

 

 

이제 완성된 CustomAuthenticationProvider를 이제 Bean으로 등록해주어야 하는데, 이것을 WebSecurityConfig에 작성하면 아래와 같다.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailsService userDetailsService;

    // 정적 자원에 대해서는 Security 설정을 적용하지 않음
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                // /about 요청에 대해서는 로그인 요구
                .antMatchers("/about").authenticated()
                // /admin 요청에 대해서는 ROLE_ADMIN 역할을 가지고 있어야 함
                .antMatchers("/admin").hasRole("ADMIN")
                // 나머지 요청에 대해서는 로그인을 요구하지 않음
                .anyRequest().permitAll()
                .and()
                // 로그인하는 경우에 대해 설정
                .formLogin()
                // 로그인 페이지 제공하는 URL 설정
                .loginPage("/user/loginView")
                // 로그인 성공 URL 설정
                .successForwardUrl("/index")
                // 로그인 실패 URL 설정
                .failureForwardUrl("/index")
                .permitAll()
                .and()
                .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CustomAuthenticationFilter customAuthenticationFilter() throws Exception{
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        customAuthenticationFilter.setFilterProcessesUrl("/user/login");
        customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler());
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    @Bean
    public CustomLoginSuccessHandler customLoginSuccessHandler(){
        return new CustomLoginSuccessHandler();
    }

    @Bean
    public CustomAuthenticationProvider customAuthenticationProvider(){
        return new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customAuthenticationProvider());
    }
}

 

 

 

 

[ 9. 인증된 토큰을 AuthenticationFilter에게 전달 ]

AuthenticationProvider에서 인증이 완료된 UsernamePasswordAuthenticaionToken을 AuthenticationFilter로 반환하고, AuthenticationFilter에서는 LoginSuccessHandler로 전달한다.

 

 

[ 10.  인증된 토큰을 SecurityContextHolder에 저장 ]

LoginSuccessHandler로 넘어온 Authentication 객체를 SecurityContextHolder에 저장하면 인증 과정이 끝난다.

 

 

 

로그인이 성공한 후에는 SecurityContextHolder라는 세션을 활용하여 저장하기 때문에 1번만 로그인하면 로그인이 필요한 페이지들에 바로 접근 가능하다.

 

 

728x90
반응형