WEB/Spring

Spring boot + JWT + RefreshToken 구현하기

 

아주 기본적인 코드까지 모두 있습니다.. 왜냐면... 저도 기술 블로그들을 보면서 빠져있는 부분을 보면 나같은 초보는 어쩌라고 하며 슬퍼했기 때문에,,,

 

사실 Security의 여러가지 Filter를 사용하여 JWT 방식을 이용하는게 맞지만 

프로젝트 크기,, 기간상 불필요하다 판단하게 되어서 JWT 만을 사용하여 구현하였다. 나중에 Security도 같이 구현하여 올리도록 하겠음!

이번에는 git에 올려 코드까지 공유하기로 했다! 처음보면 약간 복잡하다고 생각되고 나또한 그랬다 ㅠㅠ,, (부끄러운) 구현코드를 보면서 이해하고 혹시 이상한곳은 PR해주시면 감사하겠습니다...(간절,,) 

 

git 주소 (실무에서 실제로 사용하면서 수정하고 있습니다 ~!)

git clone https://github.com/aejeong-context/tokenTest

 

 

API 방식 이기 때문에 Server만 구현했습니다!

제가 구현 한 방식은 이렇습니다!

1. 유저가 회원가입을 할때 해당 유저의 아이디로 Token을 생성해서 AccessToken, RefreshToken을 생성한다. 

2. 이 때 생성한 RefreshToken을 DB에 저장한다.

3. 사용자가 AccessToken을 이용하여 로그인을 하고 그 token으로 여러 요청의 대한 리소스를 제공받는다.

4. AccessToken은 30분으로 정했다. 30분 후에 어떠한 요청을 할 경우 다시 로그인을 해서 RefreshToken의 Token과 유효시간도 확인하고 시간이 자났다면 재발급 해주고 AccessToken도 재발급 받는다. (이때 프론트에서 로그인요청을 다시 하겠죠?)

-> 여기서 고민인게 Interceptor에서 요청마다 확인을 해서 로그인을 안시키도록 해야할 지, 사용성을 위해 다시 로그인 시키는게 맞는지 고민이다.... 

5.재발급 받은 AccessToken으로 리소스를 얻는다.

 

이렇게 되면 session관리를 해줄 필요 없이 요청마다 Token을 확인 해주면 된다!

 


0. 패키지 구성

 

1. build.gradle 의존성 추가하기

implementation 'io.jsonwebtoken:jjwt:0.9.1'

2. UserEntitiy.class 정하기

Test용이라서 userId, pw로 나누었지만 이부분은 변경하셔도 됩니다 email이라던가... 

package com.token.domains.users.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import javax.persistence.*;

@Getter
@RequiredArgsConstructor
@Table(name = "users")
@Entity
public class UsersEntity {

  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Id
  private Long id;

  private String userId;

  private String pw;

  @Builder
  public UsersEntity(String userId, String pw) {
    this.userId = userId;
    this.pw = pw;
  }
}

 

3. UserRepository.intrerface

Optional은 Java8문법인데 null처리에 아주 유용하니 한번씩 사용해보시길 추천드립니다!

우선 작성하고 사용할 때 설명을 해보겠습니다

public interface UsersRepository extends JpaRepository<UsersEntity,Long> {
    Optional<UsersEntity> findByUserIdAndPw(String userId,String pw);
    Optional<UsersEntity> findByUserId(String userId);
}

 

4.UserController.class

사실 이름을 이딴식으로 지으면 혼납니다. 이건 Test이기 때문에 ^^...

/signUp, /signIn은 회원가입, 로그인이구요 Test로 AccessToken이 유효해야만 리소스를 받을 수 있는 /info가 있습니다.

이 모든 요청들은 Interceptor가 먼저 처리되고 들어가기 때문에 여기서 딱히 해야하는건 없습니다.

@RequiredArgsConstructor
@RestController
public class UserController {

  private final UserService userService;

  @PostMapping("/user/signUp")
  public ResponseEntity signUp(@RequestBody UserRequest userRequest) {
    return userService.findByUserId(userRequest.getUserId()).isPresent()
        ? ResponseEntity.badRequest().build()
        : ResponseEntity.ok(userService.signUp(userRequest));
  }

