[RestAPI 개발기] 8. JWT

편준민's avatar
May 09, 2025
[RestAPI 개발기] 8. JWT

1️⃣ 해시

1. 해시 해주는 라이브러리

implementation group: 'org.mindrot', name: 'jbcrypt', version: '0.4'

2. 해시 테스트

💡
해당 비밀번호만 해시를 하게되면, 똑같은 비밀번호로 해시를 해본 사람은 해당 해시값을 보고 상대방의 비밀번호를 알 수 있다. 그래서 salt(난수)를 함께 붙여서 해시를 하면 상대방은 알아 볼 수 없다.
@Test public void encode_test() { // $2a$10$z0vF.yjNdfk13sbwrQFs7uM1o8WRysh8TjucDNqe1d8tjvacivBfu // $2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC String password = "1234"; String encPassword = BCrypt.hashpw(password, BCrypt.gensalt()); System.out.println(encPassword); } @Test public void decode_test() { // $2a$10$z0vF.yjNdfk13sbwrQFs7uM1o8WRysh8TjucDNqe1d8tjvacivBfu // $2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC String dbpassword = "$2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC"; String password = "1234"; String encPassword = BCrypt.hashpw(password, BCrypt.gensalt()); if (encPassword.equals(password)) { System.out.println("비밀번호가 같아요"); } else { System.out.println("비밀번호가 달라요"); } } @Test public void decodev2_test() { // $2a$10$z0vF.yjNdfk13sbwrQFs7uM1o8WRysh8TjucDNqe1d8tjvacivBfu // $2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC String dbpassword = "$2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC"; String password = "1234"; Boolean isSame = BCrypt.checkpw(password, dbpassword); System.out.println(isSame); }

3. 실제 프로젝트에 적용

💡
User 회원가입
// RestAPI 규칙1 : insert 요청 시에 그 행을 DTO에 담아서 리턴한다. @Transactional public UserResponse.DTO 회원가입(UserRequest.JoinDTO reqDTO) { try { String encPassword = BCrypt.hashpw((reqDTO.getPassword()), BCrypt.gensalt()); reqDTO.setPassword(encPassword); User userPS = userRepository.save(reqDTO.toEntity()); return new UserResponse.DTO(userPS); } catch (Exception e) { throw new Exception400("잘못된 요청입니다"); } }
notion image
notion image

2️⃣ JWT

1. JWT 라이브러리

implementation group: 'com.auth0', name: 'java-jwt', version: '4.3.0'

2. JWT 테스트

💡

JWT 생성

JWT를 본 프로젝트에 적용하기 전에 테스트 코드로 미리 실행해보기. 미리 회원가입이 되어있는 User를 빌더로 꺼내옴. 이때 패스워드는 해시값이 DB에 저장되어 있기 때문에 해시값을 넣어야한다.

JWT를 생성 할 때에는 3가지가 적혀있어야한다.
  • header : 암호화 방식
  • PayLoad : 암호화 할 내용 (필수 사항으로는 만료 시간이 있다.)
  • sign : 전자서명의 값

header에서는 암호화 방식을 적어야한다. 해당 코드에서는 .sign(Algorithm.HMAC256); 방법을 사용하였다.HMAC256은 단방향 (해시) 방식으로 복호화를 할 수 없다.
PayLoad에 해당 하는 부분은 .withSubject .withExpiresAt .withClaim .withClaim 이다. subject는 보통 프로젝트명을 적는다. ExpiresAt은 만료시간을 적는다. 해당 코드에서는 1시간으로 설정하였다. Claim은 유저의 정보 등을 적는다. 단 민감한 정보, 예시로는 비밀번호 등은 적으면 안된다. 현실에서 종이에 정보를 적고 다니는 것과 같기 때문에 민감한 정보를 적으면 모두가 볼 수 있다.
signheader의 값과 PayLoad의 값을 가지고 Base64로 변환한 값을 저장한다. 이후 나중에 요청이 또 들어올 경우에는 headerPayLoad 값을 가지고 Base64로 변환 후 sign에 값과 같다면 인증완료 이다.
하지만 이렇게 2개의 값만 가지고 인증을 하게 되면 누군가는 값을 변조해서 sign에 덮어씌워도 인증이 된다. 그런 것을 막기 위해 추가로 인증을 하는 사람만 알 수 있는 값을 추가하여 총 3개의 값을 Base64로 변환하여 sign에 값과 같으면 인증완료가 되게 해야한다. 3번째 값을 우리는 Secret 이라고 한다. 해당 설정을 코드에서는 암호화 방식 다음 (””)하여서 적었다.
PayLoad의 값을 변조하고 와도 Secret을 알고 있는 것이 아니면 인증이 불가능하다
notion image
// 토큰 생성 @Test public void create_test() { User user = User.builder() .id(1) .username("ssar") .password("$2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC") .email("ssar@nate.com") .createdAt(Timestamp.valueOf(LocalDateTime.now())) .build(); String jwt = JWT.create() .withSubject("blogv3") // with 4가지는 payload에 들어감 .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1초 * 60 * 60 .withClaim("id", user.getId()) .withClaim("username", user.getUsername()) .sign(Algorithm.HMAC256("metacoding")); // header : HMAC256 (단방향 해시) // 198 156 236 87 42 53 186 254 56 151 169 7 107 178 5 197 147 172 56 100 145 97 133 14 17 46 135 193 73 199 201 144 // xpzsVyo1uv44l6kHa7IFxZOsOGSRYYUOES6HwUnHyZA System.out.println(jwt); }
해당 코드를 실행 할 경우 아래와 같은 로그가 콘솔에 나오는데 이것이 JWT이다. JWT 홈페이지로 가서 인코딩을 해볼 경우 우리가 적은 값이 그대로 나오는 것을 볼 수 있다.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJibG9ndjMiLCJpZCI6MSwiZXhwIjoxNzQ2NzcyNjA5LCJ1c2VybmFtZSI6InNzYXIifQ.4IPhr58Nh9XkZ9HoGtD2FL2oZurn_wrjO8kU90fRM2U
notion image

JWT 검증하기

JWT 라이브러리에서 제공해주는 함수들을 이용하면 검증도 할 수 있다.
위에서 만든 JWT를 변수에 저장하여 디코딩을 하였고, 함수들을 이용하여 JWT 내부에 어떤 데이터가 있는지도 확인이 가능하다. 그것을 마지막에 builder를 사용하여 user에게 다시 넘겨 주었다.
// 토큰 검증 @Test public void verify_test() { // 2025.05.09.11:50분 까지 유효 String jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJibG9ndjMiLCJpZCI6MSwiZXhwIjoxNzQ2NzU4OTk4LCJ1c2VybmFtZSI6InNzYXIifQ.Ifjed8Sv0VG6GU3LG1pEstuDR4Rvcw_y5f5YSzl7Xlg"; DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256("metacoding")).build().verify(jwt); // 토큰 내에 데이터 확인 Integer id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getClaim("username").asString(); System.out.println(id); System.out.println(username); User user = User.builder() .id(id) .username(username) .build(); }
토큰 내에 데이터가 잘 나오는 모습
토큰 내에 데이터가 잘 나오는 모습

  • JWT가 달라서 검증이 되지 않은 모습
notion image
 
  • 만료 시간이 다 되어서 검증이 되지 않는 모습
notion image

3. 실제 프로젝트에 적용

💡

1. JwtUtil 만들기

테스트 코드를 생성과 검증이 잘 되는 것을 확인 하였으니, Util 파일에 JwtUtil이라는 파일로 분리하여 필요한 곳에서 꺼내서 쓰겠다.
public class JwtUtil { public static String create(User user) { String jwt = JWT.create() .withSubject("blog") .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) .withClaim("id", user.getId()) .withClaim("username", user.getUsername()) .sign(Algorithm.HMAC512("metacoding")); return jwt; } public static User verify(String jwt) { DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("metacoding")).build().verify(jwt); int id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getClaim("username").asString(); return User.builder() .id(id) .username(username) .build(); } }

