Spring Security jwt 방식 - access token+ refresh token(2)

2025. 3. 12. 14:40Springboot/security

 

전체코드 : https://github.com/gks930620/spring_securty_all

 

https://brilliantdevelop.tistory.com/225에 이어  refresh token을 추가해

토큰재발급과 로그아웃기능을 구현해보자.

 

 

설정파일

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:security
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100
    open-in-view: false
  devtools:
    livereload:
      enabled: true
    freemarker:
      cache: false
    restart:
      enabled: true
  thymeleaf:
    cache: false


jwt:
  secret : ${JWT_SECRET_KEY}
    #키는 길이만 충분하고 노출되지만 않으면 됨. gpt한테 만들어하던가 내가 막 타자 아무렇게 해도됨
  expiration_access: 60000  #1분 테스트용 
  expiration_refresh : 300000   #테스트용 5분

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace
    org.springframework.security : DEBUG

 

 

SecurityConfig      

logout과 토큰 재발급관련 사항만 추가되었다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;  //내가 빈으로 등록한것들

    private final AuthenticationConfiguration authenticationConfiguration;  //authenticationManger를 갖고있는 빈.
    private final RefreshRepository refreshRepository;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http  //내부H2DB  확인용.  진짜 1도 안중요함.
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/h2-console/**").permitAll() // H2 콘솔 접근 허용
            )
            .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) // H2 콘솔 CSRF 비활성화
            .headers(
                headers -> headers.frameOptions(frame -> frame.disable())); // H2 콘솔을 iframe에서 허용

        http    //기본 session방식관련 다 X
            .csrf(csrf -> csrf.disable())
            .sessionManagement(
                session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .formLogin(form -> form.disable())
            .logout(logout -> logout.disable())  //기본 로그아웃 사용X
            .httpBasic(basic -> basic.disable());

        http   //경로와 인증/인가 설정.
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/login", "/api/join", "/api/refresh/reissue").permitAll() //login필터는 기본적으로  /login 일 때 동작
                .requestMatchers("/api/my/info","/api/logout").authenticated()  //security 기본 로그아웃 url인 /logout은 사용X
            );



        http          //필터
            .userDetailsService(customUserDetailsService)
            .addFilterAt(
                new JwtLoginFilter(authenticationConfiguration.getAuthenticationManager(), jwtUtil,
                    refreshRepository),
                UsernamePasswordAuthenticationFilter.class)  //기존 세션방식의 로그인 검증필터 대체.
            .addFilterBefore(
                new JwtAccessTokenCheckAndSaveUserInfoFilter(jwtUtil, customUserDetailsService),
                UsernamePasswordAuthenticationFilter.class);

       
        http
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((request, response, authException) -> {
                    String errorCause =
                        request.getAttribute("ERROR_CAUSE") != null ? (String) request.getAttribute(
                            "ERROR_CAUSE") : null;
                    //인증없이(access token없이) 인증필요한 곳에 로그인했을 떄.
                    if (errorCause == null) {
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"error\": \"인증이 필요합니다.\"}");
                        return;
                    }
                    // JwtAccessTokenCheckAndSaveUserInfoFilter  토큰체크하는부분
                    if (errorCause.equals("토큰만료")) {
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 응답
                        response.setContentType("application/json");
                        response.setCharacterEncoding("UTF-8");
                        response.getWriter().write("{\"error\": \"Access Token expired\"}");
                        return;
                    }
                    if (errorCause.equals("로그인실패")) { //jwtLoginFilter 로그인시도부분.
                        response.setStatus(
                            HttpServletResponse.SC_UNAUTHORIZED); //로그인실패도 401로 하는게 보통
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"error\": \"아이디 비번 틀림.\"}");
                        return;
                    }
                })
            );
        return http.build();
    }
}

 

 

 

 

 

 

JwtUtil

jwtUtil에서 refresh 토큰생성메소드와  token type( access vs refresh)를 구별하는 메소드가 추가.

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration_access}")
    private long expirationAccess;


    @Value("${jwt.expiration_refresh}")
    private long expirationRefresh;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secretKey.getBytes());   //HMAC 알고리즘일 때는 SecretKey로 return하기.
    }




    // Access Token 생성
    public String   createAccessToken(String username) {
        return Jwts.builder()
            .subject(username) // ✅ setSubject() -> subject()
            .claim("token_type", "access")   //타입구분을 위해 추가.
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expirationAccess))
            .signWith(getSigningKey()) // ✅ SignatureAlgorithm.HS256 대신 Jwts.SIG.HS256 사용
            .compact();
    }

    public String   createRefreshToken(String username) {
        return Jwts.builder()
            .subject(username)
            .issuedAt(new Date())
            .claim("token_type" ,"refresh")
            .expiration(new Date(System.currentTimeMillis() + expirationRefresh))
            .signWith(getSigningKey()) // ✅ SignatureAlgorithm.HS256 대신 Jwts.SIG.HS256 사용
            .compact();
    }



    //토큰에서 username 추출.  뭐 subject에 uuid를 넣기도하지만.. 여기서는 subject에 username세팅했었음.
    public String extractUsername(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())  // 0.12.3버전에서는 verifyWith에 Key말고 SecretKey가 와야한다.
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getSubject();
    }


    //토큰에서 인증여부 확인.  코드상 문제가없다면 보통 만료됐을 때 false
    public boolean validateToken(String token) {
        try {
            Claims claims = Jwts.parser()
                .verifyWith(getSigningKey())  // ✅ 서명 검증
                .build()
                .parseSignedClaims(token)    // ✅ JWT 파싱
                .getPayload();               // ✅ claims(토큰 정보) 추출
            //  토큰 만료 확인
            Date expiration = claims.getExpiration();
            return expiration.after(new Date()); // 현재 시간보다 만료 시간이 뒤에 있어야 유효

        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }


    // 토큰에서 token_type 클레임 추출
    public  String getTokenType(String token) {
        try {
            Claims claims = Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
            return claims.get("token_type", String.class); // token_type 값 반환
        } catch (ExpiredJwtException e) {   //만료되었어도 token type은 return
            Claims claims = e.getClaims();
            return claims != null ? claims.get("token_type", String.class) : null;
        }
    }
}

//jjwt 버전에 따라 구현방식이 다르다. 현재는 0.12.3 버전.

 

 

 

 

 

Access token과 Refresh token으로 로그인 기능 구현하기 

로그인시도

 

기존 access token 생성 후 보내는 기능에서 refresh token이 추가되었는데 

refresh token은 DB에 저장해야 된다. 

RefreshEntity

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private UserEntity userEntity;

    @Column(unique = true)
    private String token;
}

 

 

RefreshRepository

public interface RefreshRepository  extends JpaRepository<RefreshEntity,Long> {
    public void deleteByToken(String token);
    public RefreshEntity findByToken(String token);
}

 

 

 

JwtLoginFilter

로그인 성공했을 때 refresh 관련내용이 추가되었다.

@RequiredArgsConstructor
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {

    private  final AuthenticationManager authenticationManager;  //new 로 생성하면 부모의 authenticationManager필드는 null이기 때문에 생성자로 주입.
    private final JwtUtil jwtUtil;

    private  final RefreshRepository refreshRepository;

    // 로그인 시도
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            Map<String, String> credentials = new ObjectMapper().readValue(request.getInputStream(), HashMap.class);
            String username = credentials.get("username");
            String password = credentials.get("password");

            UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
            this.setDetails(request, authRequest);
            return authenticationManager.authenticate(authRequest);  //여기서 AuthenticationException 발생.
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException("Failed to parse authentication request", e);  //readValue하는과정에서 발생.
        }

    }

    // 로그인 성공 → JWT 토큰 발급
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        CustomUserAccount customUserAccount = (CustomUserAccount) authResult.getPrincipal();

        String accessToken = jwtUtil.createAccessToken(customUserAccount.getUsername());
        String refreshToken=jwtUtil.createRefreshToken(customUserAccount.getUsername());


        //  새 Refresh Token 저장 (기존 Token 삭제 X)
        RefreshEntity refreshEntity = new RefreshEntity();
        refreshEntity.setUserEntity(customUserAccount.getUserEntity());  // 이전 필터에서 사용자정보저장할 떄  userEntity는  entity상태로 저장됨.
        refreshEntity.setToken(refreshToken);
        refreshRepository.save(refreshEntity);


        // 토큰을 응답에 포함
        response.setContentType("application/json");
        response.getWriter().write(new ObjectMapper().writeValueAsString(
            Map.of("access_token", accessToken, "refresh_token", refreshToken)
        ));
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request,
        HttpServletResponse response, AuthenticationException failed)
        throws IOException, ServletException {
        request.setAttribute("ERROR_CAUSE" , "로그인실패"); //실패 후 config의 entryPoint로
        super.unsuccessfulAuthentication(request,response,failed);
    }

}

 

 

 

 

로그인 후  access token을 가지고 /api/my/info 요청 

 

/api/my/info 요청에 access token으로 로그인하는 부분으로 인해 바뀌는 코드는 없다.

 

 

 

refresh token 재발급

클라이언트가 access token이 만료됐다는 사실을 받으면

refresh token으로 토큰재발급을 신청한다.   (클라이언트가 신청한다. 서버는 각각의 요청만 잘 처리하면 됨)

 

 

/api/refresh/reiisue

이 요청은 permitAll() 이기 때문에 필터에서 통과돼야 한다. 

/api/refresh/reiisue에서는 토큰이 refresh 토큰일 거고 이 토큰에 대한 검증은 컨트롤러에서 한다. 

그래서 필터에서는 refresh 토큰이면 그냥 통과시키면 된다.

 

JwtAccessTokenCheckAndSaveUserInfoFilter

@RequiredArgsConstructor
public class JwtAccessTokenCheckAndSaveUserInfoFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;  //단순히  jwt기능제공
    private final UserDetailsService userDetailsService;  //내가만들고 빈 등록한 CustomUserDetailsService

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

        String token = getTokenFromRequest(request);
        //refresh든 access든  authroization header로 보낸다고 가정함.
        // 보내는 방식이 다르면 거기에 맞춰 따로 token얻는 방법을 작성해야함.

        if (token == null) {
            chain.doFilter(request, response);
            return;
        }

        //token은 access 아니면 refresh 2개뿐
        String tokenType = jwtUtil.getTokenType(token);
        if (tokenType.equals("refresh")) {
            chain.doFilter(request, response);   //refresh토큰이 있다 => /api/refresh/reissue는 인증 필요없는 곳 무사통과
            return;
        }


        //access token에 대해서....

        if (!jwtUtil.validateToken(token)) { //토큰이 문제 있다면.. jwtUtil에 문제가 없다면 만료되었을 때만.
            request.setAttribute("ERROR_CAUSE", "토큰만료");
            chain.doFilter(request, response);   // access_token이 만료된거라면 인증필요한 url => security가 authenticationException
            return;
        }

        //만료 안 되었다면 SecurityContext에 인증정보 담아 로그인한걸로 판단!!
        String username = jwtUtil.extractUsername(token);
        UserDetails userDetails = userDetailsService.loadUserByUsername(
            username); //내가 만든 CustomUserAccount
        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(userDetails, null,
                userDetails.getAuthorities());
        authenticationToken.setDetails(
            new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);  //이걸 해야 비로소 securityConfig가 로그인한 걸로 간주
        chain.doFilter(request, response);  //인증된 상태로 통과!

    }

    private String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {  //띄어쓰기 주의
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

 

 

 

필터를 통과해 Controller에 오면 Controller에서 토큰재발급을 하면 된다.

RefreshController

@RestController
@RequestMapping("/api/refresh")
@RequiredArgsConstructor
public class RefreshController {

    private final JwtUtil jwtUtil;
    private  final RefreshService refreshService;

    @RequestMapping("/reissue")
    public ResponseEntity<?> refreshAccessToken(@RequestHeader("Authorization") String refreshToken) {
        //refresh토큰은 Authroization 헤더에..
        String token = refreshToken.replace("Bearer ", "");




        //폐기된 토큰(로그아웃)인지도 검증 = DB에 있냐 없냐 !     ( 클라이언트가 폐기된 토큰을 사용할 수도 있음. 탈취됐을 수도 있고..)
        RefreshEntity find = refreshService.getRefresh(token);
        if(find==null){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(
                Map.of("error","Refresh Token discarded"));
        }


        //기존 refresh 토큰 삭제
        refreshService.deleteRefresh(token);

        // Refresh Token 검증
        if (!jwtUtil.validateToken(token)) { //만료되었을 때
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(
                Map.of("error","Refresh Token expired"));
        }

        //만료 안되었을 때 ,  폐기된 적도 없을 때..

        String username = jwtUtil.extractUsername(token);

        String newAccessToken = jwtUtil.createAccessToken(username);
        String newRefreshToken=jwtUtil.createRefreshToken(username);

        refreshService.saveRefresh(newRefreshToken);  //refresh토큰은 저장
        return ResponseEntity.ok(Map.of("access_token", newAccessToken , "refresh_token",newRefreshToken) );

    }
}

 

 

 

 

RefreshService

@Service
@RequiredArgsConstructor
public class RefreshService {
    private  final RefreshRepository refreshRepository;
    private  final UserRepository userRepository;
    private  final JwtUtil jwtUtil;


    @Transactional(readOnly = false)
    public RefreshEntity getRefresh(String token){
        return refreshRepository.findByToken(token);
    }

    @Transactional(readOnly = false)
    public void saveRefresh(String token){
        RefreshEntity refreshEntity = new RefreshEntity();
        String username = jwtUtil.extractUsername(token);
        refreshEntity.setUserEntity(userRepository.findByUsername(username));
        refreshEntity.setToken(token);
        refreshRepository.save(refreshEntity);
    }
    @Transactional(readOnly = false)
    public void deleteRefresh(String token){
            refreshRepository.deleteByToken(token);
    }
}

 

 

 

 

 

로그아웃

config에서 로그아웃 /api/logout을 .authenticated()로 했다.

그래서 controller에 가기 위해서는 access token을 가지고 요청해야 필터를 통과한다.

그래서 postman 에서 보낼 때 Authrization 헤더에 Bearer방식으로 access token을 보내고

별도의 header(또는 body) 등으로 refresh토큰을 보내야 한다. 

 

LogoutController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class LogoutController {
    private final JwtUtil jwtUtil;
    private final RefreshService refreshService;

    @RequestMapping("/logout")
    public ResponseEntity<?> logout(@RequestHeader("RefreshToken") String refreshToken) {
    //header 이름은 RefreshToken으로 정했다.
        refreshService.deleteRefresh(refreshToken);
        return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
    }

}

 

 

※참고로 refresh토큰을 삭제했기 때문에 refresh토큰으로  재발급요청에서 재발급은 안된다.

하지만 access token이 만료되기전까지 기존 access token으로 로그인은 가능하다. 

여기서는 클라이언트가 기존 access token 사용안한다고 믿는 거고, 

보안을 강화하려면 access token도 못 쓰게하는 별도의 기능이 서버에 필요하다. 

 

 

 

 

 

실행결과

로그인전 /my/info

 

 

 

회원가입전 로그인시도

 

 

 

 

 

회원가입

 

 

 

 

회원가입 후 로그인시도

 

 

 

 

access token으로 /api/my/info 요청

 

 

 

access token시 /api/my/info

 

 

 

 

refresh토큰 재발급  /api/refresh/reissue

 

 

 

로그아웃시도  - Authrization Barerer에는 access token , Header에는 refresh토큰 세팅 후 요청

이미지에는 access token세팅한 부분이 안보임.

 

 

 

 

로그아웃  후 재발급 신청 => discared

 

 

 

 

 

 

refresh토큰 만료 =>  expired