학습 목표 : 토큰 기반 인증과 JWT, 리프레시 토큰의 개념을 이해하고 토큰 기반 인증인 JWT 토큰 서비스를 구현하고 적용한다.
** 목차 **
9.1 사전 지식 : 토큰 기반 인증
9.2 JWT 서비스 구현하기
9.3 토큰 API 구현하기
9.1 사전 지식 : 토큰 기반 인증
1. 토큰 기반 인증
- 사용자가 서버에 접근할 때 이 사용자가 인증된 사용자인지 확인하는 방법은 다양함.
- 대표적인 사용자 인증 확인 방법에는 서버 기반 인증과 토큰 기반 인증이 있음.
- 스프링 시큐리티에서는 기본적으로 세션 기반 인증을 제공해줌. 8장에서는 기본적으로 제공해주는 세션 기반 인증을 사용해 사용자마다 사용자의 정보를 담을 세션을 생성하고 저장해서 인증을 함.
- 토큰 기반 인증은 토큰을 사용하는 방법.
- 토큰은 서버에서 클라이언트를 구분하기 위한 유일한 값. 서버가 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 이 토큰을 갖고 있다가 여러 요청을 이 토큰과 함께 신청함. 그럼 서버는 토큰만 보고 유효한 사용자인지 검증함.
- 토큰을 전달하고 인증 받는 과정

