JWT로 로그인 구현하기 (OpenSource-CSPM)

wjd15sheep·2025년 3월 10일
0

프로젝트

목록 보기
6/10
post-thumbnail

이전 포스트 Spring Security, JWT에서 이론적인 부분을 다루었습니다. 이번 포스트에서는 간단하게 정리하고 코드 위주로 보여드리겠습니다.
OS_CSPM 팀 프로젝트에서 구현한 JWT로 로그인 세션 관리하는 방식에대해 설명하겠습니다.

Spring Security란?

Spring을 활용하여 개발한 웹 어플리케이션들은 일부 혹은 모든 사용자에게 서비스를 제공하기에 보안 필수입니다.

  • Spring Security: Spring 기본적으로 로그인, 세션에 관련된 모듈 및 설정 손쉽게 사용 가능하도록 제공
  • Filter Chain: 웬만한 모듈들은 Spring Security가 제공하기에 거의다 활용 가능 or 커스텀도 가능
    • 요청 URL에 따른 처리 가능
    • 모든 요청에 따로 개발한 인증 모듈을 적용 가능

Filter (Servlet) & Interceptor (Spring)

Spring Security 에서 모든 보안처리는 Filter의 집합(SecurityFilterChain)을 통해 동작

  • Filter : Tomcat(Servlet Container)서 관리
  • Interceptor: Spring (Spring Container)서 관리

Security 설정

start.spring.io 에서 Spring Security 의존성을 추가해줍니다.
의존성을 추가하지 못했다면 의존성에 아래와 같이 추가해 주면 됩니다.

	implementation 'org.springframework.boot:spring-boot-starter-security'

설정을 모아두는 폴더가 있다면 그 아래에 SecurityConfig 파일을 생성해 줍니다.

이렇게 설정하면 모든 코드를 다 작성하게 되었습니다.

코드 설명

SecurityConfig 코드 설명

제가 완성한 코드입니다. 이것만 있다고 실행되지 않으니 꼭 마지막까지 따라서 해주시면 문제없이 동작할 겁니다.

/confing/SecurityConfing

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity // 스프링 스큐리티 필터가 스프링 필터체인에 등록이 됩니다.
public class SecurityConfig {

    //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;
    private final MemberRepository memberRepository;

    //AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .cors((cors -> cors.configurationSource(new CorsConfigurationSource() {
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                        CorsConfiguration configuration = new CorsConfiguration();

                        configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
                        configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setMaxAge(3600L);

                        // 헤더 cors에 허용
                        configuration.setExposedHeaders(Arrays.asList("access", "Set-Cookie"));
                        return configuration;
                    }
                })) );

        //csrf disable
        http
                .csrf(AbstractHttpConfigurer::disable);
        // From 로그인 방식 disable
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable);

        // 경로별 인가 작업
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
                        .requestMatchers("/api/account/**").permitAll()
                        .requestMatchers("/login").permitAll()
                        .requestMatchers("/reissue").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated()
                );

        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

        http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository, memberRepository), UsernamePasswordAuthenticationFilter.class);

        http
                .addFilterBefore(new CustomLogoutFilter(refreshRepository, jwtUtil), LogoutFilter.class);
        http
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                );

        return http.build();
    }
}

1. 클래스 선언 및 주요 필드

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
  • @RequiredArgsConstructor: final로 선언된 필드를 자동으로 주입하는 생성자를 생성하는 Lombok 어노테이션
  • @Configuration: 스프링 설정 파일임을 나타냄 (중요!!)
  • @EnableWebSecurity: Spring Security 필터가 스프링 필터 체인에 등록됨을 의미 이 어노테이션이 없다면 이전 버전입니다.

2. 주입된 필드