  @PostMapping("/user/signIn")
  public ResponseEntity<TokenResponse> signIn(@RequestBody UserRequest userRequest) {

    return ResponseEntity.ok().body(userService.signIn(userRequest));
  }

  @GetMapping("/info")
  public ResponseEntity<List<UsersEntity>> findUser() {
    return ResponseEntity.ok().body(userService.findUsers());
  }
}

 

5. AuthEntity.class

토큰을 관리해줄 테이블입니다. N:1로 단반향 맵핑 해주었습니다. 사실 양반향을 해주어도 상관없지만 우선 User가 있는지 확인 후 Token을 확인하는 플로우가 맞지 않나 싶어서 단반향으로 설정해주었습니다. 

 

- refreshUpdate 메서드는 DB에 저장하고, 사용하는 refreshToken이 유효시간이 만료되었을 때 DB에 업데이트 되는 기능입니다.

@Getter
@RequiredArgsConstructor
@Table(name = "auth")
@Entity
public class AuthEntity {

  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Id
  private Long id;

  private String refreshToken;

  @ManyToOne
  @JoinColumn(name = "user_id")
  private UsersEntity usersEntity;

  @Builder
  public AuthEntity(String refreshToken, UsersEntity usersEntity) {
    this.refreshToken = refreshToken;
    this.usersEntity = usersEntity;
  }
  public void refreshUpdate(String refreshToken) {
    this.refreshToken = refreshToken;
  }
}

 

6. AuthRepository.inrerface 

public interface AuthRepository extends JpaRepository<AuthEntity, Long> {

  Optional<AuthEntity> findByUsersEntityId(Long userId);
}

 

7. WebMvcConfig.class

TokenInterceptor를 구현해주기 위해 원하는 경로 를 추가해줍니다.

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
  private final JwtTokenInterceptor jwtTokenInterceptor;

  public void addInterceptors(InterceptorRegistry registry) {
    System.out.println("인터셉터 등록");
    registry.addInterceptor(jwtTokenInterceptor).addPathPatterns("/info");
  }
}

 /info만 해서 test할 예정이기 때문에 저렇게 해놨지만.

보통 회원가입, 메인 페이지 조회 등 은 회원이 아니어도 볼 수 있기 때문에 실제로 구현하실 때는 아래와 같이 설정해주시면 됩니다.

