dev-course day35

2rlokr·2025년 4월 21일

dev-course

목록 보기
35/43
post-thumbnail

오늘 배운 것

🧱 JWT 방식 실습

1. RefreshTokenBlackList 엔티티 클래스

무효화할 RefreshToken을 저장할 테이블이다.

2. 유효한 RefreshToken 조회

@Override
public Optional<RefreshToken> findValidRefToken(Long memberId) {

	String jpql = "select rf from RefreshToken rf left join RefreshTokenBlackList rtb on rf = rtb.refreshToken where rf.member.id = :memberId and rtb.id is null";

	return entityManager.createQuery(jpql, RefreshToken.class)
    	.setParameter("memberId", memberId)
        .getResultStream()
        .findFirst();
}
  • 해당 메서드에서는 블랙리스트에 등록되어 있지 않는 리프레시 토큰을 조회한다.
    ✅ 즉, 유효한 Refresh Token을 조회하겠다 ! (우선은 Optional로)
  • memberId로 리프레시 토큰 조회 -> RefreshTokenBlackList에 있나?
  • JPQL을 이용해 조회해준다.
  • JPQL은 객체 중심인 쿼리이므로, JOIN 조건에서도 객체 중심으로 생각한다.
    • 그 객체와 그 엔티티가 같다 ~ 이런 식으로 준다.
    • rf (리프레시 토큰 엔티티 클래스) = rtb.refreshToken (블랙리스트 엔티티에서 참조하고 있는 필드)
  • 특정 값을 나타낼 때도, member_id 가 아니라 객체 중심으로 생각하여 member.id, rtb.id와 같이 객체와 필드를 이용해서 값을 나타낸다.
  • findFirst()Optional<> 타입을 반환받는다.

3. Refresh Token & Access Token 발급

💡 Reminder
OAuth2SuccessHandler 는 로그인이 성공한 후, 즉, 인증이 성공한 후 호출된다 !
Authentication 객체가 존재한 이후에 호출되며, OAuth2 로그인 성공 이후의 처리를 담당하는 핸들러이다.

onAuthenticationSuccess 메서드

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Value("${custom.jwt.redirection.base}")
    private String baseUrl;

    private final MemberService memberService;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        MemberDetails principal = (MemberDetails) authentication.getPrincipal();

        Member findMember = memberService.getById(principal.getId());

        HashMap<String, String> params = new HashMap<>();

        // Optional 타입의 유효한 Refresh Token
        Optional<RefreshToken> refreshTokenOptional = jwtTokenProvider.findRefreshToken(principal.getId());

        // 유효하지 않다면 재발급 ! (혹은 처음 발급 받는 것)
        if( refreshTokenOptional.isEmpty() ) {
            TokenPair tokenPair = jwtTokenProvider.generateTokenPair(findMember); // access랑 refresh 발급받기
            params.put("access", tokenPair.getAccessToken());
            params.put("refresh", tokenPair.getRefreshToken());
        } else { // 유효하다면 access token만 재발급 !
            String accessToken = jwtTokenProvider.issueAccessToken(principal.getId(), principal.getRole());
            params.put("access", accessToken);
            params.put("refresh", refreshTokenOptional.get().getRefreshToken());
        }

        String urlStr = genUrlStr(params);

        getRedirectStrategy().sendRedirect(request, response, urlStr);
    }
  • 이 메서드는 OAuth 2.0 방식의 로그인 성공 후, JWT 토큰을 발급하고 클라이언트(프론트엔드)로 redirect하면서 토큰을 URL 파라미터로 전달하는 역할을 한다.
  • @Value("${custom.jwt.redirection.base}") : application.xml 파일에 환경변수처럼 baseUrl을 설정해두었다. -> http://localhost:3000/auth
  • authentication.getPrincipal() : 로그인한 유저의 principal을 꺼낸다.
  • memberService.getById() : 토큰 발급 시 DB 정보를 활용할 수 있도록 Member 엔티티를 조회한다.
  • HashMap<String, String> params : access token과 refresh token을 담아줄 자료구조이다.
  • jwtTokenProvider.findRefreshToken() : DB에 유효한 RefreshToken이 있는지 찾아본다.
  • 유효한 토큰이 없을 때, 기존 토큰이 유효할 때로 나누어 처리해준다.
    • 유효한 토큰이 없을 땐, access token과 refresh token을 모두 발급받는다.
    • 있을 때는, access token만 재발급 받는다.
    • params에 담아준다.
  • genUrlStr() : 리다이렉트할 URL을 문자열로 만들어주는 메서드이다.
  • getRedirectStrategy().sendRedirect(request, response, urlStr) : 리디렉션 전략을 반환하는 메서드이다. 사용자 혹은 클라이언트를 urlStr로 리디렉션하며, request, response 객체를 넘겨준다.