private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil;
private final RefreshRepository refreshRepository;
private final MemberRepository memberRepository;
  • AuthenticationConfiguration: AuthenticationManager를 생성할 때 사용됨
  • JWTUtil: JWT 관련 기능을 제공하는 유틸리티 클래스 (토큰 생성, 검증 등)
  • RefreshRepository: 리프레시 토큰을 관리하는 저장소 (DB 또는 In-Memory 저장소)
  • MemberRepository: 사용자 정보를 관리하는 JPA 리포지토리.

3. CORS 설정

http
    .cors((cors -> cors.configurationSource(new CorsConfigurationSource() {
        @Override
        public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

            CorsConfiguration configuration = new CorsConfiguration();
            configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
            configuration.setAllowedMethods(Collections.singletonList("*"));
            configuration.setAllowCredentials(true);
            configuration.setAllowedHeaders(Collections.singletonList("*"));
            configuration.setMaxAge(3600L);
            configuration.setExposedHeaders(Arrays.asList("access", "Set-Cookie"));
            
            return configuration;
        }
    })));

CORS 설정하지 않으면 토큰이 프론트로 응답이 안되어서 생기는 문제가 생깁니다 꼭 해줍시다.

  • setAllowedMethods("*"): 모든 HTTP 메서드 허용, "POST", "GET" 이렇게도 가능
  • setAllowCredentials(true): 쿠키를 포함한 요청 허용.
  • setExposedHeaders(Arrays.asList("access", "Set-Cookie")) : 브라우저가 읽을 수 있는 응답 헤더 지정

4. 보안 설정(CSRF, 로그인, 인증 및 권한 관리

http
    .csrf(AbstractHttpConfigurer::disable);
  • CSRF 보호 비활성화.
  • JWT 기반 인증을 사용하기 때문에 CSRF 보호가 필요 없음
http
    .httpBasic(AbstractHttpConfigurer::disable)
    .formLogin(AbstractHttpConfigurer::disable);
  • httpBasic().disable() : HTTP Basic 인증 비활성화
  • formLogin().disable(): 기본 로그인 폼 비활성화

5. 경로별 접근 제어

http
    .authorizeHttpRequests(authorize -> authorize
        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
        .requestMatchers("/api/account/**").permitAll()
        .requestMatchers("/login").permitAll()
        .requestMatchers("/reissue").permitAll()
        .requestMatchers("/admin").hasRole("ADMIN")
        .anyRequest().authenticated()
    );
  • permitAll(): 누구나 접근 가능한 경로 지정 (Swagger API, 회원가입 및 로그인 관련 API 허용)
  • hasRole("ADMIN"): /admin 경로는 ADMIN 권한이 있는 사용자만 접근 가능.
  • anyRequest().authenticated(): 그 외 모든 요청은 인증 필요

6. JWT 기반 필터 추가

http
    .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
  • JWTFilterLoginFilter 앞에 배치
  • JWTFilter는 JWT 토큰을 검증하여 사용자 인증을 수행
http
    .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository, memberRepository), UsernamePasswordAuthenticationFilter.class);
  • LoginFilter: UsernamePasswordAuthenticationFilter 위치에 추가
  • LoginFilter는 사용자 로그인 요청을 처리하고 JWT를 생성
http
    .addFilterBefore(new CustomLogoutFilter(refreshRepository, jwtUtil), LogoutFilter.class);
  • CustomLogoutFilter를 LogoutFilter 앞에 배치.
  • 로그아웃 시 JWT 토큰을 무효화하고, 리프레시 토큰을 삭제.

7 세션 설정

http
    .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    );
  • SessionCreationPolicy.STATELESS: 세션을 사용하지 않음
  • 모든 요청에서 JWT를 기반으로 인증

CustomLogoutFilter 코드 설명

JWT 기반 로그아웃 기능을 구현하는 필터입니다.
사용자가 /logout 엔드포인트로 POST 요청을 보내면, 제검증 토큰을 삭제하고 세션을 종료합니다.

/jwt/CustomLogoutFilter

@RequiredArgsConstructor
public class CustomLogoutFilter extends GenericFilterBean {

