아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.
강의 코드를 그대로 따라 친 것이 아닌 제 나름대로 작성한 코드들이 있기 때문에 강의 코드와 동일하진 않습니다.
스프링부트 3.0 부터 스프링 시큐리티 6.0 이상의 버전이 적용되면서 몇 가지 변경사항이 존재합니다. ( Spring Security 6.0 변경사항 )
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
...
}
}
이전 버전에서는 WebSecurityConfigAdapter 를 상속받아 config 를 구현할 수 있었습니다.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.build();
}
}
상위 버전에서는 WebSecurityConfigurerAdapter 가 deprecated 되었기 때문에 filterChain 을 이용하여 작성해야 합니다.
위와 관련된 사항은 Spring Security without the WebSecurityConfigurerAdapter 문서에서 확인할 수 있습니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.maxAge(500)
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS");
}
}
위의 방식은 Spring MVC에서 제공하는 CORS 설정 방법으로, addCorsMappings()
를 오버라이드하여 CORS 구성을 직접 지정합니다. 이렇게 설정된 클래스는 Spring MVC 의 모든 엔드포인트에 대해 CORS 정책을 적용합니다.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(httpSecurityCorsConfigurer ->
httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource())
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOriginPatterns(List.of("*"));
corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"));
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type"));
corsConfiguration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration); // 모든 경로에 대해서 CORS 설정을 적용
return source;
}
}
corsConfigurationSource()
는 Spring Security 에서 CORS를 설정하는 메서드입니다. 설정된 CORS 를 filterChain 에서 http.cors()
메서드를 호출하여 활성화합니다.
CORS는 다른 도메인에서 리소스에 접근할 수 있도록 웹 애플리케이션 간의 리소스 공유를 허용하는 메커니즘입니다. 이를 통해 동일 출처 정책(Same Origin Policy)에 의해 제한되는 다른 도메인에서의 Ajax 요청 등을 처리할 수 있게 됩니다.
WebMvcConfigurer : Spring MVC에 대한 CORS 설정이므로, 주로 컨트롤러에 대한 CORS 정책을 지정하는 데 사용됩니다. Spring Security 와 독립적으로 동작하며 Spring Security가 활성화되어 있어도 사용 가능합니다.
CorsConfigurationSource : Spring Security에 대한 CORS 설정이므로, 주로 보안 관련된 역할을 하는 컨텍스트에서 사용됩니다. 보안 설정과 통합되어 클라이언트 측의 보안 요구 사항을 처리하는 데 사용되며 Spring Security가 적용되는 컨텍스트에서 동작합니다.
WebMvcConfigurer 를 구현한 클래스는 Spring MVC 에 대한 CORS 설정이기 때문에 Spring Security 에 영향을 주지 않고 CorsConfigurationSource 는 Spring Security 에 대한 CORS 설정이기에 Spring MVC에 영향을 주지 않습니다.
public class MemberDTO extends User {
...
public MemberDTO(String email, String password, List<String> roleNames, String nickname, boolean socialFlag) {
super(email, password,
roleNames.stream().map(str ->
new SimpleGrantedAuthority("ROLE_"+str)).collect(Collectors.toList()));
...
}
}
User 클래스는 Spring Security 에서 제공하는 UserDetails 인터페이스를 구현한 클래스 중 하나입니다. 생성자는 아래와 같으며, authorities 는 사용자의 권한을 나타내는 GrantedAuthority 컬렉션입니다.
public User(String username, String password, Collection<? extends GrantedAuthority> authorities)
SimpleGrantedAuthority 는 Spring Security에서 제공하는 GrantedAuthority 인터페이스의 간단한 구현체 중 하나입니다. GrantedAuthority 는 사용자가 가지는 권한을 나타내는 인터페이스이며, 인증 및 권한 부여를 위해 사용됩니다.
SimpleGrantedAuthority 는 문자열로 표현된 권한을 담는 클래스로, 일반적으로 "ROLE_"
접두사를 포함한 문자열로 권한을 표현합니다. 예를 들어, "ADMIN"이라는 권한은 "ROLE_ADMIN"으로 표현됩니다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
...
}
UserDetails 인터페이스는 Spring Security 에서 사용자의 정보를 제공하는 인터페이스입니다. 이 인터페이스는 사용자의 주요 정보와 권한 정보를 제공하여 Spring Security가 인증 및 권한 부여를 수행할 때 사용됩니다.
UserDetails 를 구현하는 클래스는 사용자의 식별자, 비밀번호, 권한 목록 등을 포함하여 사용자의 상세 정보를 제공해야 합니다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("################## UserDetailsService ##################");
log.info("################## loadUserByUsername ##################");
log.info("username = {}", username);
Member member = memberRepository.findMemberWithRoles(username);
if (member == null) {
throw new UsernameNotFoundException("존재하지 않는 사용자입니다");
}
log.info("member = {}", member);
log.info("member's role = {}", member.getMemberRoleList());
return new MemberDTO(
member.getEmail(),
member.getPassword(),
member.getNickname(),
member.isSocialFlag(),
member.getMemberRoleList().stream()
.map(Enum::name).collect(Collectors.toList()));
}
}
UserDetailsService 는 로그인을 처리할 때 동작합니다. 사용자가 입력한 id 를 전달받고, repository 를 이용해 DB 에서 사용자를 찾아 있다면 MemberDTO 를 반환하고, 없다면 예외를 발생시킵니다.
loadUserByUsername 의 반환형을 보면 UserDetails 임을 알 수 있습니다. UserDetails 를 구현한 클래스가 User 이고, MemberDTO 가 User 를 상속받기 때문에 MemberDTO 를 반환할 수 있습니다.
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("################## LoginSuccessHandler ##################");
log.info("################## 로그인 성공!!! ##################");
log.info("authentication = {}", authentication);
log.info("authentication.getPrincipal() = {}", authentication.getPrincipal());
MemberDTO principal = (MemberDTO) authentication.getPrincipal();
...
}
}
AuthenticationSuccessHandler 는 사용자의 인증이 성공했을 때의 동작을 정의하는 인터페이스이며, 사용자 인증에 성공하면 onAuthenticationSuccess()
메서드가 호출됩니다.
즉, UserDetailsService 에서 로그인이 성공하면 동작하는 핸들러이며 SecurityConfig 의 filterChain 에서 해당 핸들러를 스프링 빈으로 등록할 수 있습니다.
반면 로그인을 실패하면 AuthenticationFailureHandler 의 onAuthenticationFailure()
메서드를 호출하게 됩니다.
Authentication 객체는 Spring Security 에서 사용자의 인증 정보를 담고 있습니다. getPrincipal()
메서드를 통해 사용자 정보에 접근할 수 있습니다. 이 정보는 UserDetails 를 구현한 객체이며, 이 경우에는 MemberDTO 객체가 사용됩니다.
public class JwtVerifyFilter extends OncePerRequestFilter {
private static void checkAuthorizationHeader(String header) {
if(header == null) {
throw new CustomJWTException("토큰이 전달되지 않았습니다");
} else if (!header.startsWith(JwtConstant.JWT_TYPE)) {
throw new CustomJWTException("BEARER 로 시작하지 않는 올바르지 않은 토큰 형식입니다");
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
...
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("################## JwtVerifyFilter doFilterInternal ##################");
String authHeader = request.getHeader(JwtConstant.JWT_HEADER);
try {
checkAuthorizationHeader(authHeader); // header 가 올바른 형식인지 체크
String token = JwtUtils.getTokenFromHeader(authHeader);
Map<String, Object> claims = JwtUtils.validateToken(token);
filterChain.doFilter(request, response); // 다음 필터로 이동
} catch (Exception e) {
...
}
}
}
OncePerRequestFilter 는 각각의 HTTP 요청에 대해 doFilterInternal()
가 단 한 번만 호출되며, 동일한 필터가 동일한 요청에 대해 여러 번 실행되는 것을 방지할 수 있습니다.
shouldNotFilter()
는 해당 필터를 건너뛸 조건을 정의할 수 있는데 해당 메서드가 true 를 반환하면 건너뛰고, false 를 반환하면 현재 필터가 동작하게 됩니다.
doFilterInternal()
내부에 필터의 동작을 정의하게 되는데 filterChain.doFilter(request, response)
를 꼭 작성해야 다음 필터로 이동하게 됩니다.
이때 Authorization 헤더가 전달되는지, 내부의 값은 올바르게 시작하는지를 판단하는 것이 필요했는데 try, catch 내부에서 if 문을 사용하는 것은 좋지 않다고 판단하여 메서드를 따로 작성하기로 하였습니다.
header 가 올바른 형식인지 체크하는 checkAuthorizationHeader()
에서 올바르지 않은 경우 예외를 발생시키도록 하였고, 헤더의 값을 판단하는 것과 토큰이 유효한지 판단하는 것 모두를 try 문 안에서 처리하여 한 번에 예외처리가 가능하도록 하였습니다.
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
// 필터 설정
http.addFilterBefore(new JwtVerifyFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
가장 상단은 서버로 들어온 요청을 의미합니다. 그 아래 로그를 보면 addFilterBefore()
를 통해 등록한 JwtVerifyFilter 가 UsernamePasswordAuthenticationFilter 앞에서 동작한 것을 알 수 있습니다.
@PreAuthorize
는 Spring Security에서 제공하는 어노테이션 중 하나로, 메서드 레벨에서 접근 제어를 설정하는 데 사용됩니다.
이 어노테이션을 사용하면 메서드를 호출하기 전에 미리 정의된 보안 조건을 확인할 수 있습니다. 지정된 보안 조건을 확인하며, 조건을 만족하지 않으면 메서드가 실행되지 않습니다. SpEL을 사용하여 역할, 권한 등을 유연하게 지정할 수 있습니다.
@Service
public class MyService {
@PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_ADMIN')")
public void userOrAdminOperation() {
// 이 메서드는 ROLE_USER 또는 ROLE_ADMIN 역할을 가진 사용자에게만 허용됨
// 다른 사용자는 접근 불가
}
@PreAuthorize("hasPermission(#itemId, 'read')")
public void checkItemPermission(Long itemId) {
// 이 메서드는 현재 사용자가 특정 itemId에 대한 'read' 권한을 가지고 있는지 확인
}
}
위의 예시처럼 hasAnyRole
을 사용하여, 메서드 접근 권한을 설정할 수도 있고, hasPermission(#targetObject, 'permission')
을 사용하여 특정 object 에 대한 권한을 설정할 수도 있습니다.
@EnableMethodSecurity
는 Spring Security 에서 제공하는 어노테이션 중 하나로, 메서드 레벨의 보안을 활성화하는 데 사용됩니다.
이 어노테이션을 SecurityConfig 에 사용하면 @PreAuthorize
, @Secured
, @RolesAllowed
등의 메서드 레벨 보안 어노테이션을 사용할 수 있게 됩니다.
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...
}
@EnableMethodSecurity
에 속성을 부여해서 특정 어노테이션만을 활성화할 수 있습니다. 속성을 true 로 설정하면 특정한 어노테이션을 사용할 수 있습니다. ( Method Security)
예를 들면 securedEnabled 속성은 @Secured
의 활성화 여부를, prePostEnabled 속성은 @PreAuthorize
및 @PostAuthorize
의 활성화 여부를 지정합니다.
위의 어노테이션들을 활용해서 메서드 레벨에서 권한을 검사하기 위해서는 아래처럼 JwtVerifyFilter 에서 추가적인 작업이 필요합니다.
public class JwtVerifyFilter extends OncePerRequestFilter {
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
...
try {
...
Map<String, Object> claims = JwtUtils.validateToken(token);
// 토큰 검증이 성공한 경우 추출할 수 있는 사용자 정보들
String email = (String) claims.get("email");
String password = (String) claims.get("password");
...
// 사용자의 정보를 통해 새 DTO 객체를 생성
MemberDTO memberDTO = new MemberDTO(email, password, nickname, socialFlag, roleNames);
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(memberDTO, password, memberDTO.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response); // 다음 필터로 이동
} catch (Exception e) {
...
}
}
}
1. UsernamePasswordAuthenticationToken 객체 생성
UsernamePasswordAuthenticationToken 은 Spring Security에서 사용자 인증에 사용되는 특별한 Authentication 구현체입니다.
토큰에 있던 정보를 활용하여 MemberDTO 를 생성하고, 생성자에 사용자 정보(memberDTO), 사용자의 비밀번호(password), 사용자의 권한(memberDTO.getAuthorities())를 전달하여 새로운 인증 토큰을 생성합니다.
2. SecurityContextHolder에 인증 정보 설정
SecurityContextHolder 는 Spring Security에서 현재 실행 중인 스레드의 SecurityContext 를 관리하는 역할을 합니다.
SecurityContextHolder.getContext()
로 현재의 SecurityContext 를 얻은 후, setAuthentication()
을 사용하여 새로 생성한 UsernamePasswordAuthenticationToken 을 설정합니다.
이 코드의 주요 목적은 사용자를 로그인 상태로 만들어 현재 실행 중인 스레드에서 인증된 사용자에 대한 정보를 사용할 수 있도록 하는 것입니다.
이렇게 하면 현재 스레드의 인증 정보가 설정되어, 해당 사용자가 인증되었다는 정보를 유지할 수 있습니다. 즉, 서버를 내렸다가 올려도 토큰은 유지가 되기 때문에 보호된 리소스에 접근할 수 있습니다.
이후에는 이 인증 정보를 기반으로 보호된 리소스에 접근할 때 Spring Security 가 이를 활용하여 권한 검사 등을 수행할 수 있습니다.
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
...
}
}
인증은 성공했지만 특정 리소스에 대한 권한이 없어 접근이 거부 되었을 때는 위의 AccessDeniedHandler 가 동작합니다. 접근이 거부되었을 때 어떤 동작을 할지를 handle()
메서드 안에 정의합니다.
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
// 접근 거부 시 핸들러 등록
http.exceptionHandling(httpSecurityExceptionHandlingConfigurer -> {
httpSecurityExceptionHandlingConfigurer.accessDeniedHandler(new CustomAccessDeniedHandler());
});
return http.build();
}
}
Access Token 과 Refresh Token 에 대한 설명은 이전 게시글에서 확인하실 수 있습니다.
private static final String[] whitelist = {"/api/member/**", "/api/refresh"};
refresh 를 위해 호출할 때, Access Token 이 만료되었다면 JwtVerifyFilter 에서 잡히기 때문에 해당 URI 는 filter 를 거치지 않도록 추가합니다.
@RestController
@RequiredArgsConstructor
public class JwtController {
@RequestMapping("/api/refresh")
public Map<String, Object> refresh(@RequestHeader("Authorization") String authHeader, String refreshToken) {
...
String accessToken = JwtUtils.getTokenFromHeader(authHeader);
// Access Token 의 만료 여부 확인
if (!JwtUtils.isExpired(accessToken)) {
return Map.of("Access Token", accessToken, "Refresh Token", refreshToken);
}
// refreshToken 검증 후 새로운 토큰 생성 후 전달
Map<String, Object> claims = JwtUtils.validateToken(refreshToken);
String newAccessToken = JwtUtils.generateToken(claims, JwtConstant.ACCESS_EXP_TIME);
String newRefreshToken = refreshToken;
// Refresh Token 의 만료 시간이 한 시간도 남지 않은 경우
if (JwtUtils.tokenRemainTime((Integer) claims.get("exp")) <= 60) {
newRefreshToken = JwtUtils.generateToken(claims, JwtConstant.REFRESH_EXP_TIME);
}
return Map.of("Access Token", newAccessToken, "Refresh Token", newRefreshToken);
}
}
위의 Controller 에서는 Access Token 의 만료여부를 판단하고, 만료된 경우 Refresh Token 을 검증합니다.
Refresh Token 검증 후, Access Token 을 재발급하는데 이때, Refresh Token 의 만료시간이 한 시간이 남지 않았다면 Refresh Token 도 재발급합니다.
public class JwtUtils {
...
public static Map<String, Object> validateToken(String token) {
Map<String, Object> claim = null;
try {
...
} catch(ExpiredJwtException expiredJwtException){
throw new CustomExpiredJwtException("토큰이 만료되었습니다", expiredJwtException);
}...
}
}
validateToken()
은 토큰을 검증하는 메서드입니다. 토큰 검증 시 토큰이 만료된 경우라면 ExpiredJwtException 예외를 던지게 되는데 이를 이용해 CustomExpiredJwtException 를 생성하였습니다.
public class JwtUtils {
...
public static boolean isExpired(String token) {
try {
validateToken(token);
} catch (Exception e) {
return (e instanceof CustomExpiredJwtException);
}
return false;
}
public static long tokenRemainTime(Integer expTime) {
Date expDate = new Date((long) expTime * (1000));
long remainMs = expDate.getTime() - System.currentTimeMillis();
return remainMs / (1000 * 60);
}
}
validateToken()
은 토큰 만료 시, 예외를 던지기 때문에 만료된 경우의 추가적인 동작을 위해 만료 여부를 판단하는 isExpired()
메서드를 작성하였습니다.
validateToken()
을 호출하고, 만약 catch 한 예외가 CustomExpiredJwtException 와 동일하다면 토큰이 만료된 것이 맞다고 boolean 을 반환합니다.
tokenRemainTime()
은 토큰에 있는 만료 시간을 전달 받아 만료 시간까지 남은 시간을 계산합니다. 이 함수에서 반환한 값으로 Refresh Token 재발급 여부를 결정합니다.