[스프링부트 시큐리티] 3. SpringBoot Security : JWT + REST API

문정준's avatar
Jul 25, 2025
[스프링부트 시큐리티] 3. SpringBoot Security : JWT + REST API

JWT를 활용한 인증 및 REST 서버 전환

  • 기존에 있던 Session 인증을 JWT를 활용한 인증으로 변경
  • Session을 사용하지 않기 때문에 UsernamePasswordAuthenticationFilter 비활성화
    • 커스텀 필터를 통한 JWT 인가 필터 구현
      • 인증은 Aware Filter에서 Authentication에서 그대로 수행
      • JWT 인가 필터에서 Authentication에 User 정보 삽입 필요
  • 인증 중 token이 없거나 권한이 일치하지 않을 경우 Handler 구현
  • RestController 및 Resp 객체를 통한 REST API 서버 전환
 

Add Dependencies

  • JWT 인증을 위한 JWT 라이브러리 추가
implementation 'com.auth0:java-jwt:4.4.0'
 

Codes

  • SecurityConfig.java
    • 세션 비활성화 → 토큰으로 인증을 수행하기 때문에 세션에 값을 계속 저장하지 않아도 됨
    • formLogin 비활성화 → 화면 사용 X
      • 관리자용 웹 사용 시에는 허용해야 함
    • HTTP BasicAuthentication 비활성화
      • BasicAuthentication : UsernamePasswordAuthentication이 비활성화될 경우 사용
      • username, password를 직접 입력하면서 인증하는 방식
      • 제일 안전한 방법이지만 사용자의 UX를 많이 해침
    • 커스텀 필터 : JWTAuthenticationFilter 작성
      • 맨 앞에 부착 : 인가용
    • exceptionHandling : 인증 수행 중 발생하는 에러를 catch
@Configuration public class SecurityConfig { @Bean public BCryptPasswordEncoder encodePwd() { return new BCryptPasswordEncoder(); } // 시큐리티 컨텍스트 홀더에 세션 저장할 때 사용하는 클래스 @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 1. iframe 허용 : mysql로 전환 시 삭제 http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())); // 2. csrf 허용 : HTML 사용 안 함 http.csrf(csrf -> csrf.disable()); // 3. 세션 비활성화 (STATELESS) : 세션 자체는 사용 가능하나, 정보를 계속 저장해두지는 않음 http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 4. 폼 로그인 비활성화 (JWT 사용하므로) : UsernamePasswordAuthenticationFilter 비활성화 http.formLogin(form -> form.disable()); // 5. HTTP Basic 인증 비활성화 : BasicAuthenticationFilter 비활성화 // username, password를 직접 들고 다니기 때문에 제일 안전하나 유저가 이를 관리해야 함 : UX 저하 http.httpBasic(basicLogin -> basicLogin.disable()); // Q. 현재 인증 필터가 없는데 어떻게 인증을 수행할 건가? // 현재 이 예제에서는 서비스에서 인증 처리 진행 (login 직접 작성) // 또는 커스텀 필터를 작성해서 추가 // 6. 커스텀 필터 작성 // 인가 필터 장착 : 토큰이 정상적으로 인증되면 AwareFilter로 접근 허용 http.addFilterBefore(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class); // 7. 예외 처리 Handler 등록 http.exceptionHandling(ex -> ex .authenticationEntryPoint(new JWT401Handler()) .accessDeniedHandler(new JWT403Handler())); /*http.formLogin(form -> form .loginPage("/login-form") .loginProcessingUrl("/login") // username=ssar&password=1234 .defaultSuccessUrl("/main"));*/ http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/main").authenticated() .requestMatchers("/user/**").hasRole("USER") .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().permitAll() ); return http.build(); } }
 
  • JWTAuthorizationFilter.java
    • OncePerRequestFilter : 요청 당 1번만 실행되는 필터
      • 요청 중에 이전으로 돌아가서 처리하는 로직 발생 시 JWT 인증을 스킵
    • JWT 인증 후 Authentication 객체에 User 정보 저장
// 인가 필터 : 로그인 인증 필터 아님 // 단 한 번만 실행되는 필터 public class JWTAuthorizationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String jwt = request.getHeader(JWTUtil.HEADER); // 요청 헤더에서 JWT 토큰 추출 if (jwt == null || !jwt.startsWith(JWTUtil.TOKEN_PREFIX)) { filterChain.doFilter(request, response); return; } try { jwt = jwt.replace(JWTUtil.TOKEN_PREFIX, ""); User user = JWTUtil.verify(jwt); Authentication authentication = new UsernamePasswordAuthenticationToken( user, null, user.getAuthorities() ); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { System.out.println("JWT 오류 : " + e.getMessage()); } filterChain.doFilter(request, response); } }
 
  • JWTUtil.java
    • JWT 토큰 생성 및 검증
// JWT 토큰 생성 및 검증 유틸리티 public class JWTUtil { public static final String HEADER = "Authorization"; // HTTP 헤더 이름 public static final String TOKEN_PREFIX = "Bearer "; // 토큰 접두사 public static final String SECRET = "메타코딩시크릿키"; // 토큰 서명에 사용될 비밀 키 (강력하게 변경 필요!) public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; // 7일 (밀리초) // JWT 토큰 생성 public static String create(User user) { String jwt = JWT.create() .withSubject(user.getUsername()) // 토큰의 주체 (여기서는 사용자 이름) .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 토큰 만료 시간 .withClaim("id", user.getId()) // 사용자 ID 클레임 추가 .withClaim("roles", user.getRoles()) // 사용자 역할 클레임 추가 .sign(Algorithm.HMAC512(SECRET)); // 비밀 키로 서명 return TOKEN_PREFIX + jwt; // "Bearer " 접두사 붙여 반환 } // JWT 토큰 검증 및 디코딩 public static User verify(String jwt) { DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET)) .build() .verify(jwt); // 토큰 검증 Integer id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getSubject(); String roles = decodedJWT.getClaim("roles").asString(); return User.builder().id(id).username(username).roles(roles).build(); } }
 