    private final RefreshRepository refreshRepository;
    private final JWTUtil jwtUtil;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        //path and method verify
        String requestUri = request.getRequestURI();
        if (!requestUri.matches("^\\/logout$")) {

            filterChain.doFilter(request, response);
            return;
        }
        String requestMethod = request.getMethod();
        if (!requestMethod.equals("POST")) {

            filterChain.doFilter(request, response);
            return;
        }

        //get refresh token
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {

            if (cookie.getName().equals("refresh")) {

                refresh = cookie.getValue();
            }
        }

        //refresh null check
        if (refresh == null) {

            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //expired check
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {

            //response status code
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);
        if (!category.equals("refresh")) {

            //response status code
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //DB에 저장되어 있는지 확인
        Boolean isExist = refreshRepository.existsByRefresh(refresh);
        if (!isExist) {

            //response status code
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        //로그아웃 진행
        //Refresh 토큰 DB에서 제거
        refreshRepository.deleteByRefresh(refresh);

        //Refresh 토큰 Cookie 값 0
        Cookie cookie = new Cookie("refresh", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");

        response.addCookie(cookie);
        response.setStatus(HttpServletResponse.SC_OK);
    }
}

1. 클래스 선언 및 필드

@RequiredArgsConstructor
public class CustomLogoutFilter extends GenericFilterBean {
  • extends GenericFilterBean: Spring Security의 필터로 동작하도록 GenericFilterBean을 확장
private final RefreshRepository refreshRepository;
private final JWTUtil jwtUtil;
  • RefreshRepository: Refresh 토큰을 저장하고 관리하는 Repository(DB에서 조회 및 삭제)
  • JWTUtil: JWT 관련 유틸 클래스(토큰 만료 여부 확인, 토큰 정보 추출)

2. doFilter 메서드(필터 실행)

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
        throws IOException, ServletException {
    doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
  • HttpServletRequest, HttpServletResponse로 캐스팅 후, 아래 doFilter 메서드를 실행.

3. 로그아웃 요청 검증

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 
        throws IOException, ServletException {

    //path and method verify
    String requestUri = request.getRequestURI();
    if (!requestUri.matches("^\\/logout$")) {
        filterChain.doFilter(request, response);
        return;
    }
    String requestMethod = request.getMethod();
    if (!requestMethod.equals("POST")) {
        filterChain.doFilter(request, response);
        return;
    }
  • 요청 URI가 /logout인지 확인, 요청 방식이 POST인지 확인 두 조건으 만족하지 않으면 필터 체인을 계속 식행, 즉 logout이 아닌 다른 요청은 그냥 통과

4. Refresh 토큰 가져오기

//get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
    if (cookie.getName().equals("refresh")) {
        refresh = cookie.getValue();
    }
}

//refresh null check
if (refresh == null) {
    response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    return;
}
  • 쿠키에서 refresh 토큰을 가져옴
  • refresh 쿠키가 존재하지 않으면 400 BAD REQUEST 반환

5. 토큰 만료 여부 검사

try {
    jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
    response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    return;
}
  • JWTUtil을 사용해 토큰이 만료되었는지 확인.
  • 만료된 토큰이면 400 BAD REQUEST 반환

6. 토큰 유형 검사

// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
    response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    return;
}
  • JWT의 페이로드에서 "category" 값을 가져와 "refresh"인지 확인
  • 만약 refresh 토큰이 아니라면 400 BAD REQUEST 반환

7. Refresh 토큰이 DB에 존재하는지 확인

//DB에 저장되어 있는지 확인
Boolean isExist = refreshRepository.existsByRefresh(refresh);
if (!isExist) {
    response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    return;
}
  • DB에 해당 Refresh 토큰이 존재하는지 확인
  • 존재하지 않으면 이미 로그아웃된 상태이므로 400 BAD REQUEST 반환

8. Refresh 토큰 삭제 및 쿠키 제거

// 로그아웃 진행
// Refresh 토큰 DB에서 제거
refreshRepository.deleteByRefresh(refresh);

// Refresh 토큰 Cookie 값 0
Cookie cookie = new Cookie("refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");

response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
  • DB에서 해당 Refresh 토큰 삭제 (refreshRepository.deleteByRefresh(refresh)).
  • 쿠키에서 Refresh 토큰 제거 (Max-Age=0, Path="/").
  • HTTP 응답 코드 200 OK 반환.

JWTFilter 코드 설명

클라이언트가 요청 시 보낸 JWT Access 토큰을 검증하는 역할을 합니다.

/jwt/JWTFilter

@Slf4j
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access");
        log.info("토큰을 꺼냄");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {
            log.info("토큰이 없음");
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
        try {
            jwtUtil.isExpired(accessToken);
        } catch (ExpiredJwtException e) {

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("access token expired!!");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

// 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(accessToken);

        if (!category.equals("access")) {
            //response body
            PrintWriter writer = response.getWriter();
            writer.print("invalid access token");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

// username, role 값을 획득
        String username = jwtUtil.getUsername(accessToken);
        String role = jwtUtil.getRole(accessToken);

        Member member = new Member();
        member.setEmail(username);
        member.setRole(role);
        CustomUserDetails customUserDetails = new CustomUserDetails(member);

        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

1. 클래스 선언 및 필드

@Slf4j
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
  • extends OncePerRequestFilter: 요청당 한 번만 실행되는 필터 (Spring Security 필터 체인에서 사용)

2. 필터의 핵심 로직 doFilterInternal

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 
        throws ServletException, IOException {
  • Spring Security 필터 체인에서 요청이 올 때마다 실행되는 메서드
  • request, response를 받아서 JWT 검증 후 SecurityContext에 인증 정보 저장

3. Access 토큰 가져오기

// 헤더에서 access키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");
log.info("토큰을 꺼냄");

// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null) {
    log.info("토큰이 없음");
    filterChain.doFilter(request, response);
    return;
}
  • HTTP 요청 헤더에서 access 키 값을 가져와 Access 토큰을 추출
  • Access 토큰이 없으면 다음 필터로 넘김 (filterChain.doFilter)
    즉, 비로그인 사용자도 접근 가능한 경로는 통과

4. 토큰 만료 여부 확인

// 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
try {
    jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
    // response body
    PrintWriter writer = response.getWriter();
    writer.print("access token expired!!");

    // response status code
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    return;
}
  • 토큰이 만료되었는지 확인 (jwtUtil.isExpired(accessToken))
  • 만료된 경우 401 UNAUTHORIZED 반환 및 에러 메시지 출력
  • 만료된 경우 요청을 처리하지 않고 종료

5. Access 토큰인지 확인

// 토큰이 access인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(accessToken);

if (!category.equals("access")) {
    // response body
    PrintWriter writer = response.getWriter();
    writer.print("invalid access token");

    // response status code
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    return;
}
  • JWT 토큰이 "access" 토큰인지 검증
  • access 토큰이 없다면 401 UNAUTHORIZED 반환.

6. 사용자 정보 추출 및 SecurityContext에 저장

String username = jwtUtil.getUsername(accessToken);
String role = jwtUtil.getRole(accessToken);
  • JWT에서 사용자 이메일과 역할을 추출
Member member = new Member();
member.setEmail(username);
member.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(member);
  • Member 객체를 생성하고, 사용자 정보 설정
  • Spring Security의 UserDetails 구현체로 변환
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
  • UsernamePasswordAuthenticationToken을 사용하여 Authentication 객체 생성.
  • SecurityContext에 인증 정보 저장 → 이후 컨트롤러에서 @AuthenticationPrincipal로 사용자 정보 접근 가능

7. 다음 필터로 요청 전달

filterChain.doFilter(request, response);
  • JWT가 유효한 경우, 요청을 계속 진행

JWTUtil 코드 설명

JWT 토큰을 생성 및 검증하는 역할을 수행합니다.
/jwt/JWTUtil/

@Component
public class JWTUtil {

    private SecretKey secretKey;

    public JWTUtil(@Value("${jwt.secretKey}") String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }

    public String getRole(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public String getCategory(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
    }

    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());

    }

    public String createJwt(String category,String username, String role, Long expiredMs) {

        return Jwts.builder()
                .claim("category", category)
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }
}

1. 클래스 선언 및 secretKey 초기화

@Component
public class JWTUtil {
  • @Component: Spring 빈으로 등록되어 의존성 주입 가능
private SecretKey secretKey;
  • JWT 서명을 위한 비밀키.
public JWTUtil(@Value("${jwt.secretKey}") String secret) {
    this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), 
                                       Jwts.SIG.HS256.key().build().getAlgorithm());
}
  • @Value("${jwt.secretKey}")
    • application.properties 또는 application.yml에서 jwt.secretKey 값을 가져옴.
    • HMAC SHA-256 알고리즘으로 서명 키를 생성 (Jwts.SIG.HS256).

2. 토큰에서 정보 추출

public String getUsername(String token) {
    return Jwts.parser()
            .verifyWith(secretKey) // 서명 검증
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .get("username", String.class);
}

public String getRole(String token) {
    return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .get("role", String.class);
}

public String getCategory(String token) {
    return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .get("category", String.class);
}
  • getUsername: 이메일 추출
  • getRole: 권한 추출
  • getCategory: 카테코리 정보 추출

3. 토큰 만료 여부 확인

public Boolean isExpired(String token) {
    return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getExpiration()
            .before(new Date());
}
  • 토큰의 만료 시간을 가져와서 현재 시간과 비교
    만약 현재 시간보다 만료 시간이 이전이면 true 반환

4. JWT 토큰 생성

public String createJwt(String category, String username, String role, Long expiredMs) {
    return Jwts.builder()
            .claim("category", category) // 토큰 타입 (Access/Refresh)
            .claim("username", username) // 사용자 이메일
            .claim("role", role) // 권한 (예: "USER", "ADMIN")
            .issuedAt(new Date(System.currentTimeMillis())) // 발급 시간
            .expiration(new Date(System.currentTimeMillis() + expiredMs)) // 만료 시간
            .signWith(secretKey) // 서명 (HMAC SHA-256)
            .compact();
}
  • claim(키, 값) : 토큰에 키와 값 입력
  • .issuedAt(new Date()): 토큰 발급 시간 설정
  • .expiration(new Date(System.currentTimeMillis() + expiredMs)) : 만료 시간 설정
  • .signWith(secretKey): 서명을 생성하여 토큰의 무결성 보장

LoginFilter 코드 설명

사용자가 로그인할 때 실행되는 Spring Security 필터입니다.
사용자 인증을 수행하고, JWT 토큰을 생성하여 응답으로 반환하는 역할을 합니다.

/jwt/LoginFilter

@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;
    private final MemberRepository memberRepository;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {

        LoginRequestDto loginDTO = new LoginRequestDto();

        try {
            ObjectMapper objectMapper = new ObjectMapper();
            ServletInputStream inputStream = req.getInputStream();
            String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            loginDTO = objectMapper.readValue(messageBody, LoginRequestDto.class);
            //클라이언트 요청에서 username, password 추출

        }catch(IOException e) {
            throw new RuntimeException(e);
        }
        String username = loginDTO.getUsername();
        String password = loginDTO.getPassword();

        System.out.println(username);

        //스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password, null);
        //token에 담은 검증을 위한 AuthenticationManager로 전달
        return authenticationManager.authenticate(authRequest);
    }

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        //유저 정보
        String username = authentication.getName();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();

        //토큰 생성
        String access = jwtUtil.createJwt("access", username, role, 3600000L); // 수정
        String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);

        //Refresh 토큰 저장
        addRefreshEntity(username, refresh, 86400000L);
        log.info("로그인 성공 : " + username );
        log.info("AccessToken 생성 시간 - {}", Instant.now());
        //응답 설정
        response.setHeader("access", access);
        response.addCookie(createCookie("refresh", refresh));
        response.setStatus(HttpStatus.OK.value());
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
        System.out.println("로그인 안됨");
        response.setStatus(401); // 기본적으로 인증 실패 시 401 반
    }

    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60);
        //cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }

    private void addRefreshEntity(String username, String refresh, Long expiredMs) {

        Date date = new Date(System.currentTimeMillis() + expiredMs);
        LocalDateTime expirationDateTime =  LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());

        RefreshEntity refreshEntity = new RefreshEntity();
        refreshEntity.setMember(memberRepository.findByEmail(username).orElseThrow());
        refreshEntity.setRefresh(refresh);
        refreshEntity.setExpiration(expirationDateTime);

        refreshRepository.save(refreshEntity);
    }
}

