SpringSecurity와 JWT.2

ys·2024년 6월 30일

Spring공부

목록 보기
14/14

🤔 우리가 JWT Token을 사용해서 얻는 이점이 뭘까?

  • 일단 Http는 기본적으로 state-less 상태를 지향하기 때문이다
  • session 처럼 계속 연결이 되어있다면, 연결하는데 자원을 많이 사용하게 된다
  • 또한 session을 바꿔서 연결을 한다면, 다시 인증을 받아야 한다
  • 또한 claim에 여러 데이터를 보관할 수 있다 (db 조회 없이 jwt token으로 필요한 정보를 얻을 수 있다)
  • 여러 서버에 대해 확장성이 높다
  • 이러한 이유로, state-less를 유지할 수 있는 Jwt token을 사용한다


1. 단일 토큰의 문제점


다음은 jwt token을 이용한 로직이다


로그인 성공 : 서버 -> (jwt token 발행) -> 클라이언트
권한이 필요한 요청 : 클라이언트 -> (jwt token) -> 서버

  • 그런데 이런 토큰은 🤔 해커가 탈취할 수 있다...
    • 클라이언트측의 XSS를 이용해 token을 탈취하거나
    • Http 통신을 가로채 token을 탈취한다

그렇기에 우리는 대비 로직이 필요하다!


2. 다중 토큰 발급 (Refresh)


  • 단일 토큰은 탈취 시, 보안에 매우 취약하므로 다중 토큰을 발급해보도록 하겠다
  • Spring Security의 Authentication Manager에서 로그인이 성공시
  • successfulAuthentication()에서 2개의 토큰을 발급한다

1. Access Token

  • 해당 토큰을 가지고, 권한이 필요한 요청을 하게 된다
  • 보통 짧은 만료 시간을 가지고 있다
  • Response의 header에 발급한 후, 프론트에서 로컬 스토리지에 저장한다

2. Refresh Token

  • Access Token이 만료시, Refresh Token을 가지고 Access Token을 재발급을 하는 역활을 한다
  • 긴 만료 시간을 갖는다
  • 많은 내용을 Payload에 담지 않고, Access Token을 재발급할 수 있으면 된다

A. successfulAuthentication

@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, 600000L);
    String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);

    //응답 설정
    response.setHeader("access", access);
    response.addCookie(createCookie("refresh", refresh));
    response.setStatus(HttpStatus.OK.value());
}

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;
}

B. Jwt Filter

  • 해당 클래스는, 서버 측에서 Access 토큰 검증 로직을 한다
// 헤더에서 access키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");

// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null) {

    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);

UserEntity userEntity = new UserEntity();
userEntity.setUsername(username);
userEntity.setRole(role);
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

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

filterChain.doFilter(request, response);
  • 지금은 access token에 대해서만 다루고 있다
  • null인지, category가 access인지, 만료되었는지를 확인한다

이 때, 🤔만약 만료된 토큰이면 해당 응답에 대해서는 프론트 개발자와 협의가 필요하다!!!

  • 해당 응답을 받으면, 프론트 개발자는 Cookie에 있는 Refresh Token을 가지고 Access Token 재발급을 요청해야 한다

3. Refresh Token을 이용해 Access Token 발급


  • 앞서 말했듯이, Access Token이 만료가 됐다면 Refresh Token을 가지고 재발급을 받는다
  • Access Token이 만료될 경우, 합의된 응답을 내리고
  • 프론트 개발자는 해당 응답을 받을 시, Refresh Token을 서버측으로 전송해 Access Token을 재발급 받을 것이다
  • 백엔드 개발자는 Refresh Token을 받으면 검증하고 Access Token을 발급할 엔드 포인트를 개발해줘야 한다!

ReissueController

@Controller
@ResponseBody
public class ReissueController {

    private final JWTUtil jwtUtil;

    public ReissueController(JWTUtil jwtUtil) {

        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/reissue")
    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);
        }

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

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

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

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

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

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

        //make new JWT
        String newAccess = jwtUtil.createJwt("access", username, role, 600000L);

        //response
        response.setHeader("access", newAccess);

        return new ResponseEntity<>(HttpStatus.OK);
    }
}

4. Refresh Token 탈취? & 주도권(서버로)

  • 아무리 Refresh Token을 사용해 탈취를 방지한다고 해도, Refresh Token도 해커에게 탈취당할 수 있다
  • 탈취를 당한 순간, 바로 프론트 측에서 토큰을 삭제한다고 해도 -> 해커가 이 token을 이용해 서버에 보내면 작동을 한다...

