
custom :
jwt :
validation :
access: 600000
refresh : 86400000
secrets:
origin-key : 019646e0-992d-7cbb-bdca-11c72d6b1b2e # UUID 임의로
app-key : E965C3A4EB5733F83ADFFC7AFE80083FF87618F1E290A6B0AE0 # UUID를 SHA512로 암호화한 것
@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "custom.jwt")
public class JwtConfiguration {
private final Validation validation;
private final Secrets secrets;
@Getter
@RequiredArgsConstructor
public static class Validation {
private final Long access;
private final Long refresh;
}
@Getter
@RequiredArgsConstructor
public static class Secrets {
private final String originKey;
private final String appKey;
}
}
@ConfigurationPropertiesScan
public class BackendApplication {
application.yml에 정의한 설정 값을 @ConfigurationProperties를 사용해서 자바 클래스에 자동으로 주입받는 방식이다.
@Value("${...}") 쓰는 것보다 더 확장성과 안정성이 좋다. @ConfigurationProperties를 활용해서 YML 설정값을 자바 객체에 매핑(바인딩)한 구조이다.@ConfigurationProperties를 사용하기 위해 Application 파일에 @ConfigurationPropertiesScan 어노테이션을 붙여줘야 한다.@Value("${custom.jwt.validation.exp}")
private Long exp;
위와 같이 @Value를 사용할 수도 있다.
🫨 하지만, 변수가 늘어날 때마다, 그리고 YML 설정 구조가 복잡해질수록, 지저분해지고 복잡해진다.
private String issue(Long id, Role role, Long expTime) {
return Jwts.builder()
.subject(id.toString())
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(new Date().getTime() + expTime))
.signWith(getSecretKey(), Jwts.SIG.HS256)
.compact();
}
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(jwtConfiguration.getSecrets().getAppKey().getBytes());
}
Jwts.builder().compact() : JWT는 문자열로 되어있다. JWT를 만들어서 반환해준다. subject() : sub : ~ 에 들어갈 내용을 지정해준다. 여기서는 Member의 id를 문자열로 넣어줬다. claim() : Payload에 들어갈 Claim의 key, value을 넣어준다. (value는 Object 타입)issuedAt(new Date()) : 발급 받은 시간을 현재 시간으로 넣어준다.expiration() : 만료 기간을 설정해줄 수 있다. new Date().getTime() + expTime : expTime 유효 기간을 Long 타입으로 new Date()에 넣어주어 발급한 이래로의 유효 기간을 설정해준다.signWith() : 시그니처를 넣어줄 수 있다. Jwts.SIG.HS256 : 헤더에 넣어줄 해싱 알고리즘을 적어준다. 해당 알고리즘으로 JWT 시그니처를 생성한다.getBytes() : 문자열을 바이트 배열로 변환한다.getSecretKey() : 시그니처에 포함시킬 키를 반환하는 메서드이다. 키 타입을 반환하며, Keys.hmacShaKeyFor() 메서드는 바이트 배열을 기반으로 HMAC 알고리즘을 적용한 HMAC-SHA 시크릿 키를 생성한다.public boolean validate(String token) {
try{
// JWT 토큰 분석
JwtParser parser = Jwts.parser()
.verifyWith(getSecretKey())
.build();
parser.parseSignedClaims(token);
return true;
} catch ( JwtException e ) {
log.info("token = {}", token);
log.info("토큰이 이상해요..");
} catch ( IllegalArgumentException e){
log.error("token = {}", token);
log.info("이상한 토큰이 검출되었습니다.");
} catch (Exception e) {
log.error("token = {}", token);
log.info(";;");
}
return false;
}
parser().build() : JWT를 분석하기 위해 사용한다.verifyWith(getSecretKey()) : 어떤 키를 가지고 분석할지 정해준다. 우리는 getSecretKey()로 비밀키를 만들어 토큰을 만들었기 때문에 그 메서드를 넣어준다.parseSignedClaims(token) : 토큰을 가지고 제대로 된 토큰인지, 서명이 맞는지, 문제가 있는지 등을 검사한다. 그런 후 문제가 없다면 토큰에 담긴 클레임을 반환하고, 문제가 있다면 예외를 던진다. -> 반환값은 다음과 같다. Jws<Claims> claimsJwspublic TokenBody parseJwt(String token) {
Jws<Claims> parsed = Jwts.parser()
.verifyWith(getSecretKey())
.build()
.parseClaimsJws(token);
String sub = parsed.getPayload().getSubject();
String role = parsed.getPayload().get("role").toString();
return new TokenBody(Long.parseLong(sub), Role.valueOf(role));
}
위와 같은 메서드를 활용해서 이번엔 클레임을 받아온다. 이 상황에서는 예외 처리를 해주지 않아도 된다. 즉, 토큰이 제대로 된 토큰인지 확인해줄 필요가 없다.
왜냐, 이미 validate() 검사를 하고 분석을 하는 로직이기 때문에, validate()에서 안 터졌던 예외가 여기서 터지면 이상한 것...
parseClaimsJws()는 JWT를 파싱해서 JWS (헤더 + 페이로드 + 서명) 전체를 포함한 객체로 리턴해준다. 그렇기 때문에 Payload에 담긴 내용을 받아오기 위해서는 .getPayload()을 해야 한다.
parsed.getPayload() : 클레임에 담긴 값들을 가져온다. -> DTO에 담아주기 위해 TokenBody 클래스를 만들고, 거기에 값을 넣어준다.
현재 oauth2Login 설정으로는, 로그인을 해도, 계속 로그인이 되지 않고, 로그인 창이 뜬다.
🫥 세션을 사용하지 않는 (STATELESS) 설정이다. 즉, 로그인 상태를 서버가 기억하지 못한다 !
로그인 성공 -> redirect-url 찾음 -> 따로 지정해둔 게 없음 ->
defaultTargetUrl로 설정된/로 redirect됨 -> 새로운 요청 -> 너 누구니 ❓ 인증 정보가 없는데? 🤨 -> 로그인 다시 해. ㅎ 🪪😵💫
.oauth2Login( oauth -> {
oauth.successHandler(oAuth2SuccessHandler);
})
successHandler() 를 등록해줘야 한다. @Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
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();
String token = jwtTokenProvider.issueAccessToken(principal.getId(), principal.getRole());
log.info("token = {}", token);
}
}
@Component를 붙여 빈으로 등록해준다.onAuthenticationSuccess() : 로그인이 성공하고, 인증까지 끝난 직후에 호출된다.이 메서드는 인증 성공 후, 로그인 이후 로직을 직접 처리할 수 있도록 해주는 메서드이다. 이 메서드에서는 JWT 토큰을 발급해주고 있다. -> issueAccessToken() 로 토큰을 발급받는다. authentication.getPrincipal() : SecurityContext에 저장되는 인증 객체에서 principal을 받아온다. 즉, MemberDetails 를 반환해준다. @Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@Column(name="refresh_token_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String refreshToken;
@Setter
@ManyToOne
@JoinColumn(name="member_id")
private Member member;
private LocalDateTime createdAt = LocalDateTime.now();
@Builder
public RefreshToken(String refreshToken, Member member) {
this.refreshToken = refreshToken;
this.member = member;
}
}
Member가 여러 개의 RefreshToken을 가질 수 있다. (로그인할 때마다)public TokenPair generateTokenPair(Member member) {
String accessToken = issueAccessToken(member.getId(), member.getRole());
String refreshToken = issueRefreshToken(member.getId(), member.getRole());
RefreshToken token = new RefreshToken(refreshToken, member);
refreshTokenRepository.save(token);
return TokenPair.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
save()을 통해 DB에 저장해준다.TokenPair이라는 DTO 클래스를 만들어, accessToken과 refreshToken을 담아 객체를 생성해준다.public String issueAccessToken(Long id, Role role) {
return issue(id, role, jwtConfiguration.getValidation().getAccess());
}
public String issueRefreshToken(Long id, Role role) {
return issue(id, role, jwtConfiguration.getValidation().getRefresh());
}
getAccess()와 getRefresh()는 application.yml 파일에서 지정해준 값들을 @ConfigurationProperties를 이용해 가져오는 것이다. JWT는 Json 객체에 인증에 필요한 정보들을 담은 후 비밀키로 서명한 토큰으로, HTTP 헤더에 실려 서버가 클라이언트를 식별하는 데에 쓰이는 중요한 인증방식이다.
😏 하지만, 누군가.. JWT를 탈취했다면..?
그 사람은 토큰을 이용해서 신뢰할만한 사용자인 것 마냥 인증을 통과할 수 있고, 서비스를 악용할 가능성이 있다. 서버는 JWT로만 판단하기 때문에, 해당 사용자가 JWT을 해킹한 사람인지, 신뢰할만한 사용자인지 구분할 수 없다.
🗓️ 그래서 유효기간이 필요하다 !
⚡️ BUT! 유효기간을 너무 짧게 두면 로그인을 자주 해야 하기 때문에 사용자 경험이 좋지 않다. 그렇다고, 유효기간을 길게 두면 보안상 탈취 위험이 있다.
✅ 그래서 유효기간이 다른 두 개의 JWT 토큰을 두는 것이다 !
그것이 바로 Refresh Token과 Access Token
즉, 통신 과정에서 해킹당할 위험이 큰 Access Token의 유효기간은 짧게 두고, Refresh Token으로 주기적으로 재발급함으로써 피해를 최소화하려는 것이다.
invalid_token (유효기간이 만료되었음)을 알 수 있다.통신에 잘 사용되는 Access Token은 탈취될 가능성이 높다. 하지만, 만약 운좋게 탈취했더라도 유효 기간이 짧기 때문에 다시 탈취를 시도해야 한다. 왜냐하면, JWT의 유효기간은 변경이 불가능하기 때문이다.
JWT는 Header, Payload, Signature로 구성되어 있다. 탈취자는 Payload에 있는 유효기간을 늘리려고 시도할 것이다. 하지만, Payload의 만료 기간을 변경한다 하더라도, Signature가 바뀌진 않는다.
Signature에서 복호화된 Payload와 변경된 Payload가 일치하지 않는 것을 비밀키를 가진 서버는 알 수 있고, 접근 권한을 내어주지 않게 되는 것이다.
Refresh Token이 통신에 사용되는 빈도가 적긴 하지만, 탈취될 가능성이 없는 것은 아니다. Refresh Token이 유출되는 것을 방지하기 위한 방법도 여러가지 있다.
BlackList 방법, Refresh Token Rotation 방법 등이 있다.
간단히 설명하자면,
Q1. 왜 한 멤버가 여러 개의 토큰을 가질 수 있지? 한 사용자당 하나의 토큰만 가지는 거 아닌가? 한 사용자가 여러 토큰을 받으면 DB에 같은 멤버의 refreshToken이 엄청 많을 수도 있는 거 아닌가..?
A1. 현실적으로는 여러가지의 시나리오가 있다고 한다.
📌 1. 여러 디바이스에서 로그인
같은 사용자가:
왜냐면:
👉 그래서 디바이스마다 리프레시 토큰이 따로 저장된다.
📌 2. 브라우저 or 앱을 닫고 다시 로그인
👉 이전 리프레시 토큰은 여전히 유효할 수도 있다.
→ 따라서 중복되는 리프레시 토큰이 생길 수 있다.
📌 3. 보안 측면에서 토큰 추적 및 관리
Q2. 이렇게 계속 Refresh Token을 발급 받는다면, refresh token은 언제 지워지는 걸까?
이것도 여러 경우가 있을 수 있다.
✅ 1. 로그아웃할 때
✅ 2. 리프레시 토큰이 만료됐을 때
✔ 이건 보통 주기적으로 백그라운드에서 만료된 토큰을 정리하는 작업을 따로 돌린다. (ex. 스케줄러)
✅ 3. 리프레시 토큰이 탈취되었거나, 이상 접근이 감지됐을 때
예를 들어 같은 토큰이 다른 IP, 다른 지역에서 사용되었을 때, 보안상 위험이 의심되면 해당 토큰을 강제로 삭제한다. (또는 블랙리스트에 등록)
✅ 4. 회원 탈퇴
회원 탈퇴 시, 해당 회원의 모든 리프레시 토큰을 DB에서 삭제한다.
Q3. 그럼 정리하자면, refresh Token을 삭제해줄만한 위의 경우가 아니라면, 로그인할 때마다 매번 access token과 refresh token을 발급해주는 건가?
A3. 그렇다 ! 보안 측면에서, 토큰은 결국 인증 수단이기 때문에, 오래된 토큰보다 최신 토큰을 사용하는 게 좋다. 로그인할 때마다 토큰을 발급 받으면, 이전 토큰 유출 위험이 낮아진다.
로그아웃하지 않고, 로그인 상태를 계속 유지하려면 access token이 만료돼도 refresh token으로 access token을 재발급 받을 수 있다. 이 경우 refresh token을 새로 발급받지 않고, 이걸 이용해서 access token을 갱신한다.
오랜만에 쓰는 팀 활동 후기..! 사실 쓸 생각은 없었는데,,, 느낀 점 쓰다가 갑자기 너무 웃긴 게 생각이 나서 적어본다. 난 우리 팀원분들이 좀 웃기다. 난 대놓고 웃긴 유머보다 약간 이상하게 웃긴 유머 코드가 웃긴데.. 우리 팀원분들이 좀 그런 분들인 것 같다. (이상하다는 게 아니라..) 스크럼 때 노션에 남기는 워딩이나, 말씀하실 때 좀 웃참하게 되는 부분들이 있다.. ㅎ 내가 너무 정많아 인간이라 팀원들한테 또 혼자만 엄청난 내적 친밀감을 느끼고 혼자 웃참하고 있다... ㅋㅎㅋㅎㅋㅎ 아무튼.. 웃긴 거 하나도 안 하시고,, 되게 진지하시고,, 되게 침묵이신데,, 한 번씩 웃기다. (positive)
워후.. 어제 분명히 '오늘은 괜찮았지만, 내일 JWT 들어가면,, 어렵겠지~?' 라고 했는데,, 나의 상상은 현실이 된다? ㅎ .. 맞습니다 하하 오후 돼서 refresh, access 나오니까 너무 헷갈려서 이해할 수 없었다... 그치만 열심히 이해하려고 찾아가면서 수업을 들었다. 그러니까 좀 잘 따라갈 수 있었다. 그래도 뭔가 완벽히 이해된 것 같진 않고,, 강사님이 "아~ 이러면 다음주 월요일에 리셋돼서 오는 거죠?" 이러셨을 때 너무 웃겼다. 난 홍홍홍 웃었다 ㅎㅎ... 안 까먹으려고 열심히 정리는 했다. ㅎ...!
또! 오늘 프로젝트 공지가 올라왔다..! 와우, 너무 많은 양의 정보에 머리가 어지러운 나. ㅎ 정말 데브코스는 산 넘어 산이다 하하 그치만 이미 많은 산을 넘었으니,,, 프로젝트라는 산도 잘 넘어보자.. 이런 너낌스로 열심히 해야지 !!! 팀원 분들이랑 잠깐 얘기를 나눠봤는데 재미있을 것 같기도 하지만,, 또 무섭기도 하고,, 겁나기도 하고!! 아악 !! 잘 헤쳐나갈 수 있기를... !
금요일이닷 ! 근데 어째서 벌써 11시? :) 내 소중한 금요일 밤 어디갔지.. 소중한 주말 잘 지켜야지..