로그인 시 JWT Access Token, Refresh Token 적용하기(with Redis) - 1부
이 글에서는 JWT Access Token, Refresh Token 개발에 대해서 다루겠습니다.
글의 목차는 다음과 같습니다.
1) 프로젝트에 JWT Access Token, Refresh Token을 적용한 이유
2) JWT Token vs Session
3) JWT Access Token, Refresh Token 개발하기
4) JWT Access Token, Refresh Token의 취약성과 그 해결책
1) 프로젝트에 JWT Access Token, Refresh Token을 적용한 이유
- HTTP는 기본적으로 Stateless, 즉 상태를 저장하지 않는 프로토콜입니다.
따라서 한 사용자로부터의 요청에 대한 상태를 저장하지 않으므로,
해당 사용자가 다음 요청을 보내더라도 같은 사용자가 보낸 요청인지 식별할 수 없습니다.
- 하지만 서비스 특성 상, 사용자가 요청을 통해 자신의 자원에 접근하려면
자기 자신이 보낸 요청임을 입증할 수 있어야 합니다.
따라서 사용자 인가를 위한 매개체로서 JWT Access Token, Refresh Token을 적용하였습니다.
2) JWT Token vs Session
- 사용자 인가를 위해서 사용하는 기술은 크게 JWT Token과 Session이 있습니다.
여기서는 각각의 특징과 장단점에 대해서 비교해보겠습니다.
(1) JWT 토큰
- JWT 토큰 방식은 클라이언트가 직접 토큰을 들고, 요청 시 토큰을 같이 보내는 방식입니다.
서버는 클라이언트의 요청을 받을 때, 해당 토큰을 디코딩해서, 해당 클라이언트를 식별할 수 있습니다.
- JWT 토큰은 Header, Payload, Signature로 구성됩니다.
Header는 토큰의 타입 그리고 암호화를 위한 알고리즘 정보를 갖고 있습니다.
Payload는 토큰을 통해 관리할 사용자 정보를 갖고 있습니다.
Signature는 Header와 Payload를 각각 Base64 인코딩을 한 후에,
사용자가 지정한 secret을 더해서 암호화함으로써 생성됩니다.
- JWT 토큰은 인코딩된 Header, 인코딩된 Payload, Signature가 문자열 형태로 더해져서 구성됩니다.
(2) Session
- 세션 방식은 사용자 인증 정보를 서버의 세션 저장소에 저장한 후에,
클라이언트에 Session Id를 발급하는 방식입니다.
클라이언트는 요청 시 HTTP 쿠키 헤더를 통해 Session Id를 서버에 전송하고,
서버는 클라이언트가 전달한 Session ID와 세션 저장소에 저장된 정보를 비교해서
사용자를 인증하게 됩니다.
- 다음은 JWT 토큰과 세션의 장단점을 비교해보겠습니다.
JWT 토큰 | 세션 | |
보관 주체 | 클라이언트 | 서버 |
사이즈 | 큼 | 작음 |
확장성 | 좋음 | 별도의 작업 필요 |
보안성 | 좋지 않음 | 좋음 |
서버 메모리 | 불필요 | 필요 |
- 결론적으로 저는 JWT 토큰을 선택했습니다.
그 이유는
(1) 서버의 확장성을 고려할 때, JWT 토큰이 별도의 작업이 필요하지 않으므로 유리하다고 판단했습니다.
(2) JWT 토큰의 부족한 보안성은 Refresh Token 등을 통해서 보완될 수 있다고 판단했습니다.
3) JWT Access Token, Refresh Token 개발하기
(1) 서버 - 컨트롤러 구현
- JWT Access Token과 Refresh Token은 로그인 시에 생성됩니다.
컨트롤러의 login 메소드는 요청 수신 시,
AuthenticationService 클래스의 login 메소드를 호출하도록 하였습니다.
@RestController
@RequestMapping("/api/v1/members")
@CrossOrigin(origins = "*")
@Slf4j
public class MemberController {
private final AuthenticationService authService;
private final MemberService memberService;
private final OauthService oauthService;
public MemberController(AuthenticationService authService, MemberService memberService, OauthService oauthService) {
this.authService = authService;
this.memberService = memberService;
this.oauthService = oauthService;
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
return ResponseEntity.ok(authService.login(loginRequest, response));
}
}
(2) 서버 - 서비스 구현
@Slf4j
@Service
public class AuthenticationService {
private final JwtUtil jwtUtil;
private final MemberRepository memberRepository;
private final EmitterService emitterService;
private final RedisService redisService;
public AuthenticationService(JwtUtil jwtUtil, MemberRepository memberRepository, EmitterService emitterService, RedisService redisService) {
this.jwtUtil = jwtUtil;
this.memberRepository = memberRepository;
this.emitterService = emitterService;
this.redisService = redisService;
}
public LoginResponse login(LoginRequest memberDTO, HttpServletResponse response) {
String email = memberDTO.getEmail();
String inputPassword = memberDTO.getPassword();
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new CommonException(NOT_FOUND_LOGIN));
String savedPassword = member.getPassword();
if (!isMatchPassword(savedPassword, inputPassword)) {
throw new CommonException(NOT_FOUND_LOGIN);
}
List<String> tokens = makeNewTokens(member.getId(), member.getRole());
String accessToken = tokens.get(0);
String refreshToken = tokens.get(1);
setNewAuthentication(member.getId());
redisService.setRefreshTokenOnRedis(member.getId(), refreshToken);
setNewCookie(response, refreshToken);
return createLoginResponse(accessToken, "httpOnly");
}
public boolean isMatchPassword(String savedPassword, String inputPassword) {
String encryptedInputPassword = SHA256Util.encryptSHA256(inputPassword);
return savedPassword.equals(encryptedInputPassword);
}
public List<String> makeNewTokens(Long memberId, Role role) {
String accessToken = jwtUtil.createAccessToken(memberId, role);
String refreshToken = jwtUtil.createRefreshToken(memberId, role);
return List.of(accessToken, refreshToken);
}
public void setNewAuthentication(Long memberId) {
Authentication authentication = jwtUtil.getAuthentication(String.valueOf(memberId));
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
public void setNewCookie(HttpServletResponse response, String refreshToken) {
Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setHttpOnly(true);
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setPath("/"); // 모든 경로에서 접근 가능 하도록 설정
response.addCookie(cookie);
}
public LoginResponse reissue(String refreshToken) {
if (refreshToken.equals("0")) throw new LoginException(ErrorCode.INVALID_TOKEN);
String memberId = getMemberId(refreshToken);
Role role = getRoleType(refreshToken);
if (redisService.isNotValidRefreshToken(refreshToken, memberId)) {
throw new LoginException(ErrorCode.INVALID_TOKEN);
}
String accessToken = jwtUtil.createAccessToken(Long.valueOf(memberId), role);
return createLoginResponse(accessToken, "httpOnly");
}
public String getMemberId(String token) {
Claims claims = jwtUtil.decode(token);
return claims.get("id", String.class);
}
public Role getRoleType(String token) {
Claims claims = jwtUtil.decode(token);
String roleStr = claims.get("role", String.class);
if (roleStr != null && roleStr.equals("USER")) {
return Role.USER;
}
return Role.MENTOR;
}
public void logout(String token) {
String accessToken = token.substring(7);
Long expiration = jwtUtil.getExpirations(accessToken);
String memberId = JwtUtil.getMemberId().toString();
redisService.deleteMemberOnRedis(memberId);
redisService.setLogOutWithAccessTokenOnRedis(accessToken, expiration);
emitterService.deleteMemberEmitter(memberId);
}
}
- 코드를 나눠서 살펴보겠습니다.
(2-1) login 메소드
public LoginResponse login(LoginRequest memberDTO, HttpServletResponse response) {
String email = memberDTO.getEmail();
String inputPassword = memberDTO.getPassword();
Member member = memberRepository.findByEmail(email).orElseThrow(() -> new CommonException(NOT_FOUND_LOGIN));
String savedPassword = member.getPassword();
if (!isMatchPassword(savedPassword, inputPassword)) {
throw new CommonException(NOT_FOUND_LOGIN);
}
List<String> tokens = makeNewTokens(member.getId(), member.getRole());
String accessToken = tokens.get(0);
String refreshToken = tokens.get(1);
setNewAuthentication(member.getId());
redisService.setRefreshTokenOnRedis(member.getId(), refreshToken);
setNewCookie(response, refreshToken);
return createLoginResponse(accessToken, "httpOnly");
}
- LoginRequest 객체를 통해 전달 받은 email, password를 추출한 후에,
email로 Member 객체를 찾고, password로 비밀번호가 일치하는지 확인합니다.
- 그 다음에 makeNewTokens 메소드로 Member 객체의 id와 role을 활용해서 토큰을 만들어줍니다.
makeNewTokens 메소드는 두 개의 토큰을 리스트로 반환하는데,
첫 번째는 accessToken, 두 번째는 refreshToken입니다.
- 그 다음 Member 객체의 id로 authentication을 설정하고,
Redis에 Member 객체의 id로 refreshToken을 등록한 후에,
Cookie에 refreshToken을 등록합니다.
- 최종적으로 LoginResponse에 accessToken을 추가해서 클라이언트에 반환함으로써
로그인이 마무리됩니다.
(2-2) makeNewTokens 메소드
public List<String> makeNewTokens(Long memberId, Role role) {
String accessToken = jwtUtil.createAccessToken(memberId, role);
String refreshToken = jwtUtil.createRefreshToken(memberId, role);
return List.of(accessToken, refreshToken);
}
- JwtUtil 클래스의 createAccessToken 메소드와 createRefreshToken 메소드로
각각 accessToken, refreshToken을 생성하고, 리스트로 반환합니다.
(2-3) setNewAuthentication 메소드
public void setNewAuthentication(Long memberId) {
Authentication authentication = jwtUtil.getAuthentication(String.valueOf(memberId));
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
- 사용자의 권한을 담은 authentication 객체를 생성하고, SecurityContextHolder에 저장합니다.
(2-4) setNewCookie 메소드
public void setNewCookie(HttpServletResponse response, String refreshToken) {
Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setHttpOnly(true);
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setPath("/"); // 모든 경로에서 접근 가능 하도록 설정
response.addCookie(cookie);
}
- Cookie에 refreshToken을 세팅해줍니다. RefreshToken은 쿠키의 재발급이 필요할 때, 사용됩니다.
(2-5) logout 메소드
public void logout(String token) {
String accessToken = token.substring(7);
Long expiration = jwtUtil.getExpirations(accessToken);
String memberId = JwtUtil.getMemberId().toString();
redisService.deleteMemberOnRedis(memberId);
redisService.setLogOutWithAccessTokenOnRedis(accessToken, expiration);
emitterService.deleteMemberEmitter(memberId);
}
- Redis에 accessToken을 로그아웃으로 등록하고, Redis에 있는 refreshToken을 삭제해줍니다.
(3) 서버 - JwtUtil 클래스 구현
- JwtUtil 클래스는 JWT Token과 관련한 기능들을 모아 놓은 유틸 클래스입니다.
@Component
@Slf4j
public class JwtUtil {
// access 토큰 유효 시간 3m
private final long accessTokenValidTime = Duration.ofMinutes(30).toMillis();
// 리프레시 토큰 유효시간 | 2주
private final long refreshTokenValidTime = Duration.ofDays(7).toMillis();
@Autowired
private CustomUserDetailService customUserDetailService;
private final Key key;
public JwtUtil(@Value("${jwt.secret}") String secret) {
key = Keys.hmacShaKeyFor(secret.getBytes());
}
public String createAccessToken(Long memberId, Role role) {
Date now = new Date();
return Jwts.builder()
.setId(Long.toString(memberId))
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("admin")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessTokenValidTime))
.claim("id", Long.toString(memberId))
.claim("role", role)
.signWith(key)
.compact();
}
public String createRefreshToken(Long memberId, Role role) {
Date now = new Date();
return Jwts.builder()
.setId(Long.toString(memberId))
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("admin")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + refreshTokenValidTime))
.claim("id", Long.toString(memberId))
.claim("role", role)
.signWith(key)
.compact();
}
public Long getExpirations(String token) {
// accessToken 남은 유효시간
Date expiration;
try {
expiration = decode(token).getExpiration();
} catch (Exception e) {
throw new CommonException(INVALID_TOKEN);
}
// 현재 시간
long now = new Date().getTime();
return (expiration.getTime() - now);
}
public Authentication getAuthentication(String memberId) {
UserDetails userDetails = customUserDetailService.loadUserByUsername(memberId);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public Claims decode(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(key).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
public static Long getMemberId() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String memberId = authentication.getName();
return Long.parseLong(memberId);
} catch (Exception e) {
throw new CommonException(NOT_FOUND_ID);
}
}
}
- 코드를 나눠서 살펴보겠습니다.
(3-1) createAccessToken 메소드
public String createAccessToken(Long memberId, Role role) {
Date now = new Date();
return Jwts.builder()
.setId(Long.toString(memberId))
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("admin")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessTokenValidTime))
.claim("id", Long.toString(memberId))
.claim("role", role)
.signWith(key)
.compact();
}
- AccessToken을 생성합니다.
AccessToken 생성 시, claim에 id와 role을 지정하고, expiration 즉, 유효 시간도 지정합니다.
그리고 지정한 key로 토큰을 암호화합니다.
(3-2) createRefreshToken 메소드
public String createRefreshToken(Long memberId, Role role) {
Date now = new Date();
return Jwts.builder()
.setId(Long.toString(memberId))
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("admin")
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + refreshTokenValidTime))
.claim("id", Long.toString(memberId))
.claim("role", role)
.signWith(key)
.compact();
}
- RefreshToken을 생성합니다.
RefreshToken 생성은 AccessToken과 매우 유사합니다.
다만, RefreshToken은 RefreshToken의 유효 시간을 지정합니다.
(3-3) getExpirations 메소드
public Long getExpirations(String token) {
// accessToken 남은 유효시간
Date expiration;
try {
expiration = decode(token).getExpiration();
} catch (Exception e) {
throw new CommonException(INVALID_TOKEN);
}
// 현재 시간
long now = new Date().getTime();
return (expiration.getTime() - now);
}
- AccessToken의 유효시간을 검사합니다.
AccessToken에 설정된 유효 시간이 현재 시간 보다 이전인지 이후인지 판단하는 기능을 합니다.
- 다음 내용은 2부에서 다루도록 하겠습니다.
참고