[개인 프로젝트] JWT 도입 관련 삽질 기록

Turtle·2024년 8월 30일
0

👉문제 상황

엄밀히 말하자면 문제 상황은 아니고 개인적인 공부였다. Spring Security는 기본적으로 세션 기반 인증을 사용한다. 이번 개인 프로젝트의 인증/인가 부분에서 토큰 인증 방식을 사용하고자 했다. 그래서 세션 기반 인증 → 토큰 기반 인증으로 교체한 코드를 간략히 설명하고 이를 다시 세션 기반 인증으로 바꾸면서 적용한 방법에 대해 남기려고 한다.

♻️JWT 토큰 기반 인증 방식

Cookie, Session, JWT 등에 대한 부분은 앞서 작성했던 글을 참고하면 된다.

클라이언트가 서버에 로그인 요청을 보내면 서버는 아이디와 패스워드를 확인해 유효한 사용자인지 검증한다. 서버가 유효성을 검증해 유효한 사용자라고 판단하면 토큰을 생성해서 응답을 보낸다.

클라이언트는 서버에서 준 토큰을 로컬 스토리지 쪽에 저장한다. 이후 인증이 필요한 API를 사용할 때 토큰을 함께 보낸다. 그러면 서버는 토큰이 유효한지 검증하고 토큰이 유효하다면 클라이언트가 요청한 내용을 처리한다.

토큰 기반 인증 방식의 특징은 크게 3가지가 있다.

1. 무상태성(Stateless)

무상태성은 사용자의 인증 정보가 담겨 있는 토큰이 서버가 아닌 클라이언트 측에 저장되므로 서버가 상태 관리를 할 필요가 없다. 서버가 뭔가 데이터를 보관하고 클라이언트와 연결을 유지하고 있으면 그만큼 자원이 소비될 수 밖에 없다. 따라서 클라이언트에서는 사용자 인증 상태를 유지하면서 이후 인증이 필요한 API 요청을 처리해야 하는데 이것을 상태 관리라고 한다. 토큰 기반 인증을 도입하게 된다면 서버가 상태 관리를 할 필요가 없기 때문에 무상태(Stateless) 설계가 가능하다.

2. 확장성

서버를 확장할 때 상태 관리를 신경 쓸 필요가 없으니 서버의 수평적 확장에도 용이하다.

3. 무결성

토큰 방식은 HMAC(Hash-Based Message Authentication) 기법이라고 부른다. 토큰을 발급한 이후에 토큰 정보를 변경하는 행위를 할 수 없다. 즉, 토큰의 무결성이 보장된다. 만약 누군가 토큰의 글자 하나라도 없애면 유효하지 않은 토큰이 되는 것이다.

👉(보충)토큰 유효 기간

  • 토큰을 주고 받는 환경이 보안에 취약해서 토큰 자체가 노출되면 악용할 가능성이 높아진다.
  • 토큰의 유효기간이 길면 그 발급받은 토큰으로 무엇이든 할 수 있다. 정상적인 경우라면 문제가 없겠지만 만약 토큰이 탈취당한 경우 문제가 될 수 있다.
  • 이런 경우를 생각한다면 토큰의 유효기간을 짧게 하면 되지만 반대로 말하면 사용자 경험이 저해될 수 있다.
  • 대체 방안으로 리프레시 토큰을 사용한다.
  • 리프레시 토큰은 액세스 토큰과 별개의 토큰이다. 사용자를 인증하기 위한 용도가 아니라 액세스 토큰의 유효 기간이 만료되어 재발급을 해야할 때 사용한다.

클라이언트가 서버에게 인증을 요청한다. 서버는 클라이언트에서 전달한 정보를 기반으로 인증 정보가 유효한지 확인한 뒤 액세스 토큰과 리프레시 토큰을 만들어 클라이언트에 저장한다. 클라이언트는 전달받은 토큰을 저장한다.

서버에서 생성한 리프레시 토큰은 DB에도 저장한다. 인증을 필요로 하는 API를 호출할 때 클라이언트에 저장된 액세스 토큰과 함께 API 요청을 보낸다. 이렇게 요청을 보내는 와중에 만약 유효 기간이 만료가 된 상태로 요청을 보낼 경우 서버는 토큰이 만료되었다는 응답을 보내고 클라이언트는 리프레시 토큰과 함께 새로운 액세스 토큰을 발급하는 요청을 보낸다. 서버는 전달받은 리프레시 토큰이 유효한지 리프레시 토큰 DB를 조회하여 저장해둔 리프레시 토큰과 같은지 비교한다.

만약 유효한 리프레시 토큰이라면 새로운 액세스 토큰을 생성하고 이를 응답으로 보낸다. 그 이후에 클라이언트는 다시 API를 요청한다.

♻️JWT 코드

1. JWT 관련 의존성 추가하기

implementation group: 'com.auth0', name: 'java-jwt', version: '4.4.0'
implementation'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'

2. JwtProperties

public interface JwtProperties {
	String ISSUER = "Test_Issuer";
	String SECRET_KEY = "MY_SECRET_KEY";
	String TOKEN_PREFIX = "Bearer ";
	String AUTHORIZATION_HEADER = "Authorization";
}

3. JwtTokenProvider

@RequiredArgsConstructor
@Service
public class JwtTokenProvider {

	// JWT 토큰 생성
	public String generateToken(Member member, Duration expiredAt) {
		Date date = new Date();
		return makeToken(new Date(date.getTime() + expiredAt.toMillis()), member);
	}

