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