[Spring] Spring Security + JWT 로 일반 로그인 개발

이정진·2024년 6월 23일
0

개발

목록 보기
15/21
post-thumbnail

Spring Security + JWT

Spring Boot의 버전을 3점대로 올린 이후, Security를 오랜만에 사용하게 되어, 찾는 수고로움을 덜고 이해한 내용을 정리해서 재사용하고자 이렇게 글로 작성하기로 결정했다.

개발 환경

  • Java17
  • Spring Boot 3.2.1
  • Spring Security 6.3.0
  • PostgreSQL 15.5

Spring Security

Spring Security란?

Spring Security Docs에서는 이렇게 정의하고 있다.
Spring Security is a framework that provides authentication, authorization, and protection against common attacks. With first class support for securing both imperative and reactive applications, it is the de-facto standard for securing Spring-based applications. 간단하게 요약하자면, 일반적인 공격에 대한 인증, 인가 및 보호를 제공하는 프레임워크이며, Spring 기반 애플리케이션 보안을 위한 사실상의 표준이라고 한다.

인증(Authentication)
인증은 사용자가 자신을 식별하고 자신이 주장하는 신원을 입증하는 과정

인가(Authorization)
인가는 증된 사용자에 대해 해당 사용자가 특정 자원 또는 기능에 접근할 수 있는 권한을 가지고 있는지를 확인하는 과정

인증 -> 인가의 순서이다.

Spring Security는 Filter 위치에서 로직을 처리한다. (참고 자료: Servlet Application)

Spring의 요청 처리 순서
HTTP 요청 -> WAS -> Filter -> Servlet -> Interceptor -> Controller
(참고 자료: gromit.blog)

Spring Security는 세션 방식과 토큰 방식을 둘 다 사용할 수 있다. 여기서는 세션이 아닌 토큰 방식을 사용하는 방법을 다룰 예정이다.

Spring Security 주요 모듈

(출처: Spring Security 구조, 흐름 그리고 역할 알아보기, 2024-06-23)

위 이미지에서 확인할 수 있는 모듈 중 중요한 모듈 정보들은 아래와 같다.

  • Security filter chain
    Spring Security에서 HTTP 요청을 처리하는 데 사용되는 일련의 보안 필터들의 체인이다. 일반적으로 FilterChainProxy가 이 체인을 관리하며, 이 필터 체인은 Spring Security 설정에서 정의된다.
    (출처: Spring Security Docs)

  • SecurityContextHolder
    SecurityContext를 가지고 있으며, Spring Security 인증 모델의 핵심이다.

    (출처: Spring Security Docs)

  • SecurityContext
    Authentication 객체를 보관하는 역할

  • Authentication
    현재 접근한 사용자의 정보 및 권한을 담고 있으며, 이 객체는 Security Context에 저장되어 있다.
    크게 두 가지의 목적으로 활용된다.
    1. AuthenticationManager에게 input으로 활용되어, 사용자의 자격을 증명할 때 사용됩니다.
    2. 현재 인증된 사용자가 누구인지를 나타낸다. 아래의 3가지 정보를 담고 있다.

    • principal: 사용자 기본 정보를 가지고 있으며, 주로 UserDetails 객체입니다.
    • credentials: 주로 비밀번호를 저장할 때 활용되며, 유출을 방지하고자 사용자 인증 후 삭제된다.
    • authorities: GrantAuthority 객체를 통해 사용자에게 부여된 권한을 가지고 있습니다.
  • GrantedAuthority
    GrantedAuthority는 사용자의 역할과 범위에 대한 정보를 가진다. 이 정보들은 Authentication.getAuthorities()를 활용해 조회할 수 있다. GrantedAuthority 객체는 UserDetailsService에 의해 로드된다.

  • AuthenticationManager
    실제 인증을 수행하는 부분으로, 일반적인 구현은 ProviderManager를 사용한다. 즉, AuthenticationManager에서 AuthenticationProvider를 활용하여 인증을 수행한다.

  • AuthenticationProviders
    인증 전 객체를 받아 인증 수행 후의 객체를 반환한다. ProviderManager에 여러 개의 AuthenticationProvider를 삽입하여 활용할 수 있다.


