Spring Framework/Spring Security

[Spring Security] Spring Security (Session) - Spring Security 6.1 이후

장쫄깃 2024. 8. 2. 17:14
728x90


들어가며


Spring Security 6.1부터 기존에 사용하던 and()와 non-Lambda DSL Method가 Deprecated 되고, 필수적으로 Lambda DSL을 사용하도록 변경되었다.

 

변경된 내용으로 스프링 시큐리티 세션 로그인을 구현해보려 한다.

 

다만, 본 게시글은 스프링 시큐리티 위주의 내용만 작성하려고 한다. 로그인, 회원가입 등의 별도 비즈니스 로직 코드는 게시글 하단의 깃허브를 참고하길 바란다.

 

기술스택

- Spring Boot 3.3.1
- Spring Security 6.3.1
- JPA
- Session, Form Login 구현
- Spring Security 6.1 이후 lambda 문법을 이용한 코드 적용

 

스프링 시큐리티에 대한 설명은 해당 글을 참고하면 된다.

링크 : https://jangjjolkit.tistory.com/24

 

[Spring Security] 1. Spring Security(스프링 시큐리티) 란?

Spring Security(스프링 시큐리티) 란? 스프링 시큐리티는 스프링 기반의 애플리케이션 보안(인증, 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다. 즉, 인증(Authenticate, 누구인지) 과 인가(Auth

jangjjolkit.tistory.com

 

 

1. build.gradle


plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.1'
	id 'io.spring.dependency-management' version '1.1.5'
}

group = 'com.jdh'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	implementation 'org.springframework.boot:spring-boot-starter-validation'

	// log4jdbc
	implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

	// thymeleaf
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

test {
	useJUnitPlatform()
}

compileJava {
	options.compilerArgs << '-parameters'
}

 

 

2. SpringBootApplication.java


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
public class SpringSecuritySessionApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringSecuritySessionApplication.class, args);
	}

}

 

Spring Boot Security Auto Configuration을 비활성화하기 위해 @SpringBootApplication(exclude = SecurityAutoConfiguration.class)를 적용한다.

 

추가적으로, JPA를 사용하기 때문에 @EnableJpaAuditing 어노테이션을 사용했다.

 

 

3. Spring Security Configuration


/**
 * Spring Security Config
 */
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserGetService userGetService;

    private final CustomLoginSuccessHandler customLoginSuccessHandler;

    private final CustomLoginFailHandler customLoginFailHandler;

    private final CustomLogoutSuccessHandler customLogoutSuccessHandler;

    private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler;

    private final CustomAccessDeniedHandler customAccessDeniedHandler;

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

    @Bean
    MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
        return new MvcRequestMatcher.Builder(introspector);
    }

    @Bean
    public SecurityFilterChain config(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
        MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector);

        // white list (Spring Security 체크 제외 목록)
        MvcRequestMatcher[] permitAllWhiteList = {
                mvc.pattern("/login-view"),
                mvc.pattern("/login"),
                mvc.pattern("/regist"),
                mvc.pattern("/regist-action"),
                mvc.pattern("/error/**"),
                mvc.pattern("/favicon.ico")
        };

        // http request 인증 설정
        http.authorizeHttpRequests(authorize -> authorize
                .requestMatchers(permitAllWhiteList).permitAll()
                // 사용자 삭제는 관리자 권한만 가능
                .requestMatchers("/delete").hasRole(RoleName.ROLE_ADMIN.getRole())
                // 그 외 요청 체크
                .anyRequest().authenticated()
        );

        // form login 중복 실행 방지
        http.formLogin(login -> login
                .loginProcessingUrl("/login")
                .successForwardUrl("/dashboard")
                .successHandler(customLoginSuccessHandler)
                .failureHandler(customLoginFailHandler)
        );

        // logout 설정
        http.logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessHandler(customLogoutSuccessHandler)
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .permitAll()
        );

        // csrf disable
        http.csrf(AbstractHttpConfigurer::disable);

        // session management
        http.sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
        );

        // entry point handler
        http.exceptionHandling(conf -> conf
                .authenticationEntryPoint(customAuthenticationEntryPointHandler)
                .accessDeniedHandler(customAccessDeniedHandler)
        );

        // build
        return http.build();
    }

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

    @Bean
    public AuthenticationManager authenticationManager() {
        CustomAuthenticationProvider authProvider = customAuthenticationProvider();
        return new ProviderManager(authProvider);
    }

}

 

기존에 and()를 사용하여 설정 옵션을 연결하는 방식이 아닌 Lambda DSL 방식을 사용하는 Security Configuration이다. Security, login, logout, Handler 설정 등을 담당한다. 또, 비밀번호 암/복호화에 사용할 BCryptPasswordEncoder와 특정 경로 매칭을 위한 MvcRequestMatcher를 bean으로 등록한다.

 

여기서 또 한 가지 체크해야 하는 부분은 requestMatcher 부분이다. Spring MVC를 사용 중이라면 requestMatchers(MvcRequestMatcher) 방식을 사용해야 하고, 아니라면 requestMatchers(AntPathRequestMatcher) 방식을 사용해야 한다.

 

 

4. Handler 구현


CustomLoginSuccessHandler.java

/**
 * Custom Login Success Handler
 */
@Component
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // Spring Security Context Holder 인증 정보 set
        SecurityContextHolder.getContext().setAuthentication(authentication);

        response.sendRedirect("/dashboard");
    }

}

 