  • JWT401Handler.java
    • jwt가 존재하지 않을 경우 발생하는 에러를 캐치해서 Resp 전송
    • Resp 객체 자동 변환 사용 불가 : 따로 만들어줘야 함
public class JWT401Handler implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); response.setStatus(401); PrintWriter out = response.getWriter(); String responseBody = RespFilterUtil.fail(401, authException.getMessage()); out.println(responseBody); out.flush(); } }
 
  • JWT403Handler.java
    • jwt 인증 정보가 다를 경우 발생하는 에러를 캐치해서 Resp 전송
public class JWT403Handler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); response.setStatus(403); PrintWriter out = response.getWriter(); String responseBody = RespFilterUtil.fail(403, accessDeniedException.getMessage()); out.println(responseBody); out.flush(); } }
 
  • RespFilterUtil.java
    • Spring 외부의 Filter에서 발생하는 에러를 캐치해서 전송할 Resp 정의
    • 자바 내부의 Resp 실패 생성자를 String(JSON)으로 변환
public class RespFilterUtil { private static ObjectMapper mapper = new ObjectMapper(); public static String fail(Integer status, String msg) { Resp<?> resp = new Resp(status, msg); try { return mapper.writeValueAsString(resp); } catch (JsonProcessingException e) { throw new RuntimeException("json 변환 실패"); } } }
 
  • Resp.java
    • Spring 내부에서 응답을 할 때 사용할 JSON 변환용 Resp
    • static factory method를 사용해서 더욱 직관적으로 사용할 수 있음
      • 제네릭 메서드를 사전에 결정할 수 없음 : 이는 더 공부해보고 사용해볼 것
package org.example.securityapp._core; import lombok.Data; @Data public class Resp<T> { private boolean success; private Integer status; private String msg; private T data; // static factory method로 만들 때 제네릭 타입 결정 : 공부해볼 것 public Resp() { this.success = true; this.status = 200; this.msg = "성공"; this.data = null; } public Resp(T data) { this.success = true; this.status = 200; this.msg = "성공"; this.data = data; } public Resp(Integer statusCode, String msg) { this.success = false; this.status = statusCode; this.msg = msg; this.data = null; } }
 
  • AuthController.java
    • UserController와 인증 메서드를 구분하기 위해 새로 생성
    • AuthController에는 인증을 받지 못한 사람도 접근이 가능해야 함 (회원가입, 로그인 등)
@RequiredArgsConstructor @RestController public class AuthController { private final UserService userService; @PostMapping("/join") public Resp<?> join(@RequestBody UserRequest.Join reqDTO) { userService.회원가입(reqDTO); return new Resp<>(); } @PostMapping("/login") public Resp<?> login(@RequestBody UserRequest.Login reqDTO) { String accessToken = userService.로그인(reqDTO); return new Resp<>(accessToken); } }
 
  • AdminController.java / UserController.java
    • Controller에서 데이터를 전송하는 RestController로 변환
    • 반환하는 데이터 타입도 Resp로 변환
    • @RequestMapping을 통해 컨텍스트 구분 가능 : 컨트롤러의 책임 분리
@RequestMapping("/admin") @RestController public class AdminController { @GetMapping public Resp<?> adminMain() { return new Resp<>(); } } @RequestMapping("/user") @RestController public class UserController { private UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping public Resp<?> user() { return new Resp<>(); } }
 
  • UserService.java
    • 로그인 시 인증 로직 수행
        1. username으로 User 찾기
        1. 찾은 User와 reqDTO 내부의 비밀번호 비교 (bCrypt.verify)
        1. 전부 일치 시 JWT 토큰 생성 후 반환
@RequiredArgsConstructor @Service public class UserService implements UserDetailsService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; @Transactional public void 회원가입(UserRequest.Join reqDTO) { String encPassword = bCryptPasswordEncoder.encode(reqDTO.getPassword()); String roles = "USER"; userRepository.save(roles, reqDTO.getUsername(), encPassword, reqDTO.getEmail()); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findByUsername(username); } public String 로그인(UserRequest.Login reqDTO) { User user = userRepository.findByUsername(reqDTO.getUsername()); if (user == null) throw new RuntimeException("유저네임을 찾을 수 없습니다"); if (!bCryptPasswordEncoder.matches(reqDTO.getPassword(), user.getPassword())) throw new RuntimeException("비밀번호가 틀렸습니다"); // 4. JWT 토큰 생성 String jwtToken = JWTUtil.create(user); return jwtToken; } }
 
  • UserRequest.java
    • 데이터 전달 시 사용하는 Request 용 DTO
public class UserRequest { @Data public static class Login { private String username; private String password; } @Data public static class Join { private String username; private String password; private String email; } }
 

Result

  • 로그인 성공
notion image
notion image
notion image
  • 오류 (토큰 없음 / 권한 없음)
notion image
 
Share article

sxias