1. attemptAuthentication() - 로그인 요청 처리

@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
  • 사용자가 로그인할 때 호출되는 메서드
  • 클라이언트 요청에 username과 password를 추출하고 인증을 시도
LoginRequestDto loginDTO = new LoginRequestDto();
  • 로그인 요청을 담을 DTO 객체 생성
try {
    ObjectMapper objectMapper = new ObjectMapper();
    ServletInputStream inputStream = req.getInputStream();
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
    loginDTO = objectMapper.readValue(messageBody, LoginRequestDto.class);
} catch (IOException e) {
    throw new RuntimeException(e);
}
  • 요청의 body(JSON)에서 username과 password를 추출하여 loginDTO에 저장
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();

UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password, null);
return authenticationManager.authenticate(authRequest);
  • UsernamePasswordAuthenticationToken을 생성하여 스프링 시큐리티 인증 매니저에 전달
  • authenticationManager.authenticate(authRequest)를 호출하여 사용자 인증 수행.

2. successfulAuthentication() - 로그인 성공 시 실행

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
  • 로그인 성공 시 실행, JWT 토큰을 생성하고 응답 헤더와 쿠키에 추가
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
  • authentication.getName() → 로그인한 사용자의 username (이메일).
  • authentication.getAuthorities() → 사용자 권한(Role)을 가져옴.
