-
[TIL] JWT로 인증 인가 구현하기TIL(Today I Learned) 2023. 1. 5. 12:11
*20230105의 회고
Naver API 를 이용하여 관심상품을 검색하고, 저장하고, 조회하는 서비스를 개발하던 중 인증/인가에 대한 부분을 JWT 로 해결하였다. 그 과정을 기록하려 한다.
1. 문제점
- 웹 통신을 하는데, 클라이언트의 정보를 서버가 모두 가지고 있어야 할까? 그렇다면 클라이언트의 정보가 많아지면, 서버의 부하가 많이 걸리지 않을까?
- Http 통신은 비연결성, 무상태로 이루어 지는데, 클라이언트에 유저 정보를 저장한다고 치면, 어떤 서비스를 요청할 때 마다 서버로 id와 password 를 보내줘야 할까?
2. 해결 방법과 알게된 점
- JWT 를 이용하여 인증 인가 구현을 한다.
- JWT 를 API 요청 시 마다 Header에 포함해서 요청한다.
- 서버는 secret key 를 통해 JWT 위조 여부를 검증하고, 토큰을 이용하여 유저인지 아닌지 판단하여 데이터를 응답해준다.
- <JwtUtil 클래스 - 토큰 생성, 검증>
@Slf4j @Component @RequiredArgsConstructor public class JwtUtil { public static final String AUTHORIZATION_HEADER = "Authorization"; // 헤더에 들어가는 authorization 과 bearer 부분의 키값 public static final String AUTHORIZATION_KEY = "auth"; // 사용자 권한 값의 키, 사용자 권한도 토큰안에 값을 넣어줄 건데 그거를 가지고 올 때 사용되는 키 값 private static final String BEARER_PREFIX = "Bearer "; // 토큰을 만들 때 같이 앞에 붙어서 들어가는 부분 private static final long TOKEN_TIME = 60 * 60 * 1000L; // 토큰 만료 시간에 사용할 시간 -> 1시간 @Value("${jwt.secret.key}") // @Value : 어노테이션이 필드나 메서드(혹은 생성자)의 파라미터 수준에서 표현식 기반으로 값을 주입해 줌 private String secretKey; private Key key; // 토큰을 만들 때 넣어줄 키 값 , 여기서는 secretKey를 넣어줄거임(secretKey를 디코드해서) private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; //HS256 이라는 알고리즘 으로 암호화 할거임 @PostConstruct // 처음에 객체가 생성될 때 초기화 하는 함수, 결론적으로 secretKey 를 디코드 해서 key 에 넣어주는 함수 public void init() { byte[] bytes = Base64.getDecoder().decode(secretKey); // secretKey 가 base64로 인코딩이 되어 있기 때문에, 디코드를 하고 값을 넣어줌.(반환 값은 byte 배열임) key = Keys.hmacShaKeyFor(bytes); // secretKey를 디코드 한 결과를 key 에 넣어줌. } // header 토큰을 가져오기 public String resolveToken(HttpServletRequest request) { // HttpServletRequest 객체 안에는 우리가 가져와야 할 토큰이 Header에 들어있음.(HttpServletRequest 안에는 사용자의 요청에 대한 모든 정보가 담겨 있다) String bearerToken = request.getHeader(AUTHORIZATION_HEADER); // request 안에 있는 header 값을 가져옴, 파라미터로 어떤 키를 가지고 올지 넣어줌. if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { // 토큰이 존재하는지, 혹은 bearer로 시작하는지 체크한 후에 return bearerToken.substring(7); // 'bearer' 지우고 나머지 토큰 반환해줌(토큰에 연관이 되지 않는 그냥 string 은 빼고 반환) } return null; } // 토큰 생성 public String createToken(String username, UserRoleEnum role) { // JWT를 만들어 주는 메서드 Date date = new Date(); return BEARER_PREFIX + Jwts.builder() .setSubject(username) .claim(AUTHORIZATION_KEY, role) .setExpiration(new Date(date.getTime() + TOKEN_TIME)) .setIssuedAt(date) .signWith(key, signatureAlgorithm) .compact(); // 위의 과정을 통해 username, AUTHORIZATION_KEY, role, 현재시간, TOKEN_TIME, key, signatureAlgorithm 정보를 이용한 String 형식의 JWT 토큰이 만들어져 반환됨 } // 토큰 검증 public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // 검증할 토큰을 받아서 넣어주고, 우리가 만든 key 값을 넣어서 검증을 해주는 메서드 return true; } catch (SecurityException | MalformedJwtException e) { log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다."); } catch (ExpiredJwtException e) { log.info("Expired JWT token, 만료된 JWT token 입니다."); } catch (UnsupportedJwtException e) { log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다."); } catch (IllegalArgumentException e) { log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다."); } return false; } // 토큰에서 사용자 정보 가져오기 public Claims getUserInfoFromToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); //validateToken 부분에서 getBody 를 추가해서 정보를 가져오는 메서드, 검증이 되었다고 가정해서 try,catch 문 없음 } }
- <ProductController>
@RestController @RequestMapping("/api") @RequiredArgsConstructor public class ProductController { private final ProductService productService; // 관심 상품 등록하기 @PostMapping("/products") public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, HttpServletRequest request) { // 응답 보내기 return productService.createProduct(requestDto, request); } // 관심 상품 조회하기 @GetMapping("/products") public List<ProductResponseDto> getProducts(HttpServletRequest request) { return productService.getProducts(request); } // 관심 상품 최저가 등록하기 @PutMapping("/products/{id}") public Long updateProduct(@PathVariable Long id, @RequestBody ProductMypriceRequestDto requestDto, HttpServletRequest request) { // 응답 보내기 (업데이트된 상품 id) return productService.updateProduct(id, requestDto, request); } } // HttpServletRequest 객체로 클라이언트 요청을 받아온다. -> request 헤더에 JWT 토큰 받아올 수 있음
- <UserController> 의 login 부분 -> HttpServletResponse 객체에 생성된 JWT 를 넣어서 응답값으로 클라이언트에게 보내줌
@ResponseBody @PostMapping("/login") // http 요청에 body 쪽에 ajax로 데이터가 들어가기 때문에 @RequestBody 붙여줘야 함 // 서버에서 클라이언트 쪽으로 데이터를 반환할 때 HttpServletResponse 객체 사용 public String login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) { // 데이터를 반환할 response 객체에 Header 부분에 우리가 만든 Token 을 넣어 주기 위해서 http~객체 사용 userService.login(loginRequestDto, response); return "success"; }
- <ProductService> 의 관심상품 등록 로직 부분, 이런식으로 JWT 를 검증해서 로직을 구현하면 된다.
@Transactional public ProductResponseDto createProduct(ProductRequestDto requestDto, HttpServletRequest request) { // Request 의 Header 에서 Token 가져오기 String token = jwtUtil.resolveToken(request); Claims claims; // JWT 안에 들어있는 담을 수 있는 객체 정도로 이해하기 // 토큰이 있는 경우에만 관심상품 추가 가능 if (token != null) { if (jwtUtil.validateToken(token)) { // 토큰에서 사용자 정보 가져와서 claims 객체에 넣어주기 claims = jwtUtil.getUserInfoFromToken(token); } else { throw new IllegalArgumentException("Token Error"); } // 토큰에서 가져온 사용자 정보를 사용하여 DB 조회 User user = userRepository.findByUsername(claims.getSubject()).orElseThrow( // claims.getSubject() 하면 user 의 이름을 가져올 수 있음 () -> new IllegalArgumentException("사용자가 존재하지 않습니다.") ); // 요청받은 DTO 로 DB에 저장할 객체 만들기 Product product = productRepository.saveAndFlush(new Product(requestDto, user.getId())); return new ProductResponseDto(product); } else { // 토큰이 없으면 null 반환 return null; } }
'TIL(Today I Learned)' 카테고리의 다른 글
[TIL] Spring Security 기본 개념 익히기 (0) 2023.01.09 [TIL] 세번째 프로젝트를 마치며... KPT 회고 (0) 2023.01.06 [TIL] @PathVariable의 변수명 차이로 인한 오류 (2) 2023.01.02 [TIL] 오늘의 회고... (4) 2022.12.30 [TIL] AOP 에 대하여 (0) 2022.12.28