(출처: Spring Security Docs)

  • UserDetails
    UserDetailsService에 의해 반환되는 객체로, Spring Security가 사용하는 사용자의 상세 정보를 나타내는 인터페이스이다. 포함할 수 있는 정보로는 사용자의 이름, 암호화된 비밀번호, 권한 정보 등이 있으며, 이를 묶어 하나의 VO로 활용할 수도 있다.

  • UserDetailsService
    UserDetailsService 인터페이스는 UserDetails 객체를 반환하는 단 하나의 메소드를 가지고 있다. 전달받은 사용자 식별 정보를 기반으로 UserRepository를 주입받아 DB에서 사용자 정보를 조회한 이후, 해당 정보를 UserDetails 객체로 반환한다.

Spring Security 동작 과정

Spring Security 동작 순서는 아래의 이미지로 확인할 수 있다.

인증 로직 (출처: https://mangkyu.tistory.com/76, 2024-01-13)

  1. HTTP 요청
    사용자가 ID, Password와 같은 식별 정보를 기반으로 인증 요청을 보낸다.

  2. 사용자의 요청을 Authentication 필터가 가로챈 이후, 인증에 활용되는 UserPasswordAuthenticationToken 객체를 생성한다.

  3. 해당 UserPasswordAuthenticationToken 객체를 AuthenticationManager에게 전달한다.AuthenticationManager은 일반적으로 ProviderManager를 구현체로 사용한다.

  4. ProviderManager는 등록된 AuthenticationProvider를 활용해 인증을 진행한다.

  5. AuthenticationProvider는 UserDetailsService에게 사용자 인증 정보를 반환하도록 요청한다.

  6. UserDetailsService는 전달받은 정보를 기반으로 UserRepository에서 사용자 정보를 조회한다. 이후, 이를 UserDetails 객체로 만든다.

  7. 6번 과정을 통해 생성된 UserDetails 객체를 AuthenticationProvider에게 전달한다.

  8. AuthenticationProvider에서 인증 후 Authentication 객체를 반환받는다. 이 과정에서, 인증이 완료되지 않으면 Exception이 발생한다.

  9. AuthenticationFilter에게 Authentication 객체를 반환한다.

  10. SecurityContext에 Authentication 객체를 저장한다.

위 과정은 Spring Security의 가장 기본적인 방법인데, JWT를 활용해서 진행하다 보니, 아래 실제 개발 과정은 위에서 일부분을 변경해서 사용한다.

JWT

JWT란?

jwt.io에서는 JWT을 아래와 같이 정의한다.
Json Web Token의 약자로, 당사자 간에 정보를 JSON 개체로 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준(RFC 7519)이다. 이 정보는 디지털 서명이 되어 있으므로 확인하고 신뢰할 수 있다. JWT는 비밀(HMAC 알고리즘 사용) 또는 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있습니다.
즉, Json 기반이며 Claims에 사용자 정보를 담아서 활용하는 Web Token이다.

JWT 구조

jwt.io를 보면, JWT가 크게 3가지의 속성을 가지는 것을 확인할 수 있다.

Header
Header는 alg과 typ 정보를 가지고 있다.

  • alg: 알고리즘 방식 (HS256, ..., etc)
  • typ: 토큰의 타입이 무엇인지 (여기서는 JWT)

Payload
토큰에서 사용될 정보들을 담고 있는 클레임(Claim)이 담겨 있다.
클레임은 등록 클레임, 공개 클레임과 비공개 클레임으로 나누어지며, 등록 클레임은 기본적으로 기입하도록 지정되어 있는 정보이고, 공개 클레임은 충돌 방지를 위한 공개용 정보, 비공개 클레임은 개발자가 임의로 지정한 정보들이다.
클레임은 key-value형태로 저장된다.

대표적인 클레임 목록은 아래와 같다. (자세한 클레임 정보는 이 사이트를 통해 확인할 수 있다.)

등록 클레임

  • iss: issuer의 약자로 토큰 발급자를 의미한다.
  • sub: subject의 약자로 토큰 제목을 의미한다.
  • aud: audience의 약자로 토큰 대상자를 의미한다.
  • exp: expiration의 약자로, 토큰 만료시간을 의미하며 Nuemric Date를 활용한다.

비공개 클레임 (아래는 예시)

  • email: test@test.com
  • role: admin

Signature
토큰이 클라이언트와 서버 간에 안전하게 전송되고, 변조되지 않았음을 보장하며, 발급자의 신원을 인증하는 데 역할을 하는 암호화 코드이다.
application.yml에서 설정하는 Secret Key가 암호화 코드로 사용된다.

JWT는 각 속성별로 Base64 인코딩되어 표현되며, 각 속성별 구분자는 .이다.

발급한 JWT에 어떤 정보가 담겨 있는지는 해당 토큰을 jwt.io에서 기입하여 직접 확인할 수 있다.

JWT를 사용하는 이유?

  1. 무상태성 (Stateless)
    토큰을 클라이언트에 저장하고 활용하기에, 서버는 상태 관리를 별도로 진행할 필요가 없다.

  2. 확장성 (Scalability)
    토큰을 서버에서 관리하지 않기에, 서버가 여러 대로 확장되었을 때 문제 발생 요소가 없다.

  3. 보안성
    세션 방식은 쿠키를 활용하는데, 이 쿠키 관련 취약점을 활용할 수 없게 되어 보안성에 좋다. 물론, 토큰 환경의 취약점도 존재한다.

  4. CORS
    OAuth2.0등의 소셜 로그인과 결합하여 사용할 때, CORS 문제에서 자유롭다.

JWT를 활용하는 API의 비즈니스 로직

토큰을 발급하여 활용하는 가장 대표적인 경우는 로그인과 같은 인증/인가 API다.
로그인은 일반 로그인과 OAuth를 활용한 소셜 로그인으로 나눌 수 있다.

각 방식별 비즈니스 로직은 아래와 같다.
(서비스 특성에 따라 로직은 달라질 수 있으며, Refresh Token을 활용한 방식은 포함하지 않았다.)

일반 회원가입/로그인
1. 사용자의 회원가입 요청
2. 요청 정보 확인하여, 기 가입 여부 확인 및 회원 정보 등록 및 성공 응답 반환
3. 사용자의 로그인 요청
4. 요청 정보 확인하여, 기 가입 여부 확인 및 해당 회원 정보 기반으로 Access Token 생성

OAuth 로그인
1. 사용자의 회원가입/로그인 요청
2. 요청 정보 확인하여, 기 회원가입 유저일 경우 로그인으로 아닐 경우 회원가입으로 처리
3. 해당 사용자 데이터 기반으로 Access Token 발급

적용

ERD 설계

사용자에 관한 정보는 크게 두 종류의 테이블로 나누어 관리한다.

  • users: 사용자의 기본 정보를 관리하는 테이블
  • credentials: 사용자의 중요 정보를 관리하는 테이블 (ex: 비밀번호 등)

개발

  1. build.gradle에 Dependency 추가
	// Spring Security
	implementation 'org.springframework.boot:spring-boot-starter-security'

	// JWT
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
  1. SecurityConfig 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
                .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }

    /**
     * CORS 허용하도록 커스터마이징 진행
     * @return - 변경된 CORS 정책 정보 반환
     */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // 인증정보 주고받도록 허용
        config.setAllowCredentials(true);
        //
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("*"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
  • SecurityConfig 설정 과정에서, Security 6.1.0 버전 이후로는 람다식의 형태로 선언하도록 바뀌었음을 주의해야한다. 기존 방식대로 선언 시, 아래와 같은 안내를 확인할 수 있다.
'cors()' is deprecated since version 6.1 and marked for removal
  • cors의 경우, withDefault()로 선언 시, Bean으로 CorsConfiguration이 있다면 자동으로 주입하고 여러 개가 있다면 하단과 같이 주입할 수 있다.
    (관련 내용: Spring Security 문서 - CORS)
public SecurityFilterChain myOtherFilterChain(HttpSecurity http) throws Exception {
		http
			.cors((cors) -> cors
				.configurationSource(myWebsiteConfigurationSource())
			)
			...
		return http.build();
	}
  • 세션 구조를 사용하지 않을 것이기에, STATELESS를 사용한다.
    (관련 내용: Spring Secuirty 문서 - session)

  • JWT를 활용한 API 방식이기에 httpBasic과 formlogin 비활성화
    (관련 내용: 인프런 Q&A)

  1. application.yml 설정
jwt:
  secret: {JWT_SECRET_KEY}
  expiration-time: 108000000

JWT를 사용하기 위한 Secret Key를 yml에 설정해야 한다.
해당 Secret Key는 외부에 노출되지 않아야 한다. 해당 Key를 노출된다면, 해당 Key를 악용하여 토큰 생성 및 변조 등이 가능하기 때문이다.
Secret Key는 최소 512bits 이상의 값(= 64글자 이상)을 설정하는 것을 권장한다.
(관련 내용: Auth0)
이에 더해, 만료시간은 서비스의 특성별로 달리한다.

  1. JwtProvider

JwtProvider는 아래와 같은 기능을 제공한다.

  • ValidateToken: 토큰의 유효성 검사
  • GenerateToken: 주어진 정보 기반으로 토큰 생성(여기서는 email)
  • GetEmailFromToken: 토큰에서 사용자를 식별할 수 있는 정보 추출(여기서는 email)
  • etc: 만료 시간을 확인하는 메소드 등
@Component
@RequiredArgsConstructor
public class JwtProvider {

    @Value("${jwt.secret}")
    private String secretKey;
    private Key key;
    @Value("${jwt.expiration-time}")
    private long expirationTime;

    @PostConstruct
    protected void init() {
        byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey);
        key = Keys.hmacShaKeyFor(secretKeyBytes);
    }

    /**
     * JWT 생성
     * @param user 사용자 정보
     * @return 사용자 정보를 기반으로 추출된 토큰 반환
     */
    public String generateToken(User user) {
        Claims claims = getClaims(user);

        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + expirationTime))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return claims.getBody().getExpiration().after(new Date());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public String getEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    }

    /**
     * 토큰의 만료기한 반환
     * @param token 일반적으로 액세스 토큰 / 토큰 재발급 요청 시에는 리프레쉬 토큰이 들어옴
     * @return 해당 토큰의 만료정보를 반환
     */
    public Long getExpirationTime(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().getTime();
    }

    /**
     * Claims 정보 생성
     * @param user 사용자 정보 중 사용자를 구분할 수 있는 정보 두 개를 활용함
     * @return 사용자 구분 정보인 이메일과 역할을 저장한 Claims 객체 반환
     */
    private Claims getClaims(User user) {
        return Jwts.claims().setSubject(user.getEmail());
    }
}
  1. CustomUserDetails