그렇기에, ✅ Refresh Token은 서버 사이드에서 관리를 해주어야 한다


A. Refresh Token 블랙리스팅


  • 생명 주가기 긴 Refresh Token을 클라이언트에게 발급하면서, 서버측에서는 Refresh Token을 DB 혹은 Redis 같은 곳에 저장을 한다!
  • 만약 로그아웃, 혹은 탈취를 당했을 때 -> 서버측 저장소에 해당 JWT를 삭제해 피해를 방지한다!

B. Refresh Rotate


  • Reissue 엔드포인트에서 Refresh 토큰을 받아 Access 토큰 갱신 시 Refresh 토큰도 함께 갱신하는 방법이다
  • Refresh Rotate를 하면, Refresh Token이 탈취되어도 문제가 없어진다
  • 발급했던 Refresh 토큰을 모두 기억한 뒤, Rotate 이전의 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);

    //response
    response.setHeader("access", newAccess);
    response.addCookie(createCookie("refresh", newRefresh));

    return new ResponseEntity<>(HttpStatus.OK);

C. Refresh Token 서버측에 저장


  • Redis는 TTL을 통해 주기가 지난 Refresh Token도 삭제할 수 있다고 한다
  • 아직 Redis는 공부하지 않아 일단 RDB에 먼저 구현해보도록 하겠다
  • 다음 프로젝트에서는 Redis에 Refresh Token을 구현해 보겠다

Refresh Entity

@Entity
@Getter
@Setter
public class RefreshEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String refresh;
    private String expiration;
}

Refresh Repository

public interface RefreshRepository extends JpaRepository<RefreshEntity, Long> {

    Boolean existsByRefresh(String refresh);

    @Transactional
    void deleteByRefresh(String refresh);
}

5. Refresh Rotate 구현

  • 이제 successfulAuthentication에 Refresh Rotate 기능을 구현해보자
  • 과거의 Refresh Token은 삭제해줘야 한다

A. successfulAuthentication() 일부

 //토큰 생성
    String access = jwtUtil.createJwt("access", username, role, 600000L);
    String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
    
    //Refresh 토큰 저장
    addRefreshEntity(username, refresh, 86400000L);

    //응답 설정
    response.setHeader("access", access);
    response.addCookie(createCookie("refresh", refresh));
    response.setStatus(HttpStatus.OK.value());
}

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

    Date date = new Date(System.currentTimeMillis() + expiredMs);

    RefreshEntity refreshEntity = new RefreshEntity();
    refreshEntity.setUsername(username);
    refreshEntity.setRefresh(refresh);
    refreshEntity.setExpiration(date.toString());

    refreshRepository.save(refreshEntity);
}

B. ReissueController

@PostMapping("/reissue")
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);
    }

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

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

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

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

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

    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);
    
    //Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장
		refreshRepository.deleteByRefresh(refresh);
		addRefreshEntity(username, newRefresh, 86400000L);
		
    //response
    response.setHeader("access", newAccess);
    response.addCookie(createCookie("refresh", newRefresh));

    return new ResponseEntity<>(HttpStatus.OK);
}
  • 이렇게 Reissue 과정에서 만들어진 refresh token 이후에 다음 로직을 넣어준다
  • 만들어둔 refreshRepository를 통해 이전의 refresh token을 삭제하고
  • 새로 만든 refresh token을 db에 저장하고
  • response의 cookie에 담아서 응답을 보내준다

6. 로그 아웃 기능


  • 로그아웃 기능을 통해 추가적인 JWT 탈취 시간을 줄일 수 있다
  • User가 로그아웃을 누르면, Front 측은 Access 토큰을 삭제하고 Refresh Token을 서버로 보낸다
  • 백엔드 측에서는 로그아웃 로직을 추가하고 Refresh 토큰의 쿠키를 초기화 한후 Refresh DB 토큰을 삭제한다!

백엔드 로그아웃 수행 로직!
1. DB에 저장하고 있는 Refresh 토큰 삭제
2. Refresh 토큰 쿠키 내용 null & setMaxAge = 0 을 해준다


A. CustomLogoutFilter


public class CustomLogoutFilter extends GenericFilterBean {

    private final JWTUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    public CustomLogoutFilter(JWTUtil jwtUtil, RefreshRepository refreshRepository) {

        this.jwtUtil = jwtUtil;
        this.refreshRepository = refreshRepository;
    }

    @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);
    }
}


B. SecurityConfig 등록

 .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login", "/", "/join","/reissuei").permitAll()
                        .requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class)
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration),jwtUtil,refreshRepository), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class)
profile
개발 공부,정리

0개의 댓글