JWT를 구현하기에 앞서 JWT의 취약점으로 토큰 탈취 시 유효기간을 만료시키거나 삭제할 수 없다고 했습니다. 이를 보완하기 위해 2개의 토큰을 생성하는 방식을 취하게 되며, 각각 Access Token, Refesh Token이라 합니다.
Access Token & Refresh Token
클라이언트가 API를 요청할 때 사용하는 Access Token이라 부르며, 이때 Access Token이 만료가 될 시 재발급을 요청하기 위한 토큰을 Refresh Token이라 합니다.
Access Token은 클라이언트가 보관하고, Refresh Token은 서버가 보관합니다. 즉 Refresh Token이 유효하면 서버가 Access Token을 재발급해주는 방식입니다.
JWT설정(1): 의존성 설정 및 토큰 제공자 추가
JWT 개념을 어느 정도 이해했다면 직접 구현해 보도록 하겠습니다.
implementation "io.jsonwebtoken:jjwt:0.9.1"
implementation "javax.xml.bind:jaxb-api:2.3.1"
build.gradle에 필요한 의존성을 추가해 줍니다.
jwt:
issuer: test@email.com
secret_key: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
application.yml에 JWT를 생성하기 위한 issuer(이슈 발급자), secret_key(비밀키)를 설정합니다. 각 명칭은 고정된 것이 아니기 때문에 본인이 원하시는 대로 바꾸셔도 상관없습니다.
@Data
@Component
@ConfigurationProperties("jwt") // application.yml에 "jwt"에 해당하는 값을 가져와서 사용
public class JwtProperties {
private String issuer;
private String secretKey; // secret_key -> secretKey 자동으로 맞춰 넣어줌
}
해당 값들을 변수로 접근하는 데 사용할 JwtProperties 클래스를 생성합니다. 저는 @Data를 사용했지만 @Setter & @Getter로 사용하셔도 무방합니다. 참고로 Setter가 없다면 application.yml 파일에 해당 값을 가져오지 못하기 때문에 꼭 넣어주셔야 합니다.
secretKey는 토큰을 생성하거나 파싱 할 때 사용되는 정보로 보안에 유의해야 합니다. GitHub와 같이 모두가 볼 수 있는 저장소에 저장할 경우 application.yml 파일에 보관하여 secretKey 값을 분리하는 것이 적절합니다.
꼭 .gitgnore에 application.yml이 GitHub에 올라가지 않도록 설정해야 합니다!
src/main/resources/application.yml
JWT설정(2): TokenProvider
TokenProvider 클래스는 토큰을 생성하고 토큰 유효성 검사 및 토큰의 정보를 꺼내오는 역할을 담당합니다.
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generateToken(User user, Duration expiredAt) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// JWT Access Token & Refresh Token 을 생성한다.
private String makeToken(Date expiry, User user) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // header typ: JWT
.setIssuer(jwtProperties.getIssuer()) // payload iss: 토큰 발급자
.setIssuedAt(now) // payload iat: 현재시간
.setExpiration(expiry) // payload exp: 매개변수 expiry
.setSubject(user.getEmail()) // payload sub: 토큰 제목 (유저의 이메일)
.claim("id", user.getId()) // payload claim: 유저 id
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // signature 해시값 HS256 + 비밀키 암호화
.compact();
}
// JWT 유효성을 검증한다.
public boolean validToken(String token) {
try {
Jws<Claims> claim = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 비밀키로 복호화
.parseClaimsJws(token);
return claim.getBody()
.getExpiration()
.after(new Date());
} catch (Exception e) { // 복호화 과정에서 예외가 발생하면 검증 실패
return false;
}
}
// 토큰 기반으로 인증 정보를 가져온다.
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(
new org.springframework.security.core.userdetails.User(
claims.getSubject(), "", authorities), token, authorities);
}
// 토큰 기반으로 유저 정보(id)를 가져온다.
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
// 토큰 기반으로 클레임(claim)을 가져온다.
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
}
각 메서드를 나눠 설명하겠습니다.
generateToken() & makeToken()
public String generateToken(User user, Duration expiredAt) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// JWT Access Token & Refresh Token 을 생성한다.
private String makeToken(Date expiry, User user) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // header typ: JWT
.setIssuer(jwtProperties.getIssuer()) // payload iss: 토큰 발급자
.setIssuedAt(now) // payload iat: 현재시간
.setExpiration(expiry) // payload exp: 매개변수 expiry
.setSubject(user.getEmail()) // payload sub: 토큰 제목 (유저의 이메일)
.claim("id", user.getId()) // payload claim: 유저 id
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // signature 해시값 HS256 + 비밀키 암호화
.compact();
}
저는 Access Token과 Refresh Token을 생성 방식을 따로 구분하지 않았습니다. generateToken 메서드에 사용자와 유효기간을 받을 수 있도록 하였습니다. makeToken 메서드는 실제 JWT를 생성하고 있으며, JWT 개념에서 설명드렸던 헤더(header), 내용(payload), 서명(signature)을 설정합니다.
validToken()
// JWT 유효성을 검증한다.
public boolean validToken(String token) {
try {
Jws<Claims> claim = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 비밀키로 복호화
.parseClaimsJws(token);
return claim.getBody()
.getExpiration()
.after(new Date());
} catch (Exception e) { // 복호화 과정에서 예외가 발생하면 검증 실패
return false;
}
}
토큰이 유효한지 검증하는 메서드입니다. 비밀키와 함께 복호화를 진행하며 예외가 발생하거나, 유효기간이 만료되었을 경우 false를 반환하고, 아무런 문제가 없다면 true를 반환합니다.
getAuthentication() & getUserId()
// 토큰 기반으로 인증 정보를 가져온다.
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(
new org.springframework.security.core.userdetails.User(
claims.getSubject(), "", authorities), token, authorities);
}
// 토큰 기반으로 유저 정보(id)를 가져온다.
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
// 토큰 기반으로 클레임(claim)을 가져온다.
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
토큰을 받아 인증 정보를 담은 객체 Authentication을 반환하는 메서드입니다. 토큰을 복호화하여 클레임을 가져오는 getClaims()를 호출하여 반환받고 사용자의 이메일과 토큰 기반으로 인증 정보를 생성합니다. getUserId()는 토큰을 기반으로 사용자 id를 가져오는 편의 메서드입니다.
JWT설정(3): Refresh Token 도메인
Access Token과 다르게 Refresh Token은 서버에 보관하기 때문에 DB에 저장하기 위한 엔티티와 리포지터리를 추가해야 합니다. 이를 위해 RefreshToken 클래스를 생성합니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "REFRESH_TOKEN")
@Entity
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "refresh_token_id", updatable = false)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
public RefreshToken(Long userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public RefreshToken update(String newRefreshToken) {
this.refreshToken = newRefreshToken;
return this;
}
}
user_id는 Refresh Token 생성을 요청한 사용자 id이며, refesh_token은 TokenProvider 클래스에서 generateToken()을 통해 생성되는 값을 넣게 됩니다.
Refresh Token의 리포지토리를 생성합니다.
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(@Param("userId") Long userId);
Optional<RefreshToken> findByRefreshToken(@Param("refreshToken") String refreshToken);
}
Spring Security + JWT + OAuth 2.0 기능을 구현한다면 JPA의 개념을 아실 것이라 생각하기에 설명은 생략합니다.
JWT설정(4): TokenAuthenticationFilter
이제 토큰 필터를 구현해야 합니다. 필터는 서버에 요청이 들어오면 처리를 위한 로직으로 전달되기 전후 과정에서 URL 패턴에 맞는 요청을 처리해 주는 기능을 제공합니다. 우리는 JWT 토큰이 유효한지 검증하기 위해 TokenAuthenticationFilter 클래스를 생성합니다.
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더의 Authorization 키의 값을 조회한다.
String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
// 가져온 값의 접두사(Bearer )를 제거한다.
String accessToken = getAccessToken(authorizationHeader);
// 토큰이 유효한지 검증하고, 유효할 경우 SecurityContextHolder 에 인증 정보를 저장한다.
if (tokenProvider.validToken(accessToken)) {
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getAccessToken(String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
return null;
}
}
아직 Spring Security 설정을 다루지 않았고, 해당 필터를 추가하지 않았기 때문에 Spring Security에 추가로 설정해줘야 합니다. 설정은 OAuth2를 구현할 때 하도록 하고 우선 JWT 기능에만 집중하도록 하겠습니다.
클라이언트는 API 요청을 보낼 때 헤더에 Authorization을 통해 "Bearer " + JWT(Access Token)로 입력할 것입니다.
TokenAuthenticationFilter 클래스는 요청 처리 중에 파이프 라인처럼 연결되어 흐름을 제어하고, 필터링 과정을 거친 후 기존 처리 로직으로 요청을 전달합니다. 이 필터는 HttpServletRequest의 헤더에서 Authorization 값을 추출하여 토큰의 유효성을 확인하고, 유효할 경우에는 SecurityContextHolder에 인증 정보를 저장합니다.
SecurityContextHolder를 통해 인증 정보를 담게 되며, 이는 ThreadLocal을 활용하게 됩니다. ThreadLocal은 각각의 쓰레드 별로 별도의 저장 공간을 제공하는 컨테이너입니다. 멀티 쓰레드 환경에서 각각의 쓰레드에게 별도의 자원을 제공함으로써, 공유되는 서비스에서 별도의 자원에 접근하게 끔 하여 각각의 쓰레드가 상태를 가질 수 있도록 도와줍니다.
JWT설정(5): 토큰 API
마지막으로 Refresh Token을 통해 Access Token을 재발급해주는 API를 구현하겠습니다. 우선 토큰을 생성해 주는 TokenService 클래스를 생성합니다.
@RequiredArgsConstructor
@Service
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final UserRepository userRepository;
public String createAccessToken(String refreshToken) {
// 토큰 유효성 검증을 한다. (실패하면 예외 처리)
if (!tokenProvider.validToken(refreshToken)) {
throw new IllegalArgumentException("Unexpected token");
}
Long userId = refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("Unexpected token"))
.getUserId();
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
// 유저 정보와 함께 유효시간이 2시간으로 설정된 AccessToken 을 생성한다.
return tokenProvider.generateToken(user, Duration.ofHours(2));
}
}
createAccessToken() 메서드를 통해 전달받은 Refresh Token으로 유효성 검사를 진행하여 유효한 토큰인 경우 DB에 Refesh Token을 찾은 후, 사용자 정보를 찾습니다. 마지막으로 가져온 사용자 정보를 통해 Access Token을 생성합니다.
서비스 로직을 만들었으니 컨트롤러도 추가해 줍니다. 먼저 토큰 생성 요청 및 응답을 담당할 dto 클래스를 추가하겠습니다. 저는 Java17을 사용했기 때문에 record를 사용했습니다.
public record CreateAccessTokenRequest(
String refreshToken) {
}
public record CreateAccessTokenResponse(
String accessToken) {
}
실제로 요청을 받고 처리할 컨트롤러를 생성합니다.
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<CreateAccessTokenResponse> createAccessToken(@RequestBody CreateAccessTokenRequest request) {
String accessToken = tokenService.createAccessToken(request.refreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new CreateAccessTokenResponse(accessToken));
}
}
".../api/token" POST 요청이 오면 TokenService를 통해 Access Token을 생성해 주게 됩니다.
그디어 JWT 설정이 완료되었습니다! 코드 양이 많기 때문에 동작 방식을 이해하기가 어려울 수 있습니다. 다음 포스팅을 통해 테스트 코드를 작성하여 코드가 정상적으로 작동하는지 확인하고, 어떻게 동작하는지 알아보겠습니다.
긴 글을 읽어주셔 감사합니다.
이전 포스팅으로 이동
Spring Security + JWT + OAuth 2.0 회원 기능(1) - JWT 개념
다음 포스팅으로 이동
Spring Security + JWT + OAuth 2.0 회원 기능(3) - JWT 테스트 코드
Filter와 OncePerRequestFilter
Filter와 GenericFilterBean Filter는 javax.servlet-api나 tomcat-embed-core를 사용하면 제공되는 Servlet Filter 인터페이스입니다. Filter는 서블릿이 실행되기 전, 후로 호출됩니다. 이 Filter 인터페이스를 조금 더 확
thisisprogrammingworld.tistory.com
[소셜로그인] 1탄 Spring Security, JWT, OAuth 개념
출시 전인 프로젝트에 참여하게 되었다. 전에 백엔드 개발자로 계시던 분이 나가시게 되면서 내가 들어왔다. 소셜로그인 기능이 구현된 상태였는데, 본인 계정으로 깃헙과 구글의 Oauth 프로젝트
cme10575.tistory.com
Security Filter 예외처리하기 - JWT
Spring Security에서 토큰 기반 인증 중 예외가 발생한다면 어떤 일이 일어나는지, 어떻게 핸들링 해야하는지에 대해 알아보자.
velog.io
'Spring > Spring Security' 카테고리의 다른 글
Spring Security + JWT + OAuth 2.0 회원 기능(5) - OAuth 2.0 인증 서버 등록 (0) | 2024.03.11 |
---|---|
Spring Security + JWT + OAuth 2.0 회원 기능(4) - OAuth 2.0 개념 (1) | 2024.03.11 |
Spring Security + JWT + OAuth 2.0 회원 기능(3) - JWT 테스트 코드 (0) | 2024.03.11 |
Spring Security + JWT + OAuth 2.0 회원 기능(1) - JWT 개념 (3) | 2024.03.11 |
Spring Security 6.1, xxx is deprecated and marked for removal (1) | 2024.02.26 |