기본 UserDetails의 구현체를 커스텀해서 사용한다.
보통 class로 많이 사용하지만, Java17 이후 버전부터는 record로 변환할 수 있어, record로 개발했다.
@Override를 활용해야 하는 is~~() 메소도들은 대부분 JWT 인증방식에서 활용하지 않기에, 아래와 같이 true로만 반환하도록 구성했다.

public record CustomUserDetails(User user) implements UserDetails {

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<String> roles = new ArrayList<>();
        roles.add("ROLE_" + user.getRole().toString());

        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return ""; // 비밀번호는 별도로 관리하고 있으므로, 해당 메소드는 빈 문자열을 반환하도록 설정
    }

    @Override
    public String getUsername() {
        return user.getName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true; // JWT 인증방식이므로 접근 가능하도록 설정
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // JWT 인증방식이므로 접근 가능하도록 설정
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true; // JWT 인증방식이므로 접근 가능하도록 설정
    }

    @Override
    public boolean isEnabled() {
        return true; // JWT 인증방식의므로 접근 가능하도록 설정
    }
}

Q. 왜 CustomUserDetails에 User Entity를 직접 저장했는가?

A. 사용자 기본 정보와 중요 정보 테이블을 분리하여 OneToOne의 관계로 설계했기 때문이다. 일반적으로 사용자를 확인할 수 있는 최소 정보만을 Jwt에서 추출하여 CustomUserDetails로 활용하는 것이 맞겠지만, User 엔티티에서는 사용자의 기본 정보만을 가지고 있기에 CustomerUserDetails를 활용하는 목적에서 크게 벗어나지 않는다고 판단했다. 이에 더해, 사용자 정보를 가지고 있는 상태에서 시작하기에 Service 계층에서 기본 정보를 기반으로 사용자를 조회하는 중복 코드를 줄일 수 있을 것으로 판단했기에, 직접 저장하도록 개발했다.

  1. CustomUserDetailsService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email).orElseThrow(() -> new ApplicationException(ErrorCode.USER_NOT_FOUND));

        return new CustomUserDetails(user);
    }
}
  1. JwtFilter