오늘 궁금했던 것 ❓ (1)

Q1-1. 리다이렉트는 Request, Response 객체를 재활용하지 않는 것으로 알고 있는데, 왜 리다이렉트할 때 함께 넘겨주는 걸까?

A1-1. 핵심은 response인데, response 객체를 통해서 클라이언트에게 302 응답 (리다이렉션을 의미하는 Status code) + Location 헤더를 보내준다. 아래와 같이 서버가 응답을 보낼 수 있으려면, 응답 객체를 사용해야 하기 때문에 Response 객체를 함께 넘겨주는 것이다.

response.sendRedirect("http://localhost:3000/auth?access=abc&refresh=xyz");

Location 헤더 ❓

HTTP 응답에서 사용되는 HTTP 헤더로서, 리디렉션할 대상 URL을 명시해준다. 아래와 같다.

HTTP/1.1 302 Found  
Location: https://your-frontend.com/auth?access=abc&refresh=xyz

그렇게 되면 클라이언트(브라우저)가 Location 헤더를 따라 새로 요청한다. 그리고 이 요청을 React 같은 프론트엔드 앱이 처리해서 로그인 완료 화면을 띄우거나, 토큰을 저장하기도 한다.

GET /auth?access=eyJ...&refresh=eyJ... HTTP/1.1
Host: localhost:3000

Q1-2. 그렇다면 request는 왜 넘길까?

A1-2. 보통 request는 리디렉션할 URL을 만들 때 요청 정보를 참고하기 위해 넘긴다. 예를 들어, 현재 요청이 어떤 경로로 들어왔는지, 세션 정보가 있는지 등의 정보를 담고 있다.

genUrlStr 메서드

private String genUrlStr(HashMap<String, String> params) {
	return UriComponentsBuilder.fromUriString(baseUrl)
    	.queryParam("access", params.get("access"))
        .queryParam("refresh", params.get("refresh"))
        .build()
        .toUri()
        .toString();
}
  • 토큰을 URL 쿼리 파라미터로 붙여서 최종 리다이렉트할 URL 문자열을 만드는 메서드이다.
  • 결국 http://localhost:3000/auth?access=abc123&refresh=xyz456 이러한 URL을 만들게 되는 것이다.

4. 커스텀 필터 추가하기

🔐 최초 로그인 시에는?

최초 로그인을 할 때는 OAuth2SuccessHandler가 실행되고, 이 핸들러에서 Access Token과 Refresh Token을 발급해준다. 그러고, 클라이언트에게 리디렉션을 하며 토큰을 전달하게 된다.

🔁 이후 요청 처리

사용자가 토큰을 들고 API에 접근할 때마다, 사용자가 가지고 있는 토큰을 확인해야 한다. JWT 토큰을 검증하고, 인증 정보를 SecurityContext에 설정하는 역할을 하는 메서드를 구현해야 한다.

http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
  • UsernamePasswordAuthenticationFilter 보다 jwtAuthenticationFilter을 먼저 실행하라고 설정한 것이다.
    • UsernamePasswordAuthenticationFilter는 Spring Security에서 기본 로그인을 처리하는 필터이다.