1. 로그인 요청 : 클라이언트가 아이디와 비밀번호를 서버에게 전달하면서 인증을 요청한다.
2. 토큰 생성 후 응답 : 서버는 아이디와 비밀번호를 확인해 유효한 사용자인지 검증한다. 유효한 사용자면 토큰을 생성해서 응답한다.
3. 토큰 저장 : 클라이언트는 서버에서 준 토큰을 저장한다.
4. 토큰 정보와 함께 요청 : 이후 인증이 필요한 API를 사용할 때 토큰을 함께 보낸다.
5. 토큰 검증 : 서버는 토큰이 유효한지 검증한다.
6. 응답 : 토큰이 유효하다면 클라이언트가 요청한 내용을 처리한다.
- 토큰 기반 인증의 특징
1. 무상태성
: 사용자의 인증 정보가 담겨 있는 토큰이 서버가 아닌 클라이언트에 있으므로 서버에 저장할 필요가 없다.
서버가 뭔가 데이터를 유지하고 있으려면 그만큼 자원을 소비해야 한다. 하지만 토큰 기반 인증에서는 클라이언트에서 인증 정보가 담긴 토큰을 생성하고 인증한다.
따라서 클라이언트에서는 사용자의 인증 상태를 유지하면서 이후 요청을 처리해야하는데 이것을 상태를 관리한다고 한다. 이렇게 하면 서버 입장에서는 클라이언트의 인증 정보를 저장하거나 유지하지 않아도 되기 때문에 완전한 무상태로 효율적인 검증을 할 수 있다.
2. 확장성
: 무상태성은 확장성에 영향을 준다. 서버를 확장할 때 상태 관리를 신경 쓸 필요가 없으니 서버 확장에도 용이하다.
ex) 물건을 파는 서비스가 있고, 결제를 위한 서버, 주문을 위한 서버가 분리되어 있음.
- 세션 인증 기반은 각각 API에서 인증을 해야되는 것과는 달리 토큰 기반 인증에서는 토큰을 가지는 주체는 서버가 아니라 클라이언트이기 때문에 가지고 있는 하나의 토큰으로 결제 서버와 주문 서버에게 요청을 보낼 수 있음.
- 추가로 페이스북 로그인, 구글 로그인 같이 토큰 기반 인증을 사용하는 다른 시스템에 접근해 로그인 방식을 확장할 수도 있고, 이를 활용해 다른 서비스에 권한을 공유할 수도 있다.
3. 무결성
: 토큰 방식은 HMAC 기법이라고 부른다. 토큰을 발급한 이후에는 토큰 정보를 변경하는 행위는 할 수 없다.
즉, 토큰의 무결성이 보장된다. 만약 누군가 토큰을 한 글자라도 변경하면 서버에서는 유효하지 않은 토큰이라고 판단한다.
2. JWT(JSON Web Token)
발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 키값에 Bearer+JWT 토큰값을 넣어 보내야 함.
- JWT 구조
: 헤더 . 내용 . 서명
aaaaaa . bbbbbb . cccccc
① 헤더 : 토큰의 타입과 해싱 알고리즘을 지정하는 정보를 담음.
{
"typ": "JWT",
"alg": "HS256"
}
→ JWT 토큰, HS256 해싱 알고리즘을 사용한다
② 내용 : 토큰과 관련된 정보를 담음. 내용의 한 덩어리를 클레임이라고 부르며, 클레임은 키값의 한 쌍으로 이루어져 있음. 클레임은 등록된 클레임, 공개 클레임, 비공개 클레임으로 나눌 수 있음.
- 등록된 클레임 : 토큰에 대한 정보를 담는 데 사용
- 공개 클레임 : 공개되어도 상관없는 클레임을 의미함. 충돌을 방지할 수 있는 이름을 가져야 하며, 보통 클레임 이름을 URL로 지음.
- 비공개 클레임 : 공개되면 안 되는 클레임을 의미함. 클라이언트와 서버 간의 통신에 사용됨.
{
"iss": "cool@gmail.com", // 등록된 클레임
"iat": 1622370878, // 등록된 클레임
"exp": 1622372678, // 등록된 클레임
"https://cool.com/jwt_claims/is_admin": true, // 공개 클레임
"email": "cool@gmail.com", // 비공개 클레임
"hello": "안녕하세요" // 비공개 클레임
}
- iss, iat, exp는 JWT 자체에서 등록된 클레임.
- iss : 토큰 발급자(issuer)
- iat : 토큰이 발급된 시간으로 iat은 issued at을 의미
- exp : 토큰의 만료 시간. 시간은 NumericDate 형식으로 하며, 항상 현재 시간 이후로 설정함.
- NumericDate 형식 ex) unix timestamp : 1970년 1월 1일 00:00:00 (UTC)부터의 경과 시간을 초로 환산하여 정수로 나타낸 것.
- 위의 iat는 1622370878 → 2021년 5월 30일 오후 7:34:38
- 위의 exp는 1622372678 → 2021년 5월 30일 오후 8:04:38
- URL로 네이밍된 https://cool.com/jwt_claims/is_admin 은 공개 클레임
- 그 외에 등록된 클레임도, 공개 클레임도 아닌 email과 hello는 비공개 클레임 값
③ 서명 : 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용됨. 헤더의 인코딩 값과 내용의 인코딩값을 합친 후에 주어진 비밀키를 사용해 해시값을 생성함
- 토큰 유효기간
: 토큰은 이미 발급되면 그 자체로 인증 수단이 되므로 서버는 토큰과 함께 들어온 요청이 토큰을 탈취한 사람의 요청인지 확인할 수 없음.
① 리프레시 토큰
: 엑세스 토큰과 별개의 토큰
사용자를 인증하기 위한 용도가 아닌 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급하기 위해 사용함.
액세스 토큰의 유효 기간은 짧게 설정하고, 리프레시 토큰의 유효 기간은 길게 설정하면 공격자가 액세스 토큰을 탈취해도 몇 분 뒤에는 사용할 수 없는 토큰이 되므로 더 안전함.