registry.addInterceptor(jwtTokenInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/signUp")

 

8. JwtTokenInterceptor.class

하..log를 사용해야하는데 아직도 syso가 익숙한 바람에 System 범벅입니다,, 이해부탁,,

아까 /info를 추가 해줬으니 API로 /info에 대한 리소스를 요청할때 마다 이 클래스가 실행됩니다.

 

request.getheader("ACCESS_TOKEN") 은 클라이언트가 ACCESS_TOKEN이라는 key값으로 회원가입 때 생성하여 보관하던 token을 보내주면 그 value값을 가져와서 null인지 확인합니다. null이 아닐경우 isValidToken에서 해당 token이 서버에서 생성한 token인지, 유효기간이 지났는지 확인할텐데요! 밑에서 확인해봅시다!

 

@Component
@RequiredArgsConstructor
public class JwtTokenInterceptor implements HandlerInterceptor {

  private final TokenUtils tokenUtils;

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
      throws IOException {

    System.out.println("JwtToken 호출");
    String accessToken = request.getHeader("ACCESS_TOKEN");
    System.out.println("AccessToken:" + accessToken);
    String refreshToken = request.getHeader("REFRESH_TOKEN");
    System.out.println("RefreshToken:" + refreshToken);

    if (accessToken != null) {
      if (tokenUtils.isValidToken(accessToken)) {
        return true;
      }
    }
    response.setStatus(401);
    response.setHeader("ACCESS_TOKEN", accessToken);
    response.setHeader("REFRESH_TOKEN", refreshToken);
    response.setHeader("msg", "Check the tokens.");
    return false;
  }

 

9. TokenUtils.class

대망의 Token 생성 클래스입니다,,전체 소스입니다.

@RequiredArgsConstructor
@Service
public class TokenUtils {

  private final String SECRET_KEY = "secretKey";
  private final String REFRESH_KEY = "refreshKey";
  private final String DATA_KEY = "userId";

  public String generateJwtToken(UsersEntity usersEntity) {
    return Jwts.builder()
        .setSubject(usersEntity.getUserId())
        .setHeader(createHeader())
        .setClaims(createClaims(usersEntity))
        .setExpiration(createExpireDate(1000 * 60 * 5))
        .signWith(SignatureAlgorithm.HS256, createSigningKey(SECRET_KEY))
        .compact();
  }

  public String saveRefreshToken(UsersEntity usersEntity) {
    return Jwts.builder()
        .setSubject(usersEntity.getUserId())
        .setHeader(createHeader())
        .setClaims(createClaims(usersEntity))
        .setExpiration(createExpireDate(1000 * 60 * 10))
        .signWith(SignatureAlgorithm.HS256, createSigningKey(REFRESH_KEY))
        .compact();
  }



  public boolean isValidToken(String token) {
    System.out.println("isValidToken is : " +token);
    try {
      Claims accessClaims = getClaimsFormToken(token);
      System.out.println("Access expireTime: " + accessClaims.getExpiration());
      System.out.println("Access userId: " + accessClaims.get("userId"));
      return true;
    } catch (ExpiredJwtException exception) {
      System.out.println("Token Expired UserID : " + exception.getClaims().getSubject());
      return false;
    } catch (JwtException exception) {
      System.out.println("Token Tampered");
      return false;
    } catch (NullPointerException exception) {
      System.out.println("Token is null");
      return false;
    }
  }
  public boolean isValidRefreshToken(String token) {
    try {
      Claims accessClaims = getClaimsToken(token);
      System.out.println("Access expireTime: " + accessClaims.getExpiration());
      System.out.println("Access userId: " + accessClaims.get("userId"));
      return true;
    } catch (ExpiredJwtException exception) {
      System.out.println("Token Expired UserID : " + exception.getClaims().getSubject());
      return false;
    } catch (JwtException exception) {
      System.out.println("Token Tampered");
      return false;
    } catch (NullPointerException exception) {
      System.out.println("Token is null");
      return false;
    }
  }


  private Date createExpireDate(long expireDate) {
    long curTime = System.currentTimeMillis();
    return new Date(curTime + expireDate);
  }

  private Map<String, Object> createHeader() {
    Map<String, Object> header = new HashMap<>();

    header.put("typ", "ACCESS_TOKEN");
    header.put("alg", "HS256");
    header.put("regDate", System.currentTimeMillis());

    return header;
  }

  private Map<String, Object> createClaims(UsersEntity usersEntity) {
    Map<String, Object> claims = new HashMap<>();
    claims.put(DATA_KEY, usersEntity.getUserId());
    return claims;
  }

  private Key createSigningKey(String key) {
    byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(key);
    return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
  }

  private Claims getClaimsFormToken(String token) {
    return Jwts.parser()
        .setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY))
        .parseClaimsJws(token)
        .getBody();
  }
  private Claims getClaimsToken(String token) {
    return Jwts.parser()
            .setSigningKey(DatatypeConverter.parseBase64Binary(REFRESH_KEY))
            .parseClaimsJws(token)
            .getBody();
  }
  
 }

 

private final String SECRET_KEY = "secretKey";
private final String REFRESH_KEY = "refreshKey";
private final String DATA_KEY = "userId";

위 변수는 token을 만들 때 사용할 암호입니다. DATA_KEY는 어떤 내용으로 token을 만들어지 정해줍니다.

이부분은 JWT이론을 자세히 공부해봐야 하는데요 간단히 설명하자면 아래와 같이 Payload에 들어갈 내용이 DATA_KEY입니다. 저는 userId만 설정할 예정이기 때문에 6. 을 보시면 아시겠지만 userEntity에서 userId만을 가지고와서 claims.put을 해줍니다.

물론 여러개도 사용할 수 있습니다. 하지만 token은 요청마다 header에 붙어 다니기 때문에 만약 탈취당한다면 put해준 모든 정보들이 노출됩니다,,

 

