
Spring Security
스프링 프레임워크의 보안 요구 충족을 위한 별도의 프레임워크
Spring Security를 적용하면 자동으로 SpringSecurityFilterChain 이 추가된다
이 SpringSecurityFilterChain이 FilterChain에 추가되어
이 부분에서 Spring Security는 동작한다
Session 기반으로 동작한다, JWT는 커스텀 필요
Spring Security Filter Chain
1. SecurityContextPersistenceFilter → 세션에서 SecurityContext 복원시
2. LogoutFilter → 로그 아웃
3. UsernamePasswordAuthenticationFilter → 폼 로그인 처리
보통 JWT 커스텀 필터를 이 앞에 둔다
4. DefaultLoginPageGeneratingFilter
5. DefaultLogoutPageGeneratingFilter
6. RequestCacheAwareFilter
7. SecurityContextHolderAwareRequestFilter
8. AnonymousAuthenticationFilter → 미인증 사용자 관리 필요시
9. SessionManagementFilter → 세션 관련 설정
10. ExceptionTranslationFilter → 401, 403 핸들링
인증 인가의 예외 처리 담당
11. FilterSecurityInterceptor → 권한 거부 처리 (URL 권한 검사)
요즘엔 AuthorizationFilter 라고 하는 것 같다
시큐리티 내 작동 되는 필터들 (흐름도 위와 같다) 정말 많은데,
밑줄된 3개만 우선 JWT 환경이라면 알아도 무방할 것 같다
흐름
요청
→ SecurityFilterChain 시작
→ UsernamePasswordAuthenticationFilter
또는 커스텀 Filter (JWT)
(순서는 서로 1, 2번을 바뀌게 할 수도 있다)
→ AuthenticationManager (인증 관리자)
→ AuthenticationProvider (인증 관리자가 인증 통과 제공)
→ UserDetailsService.loadUserByUsername()
→ DB에서 사용자 조회
→ Authentication 객체 생성
→ SecurityContext에 저장
→ Controller 도달
→ 응답 (Response)
흐름을 풀어서 설명하면 (실질적으로 작성해주어야하는 부분만),
1. 시큐리티 필터 체인이 돌 때, JWT 필터를 작동하게 포함해준다
2. 로그인 요청을 진행한다 (이 때 URI 매핑된 컨트롤러를 통한 서비스에서 보통 SpringSecurity 내 PasswordEncoder 활용)
3. 요청이 흘러간 필터에서 사용자 정보 (UserDetails) 가 담긴 인증 객체를 저장한다
(SecurityContextHolder 안에 인증 객체를 저장)
위 3과정만 거치면 스프링 시큐리티 내 인증 관리자를 통해 알아서 처리되는 것이다
주요 인터페이스
Authentication
인증 정보를 담는 인터페이스
public interface Authentication {
Object getPrincipal(); // 사용자 정보 (UserDetails)
Object getCredentials(); // 비밀번호
Collection<? extends GrantedAuthority> getAuthorities(); // 권한
boolean isAuthenticated(); // 인증 여부
}UserDetails
사용자 상세 정보를 담는 인터페이스
public interface UserDetails {
String getUsername(); // 사용자명
String getPassword(); // 비밀번호
Collection<? extends GrantedAuthority> getAuthorities(); // 권한
boolean isAccountNonExpired(); // 계정 만료 여부
boolean isAccountNonLocked(); // 계정 잠김 여부
boolean isCredentialsNonExpired(); // 비밀번호 만료 여부
boolean isEnabled(); // 활성화 여부
}UserDetailsService
사용자 정보 조회 인터페이스
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}GrantedAuthority
권한 관련 인터페이스
public interface GrantedAuthority {
String getAuthority(); // 권한 이름들
}SecurityContext
인증 정보 저장소
SecurityContextHolder.getContext().getAuthentication();적용 해보기
gradle 에 적용하고 나면 자동으로
모든 경로에 인증이 필요하고, /login 이라는 경로로 페이지가 생성되어있다
또한 콘솔에 Using generated security password: [ 비밀번호 ]
라는 로그가 등장하는데, 기본적으로 user / [ 비밀번호 ] 의 계정이 만들어져 있다
전체 흐름은 다음과 같이 흘러가게 할 것이다
로그인 요청 → AuthService에서 ID/PW 검증 → JWT 토큰 생성 및 반환
이후 모든 요청 → JwtAuthFilter가 요청 가로챔
→ Authorization 헤더에서 토큰 추출
→ 토큰 검증
→ 토큰에서 이메일 추출
→ UserDetailsService로 사용자 정보 조회
→ SecurityContext에 인증 객체 저장
→ 이후 필터 체인 계속 진행
JWT Filter 작성
JWT Filter 를 적용한 부분 의 설명은 생략한다
@Slf4j
@Order(3)
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
private final AdminDetailService adminDetailService;
// 인증이 필요 없는 URL 패턴
private static final List<String> PERMIT_ALL_PATH_ANT_STYLE = List.of(
"/api/auth/**",
"/api/admins/signUp",
"/api/admins/login"
);
// URL 패턴 검증용 (Ant 스타일)
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청
log.info("JwtAuthFilter IN");
// 올바른 토큰이 있는지?
// Authorization 토큰
String authorization = request.getHeader("Authorization");
if(authorization == null || !authorization.startsWith("Bearer ")) {
MessageResponse errorResponse = MessageResponse.fail(HttpStatus.UNAUTHORIZED.name(), "인증 정보가 없습니다");
String json = objectMapper.writeValueAsString(errorResponse);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(json);
return;
}
// Bearer 검증 잘라내기
String token = authorization.substring("Bearer ".length());
if(!jwtUtil.validateToken(token)) {
MessageResponse errorResponse = MessageResponse.fail(HttpStatus.UNAUTHORIZED.name(), "인증 정보에 대한 확인이 필요합니다");
String json = objectMapper.writeValueAsString(errorResponse);
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write(json);
return;
} else {
// 토큰 검증 완료 되었으니 정보를 담아서 활용
// 토큰에서 이메일 추출
String email = jwtUtil.getEmail(token);
// UserDetailService로 정보 조회
UserDetails loginAdminInfo = adminDetailService.loadUserByUsername(email);
// 인증 객체 생성
UsernamePasswordAuthenticationToken authentication
= new UsernamePasswordAuthenticationToken(
loginAdminInfo // principal (누구인지)
, null // credentials (비밀번호, 저장 하지 않는다)
, loginAdminInfo.getAuthorities()); // 권한 정보
// SecurityContext에 인증 객체 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
// 응답
log.info("Test Filter2 OUT");
}
// URI가 토큰 체크를 해야하는 URI 인지?
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
log.info("JwtFilter IN - request URI : {}", request.getRequestURI());
return PERMIT_ALL_PATH_ANT_STYLE.stream().anyMatch(antStyleUri -> antPathMatcher.match(antStyleUri, request.getRequestURI()));
}
}
SecurityConfig
스프링 시큐리티를 설정부터 하도록 하자
Session 사용을 하지 않고 JWT를 사용할 것이므로
시큐리티의 각종 필터를 disable 하고, 인증 없이 접근 가능한 url을 설정한다
(JWT 일시 csrt, formLogin, httpBasic, sessionManagement 설정은 필수)
그 후, UsernamePasswordAuthenticationFilter 이전에 JWT 필터가
작동할 수 있도록 설정한다
(저 필터가 아닌 다른 필터들 전에 작동하도록 설정하면
그만큼 시큐리티 필터내 메서드를 더 쓸 수 있다고 한다
하지만 일반적인 설정은 저 필터 이전에 설정한다고 한다)
아직 작성 중인 코드고 헛점도 있을 것이다
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
// 시큐리티가 갖고 있는 passwordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
// 시큐리티 필터 체인
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable) // CSRF disable
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 disable
.formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 disable (UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화)
.httpBasic(AbstractHttpConfigurer::disable) // http basic 인증 disable
.authorizeHttpRequests(auth -> auth // 인증 URL 체크
.requestMatchers("/api/auth/**").permitAll() // 인증 없이 접근 가능할 때 permitAll
.requestMatchers("/api/admins/signUp").permitAll()
.requestMatchers("/api/admins/login").permitAll()
.requestMatchers("/api/admins/{adminId:\\d+}/accept").hasRole("SUPER_ADMIN") // SUPER_ADMIN 만 접근 가능
.requestMatchers("/api/products").hasRole("SUPER_ADMIN") // SUPER_ADMIN 만 접근 가능
.anyRequest().authenticated()) // 나머지는 인증 필요
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가 **
return http.build();
}
}UserDetailService 생성
로그인 시 정보를 조회 할수 있도록 하게 하자
@Service
@RequiredArgsConstructor
public class AdminDetailService implements UserDetailsService {
private final AdminRepository adminRepository;
@Override
@NonNull
public UserDetails loadUserByUsername(@NonNull String adminEmail) throws UsernameNotFoundException {
Admin admin = adminRepository.findByEmail(adminEmail).orElseThrow(() -> new UsernameNotFoundException("관리자 계정을 찾을 수 없습니다"));
return User.builder()
.username(admin.getEmail())
.password(admin.getPassword())
.roles(admin.getRole().getRoleCode()) // 역할 설정 (자동으로 GrantedAuthority 객체 생성), [GrantedAuthority(authority="ROLE_SUPER_ADMIN")]
.build();
}
}여기서 roles 를 설정하면, 자동으로 GrantedAuthority 인터페이스의 객체가
생성되어, 권한, 역할 등을 시큐리티가 자동으로 관리할 수 있다
한가지 알아 둘 것은 Role 과 Authority 의 차이인데
Authroity의 특별한 형태가 Role 이라는 것이다
어떤 메뉴가 있을 때, 이 메뉴에 작성, 조회, 수정 등의 기능을 제어할 때는 Authority
예를 들어 상품을 추가, 조회 권한이라면
ADD_PRODUCT, READ_PRODUCT 같은 명칭으로 권한을 가질 것이다
그럼 추가, 조회가 가능한 사람, 조회만 가능한 사람으로 또 나뉘어야 할텐데,
그 때 Role 을 사용하는 것이다
예를 들어, CS만 관리하는 사람은 상품의 조회만
슈퍼 관리자는 추가, 조회, 수정, 제거 등 모든 것이 가능 할 것이다
@Getter
public enum AdminRole {
SUPER_ADMIN("SUPER_ADMIN", "슈퍼 관리자")
, CS_ADMIN("CS_ADMIN", "고객 관리자")
, OP_ADMIN("OP_ADMIN", "운영 관리자")
, @JsonEnumDefaultValue UNKNOWN("UNKNOWN", "알수 없음");
private final String roleCode;
private final String roleDescription;
AdminRole(String roleCode, String roleDescription) {
this.roleCode = roleCode;
this.roleDescription = roleDescription;
}
}위 코드는, 지금의 토이 프로젝트에 설정한 역할 Enum 클래스인데
세부적인 권한을 또 나눠야 한다면
@Getter
public enum Permission {
PRODUCT_READ,
, PRODUCT_WRITE
, PRODUCT_DELETE
, PRODUCT_MODIFY
}라고 클래스를 만들어 활용할 수 있을 것이다
실무에서는 역할로 계층적은 기능을 나누지만 항상 예외로 어떤 기능에 접근해야
하는 경우가 많을 것이라 충분히 구현가능 하도록 시큐리티가 되어 있는 것이다
조금만 생각을 해보면 되는 내용이라 더 자세히 적진 않고, 지금의 토이 프로젝트는
우선 Role 만 가져가고, Authroity은 추후에 생각하겠다
인증 정보를 활용하기
이제 위 2가지를 설정하면 시큐리티 내 필터를 통해 인증 객체를
SecurityContextHolder에 저장할 텐데, 이 객체의 정보 (SecurityContext)를 통해
비즈니스 로직을 구현하면 되는 것이다
다만 컨트롤러, 서비스 레이어에서 꺼내는 방법이 조금 다른데
컨트롤러 레이어에선 @AuthenticationPrincipal 를 활용하여 인증 객체에 접근한다
@GetMapping("/me")
public ResponseEntity<DataResponse<GetMyProfileResponse>> getMyProfile(
@AuthenticationPrincipal UserDetails loginAdminInfo
) {
...
}서비스 레이어에선 SecurityContextHolder에 직접 접근하여
SecurityContext 내에 인증 정보인 Authentication 을 get 하여 활용한다
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public void createProduct(ProductRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
AdminDetail adminDetail = (AdminDetail) authentication.getPrincipal();
Long adminId = adminDetail.getAdmin().getId();
...
}시큐리티의 예외처리 등은 추후에 포스트한다
이 정도만 알아도 우선 기능의 작성은 충분히 가능하리라 생각한다