하지만, 우리는 JWT를 사용하기 때문에, 토큰을 헤더에 넣어서 요청이 들어오고, 이 때 커스텀으로 작성해준 jwtAuthenticationFilter이 실행되어야 하는 것이다.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberService memberService;
  • OncePerRequestFilter : 요청마다 한 번만 실행되는 필터이다.

doFilterInternal 메서드

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

	String token = resolveToken(request);

	if (token != null && jwtTokenProvider.validate(token)) {

		TokenBody tokenBody = jwtTokenProvider.parseJwt(token);
        MemberDetails memberDetails = memberService.getMemberDetailsById(tokenBody.getMemberId());

		Authentication authentication = new UsernamePasswordAuthenticationToken(
        memberDetails, token, memberDetails.getAuthorities());

		getContext().setAuthentication(authentication);
	}
    filterChain.doFilter(request, response); 
}
  • HTTP 요청을 가로채는 필터로, 요청이 들어오면 요청을 처리한 후, 다음 필터로 요청을 넘긴다.
  • resolveToken(request) : 요청 헤더에서 JWT토큰을 추출하는 메서드이다. Authorization 헤더에서 Bearer 로 시작하는 값을 추출한다.
  • jwtTokenProvider.validate() : 해당 토큰이 문제가 없는지, 서명이 맞는지, 아직 유효기간이 끝나지 않았는지를 검사한다.
  • parseJwt(token) : JWT 토큰을 파싱하여 Payload에 있는 Claim을 가져온다. -> 여기서는 idrole을 가져와 DTO인 tokenBody에 넣어준다.
  • 이후, 파싱한 토큰에서 사용자 id를 추출하고, 그 id에 맞는 회원 정보를 데이터베이스에서 조회한다. -> 사용자 인증 정보를 제공하는 객체인 MemberDetails로 반환한다.
  • UsernamePasswordAuthenticationToken : Spring Security에서 인증을 나타내는 객체로서, principal, credentials, grantedAuthority로 구성된다.
  • SecurityContextHolder에서 현재의 SecurityContext를 가져온다.
  • setAuthentication(authentication) : Security Context에 인증 정보를 설정하여, 이후의 Spring Security 필터나 인증 과정에서 현재 사용자의 인증 정보를 사용하도록 한다.
  • filterChain.doFilter() : 다음 필터로 요청을 전달하는 메서드로, 이 메서드를 호출하지 않으면 요청이 중단된다.

resolveToken 메서드

private String resolveToken(HttpServletRequest request) {

	String bearerToken = request.getHeader("Authorization");

	if( bearerToken != null && bearerToken.startsWith("Bearer ") ) {
    	return bearerToken.substring(7);
    }
    return null;
}
  • 요청 헤더에서 Authorization 헤더를 추출해서 Bearer 로 시작하는 부분을 잘라내고 그 뒤의 토큰을 반환한다.

느낀 점

오늘 아침에 꽃가루 알러지가 너무 심해져서 잠에서 일찍 깼다. 아침 일찍부터 알러지 약도 먹고, 머리도 너무 아파서 타이레놀도 먹었다 ㅎ.. 아니 컨디션 안 좋은 채로 수업 듣는 게 너무... 말도 안돼서 약 싫어, 병원 싫어 인간도 스스로 약 먹고 병원 가도록 한다 허허허.. 다행히 한 1교시 듣고나서부터 많이 괜찮아졌었다 ! 아프지 망고 !

오우.. 오늘은 오후 수업 때 살짝 또 이해가 안됐다. 지금은 또,, 이해가 돼서 다행이다 휴.. 내일부터 프로젝튼데.. 아주 두근두근 걱정걱정이다. 한 1주일동안 파이팅해서 열심히 해봐야지 !! 파이팅..!

0개의 댓글