[심화] 13. Spring Security 토큰 인증

백하림's avatar
Jul 25, 2025
[심화] 13. Spring Security 토큰 인증

JwtUtil

package com.metacoding.securityapp1.core; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.metacoding.securityapp1.domain.user.User; import java.util.Date; /** * JWT 토큰을 생성하고 검증하는 유틸리티 클래스 * JWT 라이브러리는 Auth0 JWT(Java JWT: https://github.com/auth0/java-jwt)를 사용 */ public class JwtUtil { // Authorization 헤더 이름: 클라이언트에서 JWT를 보낼 때 이 이름으로 보냄 public static final String HEADER = "Authorization"; // 토큰 앞에 붙는 접두어. Spring Security에서는 "Bearer " 형식으로 기대함 public static final String TOKEN_PREFIX = "Bearer "; // 서명용 시크릿 키. 실무에서는 반드시 외부에 노출되지 않게 환경변수로 관리 public static final String SECRET = "메타코딩시크릿키"; // 토큰 유효 시간 (7일). System.currentTimeMillis() 기준으로 계산 public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; /** * JWT 생성 메서드 * * @param user 로그인된 사용자 객체 * @return Bearer 토큰 문자열 */ public static String create(User user) { String jwt = JWT.create() .withSubject(user.getUsername()) // 토큰의 제목(subject) 설정 (보통 유저명, 고유식별자) .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시점 설정 .withClaim("id", user.getId()) // 커스텀 클레임: id .withClaim("roles", user.getRoles()) // 커스텀 클레임: 역할 .sign(Algorithm.HMAC512(SECRET)); // HMAC512 알고리즘 + 시크릿 키로 서명 return TOKEN_PREFIX + jwt; // "Bearer " + 실제 토큰 형태로 반환 } /** * JWT 검증 및 디코딩 메서드 * * @param jwt 클라이언트가 보낸 JWT (Bearer 접두사 제거 후) * @return 토큰에서 추출한 유저 정보로 구성한 User 객체 */ public static User verify(String jwt) { // 시크릿 키 기반으로 디코더 설정 + 검증 수행 (시그니처 위조 여부 확인 포함) DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET)) .build() .verify(jwt); // JWT 문자열을 디코딩 + 유효성 검사 수행 // 클레임에서 필요한 정보 추출 Integer id = decodedJWT.getClaim("id").asInt(); // 커스텀 클레임 - ID String username = decodedJWT.getSubject(); // subject에 저장한 username String roles = decodedJWT.getClaim("roles").asString(); // 역할 정보 // 토큰 정보를 기반으로 새 User 객체 생성 (실제 인증 객체로 활용할 수 있음) return User.builder() .id(id) .username(username) .roles(roles) .build(); } }

SecurityConfig

package com.metacoding.securityapp1.core; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; // 예: localhost:8080/user/asdasd, localhost:8080/user/join-form @Configuration // 이 클래스는 스프링 설정 클래스임을 명시 (빈 등록 용도) public class SecurityConfig { // 비밀번호 암호화를 위한 단방향 해시 함수 (회원가입/로그인 시 사용) @Bean public BCryptPasswordEncoder encodePwd() { return new BCryptPasswordEncoder(); } // 시큐리티 컨텍스트 홀더에 세션 저장할 때 사용하는 클래스 @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean // SecurityFilterChain을 빈으로 등록하여 보안 설정을 적용함 public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // H2 Console은 iframe으로 동작하므로, 기본적으로 막혀있는 iframe을 sameOrigin으로 허용함 // 1. iframe 하용 -> mysql로 전환하면 삭제 http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())); // CSRF 보호 기능 비활성화 (개발/테스트용. 실서비스에서는 활성화 권장) // 2. csrf 비활성화 -> html 사용 안 할 거니까 ! http.csrf(csrf -> csrf.disable()); // 3. 세션 비활성화 (STATELESS) -> 키를 전달 안 해주고, 집에 갈 때 락카를 비워버린다. http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 4. 폼 로그인 비활성화 (UsernamePasswordAuthenticationFilter 발동을 막기) http.formLogin(form -> form.disable()); // 5. HTTP Basic 인증 비활성화 (BasicAuthenticationFilter 발동을 막기) http.httpBasic(basicLogin -> basicLogin.disable()); // 6. 커스텀 필터 장착 (인가 필터) -> 로그인 컨트롤러에서 직접하기 http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class); // 7. 예외처리 핸들러 등록 (1. 인증, 인가가 완료되면 어떻게 ? 후처리, 2. 예외가 발생하면 어떻게 ?) http.exceptionHandling(ex -> ex .authenticationEntryPoint(new Jwt401Handler()) .accessDeniedHandler(new Jwt403Handler())); // 요청 URL별 인가 설정 http.authorizeHttpRequests( authorize -> authorize .requestMatchers("/user/**").hasRole("USER") // USER 권한 있어야 접근 가능 .requestMatchers("/admin/**").hasRole("ADMIN") // ADMIN 권한 있어야 접근 가능 .anyRequest().permitAll() // 그 외 모든 요청은 인증 없이 접근 허용 ); return http.build(); // 구성된 보안 필터 체인 객체를 반환 } }

JwtAuthorizationFilter

package com.metacoding.securityapp1.core; import com.metacoding.securityapp1.domain.user.User; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; // 단 한번만 실행되는 필터 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); } }

RespFilterUtil

package com.metacoding.securityapp1.core; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; /** * 필터 또는 예외 핸들러에서 JSON 형식으로 에러 응답을 만들기 위한 유틸 클래스 * -> 예: Spring Security 필터에서 실패 응답으로 JSON 바디를 내려줄 때 사용 */ public class RespFilterUtil { // Jackson의 ObjectMapper: 자바 객체 → JSON 문자열로 변환할 때 사용 private static ObjectMapper mapper = new ObjectMapper(); /** * 실패 응답 객체를 JSON 문자열로 만들어 반환 * * @param status HTTP 상태 코드 (ex: 401, 403, 400 등) * @param msg 에러 메시지 내용 * @return JSON 형태의 실패 응답 문자열 (ex: {"status":401,"msg":"인증 실패"}) */ public static String fail(Integer status, String msg) { // Resp는 공통 응답 DTO. 제네릭으로 감싸되, 데이터는 없으므로 <?> Resp<?> resp = new Resp<>(status, msg); try { // 자바 객체 resp → JSON 문자열로 직렬화 return mapper.writeValueAsString(resp); } catch (JsonProcessingException e) { // 변환 실패 시 예외 발생 (실무에서는 로깅 + 사용자 정의 예외 처리 고려) throw new RuntimeException("json 변환 실패"); } } }
 
Share article

harimmon