String access = jwtUtil.createJwt("access", username, role, 3600000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
  • Access 토큰 1시간 유효
  • Refresh 토큰 24시간 유효
addRefreshEntity(username, refresh, 86400000L);
  • Refresh 토큰을 데이터베이스에 저장하여, 추후 로그아웃 및 재발급 시 활용
    (서버 측에서 토큰 관리)
response.setHeader("access", access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
  • Access 토큰은 Header에 추가
  • Refresh 토큰은 쿠키에 추가, HttpOnly 설정으로 보안 강화

3. unsuccessfulAuthentication() - 로그인 실패 시 실행

@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
    System.out.println("로그인 안됨");
    response.setStatus(401);
}
  • 인증 실패 시 401 Unauthorized 응답 반환

4. createCookie() - Refresh 토큰을 쿠키에 저장

private Cookie createCookie(String key, String value) {
    Cookie cookie = new Cookie(key, value);
    cookie.setMaxAge(24 * 60 * 60);
    // cookie.setSecure(true);
    cookie.setPath("/");
    cookie.setHttpOnly(true);
    return cookie;
}
  • Refresh 토큰을 쿠키에 저장하여 보안 강화
  • setHttpOnly(true) → JavaScript에서 접근 불가 (XSS 공격 방지)
  • setPath("/") → 모든 경로에서 쿠키 사용 가능
  • setSecure(true) → HTTPS에서만 쿠키 사용

5. addRefreshEntity() - Refresh 토큰을 DB에 저장

private void addRefreshEntity(String username, String refresh, Long expiredMs) {
  • Refresh 토큰을 DB에 저장하여, 이후 로그아웃 및 토큰 재발급 시 활용

ReissueController 코드 설명

토근 재발급 컨트롤러입니다.
기존 Access 토큰이 만료되었을때, 저장된 Refresh 토큰을 이용하여 새로운 Access 토큰을 발급합니다.
/controller/ReissueController

@Slf4j
@RestController
@RequiredArgsConstructor
public class ReissueController {

    private final RefreshService refreshService;

    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
        log.info("토큰 재발급");
        return refreshService.reissue(request, response);
    }
}
  • @PostMapping("/reissue"): /reissue 경로로 POST 요청을 받을 때 실행
    결과를 ResponseEntity로 클라이언트에게 응답

RefreshService 코드 설명

JWT Access 토큰을 재발급하는 서비스 클래스입니다.
Refresh 토큰을 검증하고, 새로운 Access and Refresh 토큰을 발급합니다.

토큰 재발급 서비스
/service/RefreshService

@Service
@Slf4j
@RequiredArgsConstructor
public class RefreshService {

    private final RefreshRepository refreshRepository;
    private final JWTUtil jwtUtil;
    private final MemberRepository memberRepository;

    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {

        //get refresh token
        String refresh = null;
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) {

                refresh = cookie.getValue();
            }
        }

        if (refresh == null) {
            //response status code
            return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST); //400
        }

        //expired check
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {

            //response status code
            return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST); //400
        }

        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);

        if (!category.equals("refresh")) {

            //response status code
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST); //400
        }

        //DB에 저장되어 있는지 확인
        Boolean isExist = refreshRepository.existsByRefresh(refresh);
        if (!isExist) {
            log.info("refresh 토큰 만료");
            //response body
            return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST); //400
        }


        String username = jwtUtil.getUsername(refresh);
        String role = jwtUtil.getRole(refresh);

        //make new JWT
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
        String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
        log.info("새로운 토큰 발급");

        //Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장
        refreshRepository.deleteByRefresh(refresh);
        addRefreshEntity(username, newRefresh, 86400000L);
        log.info("refresh 토큰 데이터베이스에 저장");
        //response
        response.setHeader("access", newAccess);
        response.addCookie(createCookie("refresh", newRefresh));
        log.info("access, refresh 토큰 헤더에 저장, 쿠키에 저장");


        System.out.println("정상적으로 발급됨");
        return new ResponseEntity<>(HttpStatus.OK);
    }

    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60);
       // cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }

    private void addRefreshEntity(String username, String refresh, Long expiredMs) {

        Date date = new Date(System.currentTimeMillis() + expiredMs);
        LocalDateTime expirationDateTime =  LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());

        RefreshEntity refreshEntity = new RefreshEntity();
        refreshEntity.setMember(memberRepository.findByEmail(username).orElseThrow());
        refreshEntity.setRefresh(refresh);
        refreshEntity.setExpiration(expirationDateTime);

        refreshRepository.save(refreshEntity);
    }

    public String getEmail(HttpServletRequest request){

        String refresh = null;
        Cookie[] cookies = request.getCookies();

        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) {
                refresh = cookie.getValue();
            }
        }

        if (refresh == null) {
            log.info("refresh 토큰이 없습니다.");
            return  null;
        }

        return jwtUtil.getUsername(refresh);
    }
}