2. Service

User가 로그인에 성공을 하게 되면 Token을 생성하여서 DTO에 담아서 Controller에게 넘겨준다
public UserResponse.TokenDTO 로그인(UserRequest.LoginDTO loginDTO) { User userPS = userRepository.findByUsername(loginDTO.getUsername()) .orElseThrow(() -> new ExceptionApi401("유저네임 혹은 비밀번호가 틀렸습니다")); Boolean isSame = BCrypt.checkpw(loginDTO.getPassword(), userPS.getPassword()); if (!isSame) throw new ExceptionApi401("유저네임 혹은 비밀번호가 틀렸습니다"); // 토큰 생성 String ascssToken = JwtUtil.create(userPS); return UserResponse.TokenDTO.builder().ascssToken(ascssToken).build(); }
  • 실제 DTO는 총 2개의 데이터를 담을 수 있지만, refreshToken은 잠시 사용하지 않겠다.
@Data public static class TokenDTO { private String ascssToken; private String refreshToken; @Builder public TokenDTO(String ascssToken, String refreshToken) { this.ascssToken = ascssToken; this.refreshToken = refreshToken; } }
  • Controller에서 DTO를 받았기 때문에 TokenDTO 타입으로 받아줬다.
@PostMapping("/login") // login은 민간함 정보가 들어가야하기때문에 Get 말고 Post public @ResponseBody Resp<?> login(@Valid @RequestBody UserRequest.LoginDTO loginDTO, Errors errors, HttpServletResponse response) { UserResponse.TokenDTO respDTO = userService.로그인(loginDTO); return Resp.ok(respDTO); }

3. Filter에 토큰 인증 구현

토큰을 인증 할 때에는 interceptor와 Filter 2가지 중 어느 곳에서든 할 수 있다. 하지만 interceptor로 처리를 하게 되면 DS가 실행이 되기 때문에 컴퓨터의 속도가 늦쳐질 수 있다. 어차피 인증을 해야하는 거라면 가능한 빨리 맨 앞에서 인증을 해주는 것이 효율적임으로 Filter에서 인증을 하였다.
  • AuthorizationFilter
토큰이 없을 때와, 토큰의 기간이 만료되었을 때, 토큰의 검증에 실패 했을 때를 각각 예외처리 하였다. 토큰을 확인 할 때에 프로토콜이 하나있다. 토큰의 값 앞에 Bearer 을 붙이는 것이다. 예시로 Bearer [토큰값] 을 적으면 된다.
public class AuthorizationFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String accessToken = request.getHeader("Authorization"); try { if (accessToken == null || accessToken.isBlank()) throw new RuntimeException("토큰을 전달해주세요"); if (!accessToken.startsWith("Bearer ")) throw new RuntimeException("Bearer 프로토콜 지켜주세요"); accessToken = accessToken.replace("Bearer ", ""); User user = JwtUtil.verify(accessToken); // 토큰을 다시 검증하기 귀찮아서, 임시로 세션에 넣어둠 HttpSession session = request.getSession(); session.setAttribute("sessionUser", user); chain.doFilter(request, response); } catch (TokenExpiredException e1) { e1.printStackTrace(); exResponse(response, "토큰이 만료되었습니다"); } catch (JWTDecodeException | SignatureVerificationException e2) { e2.printStackTrace(); exResponse(response, "토큰 검증에 실패했어요"); } catch (RuntimeException e3) { e3.printStackTrace(); exResponse(response, e3.getMessage()); } } private void exResponse(HttpServletResponse response, String msg) throws IOException { response.setContentType("application/json;charset=utf-8"); response.setStatus(401); PrintWriter out = response.getWriter(); Resp<?> resp = Resp.fail(401, msg); String responseBody = new ObjectMapper().writeValueAsString(resp); out.println(responseBody); } }
  • FilterConfig
기본적으로 토큰을 검증하는 필터는 URL이 /s로 시작하기 때문에 “/s/*” 을 사용하였다. 로그인 후에 토큰을 검증하기 때문에 순서도 2번째로 만들었다.
@RequiredArgsConstructor @Configuration public class FilterConfig { private final UserRepository userRepository; @Bean public FilterRegistrationBean<AuthorizationFilter> authorizationFilter() { FilterRegistrationBean<AuthorizationFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new AuthorizationFilter()); registrationBean.addUrlPatterns("/s/*"); // 모든 요청에 적용 registrationBean.setOrder(2); // 필터 순서 설정 return registrationBean; } @Bean public FilterRegistrationBean<LogFilter> loggingFilter() { FilterRegistrationBean<LogFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new LogFilter(userRepository)); registrationBean.addUrlPatterns("/*"); // 모든 요청에 적용 registrationBean.setOrder(1); // 필터 순서 설정 return registrationBean; } }
 
  • 프로젝트를 실행하여 POSTMAN을 이용하여 정상 작동하는지 확인
notion image
 
 
 
notion image
notion image
 
 
 
 
 
 
 
 
 
 
Share article

YunSeolAn