Back-end

Workload Management Platform 인증 모듈 구현

dohyunKim 2025. 3. 18. 10:59

환경 설정

Dependencies

implementation 'org.springframework.boot:spring-boot-starter-web:3.4.2'
implementation 'org.springframework.boot:spring-boot-starter-security:3.4.2'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

Spring Boot, Spring Security 기반으로 설계하였으며, JWT(Json Web Token)를 이용한 인증 방식을 채택하였습니다.

 


모듈 설계

인증 방식

HTTP protocol은 stateless합니다. 매 요청마다 새로운 연결 생성 및 응답을 반환한 후에는 연결이 끊기게 되므로 상태를 유지할 수 없습니다.

 

그러나 매 요청마다 사용자 인증정보를 받을 수 없기때문에, 유저정보를 client 측(Cookie) 또는 server 측(Session)에 저장하고 활용할 수 있어야 합니다.

  1. Session 기반 인증 (JWT 미사용)
    • 서버에서 세션을 관리하며, 클라이언트는 세션 ID를 기반으로 인증 수행
    • Spring Security의 기본적인 세션 인증 방식 활용
    • Spring Security에서 내부적으로 session 생성 및 JSESSIONID를 set-cookie
    • Spring Security는 세션 정보를 서버 메모리에 저장함.
  2. JWT 발급 (LocalStorage 사용)
    • JWT를 클라이언트의 LocalStorage에 저장하여 인증 요청 시 Authorization 헤더에 포함
    • 보안상 XSS 공격에 취약할 수 있음
  3. JWT 발급 (Cookie 사용)
    • JWT를 HttpOnly 쿠키에 저장하여 요청마다 자동으로 포함되도록 설정
    • CSRF 방어 가능하지만, CORS 정책을 고려해야 함

세션과 JWT의 차이점

JWT와 세션은 사용자 상태를 관리하는 방식에서 결정적 차이가 있습니다.

구분 세션(Session) JWT
상태 유지 위치 서버 메모리(상태 유지) 클라이언트(상태 미유지, Stateless)
식별 방법 서버의 세션 저장소에 ID로 관리 JWT 자체에 사용자 정보 포함
확장성 낮음(서버 메모리 부담) 높음(Stateless)
대표 쿠키 이름 JSESSIONID 직접 지정(예: jwtToken)
  • 세션 기반은 JSESSIONID를 통해 서버가 사용자 정보를 관리합니다.
  • JWT 기반은 JWT 자체가 사용자 정보를 포함하여 별도의 세션 ID가 필요 없습니다.
  • 세션: 서버에 사용자 상태 정보를 저장하는 기술
  • JSESSIONID: 세션을 클라이언트와 연결하기 위해 내려주는 세션 식별 쿠키 이름
  • 세션은 상태 유지형(Stateful), JWT는 상태 미유지형(Stateless) 방식입니다.

LocalStorage vs Cookie 비교

localStorage는 브라우저에 내장된 JavaScript API입니다.

  • 로컬스토리지는 사용자의 브라우저에 데이터를 저장하는 용도로 사용됩니다.
  • 사용자가 브라우저를 종료하거나 새로고침해도 데이터가 유지됩니다.
항목 LocalStorage Cookie
저장 위치 클라이언트 브라우저의 LocalStorage HttpOnly 설정이 가능한 서버 쿠키
보안성 XSS 공격에 취약 HttpOnly 설정 시 보안 강화
사용 편의성 클라이언트에서 직접 접근 가능 클라이언트가 직접 접근 불가 (HttpOnly 설정 시)
CSRF 공격 직접 취약하지 않음 CSRF 방어 가능
서버 요청 시 자동 포함 X (직접 포함 필요) O (자동 포함됨)

 

 


최종 구현

Spring Security 구성

Spring Security의 주요 설정 클래스를 구현하여 인증을 처리합니다.

SecurityFilterChain

  • HTTP 보안 설정을 정의하는 Spring Security 구성 요소
  • 특정 경로에 대한 접근 제어 및 인증 필터 설정을 수행

DaoAuthenticationProvider

  • 사용자 정보를 데이터베이스에서 가져와 인증을 수행
  • UserDetailsService에서 사용자 정보를 로드하여 검증

PasswordEncoder

  • 비밀번호를 안전하게 저장하기 위해 사용
  • BCrypt 등의 해시 함수를 활용하여 암호화

AuthenticationManager

  • 인증 요청을 처리하는 주요 컴포넌트
  • DaoAuthenticationProvider를 사용하여 사용자 인증 수행

CustomUserDetailsService

