[Java/SpringBoot] 로그인 시 JWT 인증토큰, RefreshToken 발행하기

2024. 9. 1. 11:54· Spring & SpringBoot
목차
  1. 1. JWT란?
  2. 2. JWT의 구조
  3. 3. JWT의 장단점
  4. 3. JWT로 AccessToken 발급하기

1. JWT란?

Jwt란
: Json Web Token의 준말로, 사용자가 로그인 요청을 보낼 때마다 매번 유저 검증을 위해 DB를 조회하지 않고, Token 안의 값으로 유저를 검증할 때 사용되며 양 당사자 간에 정보를 안전하게 전송하기 위한 웹 표준 (RFC 7519)이다.

2. JWT의 구조

 

- JWT는 Header.Payload.Signature 형태로 되어있다.

 

토큰 예시) 아래 링크에서 확인이 가능하다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

👉  https://jwt.io/

 

- 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; ​​​​} }

 

다음 게시물에서는 발급된 토큰을 검증하는 방식을 알아보자!


참고자료

- What is a JWT? Understanding JSON Web Tokens

'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
  1. 1. JWT란?
  2. 2. JWT의 구조
  3. 3. JWT의 장단점
  4. 3. JWT로 AccessToken 발급하기
'Spring & SpringBoot' 카테고리의 다른 글
  • [Java/Springboot] OpenAPI를 활용해 수집한 데이터 저장하기_(2)
  • [Java/Springboot] OpenAPI를 활용해 데이터 수집하기_(1)
  • Java/SpringBoot 게시판 기능 구현_Querydsl을 활용한 카테고리별 조회, 필터링
  • Java/SpringBoot 게시판 기능 구현_Token 관련 예외 처리
예령 : )
예령 : )
개발 공부를 하며 기록하고 있는 일기장입니다. 꾸준히 기록할 예정이니 많은 관심 부탁드립니다! :)
예령 : )
예령's 개발기록
예령 : )
전체
오늘
어제
  • 분류 전체보기 (98)
    • Github (2)
    • Java (1)
    • Spring & SpringBoot (10)
    • Server (8)
      • docker (6)
    • 개발 (6)
      • 자료구조 (2)
    • 스파르타코딩클럽 (66)
      • 웹개발의 봄, Spring (1)
      • 프로그래머스_Java_알고리즘 기초 (18)
      • 항해99 (39)
    • TIL (4)
    • Tech Conference (1)
    • AWS (0)

인기 글

최근 글

hELLO · Designed By 정상우.v4.2.0
예령 : )
[Java/SpringBoot] 로그인 시 JWT 인증토큰, RefreshToken 발행하기
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.