토큰 기반 인증방식으로 사용자가 로그인에 성공했을 때 발급된다. 서버가 여러 대인 경우, 같은 사용자가 서로 다른 도메인 데이터를 요청할 경우 유용하다. 세션과 토큰 인증을 함께 사용할 수 있지만 굳이 그럴 필요가 없다.
Access Token은 Header와 Payload의 값을 각각 Base64로 인코딩한 후, 인코딩 된 값을 Secret Key를 이용해 헤더에서 정의한 알고리즘으로 암호화하고 다시 Base64로 인코딩하여 생성한다.
Authorization 필드
에 담아져 보내집니다. 이때 Bearer 키워드
는 HTTP 인증 헤더(Authentication Header)에 명시되어 있습니다. 즉 jwt 토큰 앞 Bearer은 약속된 규약으로 전송할 때 자동으로 붙는다는 것이다.UsernamePasswordAuthenticationFilter
를 통해 인증을 수행하도록 구성되어있다. JWT를 사용하는 인증 필터를 구현하고 UsernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경하는 작업을 수행하는 방식으로 구성한다.header
: 토큰의 타입과 암호화 방식이 적혀있다.{
"typ": "JWT",
"alg": "HS256"
}
payload
iss(발급자), sub(제목), aud(대상자), exp(만료시간), nbf(토큰의 활성날짜), iat(발급된시간), jti(JWT 고유식별자, 일회용 토큰에 사용)
{
"https://shinsunyoung.com/jwt_claims/is_admin": true,
}
signature
: 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용하며, 헤더(header)의 인코딩 값과 정보(payload)의 인코딩값을 합친 후에 주어진 비밀키를 통해 해쉬값을 생성한다.HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
Bearer 스키마
를 사용하여 Authorization 헤더 에서 JWT를 보내야 한다.
사용자가 로그인을 한다.
서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.
JWT 토큰의 유효기간을 설정한다.
암호화할 Secret key 를 이용해 Access Token 을 발급한다.
사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.
서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.
검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.
SessionCreationPolicy.STATELESS
로 설정해준다.CSRF 토큰도 사용하지 않으므로 이것도 비활성화 한다.
csrf.disable
@Value
로 값을 가져와서 사용한다jwt.issuer=example@gmail.com
jwt.secretKey=secretKey
jwt.tokenPrefix=Bearer
@Value("${jwt.issuer}")
private String issuer;
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.tokenPrefix}")
private String tokenPrefix;
jwt:
secret: QUIWEYWEIUSDJKAFHLSZVCNASJDKCNZXCQIWE4982WIRJKLFVJJIWR894513ASD4A6S5D4AD54A5SD13ZC1X2AD4W56D5Q6WZ3ZXC1
access:
expiration: 80000
header: Authorization
refresh:
expiration: 1200000
header: Authorization-refresh
이후 필요한 것은 다음과 같다.
public String makeJwtToken(User user) { //토큰 생성
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + Duration.ofMinutes(30).toMillis()))
.claim("email", user.getEmail())
.claim("role", user.getRole())
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
HTTP 인증 토큰
은 클라이언트가 서버에 요청을 보낼 때, 해당 요청에 대한 인증 정보를 담은 헤더값으로 사용됩니다. 일반적으로 HTTP 인증 토큰은 "Authorization"이라는 이름의 헤더에 담겨 전송됩니다. 예를 들면 "Authorization: Bearer {access_token}"과 같은 형태로 사용됩니다. 즉 Authorization 헤더에 들어가는 값의 prefix이다redis
에서도 prefix
를 사용하는데 이는 동일한 Redis 서버를 사용하는 다른 어플리케이션에서 발생하는 키(Key) 중복을 막기 위해서이다.
Redis에서 엑세스 토큰의 Prefix를 "access_token:" + memberId으로 설정하면, Redis에 저장되는 실제 키(Key)는 "access_token:{memberId}:{access_token}"의 형태가 된다.
private String extractToken(String authorizationHeader) {
//토큰 (Bearer) 떼고 토큰값만 가져오는 메서드
return authorizationHeader.substring(
jwtProperties.getTokenPrefix().length());
}
private void validationAuthorizationHeader(String header) {
//헤더값이 유효한지 검증하는 메서드
if (header == null || !header.startsWith(jwtProperties.getTokenPrefix())) {
logger.error("토큰이 없습니다.(1)");
}
}
private Claims parsingToken(String token) {
//Token 값을 claims로 바꿔주는 메서드
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token) <- 토큰 몸통
.getBody();
}
public UserDto getUserDtoOf(String authorizationHeader) {
// 토큰에서 유효값을 추출하기위한 메서드
validationAuthorizationHeader(authorizationHeader);
//토큰이 Bearer로 시작하는지 형식이 맞는지 확인
String token ="";
Claims claims=null;
try {
token = extractToken(authorizationHeader);
// header에서 토큰 추출 (Bearer뗌)
claims = parsingToken(token);
return new UserDto(claims);
}catch (Exception e){
logger.error("토큰이 없습니다.(2)");
}
return null;
}
OncePerRequestFilter
을 상속합니다.public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 요청을 할떄마다 한번 거쳐가는 필터
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
//filter에서 header를 가져옴
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
try {
//token 값에서 유효값 (email, role)을 추출하여 userDTO를 만들어서 값을 세팅
UserDto user = jwtTokenProvider.getUserDtoOf(authorizationHeader);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user,
"",
user.getAuthorities()));
filterChain.doFilter(request, response);
} catch (ExpiredJwtException exception) {
logger.error("ExpiredJwtException : expired token");
} catch (Exception exception) {
logger.error("Exception : no token");
filterChain.doFilter(request, response);
}
}
엑세스 토큰의 유효시간이 경과했더라도 리프레쉬 토큰이 유효하다면 새로운 엑세스 토큰을 발급받을 수 있다
case1
: access token과 refresh token 모두가 만료된 경우 -> 에러 발생
case2
: access token은 만료됐지만, refresh token은 유효한 경우(사용자가 보낸 Refresh Token과 DB에 저장되어있는 Refresh Token을 비교한다.) -> access token 재발급, 토큰 업데이트
case3
: access token은 유효하지만, refresh token은 만료된 경우 -> refresh token 재발급, 저장, 토큰 업데이트
case4
: accesss token과 refresh token 모두가 유효한 경우
case5
: 로그아웃시 accesss token과 refresh token 모두 삭제
로그인에 성공했다면 successHandler로 JWT토큰을 발급하는 authenticationsuccesshandler
를 만든다
문자열 형태의 비밀 키
를 이진 형태의 바이트 배열로 변환해야 합니다.HS256, HS384, HS512
: HMAC 기반의 서명 알고리즘으로 비밀 키를 사용합니다. 상대적으로 빠르고 간단하지만 비밀 키가 노출될 경우 보안에 취약할 수 있습니다.
RS256, RS384, RS512
: RSA 기반의 서명 알고리즘으로 공개 키와 개인 키 쌍을 사용합니다. 보안성이 높지만 연산량이 많습니다.
ES256, ES384, ES512
: ECDSA 기반의 서명 알고리즘으로 타원 곡선 암호화를 사용합니다. RSA보다 짧은 키 길이로 높은 보안을 제공합니다.
Ed25519
: 최신의 효율적이고 안전한 서명 알고리즘입니다. 높은 성능과 보안성을 제공합니다.
유저가 로그아웃시 access token의 남은 유효기간만큼 Redis에 유효기간을 설정하여 블랙리스트로 등록해놓는다. 토큰 값 자체를 key로 두고, value로 logout이라는 값을 줬습니다. 이때 블랙리스트로 등록하는 액세스 토큰에 유효시간을 요청시 받은 엑세스토큰의 남은 유효시간만큼 줍니다. 이렇게 되면 로그아웃된 엑세스 토큰으로 요청이 들어왔을 때, 해당 토큰의 유효성이 남아있는 동안은 Redis에 해당 토큰 값이 key로 블랙리스트 등록되어 있을 것이기 때문에 로그인을 할 수 없습니다.
위 코드에서는 블랙리스트에 있는 경우 403 오류 응답을 보내는 것입니다. 그러나, 만약 다른 필터에서 이미 응답을 보냈다면, response가 이미 커밋되어 응답을 보낸 상태이므로, 다시 sendError()를 호출하면 이러한 오류가 발생합니다.