Spring Security의 UserDetailsService를 구현하여 사용자 정보를 로드합니다.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 사용자 정보 조회 로직 (DB 조회 등)
        User user = userMapper.getUserByUsername(username);
        return new CustomUserDetails(user);
    }
}

AuthController

JWT 기반 로그인 컨트롤러를 구현합니다.

@RestController
@RequestMapping("/api")
public class AuthController {
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletResponse response) {
        // 인증 로직 수행 및 JWT 생성
        Authentication auth = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.get("username"), user.get("password")));
        if(auth != null && auth.isAuthenticated()) {
            String token = jwtUtil.createToken(auth);

            ResponseCookie cookie = ResponseCookie.from("jwtToken", token)
                    .path("/")
                    .httpOnly(true)
                    .maxAge(Duration.ofHours(1))
                    .secure(false)
                    .build();

            return ResponseEntity.ok()
                    .header(HttpHeaders.SET_COOKIE, cookie.toString())
                    .body(Map.of("success", true));
        }
    }
}

GlobalControllerAdvice

전역 컨트롤러 어드바이스를 구현하여 로그인 상태를 확인할 수 있도록 합니다.

@ControllerAdvice
public class GlobalControllerAdvice {
    @ModelAttribute("isLoggedIn")
    public boolean isLoggedIn() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken);
    }
}

Client 코드 활용 예시

(thymeleaf)
let isLoggedIn = /*[[${isLoggedIn}]]*/ false;

JwtAuthenticationFilter