AuthenticationSuccessHandler의 구현체 CustomLoginSuccessHandler를 구현한다. 이 핸들러는 로그인을 성공했을 때 동작하는 클래스이다. 해당 코드에선 Spring Security Context Holder에 인증정보를 set 하고, /dashboard로 리다이렉트 하도록 구현했다.

 

CustomLoginFailHandler.java

/**
 * Custom Login Fail Handler
 */
@Slf4j
@Component
public class CustomLoginFailHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        log.info("[CustomLoginFailHandler] :: " + exception.getMessage());

        response.sendRedirect("/login-view");
    }

}

 

AuthenticationFailureHandler의 구현체 CustomLoginFailHandler를 구현한다. 이 핸들러는 로그인을 실패했을 때 동작하는 클래스이다. 해당 코드에선 로그인 화면으로 리다이렉트 하도록 구현했다.

 

CustomLogoutSuccessHandler.java

/**
 * Custom Logout Success Handler
 */
@Slf4j
@Component
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        log.info("[CustomLogoutSuccessHandler] :: 로그아웃");

        response.sendRedirect("/login-view");
    }
}

 

LogoutSuccessHandler의 구현체 CustomLogoutSuccessHandler를 구현한다. 이 핸들러는 로그아웃을 성공했을 때 동작하는 클래스이다. 해당 코드에선 로그인 화면으로 리다이렉트 하도록 구현했다.

 

CustomAccessDeniedHandler.java

/**
 * Custom Access Denied Handler
 */
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.sendRedirect("/error/403");
    }

}

 

AccessDeniedHandler의 구현체 CustomAccessDeniedHandler를 구현한다. 이 핸들러는 권한이 없는 사용자가 스프링 시큐리티에서 보호 중인 리소스에 접근할 때 동작하는 클래스이다. 해당 코드에선 403 에러 페이지로 리다이렉트 하도록 구현했다.

 

CustomAuthenticationEntryPointHandler.java

/**
 * Custom Authentication Entry Point Handler
 */
@Slf4j
@Component
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        log.info("[CustomAuthenticationEntryPointHandler] :: {}", authException.getMessage());
        log.info("[CustomAuthenticationEntryPointHandler] :: {}", request.getRequestURL());
        log.info("[CustomAuthenticationEntryPointHandler] :: 로그인 인증 정보 없음");

        response.sendRedirect("/error/401");
    }

}

 

AuthenticationEntryPoint의 구현체 CustomAuthenticationEntryPointHandler를 구현한다. 이 핸들러는 사용자가 인증되지 않았거나 유효한 인증정보를 가지고 있지 않은 경우 동작하는 클래스이다. 한마디로 로그인을 하지 않은 사용자가 로그인이 필요한 리소스에 접근할 때 동작한다. 해당 코드에선 401 에러 페이지로 리다이렉트 하도록 구현했다.

 

 

5. AuthenticationProvider 구현


@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    private final UserGetService userGetService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();

        // 사용자 정보 조회
        UserGetResponseDTO userInfo = userGetService.getUserByUserId(username);

        // password 일치 여부 체크
        if(!bCryptPasswordEncoder.matches(password, userInfo.password()))
            throw new LoginException(LoginExceptionResult.NOT_CORRECT);

        // return UsernamePasswordAuthenticationToken
        return new UsernamePasswordAuthenticationToken(
                userInfo.id(),
                userInfo.password(),
                Collections.singleton(new SimpleGrantedAuthority(userInfo.roleName().name()))
        );
    }

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

}

 

AuthenticationProvider의 구현체 CustomAuthenticationProvider를 구현한다. AuthenticationProvider에선 Spring Security AuthenticationFilter가 전달해 준 인증정보(Authentication)를 토대로 사용자 인증을 진행한다. DB에서 사용자 정보를 가져와 실제 사용자의 입력정보와 비교한다. 인증이 완료되면 해당 사용자에 대한 UsernamePasswordAuthenticationToken 객체를 반환한다.

 

 

테스트


회원가입이 완료된 이후 기능부터 테스트를 진행하겠다.

 

1. 로그인

 

로그인에 실패할 경우 로그로 확인할 수 있다.

 

2. 대시보드

로그인에 성공할 경우 다음과 같은 대시보드 화면으로 넘어간다.

 

만약 로그인을 하지 않고 대시보드에 접근할 경우 401 에러페이지를 보여준다.

 

3. 로그아웃

로그아웃에 성공하면 로그인 화면으로 돌아온다.

 

4. 계정 삭제

스프링 시큐리티 설정에서 계정 삭제는 관리자 권한만 접근이 가능하도록 설정했다. 만약 권한이 없는 사용자가 계정 삭제를 시도하는 경우 403 에러페이지를 보여준다.

 

만약 권한이 있는 사용자(관리자)가 계정 삭제를 시도하면 정상적으로 처리된다.

 

DB의 del_yn이 true로 변경되어 삭제된 것을 볼 수 있다.

 

 

정리하며


위에서 언급했듯이 스프링 시큐리티 위주의 내용 설명만을 다뤘다. 자세한 코드는 깃허브를 참고하길 바란다.

 

링크 : https://github.com/JangDaeHyeok/SpringBoot-Security

 

GitHub - JangDaeHyeok/SpringBoot-Security

Contribute to JangDaeHyeok/SpringBoot-Security development by creating an account on GitHub.

github.com

 

728x90