팀 프로젝트를 하면서 시간이 조금 남아 spring security와 jwt를 적용해 보려고 하였다. 하지만 로그인 인증, 보안등을 중간에 추가하기에는 힘들고 이러한 것은 시스템 설계 과정에서 생각하고 적용해야한다는 것을 알았다. 다음에는 이러한 실수를 하지 않기 위해 내용을 정리해 보려고 합니다.
먼저, Spring Security를 왜 사용하는가? 모든 어플리케이션은 보안도 중요한 요소이다. 보안에는 인증 및 인가 같은 복잡한 요구사항이 있지만 Spring Security는 이러한 복잡한 요구사항을 쉽게 구현할 수 있도록 해주는 프레임워크이기 때문에 많이 사용한다.
그러면 Spring Security를 사용하면 어플리케이션이 어떻게 동작 과정을 살펴보겠다.
Spring MVC의 기본 구조에서는 DispatcherServlet이 모든 요청(request)을 가로채서 해당 컨트롤러(controller)로 전달하는 흐름을 따릅니다. 하지만 Spring Security를 적용하면, DispatcherServlet이 요청을 받기 전에 Spring Security가 먼저 이를 가로채고, 필터 체인(filter chain)을 통해 보안을 강화하는 역할을 수행합니다.
요청(request) -> Spring Security -> DispatcherServlet -> Controller
그렇다면 이 필터 체인(filter chain) 에서는 어떤 일이 일어날까? Spring Security는 받은 요청에 대해 다음과 같은 작업을 수행합니다.
필터 체인 내에서 각 필터의 실행 순서는 매우 중요합니다. 일반적으로 필터는 다음 순서로 실행됩니다
이와 같은 순서로 필터가 실행되며, 이를 통해 요청이 적절하게 보호되고, 애플리케이션의 보안이 강화됩니다.
Gradle 파일에 다음과 같은 의존성만 추가하면, 해당 서버로 들어오는 모든 리소스가 자동으로 보호됩니다.
implementation 'org.springframework.boot:spring-boot-starter-security'
예를 들어, 존재하지 않는 리소스에 접근하려 해도 로그인을 요구합니다. http://www.yeonCloud.com/adfasdfasdf
처럼 도메인 뒤에 무작위 문자열을 추가해도, 서버는 로그인 요청 페이지로 리디렉션합니다. Spring Security의 기본 설정 덕분에 서버로 들어오는 모든 요청은 인증을 필요로 하게 됩니다.
Spring Security에서는 기본적으로 제공되는 로그인 페이지에서 사용자 이름(username)과 비밀번호(password)를 설정할 수 있으며, 이 로그인 기능을 비활성화하고 싶다면 요청 헤더에 Base64로 인코딩된 사용자 이름과 비밀번호를 보낼 수도 있습니다.
위 예시에서 Spring Security의 사용자 이름과 비밀번호를 설정한 후, 이를 요청에 포함시켜 보내 보겠습니다.포스트맨(Postman)을 이용한 확인 ⬇️
요청 결과 Status: 200 OK
가 표시되는 것을 확인할 수 있습니다. 이는 인증이 성공적으로 이루어졌음을 의미합니다.
이 기본 인증 방식 대신, JWT(JSON Web Token)를 사용할 수도 있습니다. JWT를 사용하면 더욱 안전하고 유연한 인증 시스템을 구축할 수 있습니다.
먼저 기본 Spring Security filter chain을 살펴보자(Ctrl + N -> SpringBootWebSecurityConfiguration
검색 후 확인)
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}
}
위 코드는 기본 설정된 Spring Security filter chain이다.
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
코드는 모든 요청이 인증되어야 한다는 코드이다.
그리고 나는 session을 통한 인증이 아니기 때문에 session 정책을 stateless로 변경한다.
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
무상태(stateless) 인증 방식을 적용하게 될 때 이는 보안 측면에서 다음과 같은 이점이 있다.
그리고 CSRF 보안 설정을 제거한다.
http.csrf(csrf -> csrf.disable());
장점
브라우저는 보안상의 이유로, 기본적으로 다른 도메인에서 제공되는 외부 리소스에 대한 API 호출을 허용하지 않습니다. 이러한 제한을 해결하기 위해 CORS(Cross-Origin Resource Sharing)라는 사양이 존재합니다. CORS를 통해 어떤 도메인 간 요청이 허용되는지 구성할 수 있습니다.
Spring MVC에서는 CORS 설정을 통해 특정 도메인 간 요청을 허용할 수 있으며, 이를 글로벌 또는 로컬 수준에서 구성할 수 있습니다.
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedMethods("*")
.allowedOrigins("http://localhost:3000");
}
@CrossOrigin(origins = "https://www.naver.com")
// or
@CrossOrigin
Spring Security에서 사용자의 신원 정보를 저장하고 관리하기 위해 UserDetailsService를 구현할 수 있습니다. 아래 예제에서는 InMemoryUserDetailsManager를 사용하여 사용자 정보를 메모리에 저장하는 방법을 보여줍니다.
@Bean
public UserDetailsService userDetailsService(){
var user = User.withUsername("yeon")
.password("1234")
.roles("USER")
.build();
var admin = User.withUsername("admin")
.password("1234")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
위 코드에서는 두 명의 사용자를 생성합니다:
yeon: 일반 사용자 역할(USER)을 가진 사용자
admin: 관리자 역할(ADMIN)을 가진 사용자
이 두 사용자는 InMemoryUserDetailsManager를 통해 메모리에 저장되며, 애플리케이션 실행 중 인증에 사용됩니다. 비밀번호는 {noop}을 사용하여 인코딩 없이 평문으로 저장되지만, 실제 애플리케이션에서는 반드시 안전한 방식으로 비밀번호를 인코딩해야 합니다.
이 방식으로 사용자를 메모리에 저장하면, 별도로 application.properties나 application.yml 파일에 사용자 정보를 저장할 필요가 없습니다. 다만, 이 방법은 주로 테스트나 프로토타입 개발 시에 사용되며, 실제 운영 환경에서는 데이터베이스나 다른 외부 저장소를 사용하여 사용자 정보를 관리하는 것이 일반적입니다.