카카오 로그인은 OAuth2.0 기반의 소셜 로그인 서비스로,
카카오 로그인을 활용하면 아이디 및 비밀번호를 입력 받고 검증하는 과정을 직접 구현하지 않고도,
우리 서비스의 사용자가 카카오 계정으로 손쉽게 서비스에 로그인할 수 있기 때문에 카카오 소셜 로그인을 채택하게 되었다.
흐름은 다음과 같다.
이번 포스팅에서는 Spring Security와 JWT를 사용하여 3번 과정을 구현하고자 한다.
또한, JWT의 단점을 보완하기 위한 새로운 방법에서 설명한 IUWT 토큰 인증 방식을 사용한다.
본 포스팅을 작성하기 전, JWT를 구현한 수 많은 블로그 글을 보면서 JWT만으로 충분히 인증(뿐만 아니라 인가도)을 구현하는 것이 가능할 것 같은데 왜 대부분 Spring Security + JWT의 조합을 사용하는건지 궁금했었다.
그래서 Spring Security 공식 문서를 찾아보았고, 의문에 대한 답을 얻을 수 있었다.
Spring Security는 인증과 인가 뿐만이 아니라 일반적인 취약점 공격에 대한 보호도 제공한다.
즉, 보안적인 고려사항(ex: CSRF 및 XSS 방어 등)을 개발자가 직접 구현할 필요없이 프레임워크를 사용함으로써, 보다 안전하고 효과적으로 보안을 적용할 수 있다.
[참고 링크]
토큰 발급
토큰 발급 요청이 오면 UUID를 생성한다.
UUID를 암호화하여 토큰의 클레임에 저장한다.
발급된 토큰과 평문 UUID를 쿠키에 저장한다.
토큰 검증
쿠키에 토큰이 없으면 익명 사용자로 처리한다.
쿠키에 토큰이 있고, UUID가 없으면 토큰 변조 위협으로 간주하여 다시 로그인 할 것을 요청한다.
쿠키에 토큰과 UUID가 있고, 토큰의 유효성 검사에 실패하면 로그인에 실패하며, 다시 로그인할 것을 요청한다.
쿠키에 토큰과 UUID가 있고, 토큰에 포함된 UUID와 쿠키에 저장된 UUID를 암호화한 값이 다르면 토큰 변조 위협으로 간주하여 다시 로그인 할 것을 요청한다.
쿠키에 토큰과 UUID가 있고 토큰과 UUID 모두 유효성 검사에 성공하면 인증 사용자로 처리한다.
JwtProvider는 토큰을 발급하고 파싱하는 메서드를 포함하는 클래스이다.
package com.ourhours.server.global.util.jwt;
import static com.ourhours.server.global.model.exception.ExceptionConstant.*;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import com.ourhours.server.global.model.exception.InvalidUUIDException;
import com.ourhours.server.global.model.exception.JwtException;
import com.ourhours.server.global.model.jwt.dto.response.JwtResponseDto;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.InvalidClaimException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Getter
@Setter
@Component
@ConfigurationProperties("jwt")
public class JwtProvider {
private static final String USER_ID = JwtConstant.USER_ID.getValue();
private static final String UUID = JwtConstant.UUID_COOKIE_NAME.getValue();
private static final String ALG = JwtConstant.ALG.getValue();
private String secret;
private Long tokenValidityTime;
private Key key;
@PostConstruct
void init() {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public JwtResponseDto generateToken(Long userId, String plainUUID, String encryptedUUID) {
Map<String, Object> headers = new HashMap<>();
headers.put(Header.TYPE, Header.JWT_TYPE);
headers.put(ALG, SignatureAlgorithm.HS256.getValue());
Claims claims = Jwts.claims();
claims.put(USER_ID, userId);
claims.put(UUID, encryptedUUID);
long now = new Date().getTime();
Date tokenExpireDate = new Date(now + tokenValidityTime);
String token = Jwts.builder()
.setHeader(headers)
.setClaims(claims)
.setExpiration(tokenExpireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtResponseDto.builder().token(token).uuid(plainUUID).tokenExpiredDate(tokenExpireDate).build();
}
public Long getUserId(String token, String uuid) throws JwtException, InvalidUUIDException {
Claims claims = parseClaims(token, uuid);
return claims.get(USER_ID, Long.class);
}
public Claims parseClaims(String token, String uuid) throws JwtException, InvalidUUIDException {
long clockSkewSeconds = 3 * 60L;
try {
return Jwts.parserBuilder()
.require(UUID, uuid)
.setAllowedClockSkewSeconds(clockSkewSeconds)
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (SignatureException e) {
throw new JwtException(INVALID_SIGNATURE);
} catch (MalformedJwtException | IllegalArgumentException e) {
throw new JwtException(INVALID_TOKEN);
} catch (ExpiredJwtException e) {
throw new JwtException(EXPIRED_TOKEN);
} catch (UnsupportedJwtException e) {
throw new JwtException(UNSUPPORTED_TOKEN);
} catch (InvalidClaimException e) {
throw new InvalidUUIDException(INVALID_UUID);
}
}
}
ExceptionConstant는 Exception Code와 Message를 정의한 Enum 클래스이다.
package com.ourhours.server.global.model.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ExceptionConstant {
// JWT
INVALID_SIGNATURE("0001", "JWT Signature 검증에 실패하였습니다."),
EXPIRED_TOKEN("0002", "토큰이 만료되었습니다."),
INVALID_TOKEN("0003", "올바르게 구성되지 않은 토큰입니다."),
UNSUPPORTED_TOKEN("0004", "지원하지 않는 형식의 토큰입니다."),
INVALID_UUID("0005", "자격 증명에 실패하였습니다."),
FAILED_TO_GET_TOKEN("0006", "쿠키로부터 토큰을 받아오는데 실패하였습니다.");
private final String code;
private final String message;
}
BaseException은 사용자 정의 예외를 만들 때 공통적으로 사용되는 구조를 제공하는 클래스이다.
이 클래스를 상속받은 사용자 정의 예외 클래스는 ExceptionConstant 타입의 객체를 매개변수로 받아 속성으로 갖는다.
이로써, 사용자 정의 예외 클래스는 ExceptionConstant를 이용하여 예외에 대한 정보를 효과적으로 관리할 수 있다.
package com.ourhours.server.global.model.exception;
import lombok.Getter;
@Getter
public class BaseException extends RuntimeException {
private final ExceptionConstant exceptionConstant;
public BaseException(ExceptionConstant exceptionConstant) {
super(exceptionConstant.getMessage());
this.exceptionConstant = exceptionConstant;
}
}
ExceptionConstant를 이용하여 예외에 대한 정보를 효과적으로 관리하기 위해
io.jsonwebtoken.JwtException을 사용하지 않고 직접 JwtException 클래스를 작성한다.
package com.ourhours.server.global.model.exception;
import lombok.Getter;
@Getter
public class JwtException extends BaseException {
public JwtException(ExceptionConstant exceptionConstant) {
super(exceptionConstant);
}
}
사용 예)
JWT Signature 검증에 실패한 경우 : throw new JwtException(INVALID_SIGNATURE);
토큰이 만료된 경우 : throw new JwtException(EXPIRED_TOKEN);
토큰이 올바르게 구성되지 않은 경우 : throw new JwtException(INVALID_TOKEN);
지원하지 않는 형식의 토큰인 경우 : throw new JwtException(UNSUPPORTED_TOKEN);
이처럼 여러 예외들을 하나의 예외 클래스로 묶고, ExceptionConstant를 통해 구분함으로써 비슷한 성격을 가진 예외들을 일관된 방식으로 처리함으로써 해당 예외를 받아 처리하는 코드의 가독성을 높일 수 있다.
package com.ourhours.server.global.model.exception;
import lombok.Getter;
@Getter
public class InvalidUUIDException extends BaseException {
public InvalidUUIDException(ExceptionConstant exceptionConstant) {
super(exceptionConstant);
}
}
사용 예)
throw new InvalidUUIDException(INVALID_UUID);
자주 사용되지만, 변하지 않는 JWT와 관련된 값들을 JwtConstant 클래스로 분리하여 상수로 정의함으로써 가독성을 향상시키고 유지보수를 용이하게 한다.
package com.ourhours.server.global.util.jwt;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum JwtConstant {
USER_ID("user_id"),
UUID_COOKIE_NAME("uuid"),
ALG("alg"),
JWT_COOKIE_NAME("Authorization");
private final String value;
}
package com.ourhours.server.global.model.jwt.dto.response;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Builder;
@Builder
public record JwtResponseDto(String token, Date tokenExpiredDate, @JsonIgnore String uuid) {
}
JwtAuthenticationFilter는 토큰을 검증하는 필터 역할을 하는 클래스이다.
JwtAuthenticationFilter에서 토큰 검증에 실패하여 Exception을 던지면, JwtExceptionHandlerFilter에서 받아 처리한다.
JwtExceptionHandlerFilter -> (doFilter()) -> JwtAuthenticationFilter
토큰 검증
쿠키에 토큰이 없으면 익명 사용자로 처리한다.
쿠키에 토큰이 있고, UUID가 없으면 토큰 변조 위협으로 간주하여 다시 로그인 할 것을 요청한다.
쿠키에 토큰과 UUID가 있고, 토큰의 유효성 검사에 실패하면 로그인에 실패하며, 다시 로그인할 것을 요청한다.
쿠키에 토큰과 UUID가 있고, 토큰에 포함된 UUID와 쿠키에 저장된 UUID를 암호화한 값이 다르면 토큰 탈취 위협으로 간주하여 다시 로그인 할 것을 요청한다.
쿠키에 토큰과 UUID가 있고 토큰과 UUID 모두 유효성 검사에 성공하면 인증 사용자로 처리한다.
package com.ourhours.server.global.util.jwt.filter;
import static com.ourhours.server.global.model.exception.ExceptionConstant.*;
import static com.ourhours.server.global.util.jwt.JwtConstant.*;
import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.ourhours.server.global.model.exception.InvalidUUIDException;
import com.ourhours.server.global.model.exception.JwtException;
import com.ourhours.server.global.model.security.AnonymousAuthentication;
import com.ourhours.server.global.model.security.JwtAuthentication;
import com.ourhours.server.global.model.security.dto.request.JwtAuthenticationRequestDto;
import com.ourhours.server.global.util.cipher.Aes256;
import com.ourhours.server.global.util.jwt.JwtProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final AnonymousAuthentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthentication();
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException, JwtException, InvalidUUIDException {
Optional<Cookie[]> optionalCookies = Optional.ofNullable(request.getCookies());
if (optionalCookies.isEmpty() || isJwtCookieMissing(optionalCookies.get())) {
setAnonymousAuthentication();
filterChain.doFilter(request, response);
return;
}
Cookie[] cookies = optionalCookies.get();
String token = getToken(cookies);
String uuid = getUuid(cookies);
Long userId = jwtProvider.getUserId(token, Aes256.encrypt(uuid));
setJwtAuthentication(new JwtAuthenticationRequestDto(token, userId));
filterChain.doFilter(request, response);
}
private boolean isJwtCookieMissing(Cookie[] cookies) {
return Arrays.stream(cookies)
.noneMatch(cookie -> cookie.getName().equals(JWT_COOKIE_NAME.getValue()));
}
private String getToken(Cookie[] cookies) {
return Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(JWT_COOKIE_NAME.getValue()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new JwtException(FAILED_TO_GET_TOKEN));
}
private String getUuid(Cookie[] cookies) {
return Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(UUID_COOKIE_NAME.getValue()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new InvalidUUIDException(INVALID_UUID));
}
private void setJwtAuthentication(JwtAuthenticationRequestDto dto) {
JwtAuthentication jwtAuthentication = new JwtAuthentication(dto);
SecurityContextHolder.getContext().setAuthentication(jwtAuthentication);
}
private void setAnonymousAuthentication() {
SecurityContextHolder.getContext().setAuthentication(ANONYMOUS_AUTHENTICATION);
}
}
package com.ourhours.server.global.model.security;
import java.util.Collection;
import java.util.Collections;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
public class AnonymousAuthentication implements Authentication {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(Role.ANONYMOUS.name()));
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getDetails() {
return null;
}
@Override
public Object getPrincipal() {
return "anonymousUser";
}
@Override
public boolean isAuthenticated() {
return false;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
}
@Override
public String getName() {
return null;
}
}
package com.ourhours.server.global.model.security;
import java.util.Collection;
import java.util.Collections;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import com.ourhours.server.global.model.security.dto.request.JwtAuthenticationRequestDto;
import lombok.Getter;
@Getter
public class JwtAuthentication implements Authentication {
private final String token;
private final Long userId;
public JwtAuthentication(JwtAuthenticationRequestDto dto) {
this.token = dto.token();
this.userId = dto.userId();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(Role.USER.name()));
}
@Override
public Object getCredentials() {
return this.token;
}
@Override
public Object getDetails() {
return null;
}
@Override
public Object getPrincipal() {
return null;
}
@Override
public boolean isAuthenticated() {
return true;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
}
@Override
public String getName() {
return null;
}
}
JwtExceptionHandlerFilter 클래스는 JwtAuthenticationFilter에서 던지는 에러를 처리하는 클래스이다.
토큰에 포함된 UUID와 쿠키에 저장된 UUID를 암호화한 값이 다르다면, 토큰 탈취 위협으로 간주하여 쿠키를 모두 비운다. (사용자는 재 로그인해야 한다.)
package com.ourhours.server.global.util.jwt.filter;
import static com.ourhours.server.global.util.jwt.JwtConstant.*;
import static jakarta.servlet.http.HttpServletResponse.*;
import static org.hibernate.type.descriptor.java.IntegerJavaType.*;
import static org.springframework.http.MediaType.*;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ourhours.server.global.model.exception.BaseException;
import com.ourhours.server.global.model.exception.ExceptionConstant;
import com.ourhours.server.global.model.exception.ExceptionResponse;
import com.ourhours.server.global.model.exception.InvalidUUIDException;
import com.ourhours.server.global.model.exception.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class JwtExceptionHandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException | InvalidUUIDException exception) {
setExceptionResponse(response, exception);
ExceptionConstant exceptionConstant = exception.getExceptionConstant();
removeCookie(exceptionConstant, response);
log.info("[Exception] Code: [{}], Message : [{}]", exceptionConstant.getCode(),
exceptionConstant.getMessage());
}
}
private void removeCookie(ExceptionConstant exceptionConstant, HttpServletResponse response) {
if (exceptionConstant.equals(ExceptionConstant.INVALID_UUID)) {
log.info("Remove UUID Cookie");
Cookie uuidCookie = new Cookie(UUID_COOKIE_NAME.getValue(), null);
uuidCookie.setMaxAge(ZERO);
uuidCookie.setPath("/");
response.addCookie(uuidCookie);
log.info("Remove Token Cookie");
Cookie tokenCookie = new Cookie(JWT_COOKIE_NAME.getValue(), null);
tokenCookie.setMaxAge(ZERO);
tokenCookie.setPath("/");
response.addCookie(tokenCookie);
}
}
private void setExceptionResponse(HttpServletResponse response, BaseException exception) throws
IOException {
response.setStatus(SC_UNAUTHORIZED);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
ObjectMapper mapper = new ObjectMapper();
PrintWriter writer = response.getWriter();
writer.write(mapper.writeValueAsString(new ExceptionResponse(exception.getExceptionConstant())));
}
}
package com.ourhours.server.global.config.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import com.ourhours.server.global.util.jwt.filter.JwtAuthenticationFilter;
import com.ourhours.server.global.util.jwt.filter.JwtExceptionHandlerFilter;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtExceptionHandlerFilter jwtExceptionHandlerFilter;
private static final String[] WHITE_LIST = {
"/",
"/error",
"/api/token"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(HttpBasicConfigurer::disable)
.formLogin(FormLoginConfigurer::disable)
.csrf(csrfConfigurer -> csrfConfigurer.csrfTokenRepository(new CookieCsrfTokenRepository()))
.sessionManagement(
sessionConfigurer -> sessionConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(request -> request
.requestMatchers(WHITE_LIST).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class)
.build();
}
}
SampleController는 카카오 소셜 로그인을 구현하기 전까지, 토큰을 발급하기 위해 임시로 사용될 controller이다.
package com.ourhours.server;
import static com.ourhours.server.global.util.jwt.JwtConstant.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ourhours.server.global.model.jwt.dto.response.JwtResponseDto;
import com.ourhours.server.global.model.security.JwtAuthentication;
import com.ourhours.server.global.util.cipher.Aes256;
import com.ourhours.server.global.util.jwt.JwtProvider;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequiredArgsConstructor
public class SampleController {
private final JwtProvider jwtProvider;
@GetMapping("/api/token")
public JwtResponseDto generateToken(HttpServletResponse response) {
String uuid = java.util.UUID.randomUUID().toString();
String encryptedUuid = Aes256.encrypt(uuid);
JwtResponseDto jwtResponseDto = jwtProvider.generateToken(1L, uuid, encryptedUuid);
addCookie(response, jwtResponseDto);
log.info("generate Token: Token [{}], UUID [{}], UUID IN TOKEN [{}]", jwtResponseDto.token(),
jwtResponseDto.uuid(), encryptedUuid);
return jwtResponseDto;
}
private void addCookie(HttpServletResponse response, JwtResponseDto jwtResponseDto) {
Cookie jwtCookie = new Cookie(JWT_COOKIE_NAME.getValue(), jwtResponseDto.token());
jwtCookie.setPath("/");
jwtCookie.setMaxAge(14 * 24 * 60 * 60);
jwtCookie.isHttpOnly();
Cookie uuidCookie = new Cookie(UUID_COOKIE_NAME.getValue(), jwtResponseDto.uuid());
uuidCookie.setPath("/");
uuidCookie.setMaxAge(14 * 24 * 60 * 60);
uuidCookie.isHttpOnly();
response.addCookie(jwtCookie);
response.addCookie(uuidCookie);
log.info("add Cookie: Cookie Name [{}], Cookie Value [{}], ", JWT_COOKIE_NAME.getValue(),
jwtResponseDto.token());
log.info("add Cookie: Cookie Name [{}], Cookie Value [{}], ", UUID_COOKIE_NAME.getValue(),
jwtResponseDto.uuid());
}
@GetMapping("/api/user")
public Long getUserId() {
JwtAuthentication authentication = (JwtAuthentication)SecurityContextHolder.getContext().getAuthentication();
return authentication.getUserId();
}
}
코드의 양이 꽤 많기 때문에, 이 글을 보고 Spring Security + JWT를 따라 구현하려고 하는 사람들에게는 구구절절 부연 설명을 적어놓는 것보다, 코드를 직접 보고 이해하는 것이 보기에 편할 것 같아 코드 위주로 포스팅을 작성했다.
그런데, 여기서 한번 짚고 넘어가야할 것이 있다.
IUWT 기반 토큰 인증 방식에 대해 처음 알게 되었을 때는 JWT 인증 방식의 모든 문제를 해결해주는 열쇠라고 생각했었다. 그런데 직접 구현하면서 찬찬히 생각해보니, 한 가지 의문이 떠올랐다.
IUWT 기반 토큰 인증 방식은 토큰만이 탈취되었을 때, 토큰이 탈취되었음을 판단하여 재로그인을 요청할 수 있다는 점*에서 분명히 RefreshToken을 사용하는 방법에 비해 개선된 방법이라고 생각한다.
토큰과 UUID 모두 쿠키에 저장되어 있는데 탈취할거라면 굳이 토큰만 탈취하지는 않을 것이라는 생각이 들었다.
탈취자가 사용자의 쿠키에 저장되어 있는 토큰과 UUID를 모두 가로채 이를 탈취자의 쿠키에 넣고 우리 서비스에 접근한다면 알아채지도, 막을 수도 없다.
이러한 문제점을 해결하기 위해, 서버에 HTTPS를 적용하고 웹브라우저가 HTTPS 연결을 통해서만 쿠키(토큰과 UUID)를 보내도록 함으로써 토큰과 UUID를 보호할 수 있다.
그런데 HTTPS를 적용하고 HTTPS 연결을 통해서만 쿠키를 보내도록 한다면, 토큰 탈취를 방지할 수 있으므로 UUID나 Refresh Token을 사용할 이유가 없어지는게 아닌가? 라는 의문이 들었다.
이 문제는 좀 더 자료를 찾아보면서, 고민해보기로 하고!
우선 개발 환경에 HTTPS를 적용해보고, 다음 포스팅에서는 그 과정을 기록하고자 한다.