이 부분이 맨 위에서 적었던 Spring Security 방식과 가장 큰 차이점이다.
기본적으로 Spring Security는 UsernamePasswordAuthenticationFilter를 활용하는데, 그 앞에 JwtFilter를 두어 처리함으로써 기본 Spring Security와 다르게 동작한다.
사실상, UsernamePasswordAuthenticationFilter를 JwtFilter가 대체하게 되는 것이다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final CustomUserDetailsService customUserDetailsService;


    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);

        // JWT 유효성 검증
        if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
            String email = jwtProvider.getEmail(token);

            // 유저 정보 생성
            UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);

            if (userDetails != null) {
                // UserDetails, Password, Role 정보를 기반으로 접근 권한을 가지고 있는 Token 생성
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

                // Security Context 해당 접근 권한 정보 설정
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // 다음 필터로 넘기기
        filterChain.doFilter(request, response);
    }

    /**
     * Request Header에서 토큰 조회 및 Bearer 문자열 제거 후 반환하는 메소드
     * @param request HttpServletRequest
     * @return 추출된 토큰 정보 반환 (토큰 정보가 없을 경우 null 반환)
     */
    private String resolveToken(HttpServletRequest request) {
        String token = request.getHeader("Authorization");

        // Token 정보 존재 여부 및 Bearer 토큰인지 확인
        if (token != null && token.startsWith("Bearer ")) {
            return token.substring(7);
        }

        return null;
    }
}
  1. JwtAccessDeniedHandler

