1. 서론
대부분의 웹/모바일 어플리케이션은 회원가입 및 로그인 서비스를 제공하고 있으며, 이는 각 개인이 어떤 어플리케이션 안에서 타인이 범접할 수 없는 공간을 가지고 있다는 것을 의미한다. 이에 따라 서비스 이용자는 계정 생성 및 접속에 있어서 이용자 본인이 맞다고 인증할 수 있어야 하고, 서비스 제공자는 인증이 완료된 이용자가 자신의 서비스를 안전하게 이용할 수 있도록 시스템을 구축해야한다. 즉, 개발자는 사용자가 안전하게 인증할 수 있도록 서비스를 구성하고, 인증된 사용자에게 적합한 자원 접근을 위한 권한을 인가하도록 구현해야 한다. 이 지향점의 중심에 JWT(JSON WEB Token)가 있다.
토큰은 웹 등의 어플리케이션에서 이용자 개인을 확인할 수 있게하는 수단 중 하나이다. 토큰에는 크게 두 가지 종류가 있으며, 아무 정보도 담기지 않는 토큰을 일반 토큰이라고 하고, 특정 데이터가 담기어 생성되는 토큰을 클레임 기반 토큰이라고 한다. 바로 이 클레임 기반 토큰 중 가장 대표적인 것이 JWT이다. 이번 게시에서 JWT의 개념부터 실제 프로젝트 적용까지 살펴보자.
2. JWT란?
- JSON : JSON(JavaScript Object Notation)은 일반적으로 서버 측에서 클라이언트 측으로 데이터를 전송할 때 사용하는 양식이다. 클라이언트가 사용하는 언어에 관계 없이 통일된 양식으로 데이터를 주고 받을 수 있게 하며, 상대적으로 상당히 간결한 양식의 형태를 가지고 있어 널리 쓰이고 있다. key, value가 한 쌍을 이루게 되고, key 값을 통해 value 값으로 접근할 수 있다.
- WEB : 데이터를 전송하는 정보 시스템을 뜻한다.
- Token : 웹 등의 어플리케이션에서 이용자 개인을 확인할 수 있게하는 수단 중 하나이다.
즉, JWT는 웹 표준(브라우저 종류 및 버전에 따른 기능 차이에 대해 호환이 가능하도록 제시된 표준)을 따르고, 데이터 전달에 있어서 JSON 객체를 사용하며, 이를 인증을 위한 수단으로 사용하는 Token인 것이다.
위 그림과 같이 JWT는 Header(헤더), Payload(내용), Signature(서명)의 세가지 데이터를 가지고 있으며, 각 데이터는 『.』(dot) 문자로 구분된다. JWT는 기본적으로 유의미한 데이터 값을 가지고 있기 때문에 해시 알고리즘으로 암호화가 되어 있으며, 실제로는 다음과 같은 데이터 형태를 가진다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header :
// 복호화 전
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// 복호화 후
{
"alg": "HS256",
"typ": "JWT"
}
헤더에는 해당 토큰을 암호화한 해시 알고리즘과 토큰의 타입이 정의 돼 있다.
- Payload :
// 복호화 전
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
// 복호화 후
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
내용에는 실제로 전달하고자 의도하는 데이터를 담는다. 이 때, 데이터 각각의 key 값("sub", "name", "iat")를 claim이라고 부른다. claim에는 3가지 종류가 존재한다.
registered claim : compact한 알파벳 3글자로 정의되며, 사용이 권장된다. iss(issuer), exp(expiration time), sub(subject), aud(audience) 등이 존재한다.
public claim : 사용자가 자유롭게 정의할 수 있다. 충돌 방지를 위해 URI 포맷을 이용한다.
private claim : 마찬가지로 사용자 정의 claim으로, 임의로 지정한 정보 공유를 위해 만들어진 커스텀된 claim이다.
Payload에는 누구나 디코딩하여 데이터 열람이 가능하므로, password와 같이 아주 민감한 정보들은 여기에 담기면 안된다. 단순 Base64 인코딩이 된 파트이기 때문이다.
- Signature :
// 복호화 전
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
// 복호화 후
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
{your-256-bit-secret}
)
서명 파트는 Base64 인코딩된 헤더 + .(dot) + 인코딩된 내용(Payload) + secret key(jwt 복호화를 위한 비밀 코드)로 해싱된다. 토큰 인코딩 및 유효성 검증에 사용된다. 암호화코드이다.
JWT는 서비스 이용자의 기기에 직접 저장된다. 이러한 JWT을 가지고 토큰 기반 인증을 구현하는 이유는 서버의 무상태성(stateless)를 보장하기 위함이다. 무상태성은 서버가 클라이언트의 상태 내지는 정보를 직접 가지고 있지 않다는 것이다. 즉, 실제로 서버는 클라이언트의 상태를 직접 저장하지 않으며 요청에 대한 응답만 줄 수 있다.
기본적으로 서비스의 규모가 커질수록 서비스 제공자는 단일 서버가 아닌 다중 서버를 가지게 되므로, HTTP 통신은 무상태성을 지향해야 한다. 왜냐하면 stateless하지 않을 경우 기존에 연결된 서버에서 장애가 발생하고 새로운 서버로 연결이 됐을 때, 새로 연결된 서버에는 사용자의 상태가 저장되어 있지 않기 때문에 기존의 작업을 처음부터 다시 진행해야 하는 상황이 발생하기 때문이다. stateless가 보장돼 있는 서버라면 서비스 이용자 본인에게 데이터가 저장되기 때문에 언급한 비효율성을 해결할 수 있고, 아울러 서버에 부담을 줄이고 성능 향상을 기대할 수 있다. 따라서 서버 확장에 제한을 두지 않을 수 있게 된다.
3. JWT 인증 Flow
본 프로젝트에서 적용시킨 JWT의 종류는 Access Token과 Refresh Token의 두 가지이다.
Access Token에는 유저의 신원이나 권한을 결정하는 민감한 데이터가 담겨 있다. 물론 이는 해시 알고리즘과 Secret key로 암호화되어 해독이 불가능할 정도로 안전하지만, 서버에 직접 저장하지 않는 stateless한 특성을 유지하기 위해 이용자의 기기에 직접 저장된다. 이러한 특성 상 Access Token에는 탈취의 위험이 있다. 탈취자는 민감한 데이터를 직접 알 수는 없지만, 이 토큰을 가지고 인증을 통과하고 자원에 접근할 수 있다. 서버는 본 주인인지 탈취자인지 구분할 수 없으므로 문제가 발생하는 것이다. 따라서 Access Token에는 유효기간이 존재해야 한다. 유효기간을 길게 둘 경우 탈취 위험에 계속 노출되므로 유효기간이 짧아야 탈취의 위험성을 최소화시킬 수 있다. 하지만 이렇게 유효기간을 짧게 둘 경우, 사용자는 유효기간이 끝날 때마다 로그인을 다시 해야하므로 사용자에게 불편을 유발하게 된다. 이를 위한 해결법이 Access Token말고도 유효기간이 다른 JWT인 Refresh Token을 두는 것이다.
Refresh Token의 유효기간은 Access Token보다 상대적으로 아주 길다.(본 프로젝트에서는 Access 30분/Refresh 1달) 즉, API 통신과정에서 탈취 위험성이 큰 Access Token의 유효 기간을 짧게 두고 Refresh Token을 통해 Access Token을 재발급하여 사용자 경험 수준을 높이고, 탈취의 피해를 최소화시킬 수 있는 것이다.
사용자가 서비스 자체 로그인이나 소셜 로그인에 성공하면, Controller나 OAuth의 Success Handler는 application.yml에 명시된 JWT Secret key로 JWT를 생성한다.
자체 로그인의 경우, Controller가 발급한 Response Body에 담긴 Access Token을 프론트 단에서 받고, 이를 쿠키에 저장한다. 만약 로그인 상태 유지를 체크한 후 로그인한다면, Response에 Refresh Token를 Cookie 형태로 담아 발급한다.
소셜 로그인의 경우, Access Token과 Refresh Token을 모두 발급한다.
생성된 JWT는 HTTP 쿠키의 형태로 서비스 이용자의 PC에 저장된다. 보안을 위해 Refresh Token은 HttpOnly 옵션이 활성화 돼 있는 상태이다.
이후 API 통신에서 권한이 필요한 자원에 접근할 경우, Filter Chain 가장 앞 단에 존재하는 JwtAuthenticationFilter.java는 HTTP Cookie에 Access Token이 존재하는지, 유효한 Token인지 검증하고, 만료됐을 경우에는 Refresh Token으로 재발급 여부를 처리한다. 여기서 검증이 정상적으로 완료되어야 자원에 접근할 수 있는 것이다.
이제 이를 실제 코드와 함께 살펴보자.
이와 관련된 의존성 주입은 다음과 같다.
build.gradle
...
dependencies {
...
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
...
}
...
4. JwtTokenProvider.java
...
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
...
JwtTokenProvider의 생성자이다. 다른 class에서 JwtTokenProvider가 Bean 주입될 때, 이 생성자를 거쳐서 주입된다. @Value 어노테이션에 의해서 application.yml에 명시돼 있는 Secret Key 값이 매개변수에 담긴다.(16진수 형식의 난수 값, 32글자 이상) Decoders.BASE64.decode() 메서드를 사용해 Text(Secret Key)를 Binary Data로 변환한다. Keys.hmacShaKeyFor() 메서드는 이 Binary Data를 기반으로 적절한 HMAC 알고리즘(최신 jjwt에서는 내부적으로 알아서 지정함.)을 적용시켜 java.security.Key 객체를 생성한다. 필드에 담긴 이 Key 객체는 토큰 검증 및 JWT 서명에 사용된다.
TokenDTO.java
...
@Builder
@Data
@AllArgsConstructor
public class TokenDTO {
private String grantType; //jwt 인증 타입. Bearer 사용.
private String accessToken;
private String refreshToken;
}
...
public TokenDTO generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
...
Access Token과 Refresh Token을 모두 생성하는 generateToken() 메서드이다. 로그인 등의 사용자 인증을 성공했을 때, 매개변수로 인증된 Authentication 객체를 넘겨받는다. 해당 Authentication 객체에 저장돼 있는 권한 목록을 "," 문자를 구분자로한 문자열로 저장한다.(authorities)
.setSubject()는 토큰의 제목을 설정한다. authentication.getName()은 인증된 사용자의 id를 반환한다. 상기 언급했던 registered claim의 "sub"에 해당한다.
.setExpiration()은 토큰의 만료 시간을 설정한다. 상기 registered claim의 "exp"이다.
.claim()은 사용자 정의 claim을 담을 수 있게 한다. key 값은 "auth"이고, 권한 목록(authorities)이 담긴다.
.signWith()는 어떤 알고리즘과 key로 서명할지 설정한다. 이를 기반으로 Header와 Payload가 Base64로 인코딩된 뒤에 해싱된다.
.compact()는 토큰을 생성하는 메서드이다.
Refresh Token은 만료 시간과 서명 방식만 설정된 뒤 만들어진다. 이를 TokenDTO 객체에 모두 담아 return한다.
...
public String generateAccessToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
return Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken() {
long now = (new Date()).getTime();
return Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
...
위 두 메서드는 Access Token과 Refresh Token을 따로 생성할 필요가 있을 때 쓰려고 만들어둔 메서드이다. 로직은 같다.
...
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if(claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
...
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
...
getAuthentication() 메서드는 Access Token에서 유저 정보(id, 권한)를 추출해서 이를 담아 Authentication 객체를 반환한다. 따로 정의한 private 메서드인 parseClaims() 메서드는 서명키가 set된 Jwts의 ParserBuilder를 통해 Access Token에 있는 Payload의 claim들을 파싱하여 Claims를 반환한다. 따라서 getAuthentication() 내부에서는 Access Token을 파싱하여 Claims를 얻은 뒤에 이 Claims에서 유저의 권한 목록(authorities)과 유저의 정보가 담긴 UserDetails 객체를 생성한다. 이를 Authentication 객체에 담아 반환하는 것이다. 즉, Access Token을 현재 유저에 대한 정보로 바꾸는 것이다.
...
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token");
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token");
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.");
}
return false;
}
...
validateToken() 메서드는 매개변수로 토큰 값을 받아 이 토큰을 검증한다. Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); 문장이 실행되면서 토큰 검증에 문제가 발생하지 않는다면 예외가 발생하지 않고 메서드가 진행되어 true를 return하지만, 아닐 경우 결국 false를 return하게 된다. 문제가 발생했을 때 진행되는 예외 처리에 따라 토큰에 어떤 문제가 발생한건지 확인할 수 있다.
전체 코드는 다음과 같다.
...
@Log4j2
@Component
public class JwtTokenProvider {
private final Key key;
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L; // ms 단위이므로 30분을 의미함.
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 30L; // 30일을 의미.
// Test를 위한 만료기간 설정
// private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 5L;
// private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 10L;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDTO generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenDTO.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public String generateAccessToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
return Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken() {
long now = (new Date()).getTime();
return Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if(claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token");
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token");
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
5. JwtCookieProvider.java
본 프로젝트는 JWT를 HTTP Cookie로 관리한다. 생성한 Access Token 또는 Refresh Token을 받아 이를 Cookie 객체로 반환해주는 class가 JwtCookieProvider이다.
...
public Cookie createOAuthInfoCookie(String email, String role, String provider) {
Cookie infoCookie = new Cookie("email_role_provider", email + "_" + role + "_" + provider);
infoCookie.setPath("/");
return infoCookie;
}
...
서비스에 처음으로 소셜 로그인한 사용자의 경우, 추가 정보 입력을 해야하기 때문에 일반 유저와 구분된다. 추가 정보 입력을 위한 페이지로 redirect 될 때, 현재 소셜 로그인한 유저의 정보가 필요했는데, 필자는 이를 Cookie로 전달했다. 그 정보를 담은 Cookie를 생성하는게 위의 createOAuthInfoCookie() 메서드이다. new Cookie() 생성자의 왼쪽 값("email_role_provider")이 쿠키의 이름(key 값)이며, 오른쪽에 담기는 값(email + "_" + role + "_" + provider)이 쿠키에 담기는 value 값이다. .setPath() 메서드는 쿠키에 존재하는 path 속성을 설정한다. 쿠키의 path 속성에 의해 이 경로 또는 이 경로의 하위 경로에서만 이 쿠키에 접근할 수 있다. 따라서 위와 같이 설정하면 어플리케이션의 모든 경로에서 쿠키에 접근할 수 있게 된다.
...
public Cookie createCookieForAccessToken(String accessToken) {
Cookie cookieForAccessToken = new Cookie("URCARCHER_ACCESS_TOKEN", accessToken);
cookieForAccessToken.setPath("/");
return cookieForAccessToken;
}
public Cookie createCookieForRefreshToken(String refreshToken) {
Cookie cookieForRefreshToken = new Cookie("URCARCHER_REFRESH_TOKEN", refreshToken);
cookieForRefreshToken.setHttpOnly(true);
cookieForRefreshToken.setSecure(true);
cookieForRefreshToken.setAttribute("SameSite", "None");
cookieForRefreshToken.setPath("/");
return cookieForRefreshToken;
}
...
먼저 createCookieForRefreshToken() 메서드를 살펴보자. 해당 쿠키에 설정된 옵션을 보면 HttpOnly, Secure, SameSite, Path가 있다. Path는 방금 설명했고 나머지를 보면,
HttpOnly 옵션은 JavaScript에서 해당 쿠키에 접근을 가능하게 할지를 설정한다. true일 경우 JavaScript에서 쿠키 접근이 불가능해지므로 XSS(Cross-Site Scripting)를 예방할 수 있다. 즉, XSS로 Refresh Token의 탈취가 불가하다. 따라서 HttpOnly 옵션을 활성화시킴으로써 보안성을 확보할 수 있다.
Secure 옵션은 프로토콜에 따라 쿠키 전송 여부를 결정한다. 이 옵션이 활성화 될 경우(true), HTTPS 프로토콜로 통신할 때만 쿠키를 전송할 수 있다.
SameSite 옵션은 Cross-Origin 요청을 받은 경우의 쿠키의 전송 여부를 결정한다. None 값으로 설정되면 리소스의 출처와 관계없이 항상 쿠키를 보낼 수 있다. 다만, 이 경우에는 Secure 옵션이 활성화 돼 있어야 한다. 그 외 Lax(Cross-Origin 요청이면 GET 메서드에 대해서만 쿠키 전송 가능), Strict(Cross-Origin이 아닌 Same-Site인 경우만 쿠키 전송 가능)등이 있다.
즉 Refresh Token은 HttpOnly 옵션을 활성화시킴으로써 탈취를 예방할 수 있고(대신 프론트 단에서 접근 불가), 이를 통해 유효 기간이 짧은 Access Token을 재발급 받을 수 있다. HttpOnly가 활성화되지 않은 Access Token은 프론트 단에서 접근할 수 있으므로 프론트엔드는 Access Token을 처리하거나 Access Token을 통해 백엔드와 통신할 수 있다. 다만 본 프로젝트에서는 토큰 검증과 관리가 모두 백엔드에서 이루어지는 구조로 구현되었으므로, Access Token에도 HttpOnly 옵션을 활성화시키는 시스템으로 금방 재구현할 수 있다.
유효한 기간을 설정하는 MaxAge 옵션도 존재하는데, Token 자체에 만료 기간이 있으므로 이 부분은 고려하지 않은 채로 구현하였으나, 쿠키에도 토큰의 유효기간과 동일한 유효 기간을 부여해서 관리하는게 옳다는 생각이 든다. 아무래도 서버에 부담이 덜 가해질 것 같다.
...
public Cookie deleteCookieForAccessToken() {
Cookie deleteForAccessToken = new Cookie("URCARCHER_ACCESS_TOKEN", "");
deleteForAccessToken.setPath("/");
deleteForAccessToken.setMaxAge(0);
return deleteForAccessToken;
}
public Cookie deleteCookieForRefreshToken() {
Cookie deleteForRefreshToken = new Cookie("URCARCHER_REFRESH_TOKEN", "");
deleteForRefreshToken.setHttpOnly(true);
deleteForRefreshToken.setSecure(true);
deleteForRefreshToken.setAttribute("SameSite", "None");
deleteForRefreshToken.setPath("/");
deleteForRefreshToken.setMaxAge(0);
return deleteForRefreshToken;
}
...
로그아웃 시 Access Token과 Refresh Token을 무효화 시키기 위해 도입한 메서드이다. 옵션과 key 값은 똑같이 하되, MaxAge 옵션을 0으로 설정하여 이를 기존의 쿠키에 덮어 씌워 쿠키를 삭제시킬 수 있다.
...
public String getRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if(cookies != null) {
for(Cookie cookie :cookies) {
if(cookie.getName().equals("URCARCHER_REFRESH_TOKEN")) return cookie.getValue();
}
}
return null;
}
...
HttpServletRequest 객체에는 유저가 현재 가지고 있는 쿠키들도 같이 담겨 온다. 여기서 Refresh Token의 key 값으로 해당하는 쿠키를 가져온 뒤 Refresh Token 값을 return한다. 따라서 현재 Cookie 형태로 저장된 Refresh Token을 불러오는 메서드이다.
전체 코드는 다음과 같다.
...
@Component
public class JwtCookieProvider {
public Cookie createOAuthInfoCookie(String email, String role, String provider) {
Cookie infoCookie = new Cookie("email_role_provider", email + "_" + role + "_" + provider);
infoCookie.setPath("/");
return infoCookie;
}
public Cookie createCookieForAccessToken(String accessToken) {
Cookie cookieForAccessToken = new Cookie("URCARCHER_ACCESS_TOKEN", accessToken);
cookieForAccessToken.setPath("/");
return cookieForAccessToken;
}
public Cookie createCookieForRefreshToken(String refreshToken) {
Cookie cookieForRefreshToken = new Cookie("URCARCHER_REFRESH_TOKEN", refreshToken);
cookieForRefreshToken.setHttpOnly(true);
cookieForRefreshToken.setSecure(true);
cookieForRefreshToken.setAttribute("SameSite", "None");
cookieForRefreshToken.setPath("/");
return cookieForRefreshToken;
}
public Cookie deleteCookieForAccessToken() {
Cookie deleteForAccessToken = new Cookie("URCARCHER_ACCESS_TOKEN", "");
deleteForAccessToken.setPath("/");
deleteForAccessToken.setMaxAge(0);
return deleteForAccessToken;
}
public Cookie deleteCookieForRefreshToken() {
Cookie deleteForRefreshToken = new Cookie("URCARCHER_REFRESH_TOKEN", "");
deleteForRefreshToken.setHttpOnly(true);
deleteForRefreshToken.setSecure(true);
deleteForRefreshToken.setAttribute("SameSite", "None");
deleteForRefreshToken.setPath("/");
deleteForRefreshToken.setMaxAge(0);
return deleteForRefreshToken;
}
public String getRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if(cookies != null) {
for(Cookie cookie :cookies) {
if(cookie.getName().equals("URCARCHER_REFRESH_TOKEN")) return cookie.getValue();
}
}
return null;
}
}
Project Github Repo.
https://github.com/1nthebleakmidwinter/Urcarcher_Card-Service_ShinhanDS-Academy
References
- https://velog.io/@vamos_eon/JWT%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EA%B0%80-1
- https://namu.wiki/w/%ED%86%A0%ED%81%B0
- https://ko.wikipedia.org/wiki/JSON_%EC%9B%B9_%ED%86%A0%ED%81%B0
- https://velog.io/@chuu1019/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C
- https://velog.io/@yena1025/JWT-%ED%86%A0%ED%81%B0%EA%B3%BC-%EB%AC%B4%EC%83%81%ED%83%9C%EC%84%B1Stateless
- https://velog.io/@junyoungs7/JWT-%EC%83%9D%EC%84%B1-%EB%B0%8F-%EA%B2%80%EC%A6%9D-%ED%85%8C%EC%8A%A4%ED%8A%B8
- https://velog.io/@eeeve/HttpOnly%EC%99%80-Secure-Cookie
'Spring boot > Spring security' 카테고리의 다른 글
[Spring boot] OAuth(Google) & JWT Login (7) - JWT 인증 필터 + 그 외 (1) | 2024.11.17 |
---|---|
[Spring boot] OAuth(Google) & JWT Login (5) - OAuth 동작 ② (14) | 2024.11.06 |
[Spring boot] OAuth(Google) & JWT Login (4) - OAuth 동작 ① (1) | 2024.11.04 |
[Spring boot] OAuth(Google) & JWT Login (3) - Security 구성 및 동작 (5) | 2024.10.31 |
[Spring boot] OAuth(Google) & JWT Login (2) - 자체 로그인 동작 구조 (6) | 2024.10.28 |