비밀글의 수많은 나열을 넘어서 드디어 개념을 정리하고 정리해서 백 년 만에 작성한 블로깅... 근데 놀랍게도 그리고 주제 넘게도 스프링 동작 원리조차 완벽하게 파악하지 않았으면서 스프링 시큐리티에 대하여 정리하는 오만방자함을 온몸으로 뽐내는 중이다(...)
사실 현업에서 시큐리티를 활용해서 처음부터 구축하는 경우는 거의 없다고 한다. 솔깃한 그 말에 그냥 넘어갈까 했지만 나중에 스스로 구현할 풀스택 프로젝트에서 꼭 적용해보고 싶기도 했고... 인공지능이랑 더불어 보안에도 관심이 있던 터라... 기왕 능숙하게 다룰 거 블로깅까지 진행하기로 결정!
보통 스프링 프레임워크를 기반으로 서버를 설계할 때, 다양한 디자인 패턴이 존재하겠지만 아직은 3 Layer Architecture를 기반으로 연습을 진행 중이다. 그리고 웹 앱의 규모가 커질 수록 거의 필수적으로 수반되는 것이 회원기능이었다.
간략히 말해서 웹 앱이 제공하는 서비스에 대하여 사용자에게 혜택을 제공하고 지속성을 영위하며 운영자 입장에서는 서비스에 대한 분석을 용이하게 해주는 필수 기능이다. 그렇기 때문에 회원기능 역시 필수 요소에 포함되며 웹 앱 개발에 있어 당연한 고려 사항이 되었다.
대략적인 웹 앱 서버사이드의 로직 흐름에 있어 회원기능의 필수 과정인 인증 및 인가가 회원기능 로직의 핵심이 된다. 즉, 결국 이것 역시 비즈니스 로직에 포함되기 때문에 통상적인 3계층 설계에 있어, 회원기능은 비즈니스 서비스 계층에서 처리가 이뤄지는 것이 보편적이다.
다만 웹 앱의 규모가 커질 수록 비단 회원기능 외에도 기타 필수 기능들이 포함되면서 자연스럽게 서비스의 규모가 비대해지게 되고, 이 과정에서 서비스의 독립성이 훼손될 가능성이 크다. 특히나 회원기능은 비회원 접근과 회원 접근을 기반으로 포괄적인 기능 관여를 담당하기 때문에 일반적인 비즈니스 계층에서 의존관계가 강하게 잡힐 우려가 다분하다.
여기서 프레임워크의 존재 의의를 생각해야 한다. 결국 개발자가 귀찮아서 대신 해주는 도구의 의미(...)이기 때문에 회원기능을 별도의 어딘가에서 관리를 하고 필요할 때에만 인증 및 인가 처리를 포함시키는 도구가 있으면 좋겠다라는 생각이 들었고 이 역할에 부합한 프레임워크가 스프링 시큐리티였다.
사실 위의 생각은 내 뇌피셜
프레임워크가 보안 관련 기능을 맡음으로써 비즈니스 간 의존관계가 강하게 잡히는 것을 막고 유연성을 확보할 수 있게 된다. 그 외에도 자체적인 강력한 기능들을 제공해주기 때문에 스프링 시큐리티는 꼭 익혀야겠다는 오기가 생겼다.
스프링 시큐리티의 흐름을 이해하기 전에 선행되어야 하는 개념이 있다. 바로 필터다.
기본적으로 스프링 기반 웹 앱에서는 외부의 요청이 들어오면 DispatcherServlet
을 거쳐 각 Controller
로 퍼져나간다. 이때, 각 요청에 대하여 공통적인 처리( ex) 인증 및 인가 )가 필요하다면 그것을 수많은 컨트롤러에 중복으로 작성해서 처리하는 것은 비효율적이다. DispatcherServlet
이전 단계에서 공통 처리를 진행하고 넘어가게 할 수 있는 것이 바로 필터의 역할이 된다.
필터는 하나의 독립 개체이기보다는 여러 층의 일련성을 보이며 처리 작업을 단계별로 나눠 진행하게 된다. 차후에 확인할 인증 필터와 인가 필터의 구분에서도 필터 체인의 모습을 보이게 되며, 자바 코드를 기반으로 한 커스텀 필터를 작성하고 @Order()
어노테이션으로 필터 순서의 정렬이 가능하다.
스프링 시큐리티 역시 필터 체인을 기반으로 인증 및 인가 처리가 이뤄진다. 정확히는 기존의 여러 필터들 사이에 위치한 FilterChainProxy
기반으로 처리가 진행된다. 아래의 그림이 대략적인 스프링 시큐리티의 흐름 모식이다.
사실 흐름을 익히는 것보다는 직접 코드를 보면서 어떤 과정이 어떻게 이뤄지는지에 대해 파악하는 것이 가장 중요하므로 개념적인 설명은 이만 줄이고, 바로 진행했던 프로젝트로 넘어가서 코드 리뷰를 진행하며 시큐리티 흐름 파악을 진행해야겠다.
프로젝트 API 명세서, 회원기능 이외의 기능 설명, 3계층 로직 설명 등은 과감히 배제하고 온전히 스프링 시큐리티 및 회원기능 관련해 집중해서 내 나름의 메모(?)를 끄적끄적할 예정.
여담으로 스프링 시큐리티는 세션 기반이 디폴트 세팅이지만, 이번 프로젝트는 토큰 기반으로 인증 및 인가 처리를 진행하므로 시큐리티 필터를 상속해서 기능을 확장할 예정.
프로젝트의 코드를 분석하기 전에 인증 및 인가 처리를 위해 시큐리티의 회원 처리 로직을 간략하게 파악하고 그것을 코드에 대치해서 이해해봅시당
UsernamePasswordAuthenticationFilter
에서 username(해당 프로젝트에서는 이메일)과 password를 확인하는데, 인증된 사용자의 정보가 담기는 인증 객체인 Authentication
의 종류 중 하나인 UsernamePasswordAuthenticationToken
(후술할 SecurityContext
에 담긴 인증 객체와는 별개)을 만들어 AuthenticationManager
에게 넘겨 인증을 시도하게 된다.
인증이 성공하면 SecurityContextHolder
에 인증 내용을 담게 되는데, 개략적인 모습은 다음과 같다.
SeucrityContextHolder
내부에는 SecurityContext
가 있고, SecurityContext
가 사용자의 인증 정보를 담은 Authentication
객체를 저장한다. Authentication
객체(앞서 본 UsernamePasswordAuthenticationToken
와는 별개)에는 Principal
, Credentials
, Authorities
정보가 담겨 있는데 간단하게 각각 사용자 식별값(ID), 사용자 비밀번호, 사용자 권한이라고 생각하면 편하다.
즉, 대략적인 흐름은 요청 기능에 대한 권한 확인이 먼저 이루어지고, 그 후에 사용자가 해당 자원에 접근할 권한이 있는지를 확인하는 순서로 이뤄진다. 이미 (정상적으로) 로그인한 사용자는 인가 필터만 거치고 바로 디스패처 서블렛을 통해 컨트롤러로 진입하고 인증 필터는 거치지 않게 되며, 만일 로그인을 시도하는 사용자는 인가 필터에서 로그인 정보를 담은 인증 객체를 들고 인증 필터로 넘어가서 토큰을 발급받아 정상적으로 인증 후, 다시 인가 필터에서 토큰 기반으로 권한을 확인받고 디스패처 서블릿으로 넘어가게 된다.
@Getter
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final User user;
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail(); // 스프링 시큐리티 에서는 Username 이라는게 '식별자' 라는 의미로 쓰인다. 회원 이름이 아니다.
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserAuthority authorityEnum = user.getAuthority(); // 유저 권한(열거형 선언)을 UserAuthority 인스턴스에 담음
String authority = authorityEnum.getAuthority(); // 그 권한들을 String 값으로 가져와서 문자열 저장
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority); // 권한값을 담은 SimpleGrantedAuthority 인스턴스
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority); // 권한을 simpleGrantedAuthority로 추상화하여 관리함(아까 말했던 권한에 따른 요청 처리를 위해서)
return authorities; // 권한'들'이 저장된(아마 요청에 따른 뽑혀지는 권한들을 저장하는 리스트일듯?) 리스트 반환
}
UserDetails
를 오버라이딩 구현해서 개발자가 직접 작성한 회원 엔티티의 식별값, 패스워드, 권한(들)을 추출할 수 있게 한다. 이것은 추후에 인증 객체에 담을 정보로써 활용하게 된다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService { // 얘의 목적은 입력된 username(여기서는 이메일)로 DB를 조회해서 일치하는 엔티티를 반환하기 위함
private final UserRepository userRepository;
@Override
// 아까 Authentication 인증 객체에 담을 Principal에 해당하는 UserDetails 생성
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email) // 입력받은 이메일로 DB에서 유저 엔티티 조회
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + email));
return new UserDetailsImpl(user);
}
}
다만, 회원의 식별값은 결국 회원가입할 때, 회원 레포지토리 연동 데이터베이스 테이블에 저장될 테고, 그것을 먼저 추출해서 일치하는 회원 엔티티를 기반으로 주입된 UserDetails
인스턴스를 생성해야 되므로 별도의 구현체를 생성한다.
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
// OncePerRequestFilter -> HttpServletRequest / HttpServletResponse 받아 올 수 있음
// 강의 다시 한번 더 확인
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService; // 사용자가 있는지 확인
인가 필터(JwtAuthorizationFilter
)에서는 토큰의 유효성 검증이 주로 이뤄지게 된다. JSON Web Token 기반으로 생성된 토큰의 개념 설명은 이번 포스팅에서는 생략하며, JWT 관련 처리 로직들은 별도의 JwtUtil
클래스에 정의해서 처리하고 있다.
String token = jwtUtil.getTokenFromRequest(request); // 쿠키에서 존맛탱 가지고 오기(없으면 뭐.. null일 테니)
// 토큰을 갖고 있니?
if (StringUtils.hasText(token)) {
token = jwtUtil.substringToken(token); // 존맛탱 앞글자 싹둑
log.info(token);
// 앞의 "Bearer " 떼어낸 토큰의 유효성 검증(안 유효한 거니?)
if (!jwtUtil.validateToken(token)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(token); // 토큰에서 사용자 정보 뽑아오기
try {
setAuthentication(info.getSubject()); // 토큰에서 뽑은 사용자 정보로 인증 시도
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
// 최종적으로 SecurityContextHolder -> SecurityContext -> Authentication 인증 객체 -> Principal, Credentials, Authorities 담겨진 내용
filterChain.doFilter(request, response); // 그 내용 들고 다음 필터로 넘어가세용
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext(); // 얘가 SecurityContextHolde를 통해 빈 SecurityContext 생성
Authentication authentication = createAuthentication(username); // 그리고 Authentication 인증 객체 만듦
context.setAuthentication(authentication); // 그 인증 객체를 SecurityContext에 담는 거임
SecurityContextHolder.setContext(context); // 최종 세팅
}
// 인증 객체 생성
private Authentication createAuthentication(String username) { // 아까 그 Authentication 인증 객체 만듦
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); // UPAT 생성
// Authentication 인증 객체에 넣는 Principal, Credentials, Authorities
// Principal : 보통 사용자 식별값(UserDetails의 인스턴스를 집어넣음)
// Credentials : 주로 비밀번호, 대부분 사용자 인증 후에 비움
// Authorities : 사용자 부여 권한을 GrantedAuthority로 추상화해서 넣음(권한에 따른 요청 허가 처리 용이를 위해서)
}
토큰의 보유 여부를 확인한 후, 토큰으로부터 사용자 정보를 추출해서 인증 객체에 담는다. 위의 인증 객체는 SecurityContextHolder
가 최종적으로 감싸고 있는 Authentication
필터이다.
인증 필터(JwtAuthenticationFilter
)에서는 로그인을 시도하는 회원의 정보를 바탕으로 유효한지 검증 후, JWT 기반으로 토큰을 발급하는 과정을 맡고 있다.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
// 아까 그 시큐리티 필터체인 중에 UsernamePasswordAuthenticationFilter
// 걔가 제공받은 사용자의 username과 password를 기반으로 UsernamePasswordAutehnticationToken을 만듦
// 저 UPAT가 인증 객체 Authentication 종류 중 하나임
// 저 UPAT를 Authentication Manager에게 넘겨서 인증을 시도하는 건데...
// 직접 상속해서 필터 기능을 확장하는 이유는, 현재 구현하는 방식이 존맛탱이어서 존맛탱 생성까지 기능을 덧붙이기 위함
private final JwtUtil jwtUtil; // 로그인 성공 시, 존맛탱 발급을 위한 의존성 주입
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/users/login"); // 로그인 처리 경로 설정(매우매우 중요)
}
인증 필터 클래스의 생성자에서는 setFilterProcessUrl()
메소드를 기반으로 로그인을 처리할 수 있는 URI 경로를 직접 설정할 수 있다. 다시 말해서 컨트롤러에서 로그인 처리를 위한 Http 기반 메소드를 작성하지 않아도 된다. 이 역시 앞서 언급한 3계층 아키텍처와의 철저한 책임 분리와도 통할 것이다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 로그인 시도를 담당함
log.info("로그인 단계 진입");
try {
UserLoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), UserLoginRequestDto.class); // 입력 스트림, 변환 타입 매개값으로
// JSON 형태로 입력받은 요청을 Object로 바꿈
// 인증 처리 (인증 객체 토큰 생성) - UsernamePasswordAuthenticationFilter 상속 받아서 메서드를 사용할 수 있게 된다
// 인증 처리에서는 권한이 필요 없기 때문에 authorities 를 null 로 넣어준다
return getAuthenticationManager().authenticate( // 인증 처리 하는 메소드
new UsernamePasswordAuthenticationToken(
requestDto.getEmail(),
requestDto.getPassword(),
null
) // Authentication 인증 객체의 종류 중 하나인 UPAT 생성해서 Authentication Manager에게 넘겨줘서 인증 성공 실패 여부 판단 시작
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
JSON
형태로 입력받은 로그인 정보를 자바의 Object
로 매핑한 후, 인증 객체 토큰인 UsernamePasswordAuthenticationToken
을 생성해서 인증 관리 역할을 맡는 AutheticationManager
에게 넘겨주게 된다.
결국 스프링 시큐리티 프레임워크도 스프링 내에서 돌아가는 프레임워크이기 때문에 빈 객체 등록 등의 기본적인 세팅이 필요하다. 다만 얘는 이제 보안 기능과 관련한 설정 정보들을 모아둔 점이 차이점이겠지.
// 커스텀 필터와 시큐리티는 기왕이면 공존시키지 말 것(충돌 우려 열심히 겪어봤으니...)
@Configuration // 빈 등록 수동 등록
@EnableWebSecurity
// 시큐리티 사용하겠다는 의미 꼭 잊지 말 것...
@EnableMethodSecurity(securedEnabled = true)
// securedEnabled는 권한 당 접근 여부 부여(secured 어노테이션을 위해서)
@RequiredArgsConstructor
눈물나는 주석들의 향연.... 커스텀 필터와 시큐리티 필터를 동시에 다룰 때에는 조심해서 다뤄야 한다. 정말 수많은 충돌을 겪을 수 있기 때문이다. 저기서 눈여겨볼 것은 @EnableMethodSecurity
이다. 추후에 컨트롤러 클래스의 메소드에서 적용될 @Secured
어노테이션 적용을 위한 설정 정보이기 때문.
public class SecurityConfig { // 인증 인가를 위한 기본 세팅
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
// Authentication 매니저 만들거임 이거로
// 왜냐하면 곧바로 Authentication Manager를 바로 가져올 수 없어서
private final AuthenticationConfiguration authenticationConfiguration;
// 인증매니저 생성 메서드
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
앞서 봤던 AuthenticationManger
는 직접 생성하지 못 하고, AuthenticationConfiguration
을 기반으로 생성할 수 있다. 그렇기 때문에 인증매니저 생성 메소드가 별도로 빈으로 등록되어 있다.
확장 필터의 서순 정렬은 결국 커스텀의 영역이어서 그부분의 설명은 생략하고, 기본적인 시큐리티의 설정과 관련해서 정리로 마무리할 예정.
시큐리티는 디폴트 로그인이 제공된다. 이는 폼 로그인 기반의 동작 방식이며, 서버를 실행시킬 때마다 랜덤하게 일련된 비밀번호가 제공된다. 물론 이 역시 없애거나 직접 커스터마이징이 가능하다. 아래는 디폴트 로그인을 비록한 기본적인 시큐리티 필터 세팅과 관련된 빈 객체다.
@Bean // 시큐리티에 있어 가장 기본적인 설정 담당(특히 URL 경로별 접근 여부)
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable()); // 크로스 사이트 요청 위조
// 존맛탱 채택을 위한 세션 무상태성화(시큐리티는 세션이 디폴트 세팅)
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers("/api/users/signup").permitAll() // 회원가입만 바로 통과
.anyRequest().authenticated() // 그 외 모든 요청 인증처리 요구
);
// 필터를 만들기만 하면 안 되고, 이제 시큐리티 필터에 끼워넣어야 함
// 로그인 통한 토큰 발급 전에 우선 회원이 토큰을 담아 요청 보냈는지 확인이 우선이어서
// 인가 필터를 인증 필터 앞에 넣어줌
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class); // 인가 처리 필터
// UPAF 전에 입력받은 사용자 아이디랑 비번 바탕으로 인증하고 토큰 발급하고 담아주는 처리해야 함
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); // 인증(+ 로그인) 처리 필터
return http.build();
}