기존에 Filter와 Argument Resolver로 처리하던 인증/인가 로직을 Spring Security로 변경하면서 구현 과정에 대해 정리했습니다. Spring Security의 기본적인 동작 흐름과 이를 기반으로 한 인증 및 인가 로직을 설명해 보겠습니다.
Spring Security 동작 과정
Spring Security는 Filter 기반으로 동작하며, Dispatcher Servlet으로 요청이 도달하기 전에 필터 체인에서 처리가 이루어집니다. 아래는 Spring Security의 동작 구조입니다.
저는 SecurityConfig, 인증(Authentication) 인가(Authorization) 흐름으로 나누어 개발했습니다.
Config(SecurityConfig 구현)
1. AuthenticationManager 빈 정의
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
2. SecurityFilterChain 정의
Spring Security 필터 체인을 설정하며, 애플리케이션의 보안 정책을 정의합니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
AuthenticationManager authManager = authenticationManager();
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.addFilterBefore(new JwtAuthenticationFilter(authManager, jwtUtil),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtAuthorizationFilter(jwtUtil),
UsernamePasswordAuthenticationFilter.class)
.httpBasic(AbstractHttpConfigurer::disable);
return http.build();
}
3. SecurityFilterChain 동작 과정
1. HTTP 요청 권한 관리
- /auth/**로 시작하는 요청은 누구나 접근할 수 있도록 허용합니다.(permitAll).
- 그 외의 모든 요청은 인증이 필요합니다.(authenticated).
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
2. CSRF 비활성화
- REST API에서는 보통 CSRF 보호가 필요하지 않으므로 비활성화.
.csrf(AbstractHttpConfigurer::disable)
3. 세션 정책 설정
- JWT 기반 인증에서는 세션을 사용하지 않기 때문에 STATELESS로 설정.
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
4. 폼 로그인 비활성화
- REST API에서는 브라우저 기반 폼 로그인이 필요 없으므로 비활성화.
.formLogin(AbstractHttpConfigurer::disable)
5. JwtAuthenticationFilter: 사용자 인증(로그인 시 JWT 생성) 담당.
- UsernamePasswordAuthenticationFilter 앞에 배치.
.addFilterBefore(new JwtAuthenticationFilter(authManager, jwtUtil),
UsernamePasswordAuthenticationFilter.class)
6. JwtAuthorizationFilter: 요청마다 JWT를 검증해 사용자 인증 상태를 확인.
- UsernamePasswordAuthenticationFilter 앞에 배치.
.addFilterBefore(new JwtAuthorizationFilter(jwtUtil),
UsernamePasswordAuthenticationFilter.class)
인증 흐름(AuthenticationFilter 구현)
1. AuthenticationFilter를 통한 인증 처리
사용자가 로그인 요청을 보내면, JwtAuthenticationFilter가 요청을 가로채 처리합니다. 기본적으로 Spring Security는 /login URL을 처리하도록 설정되어 있지만, 저희 프로젝트에서는 /auth/signin 경로를 사용했으므로 다음과 같이 URL을 변경했습니다.
public JwtAuthenticationFilter(final AuthenticationManager authenticationManager, final JwtUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/auth/signin");
}
AuthenticationFilter가 어떻게 요청을 가로챌까?
.addFilterBefore(new JwtAuthenticationFilter(authManager, jwtUtil),
UsernamePasswordAuthenticationFilter.class)
SecurityConfig에서 JwtAuthenticationFilter가 UsernamePasswordAuthenticationFilter 이전에 작동하도록 설정합니다
2. 인증 시도: attemptAuthentication
JwtAuthenticationFilter는 인증 시도를 위해 attemptAuthentication 메서드를 호출합니다. 여기에서 사용자 입력을 기반으로 UsernamePasswordAuthenticationToken을 생성하고, 이를 AuthenticationManager에 전달합니다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
try {
final SigninRequest req = new ObjectMapper().readValue(request.getInputStream(), SigninRequest.class);
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(req.getEmail(), req.getPassword());
return authenticationManager.authenticate(token);
} catch (IOException e) {
throw new AuthenticationServiceException("인증에 실패하였습니다.");
}
}
AuthenticationManager는 전달받은 토큰을 기반으로 AuthenticationProvider에 인증을 위임합니다.
+) token은 이렇게 생겼습니다.
3. 사용자 인증 처리: AuthenticationProvider
JwtAuthenticationProvider는 Authentication 객체를 사용하여 DB에서 사용자 정보를 확인한 후 인증 여부를 결정합니다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final PasswordEncoder passwordEncoder;
private final AuthUserDetailService authUserDetailService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final String email = authentication.getName();
final String password = (String) authentication.getCredentials();
UserDetails user = authUserDetailService.loadUserByUsername(email);
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new AuthException("패스워드가 일치하지 않습니다.");
}
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
+) 이때 supports() 메서드는 어떤 걸 하는 역할일까?
미리 체크하여 실행 가능한 상태인지 확인하는 하는 역할입니다.
4. 사용자 정보 조회: UserDetailsService
사용자 인증을 위해 DB에 저장된 사용자 정보를 조회합니다. Spring Security의 UserDetailsService 인터페이스를 구현하여 사용자 세부 정보를 반환하는 클래스를 작성했습니다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final String email = authentication.getName();
final String password = (String) authentication.getCredentials();
UserDetails user = authUserDetailService.loadUserByUsername(email);
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new AuthException("패스워드가 일치하지 않습니다.");
}
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
@Service
@RequiredArgsConstructor
public class AuthUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(final String email) throws UsernameNotFoundException {
final User user = userRepository.findByEmail(email).orElseThrow();
return new AuthUser(user);
}
}
조회된 사용자 정보를 담은 AuthUser 객체는 UserDetails 인터페이스를 구현하며, 이후 인증 과정에서 활용됩니다.
5. 인증 성공/실패 처리
successfulAuthentication 메서드는 인증 성공 시 JWT 토큰을 생성하여 응답 헤더에 추가합니다. 반대로 인증 실패 시 unsuccessfulAuthentication 메서드에서 실패 처리를 수행합니다.
이때 setAuthenticated가 true로 바뀝니다.
그 이후 성공하면 successfulAuthentication로, 실패하면 unsuccessfulAuthentication로 처리가 됩니다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authentication) {
final AuthUser authUser = (AuthUser) authentication.getPrincipal();
final String token = jwtUtil.createToken(authUser);
response.addHeader("Authorization", token);
response.setStatus(200);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) {
System.out.println("fail");
}
JWT 토큰 확인 (JwtFilter구현)
인가 과정에서는 사용자가 보낸 요청 헤더의 JWT 토큰을 검증합니다. OncePerRequestFilter를 확장한 JwtAuthorizationFilter를 작성하여 요청마다 필터링을 수행하고, 토큰에서 추출한 사용자 정보를 기반으로 인증 객체를 생성하여 SecurityContextHolder에 저장합니다.
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
if (request.getRequestURI().contains("/auth")) {
chain.doFilter(request, response);
return;
}
String bearerJwt = request.getHeader("Authorization");
if (!isExistHeaderToken(request)) {
throw new IllegalArgumentException("토큰이 존재하지 않음");
}
final String token = jwtUtil.substringToken(bearerJwt);
final AuthUser authUser = jwtUtil.validateToken(token);
Authentication authentication = new UsernamePasswordAuthenticationToken(authUser, null,
authUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
} catch (Exception e) {
log.error("Internal server error", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
private boolean isExistHeaderToken(final HttpServletRequest request) {
final String header = request.getHeader("Authorization");
return header != null && header.startsWith("Bearer ");
}
}
끝!
'WEB' 카테고리의 다른 글
@Async를 통한 이메일 비동기 처리 (0) | 2024.12.29 |
---|---|
Index와 Redis를 통해 조회 속도 개선하기 (2) | 2024.11.20 |
[JPA] N+1 문제와 프록시 강제 초기화 해결 (0) | 2024.10.23 |
[WebClient] WebClient를 사용한 날씨 API 리팩토링 (0) | 2024.10.15 |
[JPA] JPA Update 실패 해결기 (0) | 2024.10.10 |
소프트 딜리트란? (1) | 2024.09.27 |
Spring Data JPA로 된 코드를 JDBC로 다시 짜보기 (1) | 2024.09.25 |
MDC를 이용해 세부 로그 확인하기 (0) | 2024.09.23 |