AccessDeniedHandler는 인가되지 않은 요청일 경우에 대한 예외 처리 핸들러 인터페이스이다. 사용자의 요청이 인가되지 않았을 경우, 예외 처리되도록 아래와 같이 구현했다.

@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    /**
     * 인가 실패 관련 403 핸들링
     * @param request ServletRequest 객체
     * @param response ServletResponse 객체
     * @param accessDeniedException 접근권한 거부 예외 정보
     * @throws IOException IO 예외 가능성 처리
     */
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        setResponse(response);
    }

    /**
     * Error 관련 응답 Response 생성 메소드
     * @param response ServletResponse 객체
     * @throws IOException IO 예외 가능성 처리
     */
    private void setResponse(HttpServletResponse response) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(ErrorCode.FORBIDDEN.getHttpStatus().value());

        ExceptionResponse errorResponse = ExceptionResponse.of(ErrorCode.FORBIDDEN);
        String errorJson = objectMapper.writeValueAsString(errorResponse);

        response.getWriter().write(errorJson);
    }
}
  1. JwtAuthenticationEntryPoint

AuthenticationEntryPoint는 사용자가 인증을 요구하는 엔드포인트에 접근할 때, 발생하는 예외를 처리하는 인터페이스다. 여기서는 JWT 인증방식이므로 아래와 같이 예외 처리 로직을 구현했다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        Object exception = request.getAttribute("exception");

        // exception에 할당된 속성이 ErrorCode일 경우, 관련된 응답 객체 정보를 삽입하도록 설정
        if (exception instanceof ErrorCode) {
            setResponse(response, (ErrorCode) exception);

            return;
        }

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }

    /**
     * Error 관련 응답 Response -> ServletResponse 저장하는 메소드
     * @param response ServletResponse 객체
     * @param errorCode 발생한 에러 정보를 담고 있는 객체
     * @throws IOException IO 과정에서 예외
     */
    private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(errorCode.getHttpStatus().value());

        ExceptionResponse exceptionResponse = ExceptionResponse.of(errorCode);
        String errorJson = objectMapper.writeValueAsString(exceptionResponse);

        response.getWriter().write(errorJson);
    }
}
  1. 최종 SecurityConfig

