GitHub basic 레포지토리의 b2_board 모듈에서 초기 데이터 로드 → JWT 토큰 생성/검증 → 필터 체인 → Security 설정 → 컨트롤러 적용까지의 인증 시스템 구현 과정을 순서대로 따라가보자.
각 클래스의 역할과 서로 연동되는 워크플로우를 정리한다.
사용자가 제공한 실제 레포지토리 코드를 보면, @Component와 @Transactional을 사용한 CommandLineRunner 구현체로 작동한다.
애플리케이션 시작 시 admin 사용자 더미 데이터를 DB에 넣는 초기화 클래스다. @Transactional로 트랜잭션 안전성을 보장하고, PasswordEncoder로 비밀번호를 해시 처리한다.
@Component
@Transactional
public class InitialDataLoad implements CommandLineRunner {
private final AuthorRepository authorRepository;
private final PasswordEncoder passwordEncoder;
@Autowired
public InitialDataLoad(AuthorRepository authorRepository, PasswordEncoder passwordEncoder) {
this.authorRepository = authorRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public void run(String... args) throws Exception {
// admin 계정이 이미 존재하면 중복 생성 방지
if (authorRepository.findAllByEmail("admin@naver.com").isPresent()) {
return;
}
// 최초 1회(없을 때만) admin 계정 생성
// - 비밀번호는 PasswordEncoder로 해시 처리 후 저장(평문 저장 방지)
authorRepository.save(Author.builder()
.name("admin")
.email("admin@naver.com")
.role(Role.ADMIN)
.password(passwordEncoder.encode("12341234"))
.build());
}
}
왜 필요한가?
로그인 테스트를 위해 DB에 최소 사용자 데이터가 있어야 JWT 발급이 가능하다. 이전 설명과 달리CommandLineRunner로 애플리케이션 시작 직후 동기 실행되며,findAllByEmail로 중복 체크하고 실제 BCrypt 인코딩을 적용해 보안이 강화됐다.
로그인 성공 시 토큰 발급하고, 이후 요청에서 토큰 검증/사용자 추출을 담당하는 핵심 클래스다.
@Value로 application.yml에서 secretKey와 만료시간을 주입받고, @PostConstruct로 Base64 디코딩 후 SecretKey 초기화한다.
@Component
public class JwtTokenProvider {
@Value("${jwt.secretKey}")
private String st_secret_key;
@Value("${jwt.expiration}")
private int exp_minuet;
private Key secret_key;
@PostConstruct
public void init(){
secret_key = new SecretKeySpec(Base64.getDecoder().decode(st_secret_key), SignatureAlgorithm.HS512.getJcaName());
}
// 로그인 성공 → 토큰 발급 (Author 객체 기반)
public String createToken(Author author) {
Claims claims = Jwts.claims().setSubject(author.getEmail());
claims.put("role", author.getRole().toString());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + exp_minuet * 60 * 1000L))
.signWith(secret_key)
.compact();
}
}
핵심:
createToken→ 로그인 Controller에서 Author 객체 전달 후 호출 (role 등 사용자 정보 포함). 이전 예시와 달리Author전체를 받아 role을 동적으로 넣고, 만료시간은 분 단위 설정값 사용하며 HS512 서명으로 보안 강화됐다. 검증 로직은 필터에서 직접 Jwts 파싱으로 처리.
모든 요청 전에 Authorization 헤더에서 토큰을 추출하고 검증하는 필터다.
GenericFilter를 상속하며, JwtTokenProvider 없이 직접 @Value로 secretKey 주입받아 Jwts로 토큰 파싱/검증한다.
@Component // 스프링 빈 등록용 어노테이션
public class JwtTokenFilter extends GenericFilter {
@Value("${jwt.secretKey}")
private String st_secret_key;
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
try {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String bearerToken = httpServletRequest.getHeader("Authorization");
if (bearerToken == null) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
String token = bearerToken.substring(7);
// 토큰 검증 및 claims 추출
Claims claims = Jwts.parserBuilder()
.setSigningKey(st_secret_key)
.build()
.parseClaimsJws(token)
.getBody();
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + claims.get("role")));
Authentication authentication = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
e.printStackTrace();
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
중요 포인트:
SecurityContextHolder.setAuthentication()으로 컨트롤러에서@AuthenticationPrincipal사용 가능. 이전 설명과 달리OncePerRequestFilter대신GenericFilter사용하고, Provider 없이 필터 내에서 직접 secretKey로 검증하며 try-catch로 예외 처리 후 체인 진행한다.
JwtTokenFilter를 Spring Security 필터 체인에 등록하고, 인증/인가 규칙을 정의한다.
@EnableMethodSecurity로 @PreAuthorize 지원하고, CORS/CSRF/세션 등 JWT에 맞게 설정하며 JwtAuthenticationHandler로 401 예외 처리한다.
@Configuration
@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위한 설정
public class SecurityConfig {
private final JwtTokenFilter jwtTokenFilter;
private final JwtAuthenticationHandler jwtAuthenticationHandler;
@Autowired
public SecurityConfig(JwtTokenFilter jwtTokenFilter, JwtAuthenticationHandler jwtAuthenticationHandler) {
this.jwtTokenFilter = jwtTokenFilter;
this.jwtAuthenticationHandler = jwtAuthenticationHandler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(c -> c.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(e -> e.authenticationEntryPoint(jwtAuthenticationHandler))
.authorizeHttpRequests(a -> a
.requestMatchers("/author/create", "/author/login").permitAll()
.anyRequest().authenticated())
.build();
}
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000","https://www.jiyean.shop", "http://localhost:5173"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
필터 체인 순서:
JwtTokenFilter→UsernamePasswordAuthenticationFilter→ Controller.
CORS 프론트 도메인 허용(로컬+프로덕션),
/author/create와/author/login만 permitAll,
PasswordEncoder빈 제공하며authenticationEntryPoint로 JWT 예외 핸들링.
AuthorizationDeniedException 같은 권한 예외를 잡아서 일관된 JSON 에러 응답을 반환한다.
CommonErrorDto로 status_code와 error_message를 표준화해 403 Forbidden 응답을 처리한다.[1]
@RestControllerAdvice
@Slf4j
public class CommonExceptionHandler {
@ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<?> authorizationDeniedException(AuthorizationDeniedException e) {
e.printStackTrace();
CommonErrorDto dto = CommonErrorDto.builder()
.status_code(403)
.error_message(e.getMessage())
.build();
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(dto);
}
}
ErrorResponse 예시:
{ "status_code": 403, "error_message": "접근 권한이 없습니다" }
토큰 관련 예외는 401 Unauthorized로 명확히 처리한다.
AuthenticationEntryPoint 구현체로 필터 체인에서 토큰 에러 발생 시 ObjectMapper로 JSON 응답을 직접 스트림에 씀.[1]
@Component
public class JwtAuthenticationHandler implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Autowired
public JwtAuthenticationHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// Header 설정 (401 + JSON)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// Body: CommonErrorDto 직렬화
String data = objectMapper.writeValueAsString(CommonErrorDto.builder()
.status_code(401)
.error_message("토큰이 없거나 유효하지 않습니다.")
.build());
PrintWriter printWriter = response.getWriter();
printWriter.write(data);
printWriter.flush();
}
}
왜 분리했나?
일반 비즈니스 예외와 인증 실패를 HTTP 상태코드로 구분하기 위해. SecurityConfig에서authenticationEntryPoint(jwtAuthenticationHandler)로 등록해 필터 에러를 JSON으로 처리한다.
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AuthorLoginDto dto) {
// 1. authorService.login()에서 이메일/비밀번호 검증 후 Author 반환
Author author = authorService.login(dto);
// 2. JWT 토큰 발급 (Author 전체 정보 사용)
String token = jwtTokenProvider.createToken(author);
return ResponseEntity.status(HttpStatus.OK).body(token);
}
@GetMapping("/list")
@PreAuthorize("hasRole('ADMIN')") // ADMIN 권한만 접근 가능
public List<AuthorListDto> findAll() {
// JwtTokenFilter에서 세팅된 Authentication 자동 사용
return authorService.findAll();
}
인증 흐름:
필터에서 토큰 검증 →SecurityContext에Authentication저장 →@PreAuthorize로 권한 체크 → 컨트롤러 실행. 로그인 시authorService.login()에서 검증 후 토큰 반환, 문자열 직접 body로 간단 응답.
1. InitialDataLoad → DB에 admin@naver.com (ADMIN) 생성
2. POST /author/login 요청 (permitAll)
→ Controller.login() → authorService.login() (이메일/비번 검증)
→ jwtTokenProvider.createToken(author) → 200 "eyJhbGciOiJIUzUxMiJ9..."
3. GET /author/list 요청 (Bearer <토큰>)
→ JwtTokenFilter (토큰 파싱 → Authentication 저장)
→ @PreAuthorize("hasRole('ADMIN')") 체크 → Controller.findAll()
→ 200 [사용자 목록]
❌ 토큰 없음/만료 → JwtAuthenticationHandler → 401 JSON
❌ 권한 부족 → CommonExceptionHandler → 403 JSON
service.login() + @PreAuthorize만으로 완성