Spring Security + JWT 인증 흐름 정리

kik·3일 전
  1. 왜 JWT를 썼나 (세션 vs JWT)
    : JWT를 공부하고 싶은 마음이 있었지만 세션에 비해 JWT에 대한 장점을 말해보자면
    세션은 서버에 수가 늘어나면 늘어난 서버에서 세션 저장을 정리해야 하고 꼬일수 있는 단점이 있고
    JWT는 서버의 수가 늘어나도 JWT 토큰 한개로 처리 가능하기 때문에 JWT를 사용하는게 좋다고 생각한다.
  1. SecurityConfig 설정과 이유
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // JWT를 헤더로 보내면 CSRF 불필요, 쿠키로 보내면 CSRF 필요
            .sessionManagement(session ->
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/", "/login", "/register").permitAll()           // 인증 페이지
                    .requestMatchers("/api/auth/register", "/api/auth/login",
                            "/api/auth/refresh", "/api/auth/logout").permitAll()       // 인증 API
                    .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()  // Swagger UI
                    .requestMatchers("/member/**").hasRole("MEMBER")
                    .requestMatchers("/admin/**").hasAnyRole("TRAINER", "MASTER")
                    .requestMatchers("/master/**").hasRole("MASTER")
                    .requestMatchers("/api/admin/**").hasAnyRole("TRAINER", "MASTER")
                    .requestMatchers("/api/master/**").hasRole("MASTER")
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)  // JWT 필터를 앞에 등록
            .addFilterBefore(rateLimitFilter, JwtFilter.class);                      // Rate Limit 필터를 JWT 필터보다 앞에 등록

        return http.build();
    }
========================================================================================================================
.csrf(csrf -> csrf.disable()) 설명

**CSRF 공격**"사용자 모르게 요청을 위조하는 것" 이다.
- 브라우저는 요청을 할때 쿠키를 자동으로 포함해서 요청한다.
이때 해커가 몰래 돈을 자신들의 계좌로 보내도록 요청 위조를 할 수 있는것이다.
요청을 받은 서버는 쿠키가 있으니 정상 요청으로 인식하게 된다.

- 하지만 서버에서 페이지를 내려줄 때 CSRF 토큰을 같이 내려주고
브라우저가 요청을 보낼 때 form 형식이든 헤더든 CSRF 토큰을 보내주면
해커는 CSRF 토큰을 모르기 때문에 위조 요청에 토큰을 넣을 수 없고
서버는 CSRF 토큰 인증 후 해당 요청을 처리하게 된다.

=> 나는 JWT 토큰을 헤더에 Authorization 로 보내기 때문에 불필요로 처리했다.
Thymeleaf 페이지는 모두 GET 전용이고 변경 요청은 전부 JS fetch → /api/**로 보내는 구조기 때문에 불필요 처리
========================================================================================================================
.sessionManagement(session ->
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 설명
                    
이 코드는 세션을 사용하지 않는다는 코드다.
JWT 토큰을 사용하면 세션을 사용하지 않아도 된다.
JWT를 사용하지 않으면 로그인 시 서버가 세션을 생성하고
세션ID를 쿠키에 담아 사용자에게 내려준다.
이후 요청마다 브라우저가 쿠키를 자동으로 포함해서 보내고
서버는 세션ID로 저장된 사용자 정보를 찾아서 인증한다.
========================================================================================================================
.requestMatchers("/member/**").hasRole("MEMBER")
.requestMatchers("/admin/**").hasAnyRole("TRAINER", "MASTER") 설명

이 코드는 requestMatchers("~") 에서 ~의 API로 요청이 올때
hasRole("MEMBER")는 MEMBER 권한을 가진 사용자만 요청을 받아들이고
hasAnyRole("TRAINER", "MASTER")는 TRAINER, MASTER 둘중 하나의 권한을 가진 사용자만 요청을 받아들인다는 뜻이다.
  1. JwtFilter 흐름
public class JwtFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    // 요청에서 JWT를 추출해 검증하고, 유효한 경우 SecurityContext에 인증 정보를 설정한다
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String token = null;

        // 1순위: Authorization 헤더의 Bearer 토큰 추출
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            token = header.substring(7);
        } else if (request.getCookies() != null) {
            // 2순위: accessToken 쿠키에서 토큰 추출 (Thymeleaf 페이지 지원)
            for (Cookie cookie : request.getCookies()) {
                if ("accessToken".equals(cookie.getName())) {
                    token = cookie.getValue();
                    break;
                }
            }
        }

        if (token != null && jwtProvider.validateToken(token)) {
            Claims claims = jwtProvider.parseToken(token);
            String username = claims.getSubject();
            String role = claims.get("role", String.class);

            // 컨트롤러에서 @RequestAttribute("username")로 접근할 수 있도록 설정
            request.setAttribute("username", username);

            // SecurityContext에 인증 정보 등록: Spring Security 권한 체계와 연동
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            username, null,
                            List.of(new SimpleGrantedAuthority("ROLE_" + role))
                    );
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

코드 흐름
1. header에 Authorization를 추출하고 Bearer나 쿠키에서 토큰 추출 한다.
2. jwtProvider.validateToken(token)에서 토큰 확인 후 
username과 role을 SecurityContextHolder에 담는다.
SecurityContextHolder란 출입 명부 같은것이다.
컨트롤러에서 지금 누가 요청을 한것인지 판단할 때 SecurityContextHolder의 username과 role로 확인할 수 있다.
3. JWT토큰 안에 password는 넣지 않는다. 
그 이유는 JWT토큰이 암호화 된것이 아니라 인코딩 된것이기 때문이다.
토큰만 탈취되면 비밀번호가 노출되는 위험 때문에 식별에 필요한 최소한의 정보만 넣어야 한다.
  1. JwtProvider 토큰 생성·검증
public class JwtProvider {

    private final Key key;
    private final long expirationMs; // Access Token 유효기간 (밀리초)
    
    // 토큰 파싱: 서명을 검증하고 Claims(페이로드)를 반환한다
    // 서명 불일치·만료 등 이상이 있으면 JwtException을 던진다
    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    // 토큰 유효성 검사: 파싱 성공 여부로 유효성을 판단하며 예외는 false로 변환한다
    public boolean validateToken(String token) {
        try {
            parseToken(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

1. validateToken이 parseToken을 호출한다.
2. parseToken 안에서 위조·만료·형식 세 가지를 동시에 검사한다.
3. 통과하면 true, JwtException 발생하면 false를 리턴한다.
4. parseToken 내
.parseClaimsJws(token) 에서 세가지 검사를 한다.
위조 확인, 만료 확인, 형식 확인
  1. 전체 흐름 한눈에 보기
요청 들어옴
    ↓
RateLimitFilter (과도한 요청 차단)
    ↓
JwtFilter (토큰 꺼내서 검증 → SecurityContextHolder에 등록)
    ↓
SecurityConfig (등록된 인증 정보 보고 경로별 권한 확인)
    ↓
컨트롤러
profile
신생아 개발자

0개의 댓글