1. reissue() - 토큰 재발급 메서드

1. Refresh 토큰 가져오기

//get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
    if (cookie.getName().equals("refresh")) {
        refresh = cookie.getValue();
    }
}

if (refresh == null) {
    return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST); // 400
}
  • 클라이언트의 요청에서 refresh 토큰 검증 및 쿠키에서 가져옴

2. Refresh 토큰 유효성 검사

// expired check
try {
    jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
    return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST); // 400
}
  • jwtUtil.isExpired(refresh)를 호출하여 토큰이 만료되었는지 검사

3. Refresh 토큰인지 검증

// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
    return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST); // 400
}
  • jwtUtil.getCategory(refresh)를 이용해 토큰 타입을 확인

4. DB에서 Refresh 토큰 존재 여부 확인

Boolean isExist = refreshRepository.existsByRefresh(refresh);
if (!isExist) {
    log.info("refresh 토큰 만료");
    return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST); // 400
}
  • Refresh 토큰이 DB에 존재하는지 확인

5. 새 Access and Refresh 토큰 생성

String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);

//make new JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
log.info("새로운 토큰 발급");
  • Refresh 토큰에서 username, role 정보를 추출
  • 새로운 Access and Refresh 토큰 생성

6. 기존 Refresh 토큰 삭제, 새 토큰 저장

//Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장
refreshRepository.deleteByRefresh(refresh);
addRefreshEntity(username, newRefresh, 86400000L);
log.info("refresh 토큰 데이터베이스에 저장");
  • 기존 Refresh 토큰 삭제 후, 새로운 Refresh 토큰을 DB에 저장

7. 응답에 새로운 토큰 추가

//response
response.setHeader("access", newAccess);
response.addCookie(createCookie("refresh", newRefresh));
log.info("access, refresh 토큰 헤더에 저장, 쿠키에 저장");
  • 새로운 Access 토큰을 HTTP 헤더에 추가
  • 새로운 Refresh 토큰을 HttpOnly Cookie에 추가

8. 정상 응답 반환

System.out.println("정상적으로 발급됨");
return new ResponseEntity<>(HttpStatus.OK);

정상적으로 완료되면 200 OK 응답

3. createCookie() - 쿠키 생성 메서드

private Cookie createCookie(String key, String value) {
    Cookie cookie = new Cookie(key, value);
    cookie.setMaxAge(24*60*60);
    cookie.setPath("/");
    cookie.setHttpOnly(true);
    return cookie;
}

쿠키 생성 만료시간을 24시간으로 설정

4. addRefreshEntity() - Refresh 토큰 DB 저장

private void addRefreshEntity(String username, String refresh, Long expiredMs) {
    Date date = new Date(System.currentTimeMillis() + expiredMs);
    LocalDateTime expirationDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());

    RefreshEntity refreshEntity = new RefreshEntity();
    refreshEntity.setMember(memberRepository.findByEmail(username).orElseThrow());
    refreshEntity.setRefresh(refresh);
    refreshEntity.setExpiration(expirationDateTime);

    refreshRepository.save(refreshEntity);
}
  • 새로운 Refresh 토큰을 DB에 저장

