Workload Management Platform 인증 모듈 구현
환경 설정
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)에 저장하고 활용할 수 있어야 합니다.
- Session 기반 인증 (JWT 미사용)
- 서버에서 세션을 관리하며, 클라이언트는 세션 ID를 기반으로 인증 수행
- Spring Security의 기본적인 세션 인증 방식 활용
- Spring Security에서 내부적으로 session 생성 및 JSESSIONID를 set-cookie
- Spring Security는 세션 정보를 서버 메모리에 저장함.
- JWT 발급 (LocalStorage 사용)
- JWT를 클라이언트의 LocalStorage에 저장하여 인증 요청 시 Authorization 헤더에 포함
- 보안상 XSS 공격에 취약할 수 있음
- 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);
}
}
클라이언트 요청 흐름 정리
최초 요청 시 (로그인 정보 없이 요청)
- 클라이언트가 서버에 접근 (/api/resource)
- JwtAuthenticationFilter가 실행되지만, 쿠키에 JWT가 없어 인증되지 않음
- SecurityFilterChain에서 로그인 페이지로 리디렉션
로그인 및 이후 요청 시
- 클라이언트가 /api/login 요청을 보냄
- AuthController에서 인증을 수행하고 JWT 발급 후 Set-Cookie 헤더에 추가
- 이후 요청 시 브라우저가 JWT 쿠키를 자동으로 포함
- JwtAuthenticationFilter가 실행되고 JWT를 검증하여 인증 수행
- 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 요청 처리 순서
- JwtAuthenticationFilter#doFilter
- JWT를 추출하여 검증 후 SecurityContextHolder에 인증 객체 저장
- SecurityFilterChain
- 인증된 요청을 적절한 핸들러로 전달
- 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 기반 인증을 사용할 때 발생할 수 있는 주요 보안 위협을 효과적으로 방어할 수 있습니다.