Spring & SpringBoot

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

예령 : ) 2024. 9. 1. 11:54

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