	// JWT 토큰 생성
	private String makeToken(Date expiry, Member member) {
		Date date = new Date();
		return Jwts.builder()
				.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
				.setIssuer(JwtProperties.ISSUER)
				.setIssuedAt(date)
				.setExpiration(expiry)
				.setSubject(member.getEmail())
				.claim("id", member.getId())
				.signWith(SignatureAlgorithm.HS256, JwtProperties.SECRET_KEY)
				.compact();
	}

	// 토큰 유효성 검증
	public boolean validToken(String token) {
		try {
			Jwts.parser()
					.setSigningKey(JwtProperties.SECRET_KEY)
					.parseClaimsJws(token);
			return true;
		} catch (Exception e) {
			return false;
		}
	}

	// 토큰 정보로 인증 정보 가져오기
	public Authentication getAuthentication(String token) {
		Claims claims = getClaims(token);
		Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
		return new UsernamePasswordAuthenticationToken(new User(claims.getSubject(), null, authorities), token, authorities);
	}

	// 토큰 정보로 유저 ID 가져오기
	public Long getMemberId(String token) {
		Claims claims = getClaims(token);
		return claims.get("id", Long.class);
	}

	// 클레임 조회
	private Claims getClaims(String token) {
		return Jwts.parser()
				.setSigningKey(JwtProperties.SECRET_KEY)
				.parseClaimsJws(token)
				.getBody();
	}
}

4. JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

	private final JwtTokenProvider jwtTokenProvider;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    	// 요청 헤더의 Authorization 값 조회 	
		String header = request.getHeader(JwtProperties.AUTHORIZATION_HEADER);
		
        // 가져온 값에서 접두사 제거
        String token = getAccessToken(header);

		// 가져온 토큰이 널값이 아니면서 유효한다면 인증 정보를 설정
		if (token != null) {
			if (!jwtTokenProvider.validToken(token)) {
				Authentication authentication = jwtTokenProvider.getAuthentication(token);
				SecurityContextHolder.getContext().setAuthentication(authentication);
			}
		}

		filterChain.doFilter(request, response);
	}

	private String getAccessToken(String authorizationHeader) {
		if (authorizationHeader != null && authorizationHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
			return authorizationHeader.substring(JwtProperties.TOKEN_PREFIX.length());
		}
		return null;
	}
}

5. MemberService

@Transactional
public void login(LoginRequestDTO requestDTO, HttpServletResponse response) {
	log.info("로그인 서비스 코드 호출");
	Optional<Member> optionalMember = memberRepository.findByEmail(requestDTO.getEmail());
	log.info("optionalMember={}", optionalMember);
	if (optionalMember.isEmpty()) {
		log.warn("존재하지 않는 회원");
		throw new IllegalArgumentException("존재하지 않는 회원");
	}

	Member member = optionalMember.get();

	if (!bCryptPasswordEncoder.matches(requestDTO.getPassword(), member.getPassword())) {
		log.warn("패스워드가 일치하지 않음");
		throw new IllegalArgumentException("패스워드가 일치하지 않음");
	}

	Cookie cookie = new Cookie(JwtProperties.AUTHORIZATION_HEADER, jwtTokenProvider.generateToken(member, Duration.ofDays(7 * 24 * 60 * 60)));
	cookie.setPath("/");
	cookie.setDomain("localhost");
	cookie.setSecure(false);
	response.addCookie(cookie);
}

♻️실행 결과

💢어렵사리 이해하고 구현했으나 다시 세션 인증 방식으로 바꾼 이유(개인적인 생각 및 블로그 내용 기반)

👉로그아웃 구현이 어려울 것 같다.

  • 세션 기반 인증의 경우 세션을 무효화시키거나 삭제함으로써 클라이언트 - 서버 간 연결을 끊어 로그아웃을 처리할 수 있는 반면에 JWT의 경우 서버에서 토큰을 발급하면 그 이후의 관리에 대해선 전혀 관여를 하지 않기 때문에 클라이언트 측의 토큰을 무효화시킬 방법이 없다. 클라이언트에서 토큰을 삭제하지 않는 한 로그아웃을 처리하기 어렵다.

👉구현만을 위한 약간의 어거지성 코드 같으면서 보안에 정말 취약한 것 같다.

  • MemberController에서 로그인 요청을 받아 MemberService에서 로그인을 처리하는 부분에서 클라이언트 인증 요청이 정상적으로 이루어졌다면 응답 쿠키에 JWT 토큰을 포함시켜 전달했다.
  • 그럼 쿠키와 같은 로그인 처리 흐름과 다를 바가 없을 것 같다고 생각했다. 쿠키의 경우 조작이 쉽고 클라이언트 측에서 삭제가 가능하다.
  • 또한 JWT에 저장하는 정보가 많은 경우 클라이언트 측 스토리지에 저장하는 것에는 한계가 있다.

👉SSR의 특성과 JWT의 특성이 안 맞는다.

  • SSR은 CSR과 달리 서버에서 클라이언트에게 정적 HTML을 뿌려준다.
  • 하지만 JWT는 정적이 아니라 동적인 토큰이고 SSR의 정적 특성과 부합하지 않을 수 있다.

♻️세션 기반 인증 → 토큰 기반 인증 → 세션 기반 인증(성능 고민)

다시 원상태로 돌아왔다. 그럼 이 스프링 시큐리티에서 제공하는 세션 기반 인증을 어떻게 하면 더 좋은 쪽으로 사용할 수 있을까 고민하다가 생각한 답은 바로 레디스였다.

레디스와 같은 인메모리 데이터베이스를 사용해 세션 정보를 저장하는 방법을 떠올렸다. 이제부터 그 부분을 공부해서 구현해봐야겠다.

0개의 댓글