inblog logo
|
devleekangho
    스프링부트

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

    KangHo Lee's avatar
    KangHo Lee
    Jan 09, 2025
    [스프링 부트] 37. JWT 인증
    Contents
    1. 라이브러리 세팅2. application.properties 3. FilterConfig4. JwtAuthorizationFilter 5. JwtUtil 6. UserService 7. UserController 8. SessionUserResolver 9. Postman으로 테스트
    💡
    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
    Contents
    1. 라이브러리 세팅2. application.properties 3. FilterConfig4. JwtAuthorizationFilter 5. JwtUtil 6. UserService 7. UserController 8. SessionUserResolver 9. Postman으로 테스트

    devleekangho

    RSS·Powered by Inblog