1. 인증 요청 : 클라이언트가 서버에게 인증을 요청한다.
2. 액세스 토큰&리프레시 토큰 응답 : 서버는 클라이언트에서 전달한 정보를 바탕으로 인증 정보가 유효한지 확인한 뒤, 액세스 토큰과 리프레시 토큰을 만들어 클라이언트에게 전달한다.
3. 리프레시 토큰 저장 : 서버에서 생성한 리프레시 토큰은 DB에도 저장해둔다.
4. 요청 : 인증을 필요로 하는 API를 호출할 때 클라이언트에서 저장된 액세스 토큰과 함께 API를 요청한다.
5. 토큰 유효성 검사 & 응답 : 서버에서 전달받은 액세스 토큰이 유효한지 검사한 뒤에 유효한다면 클라이언트에서 요청한 내용을 처리한다.
6. (만료된 액세스 토큰과 함께) 요청 : 시간이 지나고 액세스 토큰이 만료된 뒤에 클라이언트에서 원하는 정보를 얻기 위해 서버에게 API 요청을 보낸다.
7. 토큰 만료 응답 : 서버에서 액세스 토큰이 유효한지 검사한다. 만료된 토큰이면 유효하지 않기 때문에 토큰이 만료되었다는 에러를 전달한다.
8. (리프레시 토큰과 함께) 액세스 토큰 발급 요청 : 클라이언트에서는 이 응답을 받고 저장해둔 리프레시 토큰과 함께 새로운 액세스 토큰을 발급하는 요청을 전송한다.
9. 리프레시 토큰 조회 & 유효성 검사 : 서버에서는 전달받은 리프레시 토큰이 유효한지, DB에서 리프레시 토큰을 조회한 후 저장해둔 리프레시 토큰과 같은지 확인한다.
10. 새로운 액세스 토큰 응답 : 만약 유효한 리프레시 토큰이라면 새로운 액세스 토큰을 생성한 뒤 응답한다.
→ 이후 클라이언트는 4번과 같이 다시 API를 요청한다.
9.2 JWT 서비스 구현하기
의존성, 토큰 제공자 추가
리프레시 토큰 도메인과 토큰 필터 구현 → JWT 서비스 사용할 준비 완료
1. 의존성 추가하기
① build.gradle
implementation 'io.jsonwebtoken::jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
testImplementation 'org.projectlombok:lombok' // 자바 JWT 라이브러리
testAnnotationProcessor 'org.projectlombok:lombok' // XML 문서와 Java 객체 간 매핑을 자동화
2. 토큰 제공자 추가하기
① src/main/resources/application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
datasource:
url: jdbc:h2:mem:testdb
username: sa
h2:
console:
enabled: true
jwt:
issuer: ajufresh@gmail.com
secret_key: study-springboot
- JWT 토큰을 만드려면 이슈 발급자(issuer), 비밀키(secret_key)를 필수로 설정해야 함.
② src/main/java/me.cool/springbootdeveloper/config/jwt/JwtProperties.java
@Setter
@Getter
@Component
@ConfigurationProperties("jwt") // 자바 클래스에 프로피티값을 가져와서 사용하는 애너테이션
public class JwtProperties {
private String issuer;
private String secretKey;
}
③ src/main/java/me.cool/springbootdeveloper/config/jwt/TokenProvider.java
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generatedToken(User user, Duration expiredAt){
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// 1. JWT 토큰 생성 메서드
private String makeToken(Date expiry, User user){
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 typ : JWT
// 내용 iss : ajufresh@gmail.com (properties 파일에서 설정한 값)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now) // 내용 iat : 현재 시간
.setExpiration(expiry) // 내용 exp : expiry 멤버 변숫값
.setSubject(user.getEmail()) // 내용 sub : 유저의 이메일
.claim("id", user.getId()) // 클레임 id : 유저 ID
// 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
// 2. JWT 토큰 유효성 검증 메서드
public boolean vaildToken(String token){
try{
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 비밀값으로 복호화
.parseClaimsJws(token);
return true;
} catch (Exception e){ // 복호화 과정에서 에러가 나면 유효하지 않은 토큰
return false;
}
}
// 3. 토큰 기반으로 인증 정보를 가져오는 메서드
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);
}
// 4. 토큰 기반으로 유저 ID를 가져오는 메서드
public Long getUserId(String token){
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
public Claims getClaims(String token) {
return Jwts.parser() // 클레임 조회
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
}
1. JWT 토큰 생성 메서드
: 토큰을 생성하는 메서드이다. 인자는 만료 시간(Date expiry), 유저 정보(User user)를 받는다. 이 메서드에서는 set 계열의 메서드를 통해 여러 값을 지정한다. 헤더는 typ(타입), 내용은 iss(발급자), iat(발급일시), exp(만료일시), sub(토큰 제목)이, 클레임에는 유저 ID를 지정한다. 토큰을 만들 때는 프로퍼티즈 파일에 선언해둔 비밀값과 함께 HS256 방식으로 암호화한다.
2. JWT 토큰 유효성 검증 메서드
: 토큰이 유효한지 검증하는 메서드이다. 프로퍼티즈 파일에 선언한 비밀값과 함께 토큰 복호화를 진행한다. 만약 복호화 과정에서 에러가 발생하면 유효하지 않은 토큰이므로 false를 반환하고 아무 에러도 발생하지 않으면 true를 반환한다.
3. 토큰 기반으로 인증 정보를 가져오는 메서드
: 토큰을 받아 인증 정보를 담은 객체 Authentication를 반환하는 메서드이다. 프로퍼티즈 파일에 저장한 비밀 값으로 토큰을 복호화한 뒤 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받아 사용자 이메일이 들어 있는 토큰 제목 sub와 토큰 기반으로 인증 정보를 생성한다. 이때 UsernamePasswordAuthenticationToken의 첫 인자로 들어가는 User는 프로젝트에서 만든 User 클래스가 아닌, 스프링 시큐리티에서 제공하는 객체인 User 클래스를 임포트해야 한다.
4. 토큰 기반으로 유저 ID를 가져오는 메서드
: 토큰 기반으로 사용자 ID를 가져오는 메서드이다. 프로퍼티즈 파일에 저장한 비밀값으로 토큰을 복호화한 다음 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받고 클레임에서 id 키로 저장된 값을 가져와 반환한다.
- Jwt.parser() : 'Jwt' 클래스의 정적 메서드로, JWT를 파싱하기 위한 JwtParser 객체를 반환한다.
- setSigningKey(secretKey) : JwtParser 객체에 JWT를 검증하기 위한 서명 키를 설정한다. secretKey는 JWT를 서명할 때 사용되는 비밀 값입니다. 위에서는 jwtProperties.getSecretKey()
- parseClaimsJws(token): 설정된 서명 키를 사용하여 JWT 토큰을 파싱하고, 서명이 유효한지 확인한다. token은 파싱할 JWT 토큰이다.
- Collections.singleton()은 자바에서 제공하는 Collections 유틸리티 클래스의 정적 메서드 중 하나. 이 메서드는 주어진 객체로부터 단일 원소를 포함하는 불변(set) 컬렉션을 생성한다.(생성된 컬렉션 변경되지 않음, 즉 원소 추가, 제거 메서드 지원하지 않음)
- SimpleGrantedAuthority를 사용하여 "ROLE_USER"라는 하나의 권한을 가진 Set을 생성
- UsernamePasswordAuthenticationToken 생성자의 첫 번째 매개변수로 전달되는 User 객체에는 사용자 이름, 비밀번호, 권한이 포함되어 있다. 위의 예제에서는 비밀번호 정보가 없으므로 빈 문자열을 사용했다. → Authentication 객체 반환
④ 테스트 코드 작성
- src/main/test/config/jwt/JwtFactory.java
@Getter
public class JwtFactory {
private String subject = "test@email.com";
private Date issuedAt = new Date();
private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
private Map<String, Object> claims = emptyMap();
// 빌더 패턴을 사용해 설정이 필요한 데이터만 선택 설정
@Builder
public JwtFactory(String subject, Date issuedAt, Date expiration, Map<String, Object> claims) {
this.subject = subject != null ? subject : this.subject;
this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt;
this.expiration = expiration != null ? expiration : this.expiration;
this.claims = claims != null ? claims : this.claims;
}
public static JwtFactory withDefaultValues() {
return JwtFactory.builder().build();
}
// jjwt 라이브러리를 사용해 JWT 토큰 생성
public String createdToken(JwtProperties jwtProperties){
return Jwts.builder()
.setSubject(subject)
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(issuedAt)
.setExpiration(expiration)
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
}
- 빌더 패턴을 사용해 객체를 만들 때 테스트가 필요한 데이터만 선택함.
- 빌더 패턴을 사용하지 않으면 필드 기본값을 사용함.
⑤ src/main/test/java/me.cool/springbootdeveloper/config/jwt/TokenProviderTest.java
// 전체 코드
@SpringBootTest
class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
// 1. generateToken() 검증 테스트
@DisplayName("generateToken() : 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
@Test
void generateToken(){
// given
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
// when
String token = tokenProvider.generatedToken(testUser, Duration.ofDays(14));
// then
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
// 2. validToken() 검증 테스트
@DisplayName("validToken() : 만료된 토큰인 때에 유효성 검증에 실패한다.")
@Test
void validToken_invalidToken(){
// given
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createdToken(jwtProperties);
// when
boolean result = tokenProvider.validToken(token);
// then
assertThat(result).isFalse();
}
@DisplayName("validToken() : 유효한 토큰인 때에 유효성 검증에 성공한다.")
@Test
void validToken_validToken(){
// given
String token = JwtFactory.withDefaultValues()
.createdToken(jwtProperties);
// when
boolean result = tokenProvider.validToken(token);
// then
assertThat(result).isTrue();
}
// 3. getAuthentication() 검증 테스트
@DisplayName("getAuthentication() : 토큰 기반으로 인증 정보를 가져올 수 있다.")
@Test
void getAuthentication() {
// given
String userEmail = "user@email.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createdToken(jwtProperties);
// when
Authentication authentication = tokenProvider.getAuthentication(token);
// then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
@DisplayName("getUserId() : 토큰으로 유저 ID를 가져올 수 있다.")
@Test
void getUserId() {
// given
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createdToken(jwtProperties);
// when
Long userIdByToken = tokenProvider.getUserId(token);
// then
assertThat(userIdByToken).isEqualTo(userId);
}
}
1. generateToken() 메서드 : 토큰을 생성하는 메서드를 테스트하는 메서드
given : 토큰에 유저 정보를 추가하기 위한 테스트 유저를 만든다.
when : 토큰 제공자의 generateToken() 메서드를 호출해 토큰을 만든다.
then : jjwt 라이브러리를 사용해 토큰을 복호화한다. 토큰을 만들 때 클레임으로 넣어둔 id 값이 given절에서 만든 유저 ID와 동일한지 확인한다.
class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
// 1. generateToken() 검증 테스트
@DisplayName("generateToken() : 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
@Test
void generateToken(){
// given
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
// when
String token = tokenProvider.generatedToken(testUser, Duration.ofDays(14));
// then
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
}
2. validToken_invalidToken() : 토큰이 유효한 토큰인지 검증하는 메서드인 validToken() 메서드를 테스트하는 메서드
검증 실패를 확인하는 validToken_invalidToken() 메서드와 검증 성공을 확인하는 validToken_validToken() 메서드가 있음.
given : jjwt 라이브러리를 사용해 토큰을 생성한다. 이때 만료 시간은 1970년 1월 1일부터 현재 시간을 밀리초 단위로 치환한 값(new Date().getTime())에 1000을 빼, 이미 만료된 토큰으로 생성한다.
when : 토큰 제공자의 validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결괏값을 반환받는다.
then : 반환값이 false(유효한 토큰이 아님)인 것을 확인한다.
// 2. validToken() 검증 테스트
@DisplayName("validToken() : 만료된 토큰인 때에 유효성 검증에 실패한다.")
@Test
void validToken_invalidToken(){
// given
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createdToken(jwtProperties);
// when
boolean result = tokenProvider.validToken(token);
// then
assertThat(result).isFalse();
}
given : jjwt 라이브러리를 사용해 토큰을 생성한다. 만료 시간은 현재 시간으로부터 14일 뒤로, 만료되지 않은 토큰으로 생성한다.
when : 토큰 제공자의 validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결괏값을 반환받는다.
then : 반환값이 true(유효한 토큰임)인 것을 확인한다.
@DisplayName("validToken() : 유효한 토큰인 때에 유효성 검증에 성공한다.")
@Test
void validToken_validToken(){
// given
String token = JwtFactory.withDefaultValues()
.createdToken(jwtProperties);
// when
boolean result = tokenProvider.validToken(token);
// then
assertThat(result).isTrue();
}
3. getAuthentication() 메서드 : 토큰을 전달받아 인증 정보를 담은 객체 Authentication를 반환하는 메서드인 getAuthentication()를 테스트한다.
given : jjwt 라이브러리를 사용해 토큰을 생성한다. 이때 토큰의 제목인 subject는 "user@email.com"라는 값을 사용한다.
when : 토큰 제공자의 getAuthentication() 메서드를 호출해 인증 객체를 반환받는다.
then : 반환받은 인증 객체의 유저 이름을 가져와 given절에서 설정한 subject값인 "user@email.com"과 같은지 확인한다.
// 3. getAuthentication() 검증 테스트
@DisplayName("getAuthentication() : 토큰 기반으로 인증 정보를 가져올 수 있다.")
@Test
void getAuthentication() {
// given
String userEmail = "user@email.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createdToken(jwtProperties);
// when
Authentication authentication = tokenProvider.getAuthentication(token);
// then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
4. getUserId() 메서드 : 토큰 기반으로 유저 ID를 가져오는 메서드를 테스트하는 메서드
토큰을 프로퍼티즈 파일에 저장한 비밀값으로 복호화한 뒤 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받아 클레임에서 id 키로 저장된 값을 가져와 반환한다.
given : jjwt 라이브러리를 사용해 토큰을 생성한다. 이때 클레임을 추가한다. 키는 "id", 값은 1이라는 유저 ID이다.
when : 토큰 제공자의 getUserId() 메서드를 호출해 유저 ID를 반환받는다.
then : 반환받은 유저 ID가 given절에서 설정한 유저 ID값인 1과 같은지 확인한다.
@DisplayName("getUserId() : 토큰으로 유저 ID를 가져올 수 있다.")
@Test
void getUserId() {
// given
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createdToken(jwtProperties);
// when
Long userIdByToken = tokenProvider.getUserId(token);
// then
assertThat(userIdByToken).isEqualTo(userId);
}
3. 리프레시 토큰 도메인 구현하기
리프레시 토큰은 데이터베이스에 저장하는 정보이므로 엔티티와 리포지터리를 추가해야 한다.
- 만들 엔티티와 매핑되는 테이블 구조
| 컬럼명 | 자료형 | null 허용 | 키 | 설명 |
| id | BIGINT | N | 기본키 | 일련번호, 기본키 |
| user_id | BIGINT | N | 유저 ID | |
| refresh_token | VARCHAR(255) | N | 토큰값 |
① src/main/java/me.cool/springbootdeveloper/domain/RefreshToken.java
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "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;
}
}
② src/main/java/me.cool/springbootdeveloper/repository/RefreshTokenRepository.java
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
4. 토큰 필터 구현하기
- 필터는 실제로 각종 요청이 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다.
- 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효 토큰이라면 시큐리티 콘텍스트 홀더(security context holder)에 인증 정보를 저장한다.
- 시큐리티 컨텍스트 : 인증 객체가 저장되는 보관소. 여기서 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내 사용할 수 있다. 이 클래스는 스레드마다 공간을 할당하는, 즉 스레트 로컬(thread local)에 저장되므로 코드의 아무 곳에서나 코드의 아무 곳에서나 참조할 수 있고, 다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다.
- 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더

① src/main/java/me.cool/springbootdeveloper/config/TokenAuthenticationFilter.java
@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);
// 가져온 값에서 접두사 제거
String token = getAccessToken(authorizationHeader);
// 가져온 토큰이 유효한지 확인하고, 유효한 때는 인증 정보를 설정
if (tokenProvider.validToken(token)){
Authentication authentication= tokenProvider.getAuthentication(token);
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;
}
}
- 요청 헤더에서 키가 'Authorization'인 필드의 값을 가져온 다음 토큰의 접두사 Bearer를 제외한 값을 얻음.
- 만약 값이 null 이거나 Bearer로 시작하지 않으면 null을 반환함.
- 가져온 토큰이 유효한지 확인하고, 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정함.
- 위에서 작성한 코드가 실행되며 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthentication() 메서드를 사용해 인증 정보를 가져오면 유저 객체가 반환됨.
- 유저 객체에는 유저 이름(username), 권한 목록(authorities)과 같은 인증 정보가 포함됨.
9.3 토큰 API 구현하기
- 리프레시 토큰을 전달받아 검증하고, 유효한 리프레시 토큰이라면 새로운 액세스 토큰을 생성하는 토큰 API 구현
→ 토큰 서비스, 컨트롤러 구현
1. 토큰 서비스 추가하기
- 리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스 생성
① src/main/java/me.cool/springbootdeveloper/service/UserService.java에 findById() 메서드 추가
public User findById(Long userId){
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
- findById() : 전달받은 유저 ID로 유저를 검색해서 전달하는 메서드
② src/main/java/me.cool/springbootdeveloper/service/RefreshTokenService.java
@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken(String refreshToken){
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
}
}
- findByRefreshToken() : 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해서 전달하는 메서드
③ src/main/java/me.cool/springbootdeveloper/service/TokenService.java
@RequiredArgsConstructor
@Service
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserService userService;
public String createNewAccessToken(String refreshToken){
// 토큰 유효성 검사에 실패하면 예외 발생
if(!tokenProvider.validToken(refreshToken)){
throw new IllegalStateException("Unexpected token");
}
Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userService.findById(userId);
return tokenProvider.generatedToken(user, Duration.ofHours(2));
}
}
- createNewAccessToken() : 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰인 때 리프레시 토큰으로 사용자 ID를 찾는다. 마지막으로는 사용자 iD로 사용자를 찾은 후에 토큰 제공자의 generateToken() 메서드를 호출해서 새로운 액세스 토큰을 생성한다.
2. 컨트롤러 추가하기
- 실제로 토큰을 발급받는 API 생성
①
- src/main/java/me.cool/springbootdeveloper/dto/CreateAccessTokenRequest.java
// 토큰 생성 요청 담당
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
- src/main/java/me.cool/springbootdeveloper/dto/CreateAccessTokenResponse.java
// 토큰 생성 응답 담당
@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
private String accessToken;
}
② src/main/java/me.cool/springbootdeveloper/controller/TokenApiController.java
- 실제로 요청을 받고 처리할 컨트롤러
- /api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어줌.
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken
(@RequestBody CreateAccessTokenRequest request){
String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new CreateAccessTokenResponse(newAccessToken));
}
}
③ src/test/me.cool/springbootdeveloper/controller/TokenApiControllerTest.java
- createNewAccessToken() 메서드(토큰 생성 메서드) 테스트
given : 테스트 유저를 생성하고, jjwt 라이브러리를 이용해 리프레시 토큰을 만들어 데이터베이스에 저장한다. 토큰 생성 API의 요청 본문에 리프레시 토큰을 포함하여 요청 객체를 생성한다.
when : 토큰 추가 API에 요청을 보낸다. 이때 요청 타입은 JSON이며, given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보낸다.
then : 응답코드가 201 Created인지 확인하고 응답으로 온 액세스 토큰이 비어있지 않은지 확인한다.
@SpringBootTest
@AutoConfigureMockMvc
class TokenApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
JwtProperties jwtProperties;
@Autowired
UserRepository userRepository;
@Autowired
RefreshTokenRepository refreshTokenRepository;
@BeforeEach
public void mockMvcSetUp(){
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
userRepository.deleteAll();
}
@DisplayName("createNewAccessToken(): 새로운 액세스 토큰을 발급한다.")
@Test
public void createNewAccessToken() throws Exception{
// given
final String url = "/api/token";
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
String refreshToken = JwtFactory.builder()
.claims(Map.of("id", testUser.getId()))
.build()
.createdToken(jwtProperties);
refreshTokenRepository.save(new RefreshToken(testUser.getId(),
refreshToken));
CreateAccessTokenRequest request = new CreateAccessTokenRequest();
request.setRefreshToken(refreshToken);
final String requestBody = objectMapper.writeValueAsString(request);
// when
ResultActions resultActions = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
// then
resultActions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
}
'Backend > <스프링 부트 3 백엔드 개발자 되기>' 카테고리의 다른 글
| 08장 스프링 시큐리티로 로그인/로그아웃, 회원 가입 구현하기 (0) | 2023.11.03 |
|---|---|
| 06장 블로그 기획하고 API 만들기 (0) | 2023.11.02 |
| 05장 데이터베이스 조작이 편해지는 ORM (3) | 2023.11.01 |
| 04장 스프링 부트 3와 테스트 (1) | 2023.10.31 |
| 03장 스프링 부트 3 구조 이해하기 (0) | 2023.10.29 |