JWT를 검증하는 필터를 구현합니다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String jwtToken = extractJwt(request);
        if(jwtToken != null && jwtUtil.validateToken(jwtToken)) {
            Authentication authentication = jwtUtil.getAuthentication(jwtToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

 

 


클라이언트 요청 흐름 정리

최초 요청 시 (로그인 정보 없이 요청)

  1. 클라이언트가 서버에 접근 (/api/resource)
  2. JwtAuthenticationFilter가 실행되지만, 쿠키에 JWT가 없어 인증되지 않음
  3. SecurityFilterChain에서 로그인 페이지로 리디렉션

로그인 및 이후 요청 시

  1. 클라이언트가 /api/login 요청을 보냄
  2. AuthController에서 인증을 수행하고 JWT 발급 후 Set-Cookie 헤더에 추가
  3. 이후 요청 시 브라우저가 JWT 쿠키를 자동으로 포함
  4. JwtAuthenticationFilter가 실행되고 JWT를 검증하여 인증 수행
  5. SecurityFilterChain을 통과하여 컨트롤러 실행

 

요청 처리 흐름

Spring Security는 서블릿 필터 기반의 보안 프레임워크로, FilterChain을 통해 요청을 처리합니다. 이 때문에 JwtAuthenticationFilter가 먼저 동작한 후, SecurityFilterChain, ControllerAdvice, Controller의 순서로 실행됩니다.
이를 이해하려면 Spring Security의 요청 처리 과정과 DispatcherServlet의 흐름을 파악해야 합니다.

[Servlet FilterChain]
    ├── Spring Security's FilterChainProxy
    ├── SecurityContextPersistenceFilter
    ├── JwtAuthenticationFilter (Custom)
    ├── UsernamePasswordAuthenticationFilter
    ├── BasicAuthenticationFilter
    ├── AuthorizationFilter
    ├── FilterSecurityInterceptor
    ├── DispatcherServlet (Spring MVC)

 

(1) 클라이언트가 API 요청 (/api/resource)을 보냄
    브라우저나 API 클라이언트가 서버에 HTTP 요청을 보낸다.

 

(2) JwtAuthenticationFilter#doFilterInternal() 실행 (가장 먼저 실행됨)

  • OncePerRequestFilter를 상속한 JwtAuthenticationFilter는 Spring Security의 FilterChain에서 가장 먼저 실행됨.
  • 이 단계에서 JWT 토큰을 쿠키에서 가져와 검증 후 SecurityContextHolder에 Authentication 객체를 저장.
  • 이 과정이 끝나면 FilterChain을 통해 다음 필터로 요청을 넘김.

 JwtAuthenticationFilter가 먼저 실행되는 이유?

  • Spring Security의 SecurityFilterChain 내부에 등록된 필터들이 순차적으로 실행되는데, JwtAuthenticationFilter는 요청이 컨트롤러에 도달하기 전에 JWT 검증을 먼저 수행해야 하기 때문.
  • 필터에서 SecurityContextHolder에 인증 정보를 저장하고, 이후의 필터가 인증 여부를 확인한다.

(3) SecurityFilterChain에서 Spring Security의 인증/인가 처리

  • JwtAuthenticationFilter를 통과한 요청은 SecurityFilterChain(SecurityConfig의 SecurityFilterChain 설정)을 거친다.
  • SecurityFilterChain에서는 요청 URL에 대한 접근 권한을 검증하고, SecurityContextHolder의 인증 객체를 확인하여 보호된 리소스 접근 여부를 결정한다.
  • 이 단계에서 인증되지 않은 사용자는 로그인 페이지 또는 401 Unauthorized 응답을 받게 된다.

 SecurityFilterChain이 JwtAuthenticationFilter 다음에 실행되는 이유?

  • JwtAuthenticationFilter에서 SecurityContextHolder에 인증 객체를 설정했기 때문에, 이후 SecurityFilterChain에서 이를 확인할 수 있음.
  • 즉, SecurityFilterChain에서는 추가적인 인증 과정 없이 이미 검증된 사용자만 요청을 허용하게 됨.

(4) ControllerAdvice의 전역 설정 적용

  • SecurityFilterChain을 통과한 요청은 이제 DispatcherServlet을 거쳐 컨트롤러(@RestController)로 전달됨.
  • 이때, @ControllerAdvice를 적용한 GlobalControllerAdvice가 실행됨.
  • 여기서, @ModelAttribute("isLoggedIn")이 실행되어, 현재 사용자의 로그인 여부를 isLoggedIn 변수에 설정.

ControllerAdvice가 SecurityFilterChain 이후에 실행되는 이유?

  • SecurityContextHolder는 SecurityFilterChain이 처리하는 동안 설정되므로, 이 이후에 실행되는 ControllerAdvice에서 이를 활용할 수 있음.
  • isLoggedIn을 설정하는 시점에서는 SecurityContextHolder에 인증 객체가 이미 설정된 상태이므로, 인증 여부를 안전하게 판단할 수 있음.

(5) 컨트롤러(@RestController) 실행

  • 모든 필터를 통과한 후 최종적으로 컨트롤러가 실행된다.
  • @RequestMapping에 맞는 API가 실행되며, JWT가 유효한 경우 요청을 정상적으로 처리한다.

정리: Spring Security 요청 처리 순서

  1. JwtAuthenticationFilter#doFilter
    • JWT를 추출하여 검증 후 SecurityContextHolder에 인증 객체 저장
  2. SecurityFilterChain
    • 인증된 요청을 적절한 핸들러로 전달
  3. ControllerAdvice
    • 전역 컨트롤러 설정을 적용 (예: isLoggedIn 값 설정)

이와 같은 방식으로 Spring Security 기반 JWT 인증을 구성하여 인증모듈을 구현할 수 있습니다.

 


보안 강화

보안 이슈에 대응하기 위해 JWT 토큰을 쿠키에 저장할 때, Access Token 및 Refresh Token의 탈취 가능성을 최소화하기 위한 보안 설정이 필요합니다. 이를 위해 다음과 같은 방법을 적용할 수 있습니다.

1. HttpOnly 및 Secure 속성 설정

  • HttpOnly: JavaScript에서 접근할 수 없도록 설정하여 XSS 공격 방어
  • Secure: HTTPS 환경에서만 전송되도록 설정하여 네트워크 스니핑 방지
ResponseCookie accessTokenCookie = ResponseCookie.from("JWT_ACCESS", accessToken)
        .httpOnly(true)
        .secure(true)
        .path("/")
        .sameSite("Strict")
        .build();

2. SameSite 속성 활용

  • Strict: 타 사이트 요청에서는 쿠키가 자동으로 포함되지 않도록 설정하여 CSRF 공격 방어
  • Lax: 대부분의 경우 보안성을 유지하면서도 사용자 경험을 해치지 않음

3. Refresh Token을 별도의 저장소에 보관

  • Refresh Token은 서버 측 저장소 (예: Redis)에 저장하여 클라이언트에서 직접 접근 불가하도록 설정
  • Access Token이 만료되었을 때만 Refresh Token을 이용해 새로운 Access Token을 발급

4. Access Token의 짧은 수명 설정

  • Access Token의 유효 기간을 짧게(예: 15분) 설정하여 탈취 시 피해를 최소화
  • Refresh Token을 통해 재발급할 수 있도록 구현

5. JWT 서명 키 보호

  • 대칭키(HS256)보다는 비대칭키(RS256, ES256)를 사용하여 보안성 강화
  • 서명 키는 환경 변수 또는 보안 저장소에 보관하여 외부 노출 방지

이러한 보안 조치를 적용하면 JWT 기반 인증을 사용할 때 발생할 수 있는 주요 보안 위협을 효과적으로 방어할 수 있습니다.