jwt 토큰이란 프론트와 백엔드가 나눠져 있을 경우에는 서로 로그인을 했는지 안했는지를 알 수가 없다.
이를 위해서 만들어진 방식이며
JWT 토큰은 Header, Payload, Signature로 구성된 문자열이며,
이 전체 구조를 HS256 등의 알고리즘으로 서명하여 생성한다.
Header : 어떤 알고리즘으로 서명했는지
Payload : 사용자 정보 및 토큰의 메타데이터
Signature : 헤더 + 페이로드 + 비밀키를 가지고 만든 해시 값
으로 이루어져있다.
1. Base 64로 이루어진 난수(origin Secret Key)를 생성한다.
2. origin Secret Key를 바이트 배열(Byte [])로 디코딩 한다.
3. 바이트 배열을 HmacShaKeyFor을 통해 Secret Key를 생성 해준다.
4. jwt 토큰을 생성 (Header = Secret Key + 서명 알고리즘 정보 / PayLoad = 고객 id, 이름, 권한 등의 고객 정보 + 토큰의 발급,만료 시간 / Signature = Header + PayLoad를 합친 위조 방지용 서명)
( jwt 토큰의 형태 = Header. Payload. Signature 의 형태를 가짐)
Access Token과 Refresh Token은 모두 JWT 형식의 토큰이지만, **역할과 유효 기간이 다르다.**
Access Token은 유효 기간이 짧으며, 사용자의 인증 정보를 담고 있어 'API 요청 시 인증 수단'으로 사용된다.
Refresh Token은 유효 기간이 길고, Access Token이 만료되었을 때 '새로운 Access Token을 발급받기 위한 용도'로 사용된다.
즉, Access Token은 매 요청마다 서버에 전달되어 사용자를 인증하는 데 사용되며,
Refresh Token은 사용자의 로그인 상태를 유지하면서, Access Token을 갱신하는 데 사용됩니다.

