프로젝트를 구현하다 보면, 개별적으로 만든 `Filter` 에 `JWT` 토큰을 활용하여 인증과 인가를 진행하는 경우가 많습니다. 하지만 Spring Framework 에서는 강력한 보안 모듈로 `Spring Security` 를 제공하고 있습니다.
기본적으로 Spring Security 는 애플리케이션의 인증(Authentication)과 인가(Authorization) 기능을 제공합니다. 사용자 인증, 권한 부여, CSRF 방지, 세션 관리 등의 보안 기능을 제공하며, Spring 애플리케이션에서 손쉽게 설정하고 사용할 수 있도록 설계되었습니다.
1. Spring Security란?
- 인증(Authentication): 사용자가 누구인지 확인하는 과정입니다. 로그인 과정에서 사용자의 자격 증명을 검증하여 신원을 확인하는 절차입니다.
- 인가(Authorization): 인증된 사용자가 애플리케이션의 특정 리소스나 기능에 접근할 권한이 있는지 검증하는 과정입니다.
Spring Security는 인증과 인가를 처리할 수 있는 다양한 메커니즘을 제공하며, 특히 REST API에서 사용하는 JWT(Json Web Token) 기반 인증과도 쉽게 연동할 수 있습니다.
2. Spring Security의 동작 방식
`Spring Security`는 `필터 체인(Filter Chain)`을 통해 모든 요청을 처리합니다. 요청이 들어오면 여러 가지 보안 필터가 순차적으로 실행되며, 각 필터가 인증과 인가에 필요한 다양한 작업을 수행합니다. 주요 필터는 다음과 같습니다.
- UsernamePasswordAuthenticationFilter: 기본적인 사용자 이름과 비밀번호를 사용한 인증 필터.
- BasicAuthenticationFilter: 기본 인증 헤더를 이용한 인증 필터.
- JwtFilter(커스텀 필터): JWT 토큰을 이용한 인증을 위해 추가하는 필터.
- ExceptionTranslationFilter: 인증이나 인가 과정에서 발생한 예외를 처리하는 필터.
- FilterSecurityInterceptor: 인가 작업을 담당하는 최종 필터로, 요청의 URI나 메서드에 대한 접근 권한을 확인합니다.
이러한 필터들은 Spring Security의 필터 체인에 의해 실행되며, 각 요청이 애플리케이션에 접근하기 전에 인증과 인가 과정을 거치게 됩니다. 그리고 실제 API 에서는 `SecurityContext`라는 객체를 이용하여 애플리케이션의 현재 인증된 사용자 정보를 저장하고 다룰 수 있습니다. 이 인증된 객체를 이용하여 API에서 사용하는 방식은, `@AuthenticationPrincipal` 어노테이션을 사용하여, API 의 파라미터로 전달 한 뒤 비즈니스 로직에서 사용하는 형태입니다. 이 부분에 대한 내용은 7번 항목에서 설명하도록 하겠습니다.
일반적으로 회원 방식으로 관리되는 사이트에서는, 각 기능을 요청할 때 마다 필터와 JWT 등을 통해서 로그인되어 있는 사용자의 정보를 가져온 뒤, 사용자의 요청에 대한 권한이 있는지를 확인하는 방식을 사용하고 있습니다. 그렇다면 굳이 Spring Security 를 사용하는 이유는 무엇일까요?
Spring Security는 단순히 필터와 JWT만으로 구현할 수 있는 기본적인 인증 기능 외에도, 보안 관리를 체계적으로 하고 확장성을 높여주는 다양한 기능을 제공합니다. Spring Security를 사용해야 하는 주요 이유를 설명하겠습니다.
3. Spring Security 의 장점 및 특징
1. 표준화된 보안 프레임워크 제공
Spring Security는 수많은 애플리케이션에서 검증된 표준 보안 프레임워크로, 여러 가지 보안 요구사항을 다룰 수 있는 다양한 기능과 설정을 갖추고 있습니다. 이를 통해 애플리케이션마다 보안 구조를 새롭게 설계하지 않고도 안정적이고 표준화된 방식으로 인증, 인가, 세션 관리 등을 구현할 수 있습니다.
정형화된 인증 구조: Spring Security는 `UserDetails`, `UserDetailsService`, `AuthenticationProvider` 같은 표준 인터페이스를 제공하여 인증 구조를 일관되게 설계할 수 있게 합니다. 이러한 구조는 인증 로직을 표준화하여 코드의 재사용성을 높이고, 유지보수를 용이하게 합니다.
다양한 보안 기능 내장: Spring Security는 CSRF 방지, 세션 관리, 동시 접속 제한, 비밀번호 암호화 등 다양한 보안 기능을 내장하고 있어 설정만으로 손쉽게 사용할 수 있습니다.
2. 세분화된 보안 설정
JWT와 필터만으로 인증 기능을 구현할 경우, 모든 보안 규칙을 직접 처리해야 하며, 세분화된 접근 제어를 구현하기 어렵습니다. Spring Security는 URI 패턴별, 메서드별 권한 설정을 지원하고, 요청에 따른 접근 제어를 유연하게 설정할 수 있습니다.
URL 기반 권한 설정: `HttpSecurity` 에서 URI별 접근 제어가 가능하며, `hasRole`, `hasAuthority` 같은 메서드로 요청마다 권한을 설정할 수 있습니다.
메서드 보안: `@PreAuthorize`,`@Secured` 와 같은 어노테이션으로 서비스 메서드 레벨에서 권한을 제어할 수 있습니다.
3. 필터 체인 구조로 손쉬운 인증 확장
Spring Security의 필터 체인은 다양한 인증 방식이나 인가 절차를 손쉽게 추가할 수 있도록 설계되어 있습니다. `addFilterBefore`, `addFilterAfter`와 같은 메서드로 필터를 원하는 위치에 배치할 수 있어, JWT 기반 인증과 추가적인 인증 절차를 손쉽게 조합할 수 있습니다.
커스텀 필터 추가 용이: Spring Security는 JWT 외에도 OIDC, OAuth, SAML과 같은 다양한 인증 방식을 추가하기 용이합니다.
다양한 인증 공급자와 통합: `AuthenticationProvider`를 통해 인증 공급자를 확장하거나 여러 개의 인증 방식을 혼합하여 사용할 수 있습니다.
4. 통합된 예외 처리 및 오류 관리
Spring Security는 예외 처리와 오류 관리를 중앙화하여, 다양한 인증 및 인가 예외를 일관성 있게 처리할 수 있도록 돕습니다. 이를 통해 각종 예외 상황에서 일관된 응답을 제공할 수 있습니다.
ExceptionTranslationFilter: 인증이 필요한 리소스에 접근할 때 예외가 발생하면 이 필터가 적절한 응답을 처리합니다.
DataAccessException과 같은 예외 변환: Spring Security는 데이터베이스와 관련된 예외를 `DataAccessException` 계층으로 통일하여 일관된 예외 처리가 가능하게 합니다.
5. 확장성과 재사용성을 높이는 구조
Spring Security는 인터페이스 기반으로 설계되어 있어, 커스터마이징이 쉽고, 프로젝트에 따라 다양한 요구사항을 충족할 수 있습니다.
UserDetailsService와 AuthenticationProvider: 사용자 인증 로직을 표준 인터페이스로 구현하면, 인증 방식을 변경하더라도 코드를 최소한으로 수정할 수 있습니다.
PasswordEncoder: 비밀번호 인코딩을 다양한 방식으로 처리할 수 있으며, 보안 수준에 맞게 쉽게 변경할 수 있습니다. `BCryptPasswordEncoder` 같은 보안 수준이 높은 인코딩 기법을 기본적으로 지원합니다.
6. JWT 와의 쉬운 통합
Spring Security는 커스텀 필터와 보안 설정을 통해 JWT 기반 인증을 쉽게 추가할 수 있도록 돕습니다. JwtFilter를 사용하여 JWT 토큰을 검증하는 로직을 추가한 후, Spring Security의 `SecurityContextHolder`에 인증된 사용자의 정보를 설정하면, 이후의 모든 요청에서 이를 참조하여 인증과 인가를 처리할 수 있습니다.
Spring Security와 JWT 통합 예시
Spring Security를 사용한 JWT 통합은 다음과 같은 구조로 이루어집니다:
1. 로그인 요청 처리: 클라이언트가 로그인 요청을 보내면 `AuthController`가 이를 받아 사용자 정보를 확인하고, 인증에 성공하면 JWT 토큰을 생성해 반환합니다.
2. JwtFilter 추가: `JwtFilter`를 생성하여, 모든 요청에 대해 JWT 토큰을 확인하고 검증하는 필터를 추가합니다.
3. SecurityConfig에 JwtFilter 등록: SecurityConfig에서 필터 체인에 JwtFilter를 등록하여 인증 절차에 포함시킵니다.
4. Spring Security 와 JWT 연동
JWT는 세션/쿠키 기반 인증과 달리, 로그인 시 클라이언트에게 암호화된 토큰을 발급하여 이후의 요청에서 이 토큰을 인증 수단으로 사용하는 방식입니다. 이 토큰을 클라이언트가 요청 헤더에 포함하여 서버로 보내면, 서버는 토큰을 검증하여 사용자의 인증 상태를 확인합니다.
JWT 기반 인증을 위한 구현 단계
- 로그인 요청 처리: 클라이언트가 ID와 비밀번호로 로그인 요청을 보내면 서버는 자격 증명을 확인하고, 인증에 성공하면 JWT 토큰을 생성해 응답으로 반환합니다.
- 토큰 저장 및 사용: 클라이언트는 이 JWT 토큰을 저장하고, 이후의 모든 요청에 Authorization 헤더에 이 토큰을 포함해 서버에 요청합니다.
- JWT 검증: 서버는 요청이 들어올 때마다 JWT 필터(JwtFilter 등)를 통해 토큰을 검증하고, 유효한 토큰이라면 인증된 사용자로 처리합니다.
5. SecurityConfig 설정하기
JWT 기반 인증을 구현하려면 Spring Security 설정 클래스인 `SecurityConfig`에서 여러 가지 설정을 해야 합니다. `SecurityConfig`는 `@Configuration`을 붙여 설정 클래스로 만들고, `@EnableWebSecurity` 를 사용하여 Spring Security를 활성화합니다.
import {...}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsServiceImpl;
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws
Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsServiceImpl);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/auth/**").permitAll() // '/auth/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
1. JwtUtil: `JwtUtil` 은 Spring Security 에서 사용될 `JwtAuthenticationFilter` 를 생성하기 위해 주입해줍니다.
2. UserDetailsServiceImpl: Spring Security 에서 제공하는 UserDetailsService 를 구현하기 위한 구현체로, 사용자의 정보를 조회하는 역할을 하고 DB에서 사용자의 정보를 로드하여 인증에 사용합니다. 위의 예제 코드에서는 `AuthenticationFilter` 와 `AuthorizationFilter` 를 나누어 인증과 인가를 진행하고 있고, `AuthorizationFilter` 를 구현하기 위해 파라미터로 대입될 때 사용됩니다.
3. PasswordEncoder: 비밀번호를 암호화하기 위한 `PasswordEncoder`를 빈으로 등록합니다. `BCryptPasswordEncoder`는 보안 수준이 높은 암호화 방식입니다.
4. permitAll() 설정: 회원가입 및 로그인 API 는 인증이 필요 없도록 설정합니다.
5. addFilterBefore 설정: 이 부분에서 Filter 를 어느 순서대로 거치게 될지 설정합니다. addFilterBefore 은 첫 번째 파라미터에 있는 필터를, 두번째 파라미터에 있는 필터보다 먼저 실행시킨다는 의미를 가지고 있습니다.
6. UserDetails 와 UserDetailsService 구현
`UserDetails`와 `UserDetailsService`는 Spring Security에서 사용자 정보를 다루는 표준 인터페이스입니다. Spring Security 에서 `AuthenticationManager` 는 `UserDetailsService` 에서 `UserDetails` 를 반환하게 되는데, 그 `UserDetails` 는 사용자의 이름으로 데이터베이스에서 조회를 요청하여 `UserDetails` 객체를 생성합니다.
이 두 인터페이스를 구현함으로써 Spring Security는 DB에서 조회한 사용자 정보를 활용하여 인증을 처리할 수 있게 됩니다.
예제는 다음과 같습니다.
import ...
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
public Long getId() {
return user.getId();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRole role = user.getUserRole();
String authority = "ROLE_" + role.name();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
return List.of(simpleGrantedAuthority);
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
이때의 상속받은 UserDetails 인터페이스의 내용은 다음과 같습니다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
default boolean isAccountNonExpired() {
return true;
}
default boolean isAccountNonLocked() {
return true;
}
default boolean isCredentialsNonExpired() {
return true;
}
default boolean isEnabled() {
return true;
}
}
기본으로 Spring Security 에서 제공하는 이 인터페이스를 상속 받아서, 실제로 구현을 하고 메서드들을 Override 하여 확장시켜 사용할 수 있습니다.
`UserDetailsServiceImpl` 을 예제로 보도록 하겠습니다.
import ...
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 유저 이메일입니다."));
return new UserDetailsImpl(user);
}
}
UserDetailsService 인터페이스를 추가적으로 보면,
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
이렇게 작성되어 있고, 이것을 실제로 구현 후 Override 하여 사용하게 됩니다. 위의 예제에서는 사용자의 요청에서 `Email` 로 로그인하는 조건이었기 때문에 파라미터로 `Email` 을 받고, `UserRepository` 에서 `Email` 을 통해 유저가 존재하는지 조회하는 예제입니다.
왜 UserDetails 와 UserDetailsService 를 사용하는가?
Spring Security는 모든 애플리케이션에서 일관된 방식으로 사용자 정보를 다룰 수 있도록 `UserDetails`와 `UserDetailsService` 인터페이스를 정의하였습니다. 이를 통해 인증 메커니즘이 변경되더라도 사용자 정보 조회 및 검증 로직을 쉽게 재사용하고 관리할 수 있습니다.
7. Authentication, Authorization Filter
제가 보여드릴 예제에서는 인증을 하는 필터와, 인가를 하는 필터를 따로 구분하여 진행하도록 합니다.
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/auth/signin");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 시도");
try {
SigninRequest requestDto = new ObjectMapper().readValue(request.getInputStream(), SigninRequest.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getEmail(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
UserRole role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getUserRole();
String token = jwtUtil.createToken(
((UserDetailsImpl)authResult.getPrincipal()).getUser().getId(),
((UserDetailsImpl)authResult.getPrincipal()).getUser().getEmail(),
role,
((UserDetailsImpl)authResult.getPrincipal()).getUser().getNickname());
jwtUtil.addJwtToCookie(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
}
로그인 요청인 `/auth/signin` URL 에 요청이 들어오게 되면 이 필터가 동작하게 되고, 인증을 시작하게 됩니다. 요청이 잘 시행되면
return getAuthenticationManager().authenticate()
이 부분에서 `Username` 과 `Password` 로 객체가 생성되고, 이 객체는 인증 전 자격 증명의 객체로 사용됩니다. 이후 `attemptAuthentication()` 메서드에서 반환받은 객체로 JWT 토큰을 생성하고 `addJwtToCookie` 메서드를 이용해 로그인 후 헤더의 쿠키 부분에 토큰을 반환하게 됩니다.
일반적인 API 요청이 들어오게 되면 아래의 Authorization 필터를 통해 권한이 있는지 확인하게 됩니다.
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
// JWT 토큰 substring
tokenValue = jwtUtil.substringToken(tokenValue);
log.info(tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.extractClaims(tokenValue);
String email = info.get("email", String.class);
try {
setAuthentication(email);
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
Http의 쿠키에 등록되어 있는 JWT 토큰을 가져와서 검증할 수 있도록 가공한 뒤 토큰을 이용하여 토큰이 가지고 있는 유저 정보가 들어있는 `Claims` 객체를 생성하여 로그인한 유저의 Email 정보를 가져옵니다. 이 Email 정보를 토대로 `setAuthentication()` 와 `createAuthentication()` 메서드를 통해 `UserDetailsService` 에서 유저 정보가 있는지 확인 후 가져와서 인증객체를 생성한 뒤, 인증처리를 하게 됩니다. 인증처리가 되면 이 객체를 `setAuthentication()` 메서드에서 `SecurityContext` 부분에 저장을 하고 이때 `SecurityContext` 가 가지고 있는 정보를 API 에서 @AuthenticationPrincipal 어노테이션을 활용해 가져올 수 있습니다.
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(
@AuthenticationPrincipal UserDetailsImpl authUser,
@Valid @RequestBody TodoSaveRequest todoSaveRequest
) {
return ResponseEntity.ok(todoService.saveTodo(authUser, todoSaveRequest));
}
예를 들어, 일정을 등록하는 역할을 하는 `saveTodo()` 라는 API 가 이렇게 정의되어 있다면, `UserDetailsImpl` 로 구현한 authUser 를 비즈니스 로직에 전달하여, 요청하는 로그인된 사용자와 일정에 관련된 정보를 담고있는 `todoSaveRequest`를 같이 Service 에 전달하여 Todo 객체를 생성하게 됩니다.
이번 포스팅으로 Spring Security
'Spring' 카테고리의 다른 글
Spring - DB 와의 상호작용 (1) | 2024.11.16 |
---|---|
Spring - QueryDSL (1) | 2024.11.15 |
Spring - JDBC Template (1) | 2024.11.10 |
Spring - Database Driver (1) | 2024.11.09 |
Spring - H2 Database (0) | 2024.11.08 |