9. JWT로 로그인/로그아웃
9.1 토큰 기반 인증
사용자가 서버에 접근할 때 이 사용자가 인증된 사용자인지 확인하는 방법은 다양합니다. 대표적으로 서버 기반 인증과 토큰 기반 인증이 있습니다. 스프링 시큐리티에서는 기본적으로 세션 기반 인증을 제공합니다.
토큰 기반 인증은 토큰을 사용하는 방법입니다. 토큰은 서버에서 클라이언트를 구분하기 위한 유일한 값인데 서버가 토큰을 생성해서 클라이언트에게 제공하면 클라이언트는 이 토큰을 갖고 있다가 여러 요청을 이 토큰과 함께 신청합니다.
토큰 기반 인증은 무상태성, 확장성, 무결성이라는 특징이 있습니다.
무상태성
사용자의 인증 정보가 담겨있는 토큰이 서버가 아닌 클라이언트에 있으므로 서버에 저장할 필요가 없습니다. 클라이언트에서는 사용자의 인증 상태를 유지하면서 이후 요청을 처리해야 하는데 이것을 상태를 관리한다고 합니다. 이렇게 하면 서버 입장에서는 클라이언트의 인증 정보를 저장하거나 유지하지 않아도 되기 때문에 완전한 무상태로 효율적인 검증을 할 수 있습니다.
확장성
무상태성은 확장성에 영향을 줍니다. 서버를 확장할 떄 상태 관리를 신경 쓸 필요가 없으니 서버 확장에도 용이한 것입니다. 추가로 페이스북, 구글 로그인과 같이 토큰 기반 인증을 사용하는 다른 시스템에 접근해 로그인 방식을 확장할 수도 있고 이를 활용해 다른 서비스에 권한을 공유할 수도 있습니다.
무결성
토큰 방식은 HMAC(hash-based message authentication) 기법이라고도 부릅니다. 토큰을 발급한 이후에는 토큰 정보를 변경하는 행위를 할 수 없습니다. 즉 토큰의 무결성이 보장됩니다.
JWT
발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 키값에 Bearer + JWT 토큰값을 넣어 보내야 합니다.
JWT의 구조는 헤더, 내용, 서명으로 이루어져 있습니다.
헤더
토큰의 타입과 해싱 알고리즘을 지정하는 정보를 담습니다.
{
"typ": "JWT",
"alg": "HS256"
}
JWT 토큰, HS256 해싱 알고리즘을 사용한다는 내용입니다.
typ는 토큰의 타입을 지정합니다. JWT라는 문자열이 들어갑니다.
alg는 해싱 알고리즘을 지정합니다.
내용
토큰과 관련된 정보를 담습니다. 내용의 한 덩어리를 클레임이라고 부르며 키값의 한 쌍으로 이루어져있습니다. 클레임은 등록된 클레임, 공개 클레임, 비공개 클레임으로 나눌 수 있습니다.
등록된 클레임은 토큰에 대한 정보를 담는데 사용합니다.
이름 | 설명 |
iss | 토큰 발급자(issuer) |
sub | 토큰 제목(subject) |
aud | 토큰 대상자(audience) |
exp | 토큰의 만료시간. 시간은 NumericDate 형식으로 하며 항상 현재 시간 이후로 설정합니다. |
nbf | 토큰의 활성화 날짜와 비슷한 개념으로 nbf는 Not Before을 의미합니다. NumericDate 형식으로 날짜를 지정하며 이 날짜가 지나기전까지는 토큰이 처리되지 않습니다. |
iat | 토큰이 발급된 시간으로 iat은 issued at을 의미합니다. |
jti | JWT의 고유 식별자로서 주로 일회용 토큰에 사용합니다. |
공개 클레임은 공개되어도 상관없는 클레임을 의미합니다. 충돌을 방지할 수 있는 이름을 가져야 하며 보통 클레임 이름을 URL로 짓습니다.
비공개 클레임은 공개되면 안되는 클레임을 의미합니다. 클라이언트와 서버 간의 통신에 사용됩니다.
{
"iss": "ajufresh@gmail.com", // 등록된 클레임
"iat": 1622370878, // 등록된 클레임
"exp": 1622372678, // 등록된 클레임
"https://shunsunyoung.com.jwt_claims/is_admin": true // 공개 클레임
"email": "ajufresh@gmail.com" // 비공개 클레임
"hello": "안녕하세요" // 비공개 클레임
}
iss, iat, exp는 JWT자체에서 등록된 클레임이고 URL로 네이밍된 https://는 공개 클레임이고 나머지는 비공개 클레임입니다.
서명
해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용되며 헤더의 인코딩값과 내용의 인코딩값을 합친후에 주어진 비밀키를 사용해 해시값을 생성합니다.
9.2 JWT 서비스 구현하기
JwtProperties.java
@Setter
@Getter
@Component
@ConfigurationProperties("jwt")
public class JwtProperties {
private String secretKey;
private String issuer;
}
TokenProvider.java
@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);
}
// (1) JWT토큰 생성 메서드
private String makeToken(Date expiry, User user) {
Date now= new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now)
.setExpiration(expiry)
.setSubject(user.getEmail())
.claim("id",user.getId())
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
// (2) JWT 토큰 유효성 검사 메서드
public boolean validateToken(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);
}
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
}
(1) 토큰을 생성하는 메서드입니다. 인자는 만료시간, 유저 정보를 받습니다. set메서드를 통해 값을 저장합니다.
(2) 토큰이 유효한지 검증하는 메서드입니다. 프로퍼티즈 파일에 선언한 비밀값과 함께 토큰 복호화를 진행합니다.
(3) 토큰을 받아 인증 정보를 담은 객체 Authentication을 반환하는 메서드입니다.
(4) 토큰 기반으로 사용자 ID를 가져오는 메서드입니다. 프로퍼티즈 파일에 저장한 비밀값으로 토큰을 복호화한 다음 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받고 클레임에서 id키로 저장된 값을 가져와 반환합니다.
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 createToken(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();
}
}
코드가 제대로 동작하는지 확인하기 위한 코드입니다.
TokenProviderTest.java
@SpringBootTest
public class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
//generateToken() 검증
@DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
@Test
void generateToken() {
//given
User testUser=userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
//when
String token= tokenProvider.generateToken(testUser, Duration.ofDays(14));
//then
Long userId= Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id",Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
//validToken() 검증
@DisplayName("validToken(): 만료된 토큰인 때에 유효성 검증에 실패한다")
@Test
void validToken() {
//given
String token= JwtFactory.builder()
.expiration(new Date(new Date().getTime()- Duration.ofDays(7).toMillis())).build().createToken(jwtProperties);
//when
boolean result= tokenProvider.validateToken(token);
//then
assertThat(result).isFalse();
}
//getAuthentication() 검증
@DisplayName("getAuthentication(): 토큰 기반으로 인증 정보를 가져올 수 있다.")
@Test
void getAuthentication() {
//given
String userEmail="user@gmail.com";
String token= JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
//when
Authentication authentication = tokenProvider.getAuthentication(token);
//then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
//get UserId() 검증
@DisplayName("getUserId(): 토큰으로 유저 ID를 가져올 수 있다")
@Test
void getUserId() {
//given
Long userId=1L;
String token=JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createToken(jwtProperties);
//when
Long userIdByToken = tokenProvider.getUserId(token);
//then
assertThat(userIdByToken).isEqualTo(userId);
}
}
generateToken()
given: 토큰에 유저 정보를 추가하기 위한 테스트 유저 생성
when: 토큰 제공자의 generateToken() 메서드를 호출해 토큰 생성
then: jjwt 라이브러리를 사용해 토큰을 복호화. 토큰을 만들 때 클레임으로 넣어둔 id값이 given절에서 만든 유저 id와 동일한지 확인
validToken_invaildToken
givne: jjwt 라이브러리를 사용해 토큰을 생성. 만료된 토큰으로 생성.
when: 토큰 제공자의 validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결괏값을 반환.
then: 반환값이 false인 것을 확인.
givne: jjwt 라이브러리를 사용해 토큰을 생성. 만료되지 않은 토큰으로 생성.
when: 토큰 제공자의 validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결괏값을 반환.
then: 반환값이 true인 것을 확인.
getAuthentication()
givne: jjwt 라이브러리를 사용해 토큰을 생성.
when: 토큰 제공자의 getAuthentication() 메서드를 호출해 인증 객체를 반환.
then: 반환받은 인증 객체의 유저 이름을 가져와 given절에서 설정한 subject값과 같은지 확인
getUserId()
given: jjwt 라이브러리를 사용해 토큰을 생성
when: 토큰 제공자의 getUserId() 메서드를 호출해 유저 ID를 반환.
then: 반환받은 유저 ID가 given절에서 설정한 유저 ID값과 같은지 확인
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;
}
}
RefreshTokenRepository.java
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
토큰 필터 구현하기
필터: 실제로 각종 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공. 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효한 토큰이라면 시큐리티 콘텍스트 홀더에 인증 정보를 저장.
시큐리티 컨텍스트는 인증 객체가 저장되는 보관소입니다. 인증 정보가 필요할 때 꺼내 사용할 수 있습니다. 스레드마다 공간을 할당하는 스레드 로컬에 저장되므로 코드 아무 곳에서나 참조할 수 있고 다른 스레드와 공유하지 않아 독립적으로 사용할 수 있습니다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더입니다.
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= getAccesToken(authorizationHeader);
//가져온 토큰이 유효한지 확인하고 유효하면 인증 정보 설정
if (tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getAccesToken(String authorizationHeader) {
if(authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
return null;
}
}
이 필터는 엑세스 토큰이 담긴 Authorization 헤더값을 가져온 뒤 엑세스 토큰이 유효하다면 인증 정보를 설정합니다.
토큰 API 구현
UserService.java
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
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 메서드 구현
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.validateToken(refreshToken)) {
throw new IllegalArgumentException("Unexpected token");
}
Long userId= refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userService.findById(userId);
return tokenProvider.generateToken(user, Duration.ofHours(2));
}
}
createNewAccessToken() 메서드는 전달받은 리프레시 토큰으로 유효성 검사를 하고 유효한 토큰일 때 리프레시 토큰으로 사용자 ID를 찾습니다.
사용자 ID로 사용자를 찾은 후에 토큰 제공자의 generateToken() 메서드를 호출해서 새로운 액세스 토큰을 생성합니다.
토큰 생성 요청 및 응답을 담당할 DTO인 CreateAccessTokenRequest와 CreateAccessTokenResponse를 작성합니다.
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
private String accessToken;
}
TokenApiController.java
@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));
}
}
/api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어줍니다.
TokenApiControllerTest.java
@SpringBootTest
@AutoConfigureMockMvc
public 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()
.createToken(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());
}
}
given: 테스트 유저를 생성하고 jjwt 라이브러리를 이용해 리프레시 토큰을 만들어 데이터베이스에 저장합니다. 토큰 생성 API의 요청 본문에 리프레시 토큰을 포함하여 요청 객체를 생성합니다.
when: 토큰 추가 API에 요청을 보냅니다. 이때 요청 타입은 JSON이며 given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보냅니다.
then: 응답 코드가 201 Created인지 확인하고 응답으로 온 액세스 토큰이 비어있지 않은지 확인합니다.
요약
1. 토큰 기반 인증은 인증에 토큰을 사용하는 방식입니다. 토큰은 클라이언트를 구분하는 데 사용하는 유일한 값으로서 서버에서 생성해서 클라이언트에게 제공한 뒤 클라이언트는 서버에 요청할 때마다 요청 내용과 함께 전송합니다. 서버에서는 토큰으로 유효한 사용자인지 검증합니다.
2. JWT는 토큰 기반 인증에서 주로 사용하는 토큰입니다. JSON 형식으로 사용자의 정보를 저장합니다. 헤더, 내용, 서명 구조로 이루어져 있으며 헤더는 토큰의 타입과 해싱 알고리즘을 포함하고 정보에는 토큰에 담을 정보가 들어갑니다. 서명은 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용합니다.
3. 필터는 실제로 요청이 전달되기 전과 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공합니다.
4. 서큐리티 콘텍스트는 인증 객체가 저장되는 보관소로 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내어 사용하도록 제공되는 클래스입니다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더입니다.