위의 사진은 oauth2.0로그인이 되고 난후 SuccessHandler과 jwt필터 설정이 끝난 security config 이다.
이를보면 SuccessHandler와 Jwtfilter을 만들어야 한다는 것을 알 수 있다.
오늘은 시큐리티 필터체인에서 OauthLogin이 성공 했을때 어떻게 작동할 지에 대한 SuccessHandler를 만들어 볼 것이다.
config
ㄴ SuccessHandler(Oauth Login이 성공 후 작동할 클래스)
ㄴ onAuthenticationSuccess (SuccessHwandler를 호출 했을때 자동으로 실행 될 메서드)
ㄴ addCookie(쿠키에 추가해주는 메소드)
ㄴ JwtConfiguration (Access 토큰 및 Refresh 토큰의 정보를 가지고 있는 클래스)
repositody
ㄴ TokenRepositoryAdapter(복잡한 쿼리를 따로 분리하는 Adapter 클래스)
service
ㄴJwtTokenProvider(jwt 토큰을 발급, 검증, 파싱 하는 클래스)
domain
ㄴ RefreshToken(리프레쉬 토큰)
터미널(Git Bash 등)을 열고 명령어 openssl rand -base64 32 입력 해준다.
openssl rand -base64 32
입력하면 아래와 같은 형식의 난수 문자열이 출력된다.
1RbVb88pTsO3OPcJonK/y5441pzohe8+ECCDiCmpPk=
이 문자열은 절대로 외부에 유출되면 안 되는 중요 정보이며, 보통 시크릿 키 생성의 기반 값으로 사용된다.
origin-key란?
새로운 키를 발급하거나 키를 교체할 경우, 기존 app-key를 백업해두는 용도이며,주로 키 롤링(Key Rotation)을 위한 대비용이다.
→ 새로운 키를 사용할 때 기존 토큰을 검증하기 위해 origin-key가 필요할 수 있다.
jwt :
secret:
app-key:
origin-key:
validation:
access: 1800000 #30분(30분 × 60초 × 1000ms)
refresh: 604800000 #7일(7일 × 24시간 × 60분 × 60초 × 1000ms)
custom:
frontend:
redirect-uri: "http://localhost:3000"
app-key의 경우 위에서 만든 키를 가지고 오면 된다.
validation 부분은 access token과 refresh 토큰의 유효 시간에 대한 설정이다.
(일) × 24 × 60 × 60 × 1000 → 밀리초
(분) × 60 × 1000 → 밀리초
redirect-uri redirect-uri는 로그인 성공 후, 클라이언트로 리디렉션할 프론트엔드 주소를 설정한다.
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
JWT 토큰을 생성하고 파싱하며 서명/검증하는 기능을 제공하는 라이브러리
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String refreshToken;
@ManyToOne(fetch = FetchType.LAZY)
@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;
}
}
리프레쉬 토큰은 id, 토큰(String), member, 생성시간만 들어가면 된다.
Access 토큰은 도메인이 필요하지 않다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshTokenBlackList {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private LocalDateTime createdAt = LocalDateTime.now();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "refresh_token_id")
private RefreshToken refreshToken;
@Builder
public RefreshTokenBlackList(RefreshToken refreshToken) {
this.refreshToken = refreshToken;
}
}
이 코드는 현재 사용하지 않으나 이 다음에 만들 RefreshToken Repository에서 활성화 된 Token을 가져오는 코드를 사용하기 위해 필요하며
추후 로그아웃 기능을 만들면 연결 할 것이다.
public interface TokenRepository {
//토큰을 저장한다.
RefreshToken save(Member member,String token);
// MemberId로 유효한 토큰 찾기
Optional<RefreshToken> findValidRefTokenByMemberId(Long memberId);
@Repository
@RequiredArgsConstructor
public class TokenRepositoryAdapter implements TokenRepository {
//리프레쉬 토큰을 가져온다. 토큰의 별칭은 rt이다
//RefreshTokenBlackList를 rt에 LEFT JOIN 한다. rt가 블랙리스트에 등록되어 있다면 rtb에 매칭되고, 아니면 rtb는 null이다.
//지정한 사용자만 조회한다 (member.id = : memberId)
//블랙리스트에 id가 없는 것만 조회 한다.
@Query("""
SELECT rt FROM RefreshToken rt
LEFT JOIN RefreshTokenBlackList rtb ON rtb.refreshToken = rt
WHERE rt.member.id = :memberId
AND rtb.id IS NULL
""")
Optional<RefreshToken> findValidTokenByMemberId(Long memberId);
}
@Repository
@RequiredArgsConstructor
// RefreshToken과 RefreshTokenBlackList를 중심으로, "토큰 저장", "조회", "블랙리스트 등록" 등의 로직을 캡슐화한 데이터 접근 계층
public class TokenRepositoryAdapter implements TokenRepository {
private final RefreshTokenRepository refreshTokenRepository;
private final RefreshTokenBlackListRepository refreshTokenBlackListRepository;
//새 RefreshToken을 생성하고 DB에 저장
@Override
public RefreshToken save(Member member, String token) {
RefreshToken refreshToken = RefreshToken.builder()
.refreshToken(token)
.member(member)
.build();
return refreshTokenRepository.save(refreshToken);
}
//회원 ID로 유효한 토큰 조회 (블랙리스트 제외 조건 내장)
@Override
public Optional<RefreshToken> findValidRefTokenByMemberId(Long memberId) {
return refreshTokenRepository.findValidTokenByMemberId(memberId);
}
}
여기 까지 온다면 왜 굳이 TokenRepositoryAdapter가 필요한가를 생각 할 것이다.
블랙리스트, 복합 로직 등 ‘어댑터 레이어’에 숨기기
JPA 쿼리나 블랙리스트 제외 조건 같은 복잡한 로직은 Adapter에 숨겨서, 상위 계층인 Service는 단순한 ‘토큰 저장/조회’만 알도록 하는 게 일반적이라고 한다.
@Data
@Builder
public class KeyPair {
private String accessToken;
private String refreshToken;
// 쿠키에 값을 넣으려면 String 이여야 한다.
private String memberId;
}
@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "jwt")
public class JwtConfiguration {
// Access Token과 Refresh Token의 유효 시간 설정을 담고 있다.
// 값은 application.yml에서 설정된다.
private final Validation validation;
// 시크릿 키를 두 개로 분리해서 관리한다.
// - appKey: 현재 사용 중인 시크릿 키
// - originKey: 이전에 사용하던 시크릿 키 (기존 토큰 검증용)
private final Secret secret;
// 왜 굳이 내부 클래스로 정의했는가?
// Validation은 JwtConfiguration 내부에서만 사용되며,
// 다른 클래스에서 독립적으로 사용될 일이 없기 때문이다.
// 관련 설정을 그룹화하여 구성의 명확성과 가독성을 높이기 위함이다.
@Data
public static class Validation {
private Long access;
private Long refresh;
}
@Getter
@RequiredArgsConstructor
public static class Secret {
private final String appKey;
private final String originKey;
}
}
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
// jwt 토큰을 발급, 검증, 파싱 하는 클래스
public class JwtTokenProvider {
// access Token과 Refresh 토큰의 재발급 정보를 담당
private final JwtConfiguration configuration;
// Refresh Token의 발급, 조회, 블랙리스트 등록을 담당
private final TokenRepository refreshTokenRepositoryAdapter;
// JJWT 라이브러리의 유틸 클래스인 io.jsonwebtoken.security.Keys에서 제공하는 메서드로,HMAC 방식 서명을 위한 시크릿 키를 생성
private SecretKey getSecretKey() {
//base64 기반의 난수 Origin Secret Key
String base64SecretKey = configuration.getSecret().getAppKey().getBytes();
//byte 배열로 디코딩
byte[] BytesSecretKey = Base64.getDecoder().decode(base64SecretKey);
return Keys.hmacShaKeyFor(BytesSecretKey);
}
//jwtToken 생성
private String issue(Long memberId, String role, Long validTime) {
// Payload = subject, claim("role"), issuedAt(iat), expiration(exp)
// Signature = signWith를 통해 생성
// 아래의 코드에는 header에 해당되는 코드가 없는데 자동적으로 생성된다. (alg: HS256, typ: JWT)
String jwtToken = Jwts.builder()
.setSubject(memberId.toString()) // subject: 사용자 ID
.claim("role", role) // 사용자 역할(권한)을 추가
.issuedAt(new Date()) // 발급 시간 (iat) 현재 시간
.expiration(new Date(new Date().getTime() + validTime)) // 만료 시간 (ext) 현재 시간 + yml 설정 시간
.signWith(getSecretKey(), Jwts.SIG.HS256) // 시크릿 키로 서명하여 Signature 생성
.compact(); // Header + Payload + Signature 결합 → 최종 JWT 문자열 반환
return jwtToken;
}
// Accesss 토큰 생성
public String issueAccessToken(Long memberId, String role) {
return issue(memberId, role, configuration.getValidation().getAccess());
}
// Refresh 토큰 생성
public String issueRefreshToken(Long memberId, String role) {
return issue(memberId, role, configuration.getValidation().getRefresh());
}
// 토큰 두개를 묶는다.
public KeyPair generateKeyPair(Member member) {
String accessToken = issueAccessToken(member.getId(),member.getRole().name());
String refreshToken = issueRefreshToken(member.getId(),member.getRole().name());
refreshTokenRepositoryAdapter.save(member, refreshToken);
KeyPair jwtTokens = KeyPair.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.memberId(member.getId().toString())
.build();
return jwtTokens;
}
//특정 사용자의 유효한 RefreshToken이 DB에 있는지 확인
public RefreshToken validateRefreshToken(Long memberId) {
Optional<RefreshToken> validRefTokenOptional = refreshTokenRepositoryAdapter.findValidRefTokenByMemberId(memberId);
return validRefTokenOptional.orElse(null);
}
}
@Slf4j
@Component
@RequiredArgsConstructor
// 유저 차단 확인, 리프레쉬 토큰 확인 및 액세스토큰 생성
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
private final JwtConfiguration jwtConfiguration;
@Value("${custom.frontend.redirect-uri}")
private String baseUrl;
// SuccessHandler을 조회하면 자동적으로 onAuthenticationSuccess 메소드를 조회 한다.
// Spring Security는 인증이 성공하면 AuthenticationSuccessHandler 타입으로 등록된 객체를 찾는다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 유저 정보를 가져온다.
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
//member Id를 가져온다.
Long memberId = principal.getId();
//SecurityContext에 어떤 객체가 들어갔는지 디버깅용으로 확인하기 위해
log.info("Principal set to SecurityContext: {}", ((CustomUserPrincipal) authentication.getPrincipal()).getClass().getName());
// 쿠키 생존 시간에 대한 설정
int accessCookieMaxAge = (int) (jwtConfiguration.getValidation().getAccess() / 1000);
int refreshCookieMaxAge = (int) (jwtConfiguration.getValidation().getRefresh() / 1000);
// 유저의 벤 여부를 확인한다.
if (principal.isBlocked()) {
log.warn("Blocked user attempted to log in: {}", principal.getId());
response.sendError(HttpServletResponse.SC_FORBIDDEN, ExceptionMessage.MEMBER_BLOCKED_ERROR);
return;
}
//Refresh 토큰 조회
RefreshToken findRefreshToken = jwtTokenProvider.validateRefreshToken(memberId);
if (findRefreshToken == null) {
// 회원 조회
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFound(ExceptionMessage.MEMBER_NOT_FOUND));
// Access,Refresh 토큰 생성 및 저장
KeyPair keyPair = jwtTokenProvider.generateKeyPair(findMember);
String accessToken = keyPair.getAccessToken();
String refreshToken = keyPair.getRefreshToken();
// 쿠키에 토큰 추가
addCookie(response, "accessToken", accessToken, accessCookieMaxAge);
addCookie(response, "refreshToken", refreshToken, refreshCookieMaxAge);
log.info("accessToken = {}", accessToken);
log.info("refreshToken = {}", refreshToken);
log.info("유효한 Refresh Token 없음 Refresh,Access Token 발급");
} else {
// 토큰이 있다면 Access 토큰만 발급.
String accessToken = jwtTokenProvider.issueAccessToken(principal.getId(), principal.getRole().name());
String refreshToken = findRefreshToken.getRefreshToken();
// 쿠키에 토큰 추가
addCookie(response, "accessToken", accessToken, accessCookieMaxAge);
addCookie(response, "refreshToken", refreshToken, refreshCookieMaxAge);
log.info("기존 Refresh Token 유효 AccessToken만 발급");
}
// Success 후 리디렉션
// 기존 Security Config의 .defaultSuccessUrl("/") 대신 사용
getRedirectStrategy().sendRedirect(request,response,baseUrl);
log.info("succesHandler 성공");
}
// 쿠키에 대한 설정
private void addCookie(HttpServletResponse response,
String name,
String value,
int maxAgeSeconds) {
Cookie cookie = new Cookie(name, value);
cookie.setMaxAge(maxAgeSeconds);
cookie.setPath("/");
cookie.setHttpOnly(false);
cookie.setSecure(false); //나중에 true로 바꿔야지 https 에서만 전송 된다.
// cookie.setAttribute("SameSite", "None");
response.addCookie(cookie);
}
}
배포시 수정해야 할 부분
1. cookie.setHttpOnly(false); → true
브라우저의 JavaScript에서 접근 불가
XSS(스크립트 해킹)를 통한 쿠키 탈취 방지
보안상 **항상 true**로 설정하는 게 좋다
2. cookie.setSecure(false); → true
HTTPS 연결일 때만 쿠키 전송
HTTP 연결에서는 쿠키가 절대 노출되지 않음
실제 배포에서는 HTTPS를 기본으로 사용해야 하므로 반드시 true
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final JwtTokenFilter jwtTokenFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
//cors 기본 설정 활성화
.cors(Customizer.withDefaults())
//csrf 비활성화
.csrf(csrf -> csrf.disable())
// form 로그인 비활성화
.formLogin(formLogin -> formLogin.disable())
//인증 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
.requestMatchers("/api/auth/logout", "/api/auth/reissue").permitAll()
.requestMatchers("/api/member").authenticated()
.anyRequest().authenticated()
)
// Oauthlogin 설정
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
//꼭 추가를 해야지 작동한다!!!!
//로그인이 성공 했을 때 작동하는 핸들러
.successHandler(oAuth2SuccessHandler)
)
.build();
}
}
마지막으로 Security Config에 .successHandler(oAuth2SuccessHandler)를 추가 해주면 된다.
이렇게 하면 SuccessHandler에 대한 설정은 끝난다.
그러면 다음에는 jwt 필터를 만들어 보자
1. 어차피 디코딩 할건데 Byte 배열로 그냥 오리진 시크릿키를 입력을 하면 되는 것 아닌가?
ㄴ byte 배열은 이진 데이터이기 때문에 관리 및 저장을 하기가 어렵다. 그렇기에 base64로 만든 난수를 사용을 한다.
2. 시크릿 키를 생성 할때 hs256이란 서명 알고리즘만 사용 할 수 있는가?
ㄴ 아니다. 서명 알고리즘은 대칭키와 비대칭키, 타원 곡선 기반 비대칭키가 2가지 방식이 있으며
대칭 키에서는 HMAC 기반의 hs256, hs384, hs512 가 있으며
비대칭 키에는 RSA 기반의 rs256, rs 384, rs512가 있다. 또한 ECDSA 기반의 타원 곡선 기반 서명도 있으나 이는 여기서 설명하지 않겠다.
대칭키의 장점은 속도가 빠르고 구현이 간단하며, 대칭 키 하나만 있으면 된다
단점은 키 유출 시 보안이 위험하다.
비대칭 키 RSA의 장점은 발급자는 개인키로 서명, 검증자는 공개키로 검증을 하여 보안에 유리하며, 공개키만 배포하면 되므로 구조가 유연하다.
단점은 HMAC보다 연산이 무겁다.