[스프링 부트] 37. JWT 인증

KangHo Lee's avatar
Jan 09, 2025
[스프링 부트] 37. JWT 인증
💡
JWT (JSON Web Token)은 클라이언트와 서버 간에 정보를 안전하게 전송하기 위해 사용되는 컴팩트하고 자가 포함적인 방식의 토큰입니다.

1. 라이브러리 세팅

dependencies { implementation group: 'ch.simas.qlrm', name: 'qlrm', version: '1.7.1' implementation group: 'com.auth0', name: 'java-jwt', version: '4.3.0' implementation 'org.springframework.boot:spring-boot-starter-validation' runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' }
  • 스프링 부트 프로젝트 생성 시 라이브러리 설정
    • Lombok, Spring Boot DevTools, Spring Data JPA, H2 Database, MySQL driver
  • 따로 dependencies에 추가
implementation group: 'ch.simas.qlrm', name: 'qlrm', version: '1.7.1' implementation group: 'com.auth0', name: 'java-jwt', version: '4.3.0' implementation 'org.springframework.boot:spring-boot-starter-validation'

2. application.properties

spring.profiles.active=dev
  • application-dev.properties 을 실행
server.port=8080 server.servlet.encoding.force=true server.servlet.encoding.charset=utf-8 spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= # localhost:8080/h2-console spring.h2.console.enabled=true spring.jpa.hibernate.ddl-auto=create spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.open-in-view=false # lazy-loading test spring.jackson.serialization.fail-on-empty-beans=false # dummy spring.jpa.defer-datasource-initialization=true spring.sql.init.data-locations=classpath:db/data.sql # in-query setting spring.jpa.properties.hibernate.default_batch_fetch_size=100 # log logging.level.com.metacoding.restserver=DEBUG logging.level.org.hibernate.type=TRACE # 로컬에서 테스트 할 때 적용, JwtUtil.java에서 사용할 파일 var.jwt.secret=metacoding
  • 배포용 application-prod.properties 예시
server.port=8081 server.servlet.encoding.force=true server.servlet.encoding.charset=utf-8 # RDS_ 로 설정된 변수는 노출 안되게 환경 변수로 설정 spring.datasource.url=jdbc:mysql://${RDS_HOST}:${RDS_PORT}/${RDS_DBNAME} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.username=${RDS_USERNAME} spring.datasource.password=${RDS_PASSWORD} # 실제 배포할 때는 DB를 내가 직접 받아야 함 spring.jpa.hibernate.ddl-auto=none # spring.jpa.show-sql=true 로그 안 뜨게 해야 함 -> 리소스 잡아먹음 spring.jpa.open-in-view=false # lazy-loading test spring.jackson.serialization.fail-on-empty-beans=false # in-query setting spring.jpa.properties.hibernate.default_batch_fetch_size=100 # log logging.level.com.metacoding.restserver=INFO logging.level.org.hibernate.type=INFO # 배포한 jar 실행 시 시스템 환경 변수에서 이 값을 찾아옴 var.jwt.secret=${JWT_SECRET}

3. FilterConfig

@RequiredArgsConstructor @Configuration public class FilterConfig { private final JwtUtil jwtUtil; @Bean public FilterRegistrationBean<JwtAuthorizationFilter> jwtAuthorizationFilter() { FilterRegistrationBean<JwtAuthorizationFilter> bean = new FilterRegistrationBean<>(new JwtAuthorizationFilter(jwtUtil)); bean.addUrlPatterns("/api/*"); // 인증 필요한 곳은 다 /api를 추가시키는 게 편하다. bean.setOrder(1); // 0번인 CorsFilter랑 겹치지 않게 return bean; } @Bean public FilterRegistrationBean<CorsFilter> corsFilter() { System.out.println("debug: CorsFilter 등록됨~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter()); bean.addUrlPatterns("/*"); // * 하나만 써야됨. bean.setOrder(0); // 낮은 번호부터 실행됨. -> CORS 부터 실행 return bean; } }

4. JwtAuthorizationFilter

@RequiredArgsConstructor public class JwtAuthorizationFilter implements Filter { private final JwtUtil jwtUtil; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { System.out.println("debug: JwtAuthorizationFilter 작동됨~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"); HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; String jwt = request.getHeader("Authorization"); if (jwt == null) { onError(response, "토큰 없음"); return; } if (!jwt.startsWith("Bearer ")) { onError(response, "프로토콜 잘못됨(`Bearer `없음) 혹은 공백일 수 없다."); return; } try { // 토큰 검증 LoginUser loginUser = jwtUtil.verify(jwt); // 세션에 저장 HttpSession session = request.getSession(); session.setAttribute("sessionUser", loginUser); // SessionUserResolver에서 설정함 } catch (JWTDecodeException jwtDecodeException){ onError(response, "토큰 검증 실패"); return; } chain.doFilter(request, response); } private void onError(HttpServletResponse response, String msg) { try { // 여기서 setContentType까지는 스프링의 도움을 받지 못하기 때문에 직접 JSON으로 변환해야 한다. String responseBody = new ObjectMapper().writeValueAsString(Resp.fail(msg)); // 설정 안하면 200 response.setStatus(401); response.setContentType("application/json; charset=utf-8"); PrintWriter out = response.getWriter(); out.println(responseBody); } catch (Exception e){ // throw하면 안된다. 위임해서 처리할 곳이 없다. e.printStackTrace(); } } }
  • String jwt = request.getHeader("Authorization");
    • 요청 헤더에서 Authorization 값(JWT)를 가져옵니다.
  • Controller가 아니라서 스프링이 자동으로 해주는 JSON으로 변환, setContentType 등을 적어야 합니다.

5. JwtUtil

@Component public class JwtUtil { // 컴퍼넌트 스캔시에 @Value가 발동하고, 해당 값은 application.properties에서 가져온다. @Value("${var.jwt.secret}") private String secret; // 토큰 만료 시간 설정 public final Long EXPIRATION_TIME = 1000*60*60*24*2L; public String create(User user) { String jwt = JWT.create() .withSubject("title") // 토큰 이름 .withClaim("id", user.getId()) .withClaim("username", user.getUsername()) .withExpiresAt(Instant.now().plusMillis(EXPIRATION_TIME)) .sign(Algorithm.HMAC512(secret)); return "Bearer " + jwt; } public LoginUser verify(String jwt) throws SignatureVerificationException, TokenExpiredException, JWTDecodeException { // 서명안됨, 토큰만료, JWT형식이 아님 까지 예외 3개 발생 가능 -> 예외 처리하는 코드 추가 필요 jwt = jwt.replace("Bearer ", ""); // JWT를 검증한 후, 검증이 완료되면, header, payload를 base64로 복호화함. DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(secret)) .build().verify(jwt); int id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getClaim("username").asString(); return new LoginUser(id, username, jwt); } }
@AllArgsConstructor @Data public class LoginUser { private Integer id; private String username; @JsonIgnore private String jwt; }
  • @Value("${var.jwt.secret}")
    • application-dev.properties 에서 정해둔 값을 가져옵니다.
    • secret은 비밀 키로 관계자 외 사람에게 알려지면 안됩니다.
  • create(User user)
    • 로그인한 user 정보를 가지고 jwt 토큰을 생성합니다.
  • verify(String jwt)
    • 받은 jwt 토큰으로 검증합니다.
    • LoginUser로 반환하는 이유는 password, email 같이 노출되면 안 되는 정보를 감추기 위해서 입니다.

6. UserService

@Transactional(readOnly = true) @RequiredArgsConstructor @Service public class UserService { private final UserRepository userRepository; private final JwtUtil jwtUtil; public LoginUser 로그인(UserRequest.@Valid LoginDTO reqDTO) { User user = userRepository.findByUsername(reqDTO.getUsername()) .orElseThrow(() -> new Exception401("유저네임 혹은 패스워드가 틀렸습니다.")); if (!user.getPassword().equals(reqDTO.getPassword())) { throw new Exception401("유저네임 혹은 패스워드가 틀렸습니다."); } // 토큰 생성 String jwt = jwtUtil.create(user); // LoginUser 쓰는 이유 -> password 제외하게 return new LoginUser(user.getId(), user.getUsername(), jwt); } }

7. UserController

@RequiredArgsConstructor @RestController public class UserController { private final UserService userService; // 로그인 + jwt 생성 @PostMapping("/login") public ResponseEntity<?> login(@RequestBody @Valid UserRequest.LoginDTO reqDTO, Errors errors) { LoginUser loginUser = userService.로그인(reqDTO); // 세션에 저장안함 (이유 : stateless 서버니까) return ResponseEntity.ok() .header("Authorization", loginUser.getJwt()) // @JsonIgnore 걸린 jwt 빼고 body에 id, username만 담아서 전송 .body(Resp.success(loginUser)); } // 인증 받아야 하는 테스트 용 요청 @GetMapping("/api") public ResponseEntity<?> api(@SessionUser LoginUser loginUser) { return ResponseEntity.ok("통과함 : " + loginUser.getUsername()); } }
  • SessionUserResolver 덕분에 @SessionUser LoginUser loginUser 로 세션에 담긴 유저 정보를 가져올 수 있습니다.

8. SessionUserResolver

@RequiredArgsConstructor @Configuration public class SessionUserResolver implements HandlerMethodArgumentResolver { private final HttpSession session; // @SessionUser 어노테이션이 달려 있고 LoginUser class와 일치하면 resolveArgument 발동 @Override public boolean supportsParameter(MethodParameter parameter) { boolean isAnnotated = parameter.getParameterAnnotation(SessionUser.class) != null; boolean isClass = LoginUser.class.equals(parameter.getParameterType()); return isAnnotated && isClass; } // @SessionUser User user와 같이 선언된 파라미터에 세션 사용자 정보가 자동으로 주입됩니다. @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { LoginUser loginUser = (LoginUser) session.getAttribute("sessionUser"); return loginUser; } }
  • 매개변수에 @SessionUser가 붙어있고 그 타입이 LoginUser라면 세션에서 유저 정보를 불러올 수 있습니다.

@SessionUser 어노테이션 생성

@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface SessionUser { }

9. Postman으로 테스트

로그인 및 JWT 토큰 생성

notion image
  • 요청이 성공적으로 처리(응답코드 200)될 경우 JWT를 생성해서 헤더에 담아 응답을 보냅니다.
notion image
  • 받은 토큰을 요청 헤더의 Authorization에 담아 보내면 인증이 완료됩니다.
  • Authorization 탭에서 토큰 내용을 넣어도 됩니다.
 
Share article

devleekangho