이전에 JWT 정의를 살펴봤다면, 이번에는 JWT 관련 클래스를 직접 생성하여 구현해보려고 합니다!
들어가기 전
JWT 패키지 구조는 다음과 같습니다.
JWT 서비스를 생성하기 위해 다음과 같은 오픈 소스 라이브러리를 사용합니다!
https://github.com/auth0/java-jwt
사용하기 위해, build.gradle에 아래와 같은 의존성을 추가해줍시다!
implementation 'com.auth0:java-jwt:4.2.1'
(제 프로젝트에서는 gradle을 사용했기 때문에, maven 의존성은 위의 github 링크에서 참고하시길 바랍니다!)
또한, JWT 관련 설정 파일을 만들어주도록 하겠습니다.
이름을 application-jwt.yml으로 하여 application.yml에서 group으로 프로필을 이용하도록 하겠습니다.
application-jwt.yml
jwt:
secretKey: base64로 인코딩된 암호 키, HS512를 사용할 것이기 때문에, 512비트(64바이트) 이상이 되어야 합니다. 영숫자 조합으로 아무렇게나 길게 써주세요!
access:
expiration: 3600000 # 1시간(60분) (1000L(ms -> s) * 60L(s -> m) * 60L(m -> h))
header: Authorization
refresh:
expiration: 1209600000 # (1000L(ms -> s) * 60L(s -> m) * 60L(m -> h) * 24L(h -> 하루) * 14(2주))
header: Authorization-refresh
✅ jwt.secretKey : 서버가 가지고 있는 개인 키로,
이 secretKey를 이용하여 JWT 생성 시 암호화를 진행합니다.
암호화 알고리즘으로 HS512를 사용할 것이기 때문에, 512비트(64바이트) 이상이 되어야 합니다.
따라서, 영숫자 조합으로 아무렇게나 길게 써주세요!
ex) secretKey : 'dasdasf234fuhvertsv34789yhiuFDSIUFGYDTE5r~~~~~~';
✅ jwt.access/refresh.expiration : 액세스 토큰/리프레시 토큰의 만료 시간을 설정 파일에서 설정해줍니다.
단위가 ms이기 때문에 분으로 바꾸기 위해서 1000L(ms -> s) * 60L(s -> m)로 1분으로 바꿀 수 있습니다.
저는 액세스 토큰의 만료기간은 1시간, 리프레시 토큰의 만료 기간은 2주로 설정하였습니다.
일반적으로 리프레시 토큰의 만료 기간은 2주로 많이 잡는다고 합니다.
✅ jwt.access/refresh.header : 액세스 토큰/리프레시 토큰이 담길 헤더의 이름(Key)을 설정해줍니다.
요청/응답 시 액세스 토큰, 리프레시 토큰의 헤더를 설정해줍니다.
액세스 토큰 : Authorization
리프레시 토큰 : Authorization-refresh
각각 위의 이름으로 요청 헤더/응답 헤더에 담겨서 요청되고, 응답됩니다.
JWT 관련 클래스 생성
1. JWT 로직 관련 클래스 - JwtService
전체 코드를 먼저 보여드리고,
코드가 길기 때문에, 부분 부분 코드를 발췌하여 설명드리겠습니다.
🎯 JwtService 전체 코드
package login.oauthtest4.global.jwt.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import login.oauthtest4.domain.user.repository.UserRepository;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
/**
* JWT의 Subject와 Claim으로 email 사용 -> 클레임의 name을 "email"으로 설정
* JWT의 헤더에 들어오는 값 : 'Authorization(Key) = Bearer {토큰} (Value)' 형식
*/
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String EMAIL_CLAIM = "email";
private static final String BEARER = "Bearer ";
private final UserRepository userRepository;
/**
* AccessToken 생성 메소드
*/
public String createAccessToken(String email) {
Date now = new Date();
return JWT.create() // JWT 토큰을 생성하는 빌더 반환
.withSubject(ACCESS_TOKEN_SUBJECT) // JWT의 Subject 지정 -> AccessToken이므로 AccessToken
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) // 토큰 만료 시간 설정
//클레임으로는 저희는 email 하나만 사용합니다.
//추가적으로 식별자나, 이름 등의 정보를 더 추가하셔도 됩니다.
//추가하실 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정해주시면 됩니다
.withClaim(EMAIL_CLAIM, email)
.sign(Algorithm.HMAC512(secretKey)); // HMAC512 알고리즘 사용, application-jwt.yml에서 지정한 secret 키로 암호화
}
/**
* RefreshToken 생성
* RefreshToken은 Claim에 email도 넣지 않으므로 withClaim() X
*/
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.sign(Algorithm.HMAC512(secretKey));
}
/**
* AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* AccessToken + RefreshToken 헤더에 실어서 보내기
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
setRefreshTokenHeader(response, refreshToken);
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
/**
* 헤더에서 RefreshToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 AccessToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* AccessToken에서 Email 추출
* 추출 전에 JWT.require()로 검증기 생성
* verify로 AceessToken 검증 후
* 유효하다면 getClaim()으로 이메일 추출
* 유효하지 않다면 빈 Optional 객체 반환
*/
public Optional<String> extractEmail(String accessToken) {
try {
// 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build() // 반환된 빌더로 JWT verifier 생성
.verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생
.getClaim(EMAIL_CLAIM) // claim(Emial) 가져오기
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
/**
* AccessToken 헤더 설정
*/
public void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, accessToken);
}
/**
* RefreshToken 헤더 설정
*/
public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
response.setHeader(refreshHeader, refreshToken);
}
/**
* RefreshToken DB 저장(업데이트)
*/
public void updateRefreshToken(String email, String refreshToken) {
userRepository.findByEmail(email)
.ifPresentOrElse(
user -> user.updateRefreshToken(refreshToken),
() -> new Exception("일치하는 회원이 없습니다.")
);
}
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
}
🎯 JwtService 부분 코드 설명
💻 프로퍼티 주입 부분
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
👉 @Value를 사용하여 각 필드들에 설정 파일인 application-jwt.yml의 프로퍼티들을 주입하도록 했습니다.
각 필드들의 용도는 위의 설정 파일 설명에 기록해놨습니다!
💻 AccessToken & RefreshToken 생성 메소드 부분
/**
* AccessToken 생성 메소드
*/
public String createAccessToken(String email) {
Date now = new Date();
return JWT.create() // JWT 토큰을 생성하는 빌더 반환
.withSubject(ACCESS_TOKEN_SUBJECT) // JWT의 Subject 지정 -> AccessToken이므로 AccessToken
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) // 토큰 만료 시간 설정
//클레임으로는 저희는 email 하나만 사용합니다.
//추가적으로 식별자나, 이름 등의 정보를 더 추가하셔도 됩니다.
//추가하실 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정해주시면 됩니다
.withClaim(EMAIL_CLAIM, email)
.sign(Algorithm.HMAC512(secretKey)); // HMAC512 알고리즘 사용, application-jwt.yml에서 지정한 secret 키로 암호화
}
/**
* RefreshToken 생성
* RefreshToken은 Claim에 email도 넣지 않으므로 withClaim() X
*/
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.sign(Algorithm.HMAC512(secretKey));
}
✅ AccessToken 생성 메소드 createAccessToken()
JWT.create()로 JWT 토큰을 생성하는 빌더를 생성하며,
.withSubject()와 .withExpiresAt()으로 JWT 토큰 Payload에 들어갈 Claim을 생성합니다.
.withClaim()으로 사용자 정의 클레임을 생성합니다.
제 프로젝트에서는 토큰에서 이메일을 추출하도록 토큰에 이메일 Claim을 담고,
메소드 파라미터로 유저 이메일을 받아서 JWT 토큰 Claim에 저장하도록 구현했습니다.
.sign()으로 사용할 알고리즘과 서버의 개인 키를 지정해주면 JWT 토큰이 암호화되어 생성됩니다.
✅ RefreshToken 생성 메소드 createRefreshToken()
기본적으로 AccessToken 생성과 거의 똑같지만,
RefreshToken은 Claim에 email이 필요가 없으므로
.withClaim()을 사용하여 email을 따로 추가하지 않았습니다.
💻 AccessToken & RefreshToken Response Header 추가 메소드 부분
/**
* AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* AccessToken + RefreshToken 헤더에 실어서 보내기
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken){
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
response.setHeader(refreshHeader, refreshToken);
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
✅ AccessToken 재발급 시 헤더에 실어서 보내는 메소드 sendAccessToken()
response.setStatus()로 OK를 설정하고,
response.setHeader()로 설정 파일에서 주입 받은 accessHeader를 키로 사용하고, 생성한 accessToken을 실어보냅니다.
✅ 로그인 시 AccessToken & RefreshToken을 헤더에 실어서 보내는 메소드 sendAccessAndRefreshToken()
마찬가지로, response.setHeader()로 액세스 토큰, 리프레시 토큰을 헤더에 실어 반환합니다.
💻 클라이언트의 요청에서 JWT Token, Email을 추출하는 부분
/**
* 헤더에서 RefreshToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 AccessToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* AccessToken에서 Email 추출
* 추출 전에 JWT.require()로 검증기 생성
* verify로 AceessToken 검증 후
* 유효하다면 getClaim()으로 이메일 추출
* 유효하지 않다면 빈 Optional 객체 반환
*/
public Optional<String> extractEmail(String accessToken) {
try {
// 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build() // 반환된 빌더로 JWT verifier 생성
.verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생
.getClaim(EMAIL_CLAIM) // claim(Emial) 가져오기
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
✅ 헤더에서 Token을 추출하는 메소드 extractAccess/refreshToken()
헤더에 담긴 토큰 형식이 Bearer [토큰] 형식이므로 토큰 값을 가져오기 위해서는 Bearer를 제거해야 한다.
따라서, 헤더에서 값을 가져온 후 Bearer를 제거하고 반환
※ JWT 토큰의 헤더인 Authorization의 Value 형식
Authorization : <type> <credentials>
Bearer는 여기서 type에 해당하고, 토큰 값이 credentials에 해당한다.
이 인증 type에는 여러 가지 타입이 존재하는데, JWT 혹은 OAuth에 대한 토큰은 Bearer Type을 사용한다.
(RFC 6750 표준)
✅ accssToken에서 유저의 Email을 추출하는 메소드 extractEmail()
JWT.require()로 토큰 유효성을 검사하는 로직이 있는 JWT verifier builder를 반환한다.
그 후 반환된 builder를 사용하여 .verify(accessToken)로 Token을 검증한다.
이때, 토큰이 유효하지 않다면 예외가 발생하여 catch로 잡아 빈 값을 반환한다.
유효하다면, Token 생성 시 Claim으로 설정했던 Email Claim을 꺼내어
.asString()으로 String으로 변환 후 유저 Email을 반환한다.
💻 RefreshToken Update(저장) 메소드 & 토큰 유효성 검사 메소드
/**
* RefreshToken DB 저장(업데이트)
*/
public void updateRefreshToken(String email, String refreshToken) {
userRepository.findByEmail(email)
.ifPresentOrElse(
user -> user.updateRefreshToken(refreshToken),
() -> new Exception("일치하는 회원이 없습니다.")
);
}
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
✅ DB의 RefreshToken을 업데이트하는 메소드 updateRefreshToken()
유저 회원 가입 시에 유저 Entity가 저장될 때는, RefreshToken이 발급되기 전이기 때문에
DB에 RefreshToken Column에 null로 저장된다.
따라서, 로그인 시 RefreshToken을 발급하면서, 발급한 RefreshToken을 DB에 저장하는 메소드이다.
이후에 OAuth2 Login 성공 시 처리하는 LoginSuccessHandler에서 사용할 예정이다.
✅ 토큰의 유효성을 검사하는 메소드 isTokenValid()
매 인증 시마다(클라이언트가 토큰을 헤더에 담아서 요청할 때마다) 토큰 검증 단계를 거치게 된다.
각 AccessToken, RefreshToken의 유효성을 검증할 때 사용되는 메소드이다.
JWT.require()로 토큰 유효성을 검사하는 로직이 있는 JWT verifier builder를 반환한다.
그 후 반환된 builder를 사용하여 .verify(accessToken)로 Token을 검증한다.
토큰이 유효하지 않으면 예외를 발생시켜 false를 반환하고, 유효하다면 true를 반환한다.
2. JWT 인증 필터 - JwtAuthenticationProcessingFilter
제가 Custom한 JWT 인증 필터는 OncePerRequestFilter를 상속받아서 구현합니다.
자세한 필터의 작동 원리는 아래 블로그를 참고해주시면 감사하겠습니다!
https://ttl-blog.tistory.com/273
간략하게 필터를 요약하면, 클라이언트가 헤더에 JWT 토큰을 담아서 "/login" URL 이외의 요청을 보냈을 시,
해당 토큰들의 유효성을 검사하여 인증 처리/인증 실패/토큰 재발급 등을 수행하는 역할의 필터입니다.
인증 필터를 이해하기 위해서는, JWT 인증 로직을 이해해야합니다.
따라서, 간략하게 JWT 인증 로직을 이해하고 가도록 하겠습니다.
※ JWT 인증 로직 - AccessToken 만료 전 / AccessToken 만료 후
인증 로직은 크게 AccessToken 만료 전(정상 로직) / AccessToken 만료 후로 나뉘게 됩니다.
해당 인증 로직을 그림과 함께 설명드리곘습니다.
✅ AccessToken 만료 전 인증 과정
1. 클라이언트가 이메일/비밀번호을 담아 로그인 요청을 서버에 보냅니다.
2. 서버는 요청 받은 이메일/비밀번호로 DB에서 유저를 찾고,
유저가 존재한다면 AccessToken과 RefreshToken을 생성하여 Response에 담아 반환합니다.
(이때, 생성한 RefreshToken은 DB에 저장해둡니다.)
3. 이후 클라이언트는 매 요청 시마다 AccessToken을 담아 API를 요청합니다.
4. 서버에서는 요청받은 AccessToken을 검증하여 인증 성공/인증 실패 처리를 합니다.
✅ AccessToken 만료 후 인증 과정
1. 클라이언트에서 자체적으로 AccessToken 만료를 판단한 후, 서버에 RefreshToken만을 담아 요청합니다.
2. 서버에서 요청 받은 RefreshToken이 DB에 저장된 Refresh과 일치하는지 판단 후,
일치한다면 AccessToken과 RefreshToken을 재발급하여 Response에 담아 보냅니다.
이때, 재발급한 RefreshToken으로 DB의 RefreshToken을 업데이트합니다. (RTR 방식)
3. 클라이언트는 서버로부터 재발급 받은 AccessToken을 요청에 담아 API 요청을 보냅니다.
4. 서버에서 요청 받은 AccessToken을 검증하여 인증 성공/인증 실패 처리를 합니다.
※ RTR 방식이란?
Refresh Token Rotation 방식의 약자로, Refresh Token을 한번만 사용할 수 있게 만드는 방법입니다.
RefreshToken을 사용하여 만료된 AccessToken을 재발급 받을 때,
Refresh Token도 재발급하는 방법입니다.
이러한 방식이 나온 이유는, RefreshToken이 탈취된다면 AccessToken을 계속 생성할 수 있기 때문입니다.
RefreshToken은 만료 기간이 길기 때문에 이러한 상황이 된다면 상당히 위험해집니다.
따라서, Refresh Token를 AccessToken 재발급 시 같이 재발급하여, 만료 기간을 줄이는 방법입니다.
위의 AccessToken 만료 후 인증 과정에서도 RTR 방식을 적용했기 때문에
AccessToken을 재발급할 때 RefreshToken까지 재발급하여 DB에 업데이트해주는 것입니다.
그렇다면, 어떻게 인증 처리/인증 실패/토큰 재발급 등을 Custom 인증 필터에서 구현했는지 살펴보겠습니다.
마찬가지로, 전체 코드를 먼저 보여드리고
이후에 부분 코드 설명을 하겠습니다.
🎯 JwtAuthenticationProcessingFilter 전체 코드
package login.oauthtest4.global.jwt.filter;
import login.oauthtest4.domain.user.User;
import login.oauthtest4.domain.user.repository.UserRepository;
import login.oauthtest4.global.jwt.service.JwtService;
import login.oauthtest4.global.oauth2.util.PasswordUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* Jwt 인증 필터
* "/login" 이외의 URI 요청이 왔을 때 처리하는 필터
*
* 기본적으로 사용자는 요청 헤더에 AccessToken만 담아서 요청
* AccessToken 만료 시에만 RefreshToken을 요청 헤더에 AccessToken과 함께 요청
*
* 1. RefreshToken이 없고, AccessToken이 유효한 경우 -> 인증 성공 처리, RefreshToken을 재발급하지는 않는다.
* 2. RefreshToken이 없고, AccessToken이 없거나 유효하지 않은 경우 -> 인증 실패 처리, 403 ERROR
* 3. RefreshToken이 있는 경우 -> DB의 RefreshToken과 비교하여 일치하면 AccessToken 재발급, RefreshToken 재발급(RTR 방식)
* 인증 성공 처리는 하지 않고 실패 처리
*
*/
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login"; // "/login"으로 들어오는 요청은 Filter 작동 X
private final JwtService jwtService;
private final UserRepository userRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response); // "/login" 요청이 들어오면, 다음 필터 호출
return; // return으로 이후 현재 필터 진행 막기 (안해주면 아래로 내려가서 계속 필터 진행시킴)
}
// 사용자 요청 헤더에서 RefreshToken 추출
// -> RefreshToken이 없거나 유효하지 않다면(DB에 저장된 RefreshToken과 다르다면) null을 반환
// 사용자의 요청 헤더에 RefreshToken이 있는 경우는, AccessToken이 만료되어 요청한 경우밖에 없다.
// 따라서, 위의 경우를 제외하면 추출한 refreshToken은 모두 null
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
// 리프레시 토큰이 요청 헤더에 존재했다면, 사용자가 AccessToken이 만료되어서
// RefreshToken까지 보낸 것이므로 리프레시 토큰이 DB의 리프레시 토큰과 일치하는지 판단 후,
// 일치한다면 AccessToken을 재발급해준다.
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return; // RefreshToken을 보낸 경우에는 AccessToken을 재발급 하고 인증 처리는 하지 않게 하기위해 바로 return으로 필터 진행 막기
}
// RefreshToken이 없거나 유효하지 않다면, AccessToken을 검사하고 인증을 처리하는 로직 수행
// AccessToken이 없거나 유효하지 않다면, 인증 객체가 담기지 않은 상태로 다음 필터로 넘어가기 때문에 403 에러 발생
// AccessToken이 유효하다면, 인증 객체가 담긴 상태로 다음 필터로 넘어가기 때문에 인증 성공
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
/**
* [리프레시 토큰으로 유저 정보 찾기 & 액세스 토큰/리프레시 토큰 재발급 메소드]
* 파라미터로 들어온 헤더에서 추출한 리프레시 토큰으로 DB에서 유저를 찾고, 해당 유저가 있다면
* JwtService.createAccessToken()으로 AccessToken 생성,
* reIssueRefreshToken()로 리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드 호출
* 그 후 JwtService.sendAccessTokenAndRefreshToken()으로 응답 헤더에 보내기
*/
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
/**
* [리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드]
* jwtService.createRefreshToken()으로 리프레시 토큰 재발급 후
* DB에 재발급한 리프레시 토큰 업데이트 후 Flush
*/
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
/**
* [액세스 토큰 체크 & 인증 처리 메소드]
* request에서 extractAccessToken()으로 액세스 토큰 추출 후, isTokenValid()로 유효한 토큰인지 검증
* 유효한 토큰이면, 액세스 토큰에서 extractEmail로 Email을 추출한 후 findByEmail()로 해당 이메일을 사용하는 유저 객체 반환
* 그 유저 객체를 saveAuthentication()으로 인증 처리하여
* 인증 허가 처리된 객체를 SecurityContextHolder에 담기
* 그 후 다음 인증 필터로 진행
*/
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
/**
* [인증 허가 메소드]
* 파라미터의 유저 : 우리가 만든 회원 객체 / 빌더의 유저 : UserDetails의 User 객체
*
* new UsernamePasswordAuthenticationToken()로 인증 객체인 Authentication 객체 생성
* UsernamePasswordAuthenticationToken의 파라미터
* 1. 위에서 만든 UserDetailsUser 객체 (유저 정보)
* 2. credential(보통 비밀번호로, 인증 시에는 보통 null로 제거)
* 3. Collection < ? extends GrantedAuthority>로,
* UserDetails의 User 객체 안에 Set<GrantedAuthority> authorities이 있어서 getter로 호출한 후에,
* new NullAuthoritiesMapper()로 GrantedAuthoritiesMapper 객체를 생성하고 mapAuthorities()에 담기
*
* SecurityContextHolder.getContext()로 SecurityContext를 꺼낸 후,
* setAuthentication()을 이용하여 위에서 만든 Authentication 객체에 대한 인증 허가 처리
*/
public void saveAuthentication(User myUser) {
String password = myUser.getPassword();
if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정
password = PasswordUtil.generateRandomPassword();
}
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
🎯 JwtAuthenticationProcessingFilter 부분 코드 설명
💻 OncePerRequestFilter의 doFilterInternal() Override
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response); // "/login" 요청이 들어오면, 다음 필터 호출
return; // return으로 이후 현재 필터 진행 막기 (안해주면 아래로 내려가서 계속 필터 진행시킴)
}
// 사용자 요청 헤더에서 RefreshToken 추출
// -> RefreshToken이 없거나 유효하지 않다면(DB에 저장된 RefreshToken과 다르다면) null을 반환
// 사용자의 요청 헤더에 RefreshToken이 있는 경우는, AccessToken이 만료되어 요청한 경우밖에 없다.
// 따라서, 위의 경우를 제외하면 추출한 refreshToken은 모두 null
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
// 리프레시 토큰이 요청 헤더에 존재했다면, 사용자가 AccessToken이 만료되어서
// RefreshToken까지 보낸 것이므로 리프레시 토큰이 DB의 리프레시 토큰과 일치하는지 판단 후,
// 일치한다면 AccessToken을 재발급해준다.
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return; // RefreshToken을 보낸 경우에는 AccessToken을 재발급 하고 인증 처리는 하지 않게 하기위해 바로 return으로 필터 진행 막기
}
// RefreshToken이 없거나 유효하지 않다면, AccessToken을 검사하고 인증을 처리하는 로직 수행
// AccessToken이 없거나 유효하지 않다면, 인증 객체가 담기지 않은 상태로 다음 필터로 넘어가기 때문에 403 에러 발생
// AccessToken이 유효하다면, 인증 객체가 담긴 상태로 다음 필터로 넘어가기 때문에 인증 성공
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
👉 OncePerRequestFilter의 doFilterInternal()를 Override한 것입니다.
이 메소드안에 인증 처리/인증 실패/토큰 재발급 로직을 설정하여
필터 진입 시 인증 처리/인증 실패/토큰 재발급 등을 처리합니다.
위에서 부터 아래로 코드 흐름을 설명하겠습니다.
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response); // "/login" 요청이 들어오면, 다음 필터 호출
return; // return으로 이후 현재 필터 진행 막기 (안해주면 아래로 내려가서 계속 필터 진행시킴)
}
"/login" 요청이 들어오면 filterChain.doFilter()를 호출하여 현재 필터를 통과하고,
순서에 맞는 다음 필터를 호출하여 넘어갑니다. ("/login" URL의 요청은 해당 필터에서 처리하지 않습니다.)
이때, return;을 통해 다음 필터를 호출한 다음 현재 필터의 진행을 막습니다.
return;을 하지 않으면, 다음 필터를 호출한 후 다음 필터로 바로 넘어가지 않고,
밑의 로직들을 다 수행한 후 넘어가기 때문에 return으로 바로 튕기도록 해야합니다.
// 사용자 요청 헤더에서 RefreshToken 추출
// -> RefreshToken이 없거나 유효하지 않다면(DB에 저장된 RefreshToken과 다르다면) null을 반환
// 사용자의 요청 헤더에 RefreshToken이 있는 경우는, AccessToken이 만료되어 요청한 경우밖에 없다.
// 따라서, 위의 경우를 제외하면 추출한 refreshToken은 모두 null
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
JwtService에서 만들었던 jwtService.extractRefreshToken()을 통해
요청 헤더에서 refreshToken을 추출하고,
filter()를 통해 유효한 RefreshToken을 반환합니다.
RefreshToken이 유효하지 않거나 존재하지 않는다면 null을 반환합니다.
클라이언트의 요청 헤더에 RefreshToken이 있는 경우는,
AccessToken이 만료되어 클라이언트가 RefreshToken을 요청에 담아 보낸 경우밖에 없습니다.
따라서, RefreshToken이 있는 경우는 RefreshToken 비교 후 AccessToken을 재발급하면 됩니다.
// 리프레시 토큰이 요청 헤더에 존재했다면, 사용자가 AccessToken이 만료되어서
// RefreshToken까지 보낸 것이므로 리프레시 토큰이 DB의 리프레시 토큰과 일치하는지 판단 후,
// 일치한다면 AccessToken을 재발급해준다.
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return; // RefreshToken을 보낸 경우에는 AccessToken을 재발급 하고 인증 처리는 하지 않게 하기위해 바로 return으로 필터 진행 막기
}
efreshToken이 존재하고, 유효할 때 처리하는 로직입니다.
RefreshToken이 요청 헤더에 존재했다면, 클라이언트가 AccessToken이 만료되어서 요청한 것이므로
해당 RefreshToken을 통해 DB에서 유저를 찾고, AccessToken/RefreshToken을 재발급 해주는 메소드인
checkRefreshTokenAndReIssueAccessToken()를 호출합니다.
✅ RefreshToken을 통해 DB에서 유저를 찾고, AccessToken을 재발급 해주는 메소드
/**
* [리프레시 토큰으로 유저 정보 찾기 & 액세스 토큰/리프레시 토큰 재발급 메소드]
* 파라미터로 들어온 헤더에서 추출한 리프레시 토큰으로 DB에서 유저를 찾고, 해당 유저가 있다면
* JwtService.createAccessToken()으로 AccessToken 생성,
* reIssueRefreshToken()로 리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드 호출
* 그 후 JwtService.sendAccessTokenAndRefreshToken()으로 응답 헤더에 보내기
*/
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
위에서 추출한 RefreshToken을 통해 userRepository.findByRefreshToken()으로 유저를 찾아옵니다.
유저가 존재한다면, 리프레시 토큰을 재발급하는 reIssuedRefreshToken()을 호출하여 리프레시 토큰을 재발급하고,
jwtService.createAccessToken()으로 액세스 토큰을 재발급합니다.
그 후 jwtService.sendAccessAndRefreshToken()으로
재발급한 액세스 토큰, 리프레시 토큰을 Response에 보냅니다.
(AccessToken/RefreshToken 재발급 처리)
✅ 리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드
/**
* [리프레시 토큰 재발급 & DB에 리프레시 토큰 업데이트 메소드]
* jwtService.createRefreshToken()으로 리프레시 토큰 재발급 후
* DB에 재발급한 리프레시 토큰 업데이트 후 Flush
*/
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
jwtService.createRefreshToken()으로 RefreshToken을 생성하여,
user.updateRefreshToken()으로 DB의 RefreshToken을 업데이트 시킨 후,
재발급한 RefreshToken을 반환합니다.
// RefreshToken이 없거나 유효하지 않다면, AccessToken을 검사하고 인증을 처리하는 로직 수행
// AccessToken이 없거나 유효하지 않다면, 인증 객체가 담기지 않은 상태로 다음 필터로 넘어가기 때문에 403 에러 발생
// AccessToken이 유효하다면, 인증 객체가 담긴 상태로 다음 필터로 넘어가기 때문에 인증 성공
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
RefreshToken이 존재하지 않거나 유효하지 않을 때 처리하는 로직입니다.
checkAccessTokenAndAuthentication()을 호출하여,
AccessToken의 유효성을 검증하고 인증 성공, 실패 처리를 합니다.
✅ AccessToken의 유효성을 검증 & 인증 처리 메소드
/**
* [액세스 토큰 체크 & 인증 처리 메소드]
* request에서 extractAccessToken()으로 액세스 토큰 추출 후, isTokenValid()로 유효한 토큰인지 검증
* 유효한 토큰이면, 액세스 토큰에서 extractEmail로 Email을 추출한 후 findByEmail()로 해당 이메일을 사용하는 유저 객체 반환
* 그 유저 객체를 saveAuthentication()으로 인증 처리하여
* 인증 허가 처리된 객체를 SecurityContextHolder에 담기
* 그 후 다음 인증 필터로 진행
*/
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
jwtService.extractAccessToken()으로 액세스 토큰을 추출하여
유효성을 검증 후, jwtService.extractEmail()으로 이메일을 추출 후
해당 이메일로 유저를 찾아 saveAuthentication()의 파라미터로 유저를 넘겨서
해당 유저를 인증 처리합니다.
✅ 인증 처리 메소드
/**
* [인증 허가 메소드]
* 파라미터의 유저 : 우리가 만든 회원 객체 / 빌더의 유저 : UserDetails의 User 객체
*
* new UsernamePasswordAuthenticationToken()로 인증 객체인 Authentication 객체 생성
* UsernamePasswordAuthenticationToken의 파라미터
* 1. 위에서 만든 UserDetailsUser 객체 (유저 정보)
* 2. credential(보통 비밀번호로, 인증 시에는 보통 null로 제거)
* 3. Collection < ? extends GrantedAuthority>로,
* UserDetails의 User 객체 안에 Set<GrantedAuthority> authorities이 있어서 getter로 호출한 후에,
* new NullAuthoritiesMapper()로 GrantedAuthoritiesMapper 객체를 생성하고 mapAuthorities()에 담기
*
* SecurityContextHolder.getContext()로 SecurityContext를 꺼낸 후,
* setAuthentication()을 이용하여 위에서 만든 Authentication 객체에 대한 인증 허가 처리
*/
public void saveAuthentication(User myUser) {
String password = myUser.getPassword();
if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정
password = PasswordUtil.generateRandomPassword();
}
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
UserDetails의 User를 Builder로 생성 후 해당 객체를 인증 처리하여
해당 유저 객체를 SecurityContextHolder에 담아 인증 처리를 진행합니다.
(소셜 로그인의 경우 password가 null인데, 인증 처리 시 password가 null이면 안 되므로, 랜덤 패스워드를 임의로 부여해줍니다.)
📖 깃허브 링크 (전체 코드)
https://github.com/KSH-beginner/oauth2WithJwtLogin
📕 전체 로그인 구현 목차
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (1) - 회원(User) 관련 클래스 생성
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (2) - JWT란?
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (3) - JWT 관련 클래스 생성 / JWT 인증 로직
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (4) - 자체 JSON 로그인 커스텀하기
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (5) - OAuth란? / OAuth 2.0 인증 과정 예시
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (6) - OAuth 2.0 로그인 구현 사전 설정
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (7) - OAuth 2.0 로그인 관련 클래스 생성
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (8) - SecurityConfig 설정 클래스 생성
Spring Security + JWT를 이용한 자체 Login & OAuth2 Login API 구현 (9)- JWT 자체 로그인 & OAuth2 Login 테스트
Reference
https://ttl-blog.tistory.com/272