최종 SecurityConfig는 아래와 같이 구현했다. 여기서 특징은 /error/* 엔드포인트에 대하여 permitAll()을 걸어놓았다는 것인데, 이는 실제 개발하는 과정에서 마주친 오류 때문이다.

Spring Boot와 Spring Security 간의 충돌
실제 개발 후 테스트를 진행하는 과정에서, 401 UnAuthorized와 함께 어떠한 응답도 오지 않은 경우가 발생했다. 이는 .anyRequest().authenticated()); 때문에 발생한 것이었다.

예외가 발생했을 때, Spring Boot는 /error/*엔드 포인트로 라우팅시켜, 에러를 처리한다. BasicErrorController가 /error/* 엔드 포인트와 연결되어 있어, 기본적인 HTML, JSON, XML 에러 응답을 내려준다.

그렇기에, 모든 예외 처리를 세부적으로 구현하지 않은 상태에서 어떤 path에서 예외가 발생했는지 확인하면서 개발하기 위해서는 /error/* 엔드포인트에 대해 .permitAll() 설정을 걸어주어야 한다.

(관련 블로그: Spring Security 403 에러 관련)

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;
    private final ExceptionFilter exceptionFilter;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
                .csrf(AbstractHttpConfigurer::disable);

        http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));// Session 미사용

        // httpBasic, httpFormLogin 비활성화
        http.httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable);

        // JWT 관련 필터 설정 및 예외 처리
        http.exceptionHandling((exceptionHandling) ->
                exceptionHandling
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
        );
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(exceptionFilter, JwtFilter.class);

        // 요청 URI별 권한 설정
        http.authorizeHttpRequests((authorize) ->
                // Swagger UI 외부 접속 허용
                authorize.requestMatchers( "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                        // 로그인 로직 접속 허용
                        .requestMatchers("/v1/auth/**").permitAll()
                        // DefaultExceptionHandler 처리를 위한 error PermitAll
                        .requestMatchers("/error/**").permitAll()
                        // 이외의 모든 요청은 인증 정보 필요
                        .anyRequest().authenticated());

        return http.build();
    }

    /**
     * CORS 허용하도록 커스터마이징 진행
     * @return - 변경된 CORS 정책 정보 반환
     */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // 인증정보 주고받도록 허용
        config.setAllowCredentials(true);
        // 허용할 주소
        config.setAllowedOrigins(List.of("*"));
        // 허용하고자 하는 HTTP Method
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        // 허용할 헤더 정보
        config.setAllowedHeaders(List.of("*"));
        // 노출시킬 헤더 정보
        config.setExposedHeaders(List.of("*"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 비밀번호 암호화 방식 설정
        return new BCryptPasswordEncoder();
    }
}

최종적인 파일 구조는 아래와 같다.

이렇게 Spring Security를 구현한 이후, 회원가입/로그인 API를 Security Config에 등록한 PasswordEncoder를 활용하여 개발하면 된다. OAuth를 사용한다면, 위의 CustomUserDetails와 CustomUserDetailsService에서 OAuth를 활용해 받은 정보를 바인딩하는 구조로 활용하면 된다.

Argument Resolver 도입

Spring Security에서는 역할 및 권한 제어를 위해 아래와 같이 개발할 수 있다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    // 요청 URI별 권한 설정
    http.authorizeHttpRequests((authorize) ->
            // Swagger UI 외부 접속 허용
            authorize.requestMatchers( "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                    // 로그인 로직 접속 허용
                    .requestMatchers("/v1/auth/**").permitAll()
                    .requestMatchers("/v1/admin/**").hasRole("ADMIN")
                    // DefaultExceptionHandler 처리를 위한 error PermitAll
                    .requestMatchers("/error/**").permitAll()
                    // 이외의 모든 요청은 인증 정보 필요
                    .anyRequest().authenticated());

    return http.build();
}