그리고 token길이도 길어집니다! (상관없나..) 

 

https://research.securitum.com/jwt-json-web-token-security/

 

1.generateJwtToken

AceessToken 생성

 

2.saveRefreshToken

ResfeshToken 생성

 

3.isValidToken, isValidRefreshToken

해당 Token 의 유효성 확인

 

4. createExpireDate

유효시간 설정

 

5.  createHeader

Token 생성시 Header 부분을 저정해준다

 

6. createClaims

Token 생성시 Payload 부분을 정해줍니준다

 

7.createSigningKey

해당 key로 암호화

 

8.getClaimsFormToken,getClaimsToken

유효성 검색을 위해 token 정보를 읽어온다

 

Token의 유효성 확인과 Token 정보를 읽어오는 3,8번은 중복코드가 발생한다... 이건 나중에 다시 보고 수정해야 할 것 같다 ㅜㅜ,,

 

 

10. UserService.class

@Service
@RequiredArgsConstructor
public class UserService {
  private final UsersRepository usersRepository;
  private final TokenUtils tokenUtils;
  private final AuthRepository authRepository;

  public Optional<UsersEntity> findByUserId(String userId) {

    return usersRepository.findByUserId(userId);
  }

  @Transactional
  public TokenResponse signUp(UserRequest userRequest) {
    UsersEntity usersEntity =
        usersRepository.save(
            UsersEntity.builder()
                .pw(userRequest.getUserPw())
                .userId(userRequest.getUserId())
                .build());

    String accessToken = tokenUtils.generateJwtToken(usersEntity);
    String refreshToken = tokenUtils.saveRefreshToken(usersEntity);

    authRepository.save(
        AuthEntity.builder().usersEntity(usersEntity).refreshToken(refreshToken).build());

    return TokenResponse.builder().ACCESS_TOKEN(accessToken).REFRESH_TOKEN(refreshToken).build();
  }

  @Transactional
  public TokenResponse signIn(UserRequest userRequest) {
    UsersEntity usersEntity =
        usersRepository
            .findByUserIdAndPw(userRequest.getUserId(), userRequest.getUserPw())
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
    AuthEntity authEntity =
        authRepository
            .findByUsersEntityId(usersEntity.getId())
            .orElseThrow(() -> new IllegalArgumentException("Token 이 존재하지 않습니다."));
    String accessToken = "";
    String refreshToken= authEntity.getRefreshToken();

    if (tokenUtils.isValidRefreshToken(refreshToken)) {
      accessToken = tokenUtils.generateJwtToken(authEntity.getUsersEntity());
      return TokenResponse.builder()
          .ACCESS_TOKEN(accessToken)
          .REFRESH_TOKEN(authEntity.getRefreshToken())
          .build();
    } else {
      refreshToken = tokenUtils.saveRefreshToken(usersEntity);
      authEntity.refreshUpdate(refreshToken);
    }

    return TokenResponse.builder().ACCESS_TOKEN(accessToken).REFRESH_TOKEN(refreshToken).build();
  }

  public List<UsersEntity> findUsers() {
    return usersRepository.findAll();
  }
}

Service는 처음에 말했던 플로우와 같다! 회원가입시 ACCESS_TOKEN과 REFRESH_TOKEN을 발급하고

리소스 요청을 하다가 연결이 끊어질 경우 로그인을 다시 시키는데 이때 refreshToken의 유효성 검사를 해준 다음에 통과되면 AccessToken을 재발급해준다! 만약 refreshToken이 유효하지 않다면 refreshToken도 업데이트 해줍니다!

 

솔직히 이 로직이 맞는지 잘 모르겠네요,, 우선 POSTMAN으로 작동이 잘되니 넘어가겠숩니다!

 


회원가입 TEST

 

 

회원가입 후 해당 AccessToken으로 요청하여 리소스를 받는 TEST

 

재 로그인 시 AccessToken 재발급, RefreshToken 유효성 확인 

 

 

부족한 설명이나 틀린 부분 댓글 남겨주시면 감사드립니다!