[SpringSecurity/JWT] 스프링 JWT 심화

황인찬·2024년 8월 14일
0
post-thumbnail

보안을 위한 JWT 진화

1. 토큰 사용 추적

단일 토큰의 사용처를 추적하면 다음과 같다
1. 로그인 성공 후 JWT 발급: 서버측 -> 클라이언트로 JWT 발급
2. 권한이 필요한 모든 요청: 클라이언트측 -> 서버로 JWT 전송
권한이 필요한 요청은 보통 서비스에서 이루어짐(회원 CRUD, 게시글/댓글 CRUD, 주문 서비스 등)
따라서 JWT는 수많은 요청을 위해 클라이언트의 JS코드로 HTTP 통신을 통해 서버로 전달됨
해커는 클라이언트 측에서 XSS를 이용하거나 HTTP 통신을 가로채서 토큰을 훔칠 수 있기 때문에 여러 기술을 도입하여 탈취를 방지하고 탈취되었을 경우 대비 로직이 존재함


2. 다중 토큰 : Refresh 토큰과 생명주기

위와 같은 문제가 발생하지 않도록 Access/Refresh 토큰의 개념이 등장
자주 사용 되는 토큰의 생명주기는 짧게(약10분), 이 토큰이 만료 되었을 때 함께 받은 Refresh 토큰(24시간 이상)으로 토큰을 재발급
(생명주기가 짧으면 만료시 매번 로그인을 해야하는 문제 발생, 생명 주기가 긴 Refresh 토큰도 함께 발급)

1. 로그인 성공 시 생명주기와 활용도가 다른 토큰 2개 발급: Access/Refresh

  • Access토큰: 권한이 필요한 모든 요청 헤더에 사용될 JWT로 탈취 위험을 낮추기 위해 약 10분 정도의 짧은 생명주기를 지님
  • Refresh토큰: Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용되며 약 24시간 이상의 생명주기를 지님

2. 권한이 필요한 모든 요청: Access 토큰을 통해 요청

  • Access 토큰만 사용하여 요청하기 때문에 Refresh 토큰은 호출 및 전송 빈도가 낮음

3. 권한이 알맞다는 가정 하에 2가지 상황: 데이터 응답, 토큰 만료 응답

4. 토큰이 만료된 경우 Refresh 토큰으로 Access 토큰을 발급

  • Access 토큰이 만료되었다는 요청이 돌아왔을 경우 프론트의 로직에 의해 "1"에서 발급 받은 Refresh 토큰을 통해 서버 특정 경로에 요청을 보내 Access 토큰을 재발급 받음

5. 서버 측에서는 Refresh 토큰을 검증 후 Access 토큰을 새로 발급

3. 다중 토큰 구현 포인트

  • 로그인이 완료되면 successHandler에서 Access/Refresh 토큰 2개를 발급해 응답. 각 토큰은 각기 다른 생명주기, payload를 가짐
  • Access 토큰을 검증하는 JWTFilter에서 Access 토큰이 만료된 경우 프론트 개발자와 협의된 상태코드와 메시지 응답
  • 프론트 API 클라이언트(axios, fetch) 요청 시 Access 토큰 만료 요청이 오면 예외문을 통해 Refresh 토큰을 서버 측으로 전송하고 Access 토큰을 발급받는 로직 수행
  • 서버 측에서는 Refresh 토큰을 받을 엔드포인트를 구성하여 Refresh 토큰을 검증하고 Access 토큰을 응답

4. Refresh 토큰이 탈취 당하는 경우

단일 -> 다중 토큰으로 바뀌면서 자주 사용되는 Access 토큰이 탈취되어도 생명주기가 짧아 피해 확률이 줄었음
하지만, Refresh 토큰이 사용되는 빈도만 적을 뿐 탈취 될 확률이 존재함. 따라서 Refresh 토큰을 보호하는 방법도 필요함

  • Access/Refresh 토큰의 저장 위치 고려
    • 로컬/세션 스토리지 및 쿠키에 따라 XSS, CSRF 공격 여부가 결정되기 때문에 각 토큰 사용처에 맞게 저장소 설정
  • Refresh 토큰 Rotate
    • Access 토큰을 갱신하기 위해 Refresh 토큰 요청 시 서버 측에서 Refresh 토큰도 재발급을 진행하여 Refresh 토큰의 재사용을 제한

5. Access/Refresh 토큰 저장 위치

