1. JWT란?
Jwt란
: Json Web Token의 준말로, 사용자가 로그인 요청을 보낼 때마다 매번 유저 검증을 위해 DB를 조회하지 않고, Token 안의 값으로 유저를 검증할 때 사용되며 양 당사자 간에 정보를 안전하게 전송하기 위한 웹 표준 (RFC 7519)이다.
2. JWT의 구조
- JWT는 Header.Payload.Signature 형태로 되어있다.
토큰 예시) 아래 링크에서 확인이 가능하다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header : JWT의 유형과 해싱 알고리즘을 표현
- Payload : 이 토큰의 정보(사용자 정보, 발행인, 만기일, 권한 등등)
* 그래서 만약 사용자의 정보를 토큰에 담는다고 하면 이 Payload부분에 저장되는 것이다.
- Signature : 헤더와 페이로드를 기반으로 서명이 만들어진다.
서명을 통해 공격자가 JWT의 내용을 변조해도, 올바른 서명을 생성할 수 없기 때문에 JWT의 안전성이 보장된다.
3. JWT의 장단점
[장점]
- 서버 부하 감소 : 앞서 말했듯이 서버는 토큰만을 검증하면 되므로, 데이터베이스 조회가 필요 없고, 확장성에 유리
- 클라이언트 측 저장 : 클라이언트가 토큰을 저장하고 활용하므로, 서버 상태를 유지할 필요가 없음
[단점]
- 토큰 크기 : 페이로드에 많은 정보를 담으면 토큰의 크기가 커질 수 있어, 전송 효율이 떨어질 수 있음
- 보안 우려 : 토큰이 탈취될 경우 만료 시점까지는 악용될 수 있으므로, HTTPS와 같은 보안 통신 채널이 필수!
3. JWT로 AccessToken 발급하기
JWT 공부할 때 같이 이해해야 하는 것
👉 SpringSecurity 동작원리 (https://ye-ryung.tistory.com/75)
SpringBoot에서 로그인 기능 구현 시 SpringSecurity를 통해 토큰 인증 및 권한 부여 처리를 하므로 위 내용도 알고 가야한다.
위 내용의 한 줄 요약
👉 Authentication정보는 결국 SecurityContextHolder 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장, 우리는 결국 그 과정에서 유저의 정보를 꺼내 활용할 수 있는 것임
이제 로그인 시 토큰을 발급하는 과정이다. (회원가입이 되었다는 전제 하에)
코드 작성 전 build.gralde에 springSecurity와 jwt 관련 의존성을 추가한다.
// springSecurity
implementation 'org.springframework.boot:spring-boot-starter-security'
// jwt
implementation 'com.auth0:java-jwt:3.18.2'
> UserController
...
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@RequestMapping(value = "", method = RequestMethod.POST)
public ResponseEntity<String> signup(@Validated @RequestBody UserCreateRequest userCreateRequest) {
userService.signup(userCreateRequest);
return ResponseEntity.status(201).body("회원가입이 완료되었습니다.");
}
@RequestMapping(value = "/login", method = RequestMethod.POST)
public ResponseEntity<String> signin(@Validated @RequestBody LoginRequest loginRequest) {
HttpHeaders httpHeaders = userService.signin(loginRequest);
return ResponseEntity.status(HttpStatus.OK).headers(httpHeaders).body(null);
}
}
> UserServiceImpl.java (필자는 UserService interface class를 생성해 상속 받아 활용했다.)
...
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserAuth userAuth;
/**
* @param userCreateRequest 계정명, 비밀번호, 위도, 경도를 등록
*/
@Override
@Transactional
public void signup(UserCreateRequest userCreateRequest) {
usernameDuplicateCheck(userCreateRequest.getUsername());
User user = User.builder()
.username(userCreateRequest.getUsername())
.password(passwordEncoder.encode(userCreateRequest.getPassword()))
.lon(userCreateRequest.getLon())
.lat(userCreateRequest.getLat())
.build();
userRepository.save(user);
}
/**
* @param loginRequest 계정명, 비밀번호
* @return 헤더에 토큰을 담아 반환
*/
@Override
@Transactional
public HttpHeaders signin(LoginRequest loginRequest) {
User user = userCheck(loginRequest.getUsername());
return userAuth.generateHeaderTokens(user);
}
/**
* @param username 계정명
*/
private void usernameDuplicateCheck(String username) {
if (userRepository.findByUsername(username).isPresent())
throw new CustomException(ErrorCode.USERNAME_DUPLICATION);
}
/**
* @param username 계정명
* @return 회원 객체 반환
*/
private User userCheck(String username) {
return userRepository.findByUsername(username).orElseThrow(
() -> new CustomException(ErrorCode.USER_NOT_EXIST)
);
}
}
> UserAuthImpl.java
...
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class UserAuthImpl implements UserAuth {
private final JwtTokenUtils jwtTokenUtils;
private final RefreshTokenManager refreshTokenManager;
/**
* JwtToken 생성합니다.
*
* @param user 유저 객체
* @return 해당 유저의 accessToken 반환합니다.
*/
@Override
public String getToken(User user) {
return jwtTokenUtils.generateJwtToken(user.getUsername());
}
/**
* RefreshToken을 생성하고 Redis에 저장합니다.
*
* @param user 유저 객체
* @return 해당 유저의 refreshToken을 반환합니다.
*/
@Override
public String saveRefreshTokenToRedis(User user){
return refreshTokenManager.saveRefreshToken(user.getUsername());
}
/**
* 유저의 헤더에 accessToken과 refreshToken을 담아 반환합니다.
*
* @param user 유저 객체
* @return 토큰이 담긴 헤더 반환합니다.
*/
@Override
public HttpHeaders generateHeaderTokens(User user) {
HttpHeaders headers = new HttpHeaders(); // 여기서 HttpHeaders 객체를 생성
String accessToken = getToken(user);
String refreshToken = saveRefreshTokenToRedis(user);
headers.set(HttpHeaders.AUTHORIZATION,"Bearer " + accessToken);
headers.set("RefreshToken", refreshToken);
return headers;
}
}
💡 여기서 중요한 점
accessToken != jwtToken 이다.
필자의 경우 accessToken으로 jwtToken 인증 방식을 선택한 것이지 둘이 같은 것은 아니다.
> JwtTokenUtils.java
JWT Secret key는 리눅스 (MacOs -> 터미널 창, WindowOs -> Git bash) 창에 아래 명령어를 입력하면 발급받을 수 있다.
openssl rand -hex 64
...
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JwtTokenUtils {
private static final int DAY = 24 * 60 * 60;
// JWT 토큰의 유효기간: 3일 (단위: milliseconds)
private static final int JWT_TOKEN_VALID_MILLI_SEC = 3 * DAY * 1000;
public static final String CLAIM_USERNAME = "USERNAME";
@Value("${jwt.secretkey}")
String JWT_SECRET;
/**
* JwtToken을 생성합니다.
* 생성된 토큰에는 payload, 토큰 만료일, 그리고 signature가 포함됩니다.
*
* @param username 유저 계정명
* @return 해당 유저의 accessToken을 반환합니다.
*/
public String generateJwtToken(String username) {
String token;
token = JWT.create()
.withPayload(createClaims(username))
.withExpiresAt(new Date(System.currentTimeMillis() + JWT_TOKEN_VALID_MILLI_SEC))
.sign(generateAlgorithm(JWT_SECRET));
return token;
}
/**
* 유저 계정명을 포함하는 claims 맵을 생성합니다.
*
* @param username 유저 계정명
* @return 유저 계정명이 포함된 key-value 형식의 claims 맵을 반환
*/
private Map<String, Object> createClaims(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_USERNAME, username);
return claims;
}
/**
* HMAC256 형식의 알고리즘을 생성합니다.
* 생성된 알고리즘은 JWT의 서명(Signature) 생성에 사용됩니다.
*
* @param secretKey JWT 서명에 사용할 secret key
* @return HMAC256 알고리즘을 반환합니다.
*/
private static Algorithm generateAlgorithm(String secretKey) {
return Algorithm.HMAC256(secretKey.getBytes());
}
}
+ 추가 사항 (필자의 경우 RefreshToken도 생성하여 함께 발급되도록 하였다.)
> RefreshTokenManager.java (RefreshToken 생성 및 저장하는 클래스)
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
import java.util.Base64;
@Component
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenManager {
private final static int TOKEN_LENGTH = 32; // Refresh Token의 길이를 설정
private final SecureRandom secureRandom; // 난수 생성을 위한 SecureRandom 인스턴스
private final RefreshTokenRepository refreshTokenRepository; // RefreshToken을 저장할 Repository
/**
* RefreshToken을 생성합니다.
* TOKEN_LENGTH 길이의 난수를 생성하고, 이를 Base64 URL 형식으로 인코딩하여 반환합니다.
* 작성자 : 오예령
*
* @return 생성된 RefreshToken 반환합니다.
*/
public String refreshTokenGenerator() {
byte[] randomBytes = new byte[TOKEN_LENGTH];
secureRandom.nextBytes(randomBytes);
// 생성된 난수를 Base64로 인코딩하여 Refresh Token 생성
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
/**
* 유저의 계정명으로 생성된 RefreshToken을 저장합니다.
*
* 유저 계정명을 기반으로 새로운 Refresh Token을 생성하고,
* 해당 토큰이 이미 존재하지 않는 경우 데이터베이스에 저장합니다.
*
* 작성자 : 오예령
*
* @param username 유저 계정명
* @return 저장된 RefreshToken을 반환합니다.
*/
public String saveRefreshToken(final String username) {
// 새로운 Refresh Token을 생성
String refreshToken = refreshTokenGenerator();
log.info("generated RefreshToken = {} ", refreshToken);
// 생성된 토큰이 이미 존재하는지 확인
if (refreshTokenRepository.findByUsername(refreshToken).isPresent()) {
// 토큰이 이미 존재하면, 새로운 토큰을 재귀적으로 생성
return saveRefreshToken(username);
} else {
// 토큰이 존재하지 않는 경우 해당 유저의 이름으로 새 토큰을 발급해 저장
refreshTokenRepository.save(new RefreshToken(refreshToken, username));
}
// 최종적으로 저장된 RefreshToken을 반환
return refreshToken;
}
}
다음 게시물에서는 발급된 토큰을 검증하는 방식을 알아보자!
참고자료
'Spring & SpringBoot' 카테고리의 다른 글
[Java/Springboot] OpenAPI를 활용해 수집한 데이터 저장하기_(2) (5) | 2024.08.31 |
---|---|
[Java/Springboot] OpenAPI를 활용해 데이터 수집하기_(1) (0) | 2024.08.31 |
Java/SpringBoot 게시판 기능 구현_Querydsl을 활용한 카테고리별 조회, 필터링 (0) | 2023.10.19 |
Java/SpringBoot 게시판 기능 구현_Token 관련 예외 처리 (0) | 2023.07.05 |
SpringBoot 게시판_이미지 대표이미지 출력 (0) | 2023.05.12 |