SecurityConfig 파일로 중앙 집중형으로 권한을 제어하는 방식과 별개로 @Secured나 @PreAuthorize와 같은 어노테이션으로 메소드에 대한 권한을 관리할 수 있다.

이 과정에 더해, 개발자가 서비스 특성에 맞추어 역할/권한별 인가 여부를 관리할 때는 메소드별로 중복 코드가 발생할 수 있다. 즉, Service 계층에서 메소드별로 지속적으로 검증해야 하는 것이다. 이런 상황에서 Argument Resolver를 도입하여 중복 코드를 개선할 수 있다.

적용
직관적으로 이해하기 쉽도록 인가된 사용자의 정보를 가져오는 Argument Resolver를 개발한 내용을 정리한다. (아래의 코드에서 서비스 특성별로 로직을 추가하면 된다.)

  1. 어노테이션으로 활용할 @interface 설정
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthUser {
}
  1. ArgumentResolver 추가
@Component
@RequiredArgsConstructor
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {

    // @Auth 존재 여부 확인
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthUser.class);
    }

    // @Auth 존재 시, 사용자 정보 확인하여 반환
    @Override
    public Object resolveArgument(@NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, @NonNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            throw new ApplicationException(ErrorCode.USER_NOT_FOUND);
        }

        Object principal = authentication.getPrincipal();
        if (!(principal instanceof CustomUserDetails userDetails)) {
            throw new ApplicationException(ErrorCode.USER_NOT_FOUND);
        }

        return userDetails.user();
    }
}
  1. 설정한 ArgumentResolver를 활용할 수 있도록 설정에 추가
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthArgumentResolver authArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(authArgumentResolver);
    }
}

어노테이션으로 활용할 명칭을 지정해서 생성한다. 여기서는 @AuthUser를 인증받은 사용자라는 의미로 활용한다.

@CreatedBy, @ModifiedBy 도입

해당 데이터의 생성, 수정, 삭제 시각을 관리하기 위해 BaseTimeEntity를 만들어서 관리하곤 한다. 이에 더해, 해당 데이터를 생성한 사용자와 수정한 사용자의 정보를 추가적으로 관리해야 할 경우가 있는데, AuditAware 설정을 통해 쉽게 적용할 수 있다.

적용

// AuditConfig
@Configuration
public class AuditConfig {

    @Bean
    public AuditorAware<Long> auditorProvider() {
        return new AuditAwareImpl();
    }
}
// AuditAwareImpl
public class AuditAwareImpl implements AuditorAware<Long> {

    @Override
    public Optional<Long> getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(authentication == null || !authentication.isAuthenticated()) {
            return Optional.empty();
        }

        Object principal = authentication.getPrincipal();

        if (principal instanceof CustomUserDetails userDetails) {
            return Optional.of(userDetails.user().getId());
        }

        return Optional.empty();
    }
}
// BaseEntity
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

    @CreatedBy
    @Column(name = "created_by", nullable = false, updatable = false)
    private Long createdBy;

    @LastModifiedBy
    @Column(name = "modified_by", nullable = false)
    private Long modifiedBy;
}

생성자 적용 결과
권한 구분을 Validation 조건으로 Service 계층에서 적용한 결과는 아래와 같다.

  1. GENERAL 권한


사용자의 권한 문제로 인해 권한 오류가 발생한 것을 알 수 있다.

  1. ADMIN 권한


사용자의 pk값이 created_by와 updated_by로 적용되어 있는 것을 확인할 수 있다. (이미지를 보면, modified_by가 아닌 updated_by라고 적혀있는데, 이는 최초 칼럼명을 updated_by로 사용하다, 어노테이션에 맞추어 변경한 것이므로 modifed_by를 의미한다.)

관련 코드는 Github Repository에서 확인할 수 있다.


오랜만에 Spring Security를 다루어 보았는데, 까먹은 내용도 많고 헷갈리는 내용도 굉장히 많았다. 방대한 기능을 제공하는 만큼 시간 날 때마다 관련 내용을 학습하고 업데이트할 필요성을 느꼈다.


레퍼런스

0개의 댓글