각 스토리지에 따른 취약점

  • 로컬 스토리지: XSS 공격에 취약 -> Access 토큰 저장
  • httpOnly: CSRF 공격에 취약 -> Refresh 토큰 저장

위 방법이 필수는 아님

  • 고려
    • JWT 탈취는 보통 XSS 공격으로 로컬 스토리지에 저장된 JWT를 가져감. 쿠키 방식으로 저장하면 안전하지 않을까라는 의문이 들지만 쿠키 방식은 CSRF 공격에 취약함. 각 상황에 맞게 스토리지 선택
  • Access 토큰
    • Access 토큰은 주로 로컬 스트리지에 저장됨. 짧은 생명주기로 탈취에서 사용시간까지 매우 짧고 에디터 및 업로더에서 XSS를 방어하는 로직을 작성하여 최대한 보호할 수 있지만 CSRF 공격의 경우 클릭 한 번으로 단 시간에 요청이 진행되기 때문. 권한이 필요한 모든 경로에 사용되기 때문에 CSRF 공격의 위험보다는 XSS 공격을 받는게 나은 선택지일 수 있음.
  • Refresh 토큰
    • Refresh 토큰은 주로 쿠키에 저장됨. 쿠키는 XSS 공격은 받을 수 있지만 httpOnly 설정을 하면 방어할 수 있음. Refresh 토큰의 사용처는 토큰 재발급 경로임. CSRF 공격은 Access 토큰이 접근하는 회원 정보 수정, 게시글CRUD에 취약하지만 토큰 재발급 경로에서는 크게 피해를 입힐만한 로직이 없음.

6. Refresh 토큰 Rotate

위와 같이 저장소 특징에 맞게 수행해도 탈취 당할 수 있음. 따라서 생명주기가 긴 Refresh 토큰에 대한 추가적인 조치가 있음.

Access 토큰이 만료되어 Refresh 토큰을 가지고 서버 특정 엔드포인트에 재발급을 진행하면 Refresh 토큰 또한 재발급 하여 프론트측으로 응답하는 방식이 Refresh Rotate임

7. 로그아웃과 Refresh 주도권

  • 문제
    • 로그아웃을 구현하면 프론트측에 있는 Access/Refresh 토큰을 제거함. 프론트측에서 요청할 JWT가 없기 때문에 로그아웃이 되었다고 생각하지만 이미 해커가 JWT를 복제했다면 요청이 수행됨
    • 위와 같은 문제가 발생하는 이유는 JWT를 발급해주는 순간 서버측 주도권이 없기 때문임
    • 피해를 막을 방법은 생명주기가 끝날 때 까지 기다리는 방법밖에 없음
  • 해결방법
    • 생명주기가 긴 Refresh 토큰을 발급과 함께 서버 저장소에 저장하여 요청이 올때마다 저장소에 존재하는지 확인하는 방법으로 서버측 주도권을 가질 수 있음
    • 로그아웃이나 JWT 탈취로 인해 피해가 발생하는 경우, 서버측 저장소에서 해당 JWT를 삭제하여 방어가 가능함
    • Refresh 토큰 블랙리스트

8. 로그인시 메일 알림

네이버 서비스를 이용하다보면 평소와 다른 IP나 브라우저로 접속했을 시 사용자 계정으로 메일 알림이 옴

이 때 본인이 아닐 경우 "아니오"를 클릭하면 서버측 토큰 저장소에서 해당 유저에 대한 Refresh 토큰을 모두 제거하여 앞으로의 인증을 막을 수 있음


다중(Access/Refresh) 토큰 발급

로그인 성공 시 다중 토큰 발급과 위치

로그인이 성공하면 Access/Refresh 다중 토큰을 발급해야함
따라서 로그인이 성공한 후 실행되는 메소드 또는 핸들러에서 2개의 토큰을 발급해야함

각각의 토큰은 생명주기가 다르기 때문에 서로 다른 저장소에 저장함

  • Access 토큰: 헤더에 발급 후 프론트에서 로컬 스토리지에 저장
  • Refresth 토큰: 쿠키에 발급

로그인 성공 핸들러

  • 성공 루틴
//로그인 성공 시
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        //유저 정보
        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;
}

JWTUtil

  • createJwt 메소드
//토큰 생성
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();
    }
  • getCategory() 메소드 추가
public String getCategory(String token) {      
    return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}

Access 토큰 필터 : JWTFilter

프론트측 데이터 요청

프론트측 API Client로 서버측에 요청을 보낸 후 데이터를 획득. 이 때 권한이 필요한 경우 서버측에 Access 토큰을 요청 헤더에 첨부하는데, Access 토큰 검증은 서버측 JWTFilter에 의해 진행됨