5. getEmail - Refresh 토큰에서 이메일 추출

public String getEmail(HttpServletRequest request){
    String refresh = null;
    Cookie[] cookies = request.getCookies();

    for (Cookie cookie : cookies) {
        if (cookie.getName().equals("refresh")) {
            refresh = cookie.getValue();
        }
    }

    if (refresh == null) {
        log.info("refresh 토큰이 없습니다.");
        return  null;
    }

    return jwtUtil.getUsername(refresh);
}

요청에서 Refresh 토큰을 가져와 username(이메일)을 반환

결과 확인

이제 실제로 로그인에 성공하면 Access and Refresh 토큰이 응답이 되는지 확인해 봅니다.

로그인 확인

Postman으로 로그인하기


로그인 성공

  • access key에 JWT 토근
  • Set-Cookie key에 refresh 토큰
    성공적으로 응답이 됩니다.

DB에 refresh 토큰이 저장된 모습 확인

토큰이 있고 없다면?

토큰이 있을때

토큰이 없을때

새로운 토큰 발급

Access 토큰이 만료 새 Access 토큰 발급할때는 access 토큰은 제외합니다.
응답으로 access 토큰이 오는지 확인합니다.

DB에 기존 Refresh 토큰을 삭제하고 새로운 Refresh 토큰이 저장이 되었는지 확인합니다.

이렇게하여 Spring Boot에서 Spring Security로 JWT 토큰을 활용하여 세션을 관리하는 방법에 대해서 자세히 다루어 보았습니다.


[참고]

profile
성장 위해 노력하는 웹 개발자 주니어

0개의 댓글