이 때 Access 토큰이 만료된 경우 특정한 상태 코드 및 메시지를 응답해야함

JWTFilter

public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

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

        //access 토큰 값을 꺼냄
        String accessToken = request.getHeader("access");

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

        User user = new User();
        user.setUsername(username);
        user.setRole(role);
        CustomUserDetails customUserDetails = new CustomUserDetails(user);

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

        filterChain.doFilter(request, response);
    }
}

Refresh로 Access 토큰 재발급

요청 로직

서버측 JWTFilter에서 Access 토큰의 만료로 인해 특정 상태 코드가 응답되면 프론트측 Axios Interceptor와 같은 예외 핸들러에서 Access 토큰 재발급을 위한 Refresh를 서버측에 전송

이 때 서버측은 Refresh 토큰을 받아 새로운 Access 토큰을 응답하는 코드 작성

Reissue

  • ReissueService
@Service
public class ReissueService {
    private final JwtUtil jwtUtil;

    public ReissueService(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    public ResponseEntity<?> reissueAccessToken(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 refresh
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            //response status code
            return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
        }

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

        if (!category.equals("refresh")) {
            //response status code
            return new ResponseEntity<>("refresh token not valid", HttpStatus.BAD_REQUEST);
        }

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

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

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

        return new ResponseEntity<>(HttpStatus.OK);
    }
}
  • ReissueController
@RestController
public class ReissueController {

    private final ReissueService reissueService;

    public ReissueController(ReissueService reissueService) {
        this.reissueService = reissueService;
    }

    @PostMapping("/reissue")
    public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
        return reissueService.reissueAccessToken(request, response);
    }
}

SecurityConfig 경로 설정

.requestMatchers("/reissue").permitAll()

Refresh Rotate

Refresh Rotate란

Reissue 엔드포인트에서 Refresh 토큰을 받아 Access 토큰 갱신시 Refresh 토큰도 갱신하는 방법

  • 장점
    • Refresh 토큰 교체로 보안성 강화
    • 로그인 지속시간 증가
  • 추가 구현 작업
    • 발급했던 Refresh 토큰을 모두 기억한 뒤, Rotate 이전의 Refresh 토큰은 사용하지 못하도록 해야함

ReissueService

  • Refresh 토큰을 생성하는 과정 및 쿠키 생성 메소드 추가
@Service
public class ReissueService {
    private final JwtUtil jwtUtil;

    public ReissueService(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    public ResponseEntity<?> reissueAccessToken(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 refresh
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            //response status code
            return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
        }

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

        if (!category.equals("refresh")) {
            //response status code
            return new ResponseEntity<>("refresh token not valid", HttpStatus.BAD_REQUEST);
        }

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

        //make 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);
    }

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

        return cookie;
    }
}

주의점

Rotate 되기 이전의 토큰을 가지고 서버측으로 가도 인증이 되기 때문에 서버측에서 발급했던 Refresh를 모두 기억한 뒤 블랙리스트 처리를 진행하는 로직 필요


Refresh 토큰 서버측 저장

서버측 주도권

단순하게 JWT를 발급하여 전송하면 인증/인가의 주도권이 클라이언트에게 있음

JWT를 탈취하여 서버측으로 접근할 경우 JWT가 만료되기 전까지 서버측에서는 방어가 불가능하며 프론트측에서 토큰을 삭제하는 로그아웃을 구현해도 이미 복제되었다면 피해를 입을 수 있음

문제를 해결하기 위해 생명주기가 긴 Refresh 토큰은 발급시 서버측 저장소에 저장 후 저장되어 있는 Refresh 토큰만 사용할 수 있도록 서버측이 주도권을 가짐

구현 방법

  • 발급시
    • Refresh 토큰을 서버측 저장소에 저장
  • 갱신시(Refresh Rotate)
    • 기존 Refresh 토큰을 제거하고 새로 발급한 Refresh 토큰 저장

토큰 저장소 구현

토큰 저장소는 RDB 또는 redis와 같은 데이터베이스를 통해 Refresh 토큰을 저장. redis의 경우 TTL 설정을 통해 생명주기가 끝난 토큰은 자동으로 삭제할 수 있는 장점이 있음

  • Refresh 엔티티
@Entity
@Getter
@Setter
public class Refresh {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String refresh;
    private String expiration;
}
  • RefreshRepository
public interface RefreshRepository extends JpaRepository<Refresh, Long> {
    Boolean existsByRefresh(String refresh);

    @Transactional
    void deleteByRefresh(String refresh);
}

로그인시 : LoginSuccesHandler

  • successAuthentication() 코드 추가
	//로그인 성공 시
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
        //유저 정보
        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);

        //refresh 토큰 DB 저장
        addRefresh(username, refresh, 86400000L);

        //응답 설정
        response.setHeader("access", access);
        response.addCookie(createCookie("refresh", refresh));
        response.setStatus(HttpStatus.OK.value());
    }
  • addRefresh() 메소드
	private void addRefresh(String username, String refresh, Long expiredMs) {

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

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

        refreshRepository.save(refreshEntity);
    }

SecurityConfig 설정

  • RefreshRepository 의존성 주입
  • LoginFilter 등록 시 refreshRepository 의존성 주입
http
        .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository), 
        	UsernamePasswordAuthenticationFilter.class);

Reissue 경우

  • RefreshRepository 의존성 주입
  • ReissueService
    • addRefresh() 메소드 작성
@Service
public class ReissueService {
    private final JwtUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    public ReissueService(JwtUtil jwtUtil, RefreshRepository refreshRepository) {
        this.jwtUtil = jwtUtil;
        this.refreshRepository = refreshRepository;
    }

    public ResponseEntity<?> reissueAccessToken(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 refresh
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            //response status code
            return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
        }

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

        if (!category.equals("refresh")) {
            //response status code
            return new ResponseEntity<>("refresh token not valid", HttpStatus.BAD_REQUEST);
        }

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

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

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

        //Refresh 토큰 저장, DB에 저장되어 있는 기존 Refresh 토큰은 삭제 후 새로운 Refresh 토큰 저장
        refreshRepository.deleteByRefresh(refresh);
        addRefresh(username, newRefresh, 86400000L);

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

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

    private void addRefresh(String username, String refresh, Long expiredMs) {
        Date date = new Date(System.currentTimeMillis() + expiredMs);

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

        refreshRepository.save(refreshEntity);
    }

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

        return cookie;
    }
}

Refresh 토큰 저장소에서 기한이 지난 토큰 삭제

TTL 설정을 통해 자동으로 Refresh 토큰이 삭제되면 무방하지만, 계속해서 토큰이 쌓일 경우 문제가 될 수 있음.
따라서 스케줄 작업을 통해 만료시간이 지난 토큰은 주기적으로 삭제하는 것이 좋음


로그아웃

로그아웃 기능

로그아웃 기능을 통해 JWT 탈취 시간을 줄일 수 있음

  • 로그아웃 버튼 클릭 시
    • 프론트측: 로컬 스토리지에 있는 Access 토큰 삭제 및 서버측 로그아웃 경로로 Refresh 토큰 전송
    • 백엔드측: 로그아웃 로직을 구현하여 Refresh 토큰을 받아 쿠키 초기화 후 Refresh DB에서 해당 Refresh 토큰 삭제(모든 계정에서 로그아웃 구현시 username을 기반으로 삭제)

백엔드에서 로그아웃 수행

  • DB에 저장하고 있는 Refresh 토큰 삭제
  • Refresh 토큰 쿠키를 null로 설정

스프링 시큐리티에서 로그아웃 구현 위치

  • 필터단에서 수행
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]

프로젝트의 커스텀 필터 또한 시큐리티 필터단에 구현 예정

로그아웃 필터 구현

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 servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, filterChain);
    }

    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인지 확인 (발급시 payload에 명시)
        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("/");
        cookie.setHttpOnly(true);

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

SecurityConfig 등록

http
        .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository),
        	LogoutFilter.class);

추가적 보안 구성

요청 IP 확인 : PC 기반

PC의 경우 IP주소가 변경될 일이 적음. IP주소가 변경되는 경우 요청이 거부되도록 설정 가능

  • 로직구상
    • 로그인시 JWT 발급과 함께 JWT와 IP를 DB 테이블에 저장
    • Access 토큰으로 요청시 요청IP와 로그인시 저장한 IP주소를 대조
    • Access 토큰 재발급시 새로운 Access 토큰과 IP를 DB 테이블에 저장
  • 네이버
    • 네이버도 PC(노트북)환경에서 로그인을 진행한 후 다른 IP주소로 변경되면 재로그인을 하라는 알림이 날아옴

참조링크
개발자 유미 - 스프링 JWT 심화

profile
찬이's 개발로그

0개의 댓글