JwtUtil · JwtFilter · 로그인 구현 · 권한(Role) · @AuthenticationPrincipal · 401/403 · CORS · 실전 전체 코드
JWT 토큰을 만들고, 검증하고, 토큰에서 값을 꺼내는 유틸리티 클래스.
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private long expiration; // 1시간 = 3600000ms @Value("${jwt.refresh-expiration}") private long refreshExpiration; // 7일 = 604800000ms // Secret Key 생성 private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } // ① Access Token 생성 public String generateAccessToken(String email, String role) { return Jwts.builder() .setSubject(email) // 사용자 식별자 (이메일) .claim("role", role) // 권한 정보 .setIssuedAt(new Date()) // 발급 시간 .setExpiration(new Date( System.currentTimeMillis() + expiration)) // 만료 시간 .signWith(getSigningKey()) // 서명 .compact(); } // ② Refresh Token 생성 (subject만 넣음) public String generateRefreshToken(String email) { return Jwts.builder() .setSubject(email) .setIssuedAt(new Date()) .setExpiration(new Date( System.currentTimeMillis() + refreshExpiration)) .signWith(getSigningKey()) .compact(); } // ③ 토큰에서 이메일(subject) 꺼내기 public String getEmail(String token) { return parseClaims(token).getSubject(); } // ④ 토큰에서 role 꺼내기 public String getRole(String token) { return parseClaims(token).get("role", String.class); } // ⑤ 토큰 유효성 검사 public boolean isValid(String token) { try { parseClaims(token); return true; } catch (ExpiredJwtException e) { throw new TokenExpiredException("만료된 토큰입니다."); } catch (JwtException e) { throw new InvalidTokenException("유효하지 않은 토큰입니다."); } } // Claims 파싱 (내부 공통 메서드) private Claims parseClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } }
HMAC-SHA256 알고리즘 사용 시 Secret Key는 최소 256비트(32바이트) 이상이어야 함.
너무 짧으면 서버 시작할 때 에러 발생.
예: "mySecretKey1234567890123456789012" (32자 이상)
모든 요청에서 Authorization 헤더를 꺼내 토큰을 검증하고, 인증 정보를 SecurityContext에 저장하는 필터.
@Component @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { // OncePerRequestFilter → 요청당 딱 한 번만 실행 보장 private final JwtUtil jwtUtil; private final CustomUserDetailsService userDetailsService; @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // ① Authorization 헤더에서 토큰 꺼내기 String authHeader = request.getHeader("Authorization"); // 토큰 없거나 "Bearer "로 시작 안 하면 그냥 통과 if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } // ② "Bearer " 떼고 순수 토큰만 꺼내기 String token = authHeader.substring(7); try { // ③ 토큰 유효성 검사 jwtUtil.isValid(token); // ④ 토큰에서 이메일 꺼내기 String email = jwtUtil.getEmail(token); // ⑤ 이미 인증된 경우 skip if (SecurityContextHolder.getContext() .getAuthentication() == null) { // ⑥ DB에서 사용자 조회 UserDetails userDetails = userDetailsService.loadUserByUsername(email); // ⑦ Authentication 객체 생성 UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); // 요청 정보 추가 (IP 등) auth.setDetails( new WebAuthenticationDetailsSource() .buildDetails(request)); // ⑧ SecurityContext에 인증 정보 저장 SecurityContextHolder.getContext() .setAuthentication(auth); } } catch (TokenExpiredException | InvalidTokenException e) { // 토큰 에러는 여기서 응답 (FilterChain 안 탐) response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( "{\"status\":401,\"message\":\"" + e.getMessage() + "\"}"); return; } // ⑨ 다음 Filter로 넘기기 filterChain.doFilter(request, response); } }
일반 Filter는 경우에 따라 같은 요청에서 여러 번 실행될 수 있음.
OncePerRequestFilter를 상속하면 요청당 반드시 한 번만 실행됨.
JWT 검증은 한 번만 해야 하니까 OncePerRequestFilter가 적합.
회원가입 → 로그인 → 토큰 발급 전체 흐름을 코드로 연결.
// 로그인 요청 DTO @Getter public class LoginRequest { @NotBlank private String email; @NotBlank private String password; } // 로그인 응답 DTO @Getter @AllArgsConstructor public class LoginResponse { private String accessToken; private String refreshToken; private String email; private String role; } // 회원가입 요청 DTO @Getter public class SignupRequest { @NotBlank private String name; @NotBlank @Email private String email; @NotBlank @Size(min=8) private String password; }
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthService authService; // 회원가입 @PostMapping("/signup") public ResponseEntity<UserResponse> signup( @Valid @RequestBody SignupRequest request) { return ResponseEntity.status(201) .body(authService.signup(request)); } // 로그인 @PostMapping("/login") public ResponseEntity<LoginResponse> login( @Valid @RequestBody LoginRequest request) { return ResponseEntity.ok(authService.login(request)); } // Access Token 재발급 @PostMapping("/refresh") public ResponseEntity<LoginResponse> refresh( @RequestHeader("Refresh-Token") String refreshToken) { return ResponseEntity.ok(authService.refresh(refreshToken)); } }
@Service @RequiredArgsConstructor public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; private final AuthenticationManager authenticationManager; // 회원가입 @Transactional public UserResponse signup(SignupRequest request) { if (userRepository.existsByEmail(request.getEmail())) throw new DuplicateEmailException(); User user = User.builder() .name(request.getName()) .email(request.getEmail()) .password(passwordEncoder.encode(request.getPassword())) .role("USER") // 기본 권한 .build(); User saved = userRepository.save(user); return UserResponse.from(saved); } // 로그인 public LoginResponse login(LoginRequest request) { // AuthenticationManager가 자동으로 // 1) UserDetailsService.loadUserByUsername(email) 호출 // 2) passwordEncoder.matches(입력PW, DB해시PW) 비교 // 3) 틀리면 BadCredentialsException 던짐 Authentication auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getEmail(), request.getPassword())); CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal(); User user = userDetails.getUser(); // 토큰 발급 String accessToken = jwtUtil.generateAccessToken(user.getEmail(), user.getRole()); String refreshToken = jwtUtil.generateRefreshToken(user.getEmail()); return new LoginResponse(accessToken, refreshToken, user.getEmail(), user.getRole()); } // Access Token 재발급 public LoginResponse refresh(String refreshToken) { jwtUtil.isValid(refreshToken); String email = jwtUtil.getEmail(refreshToken); User user = userRepository.findByEmail(email) .orElseThrow(UserNotFoundException::new); String newAccessToken = jwtUtil.generateAccessToken( user.getEmail(), user.getRole()); return new LoginResponse(newAccessToken, refreshToken, user.getEmail(), user.getRole()); } }
사용자마다 다른 권한을 부여하고, URL이나 메서드 단위로 권한을 체크하는 방법.
// Role Enum으로 관리 (권장) public enum Role { USER, ADMIN, MANAGER } // User Entity @Entity public class User { @Enumerated(EnumType.STRING) // DB에 "USER", "ADMIN" 문자열로 저장 private Role role; } // CustomUserDetails에서 권한 반환 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(new SimpleGrantedAuthority( "ROLE_" + user.getRole().name())); // ⚠️ Spring Security에서 역할은 반드시 "ROLE_" 접두사 필요 // hasRole("USER") → 내부적으로 "ROLE_USER"를 찾음 }
.authorizeHttpRequests(auth -> auth // 누구나 접근 가능 .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() // ADMIN만 .requestMatchers("/api/admin/**").hasRole("ADMIN") // USER 또는 ADMIN .requestMatchers("/api/mypage/**").hasAnyRole("USER", "ADMIN") // 로그인한 사람 전부 .anyRequest().authenticated() )
SecurityConfig URL 설정만으로 부족할 때. 메서드 하나하나에 더 세밀한 권한을 걸 수 있다.
// 활성화 — SecurityConfig 또는 메인 클래스에 추가 @EnableMethodSecurity // Spring Security 3.x (Spring Boot 3.x) // 구버전: @EnableGlobalMethodSecurity(prePostEnabled = true) // Controller에서 사용 @RestController @RequestMapping("/api/admin") public class AdminController { // ADMIN만 접근 가능 @PreAuthorize("hasRole('ADMIN')") @GetMapping("/users") public ResponseEntity<?> 전체유저목록() { ... } // USER 또는 ADMIN @PreAuthorize("hasAnyRole('USER', 'ADMIN')") @GetMapping("/mypage") public ResponseEntity<?> 마이페이지() { ... } // 현재 로그인한 사용자 본인 데이터만 수정 가능 @PreAuthorize("#userId == authentication.principal.userId or hasRole('ADMIN')") @PutMapping("/users/{userId}") public ResponseEntity<?> 수정(@PathVariable int userId) { ... } // authentication.principal → CustomUserDetails 객체 // .userId → CustomUserDetails의 getUserId() 메서드 } // @PostAuthorize — 메서드 실행 후 결과에 대한 권한 체크 @PostAuthorize("returnObject.body.email == authentication.name") @GetMapping("/users/{id}") public ResponseEntity<UserResponse> 조회(@PathVariable int id) { ... } // 반환된 결과가 현재 로그인 사용자 것인지 검사
| 표현식 | 의미 |
|---|---|
hasRole('ADMIN') | ADMIN 역할인 경우 |
hasAnyRole('USER','ADMIN') | USER 또는 ADMIN 역할 |
isAuthenticated() | 로그인한 경우 |
isAnonymous() | 비로그인(익명)인 경우 |
authentication.name | 현재 로그인한 사용자의 username (email) |
authentication.principal | 현재 로그인한 UserDetails 객체 |
#변수명 | 메서드 파라미터를 표현식에서 참조 |
SecurityContextHolder에서 직접 꺼내는 것보다 훨씬 간단하게 현재 로그인한 사용자를 받을 수 있다.
@RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { // ❌ 복잡한 방법 — SecurityContextHolder에서 직접 꺼내기 @GetMapping("/me") public ResponseEntity<?> 내정보1() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal(); return ResponseEntity.ok(userDetails.getUser()); } // ✅ 간단한 방법 — @AuthenticationPrincipal @GetMapping("/me") public ResponseEntity<UserResponse> 내정보2( @AuthenticationPrincipal CustomUserDetails userDetails) { // SecurityContext에서 자동으로 꺼내줌! User user = userDetails.getUser(); return ResponseEntity.ok(UserResponse.from(user)); } // 내 게시글 목록 — 현재 로그인 유저 ID 기준 조회 @GetMapping("/me/posts") public ResponseEntity<List<PostResponse>> 내게시글( @AuthenticationPrincipal CustomUserDetails userDetails) { int userId = userDetails.getUserId(); return ResponseEntity.ok(postService.getMyPosts(userId)); } // 내 정보 수정 @PutMapping("/me") public ResponseEntity<UserResponse> 내정보수정( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody UserUpdateRequest request) { int userId = userDetails.getUserId(); return ResponseEntity.ok(userService.update(userId, request)); } }
토큰 없이 접근하면 401, 권한 없이 접근하면 403. 기본 응답이 HTML이라 REST API에서는 JSON으로 바꿔야 한다.
401/403은 Controller에 도달하기 전 Security Filter 단계에서 발생.
@ExceptionHandler는 Controller 이후에만 동작.
→ AuthenticationEntryPoint와 AccessDeniedHandler로 별도 처리해야 함.
// 로그인 안 했거나 토큰 없을 때 → 401 @Component public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( """ { "status": 401, "message": "로그인이 필요합니다.", "code": "UNAUTHORIZED" } """); } }
// 로그인은 됐지만 권한이 없을 때 → 403 @Component public class CustomAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle( HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( """ { "status": 403, "message": "접근 권한이 없습니다.", "code": "FORBIDDEN" } """); } }
Part 1 SecurityConfig의 exceptionHandling() 부분에 이미 포함돼 있음.
.authenticationEntryPoint(authEntryPoint) → 401 처리
.accessDeniedHandler(accessDeniedHandler) → 403 처리
프론트엔드(React 등)가 다른 도메인에서 API를 호출할 때 브라우저가 막는 것. 서버에서 허용해줘야 한다.
브라우저는 보안상 다른 출처(Origin)의 API 호출을 기본적으로 막음.
예: 프론트가 localhost:3000에서 백엔드 localhost:8080 API 호출 → 브라우저가 차단.
서버에서 "이 출처는 허용한다"고 응답 헤더에 명시해야 브라우저가 허용함.
서버끼리 통신(Postman 등)에는 CORS가 없음. 브라우저 전용 정책.
@Configuration public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); // 허용할 출처 (프론트엔드 주소) config.setAllowedOrigins(List.of( "http://localhost:3000", // 개발 환경 "https://myapp.com" // 운영 환경 )); // 허용할 HTTP 메서드 config.setAllowedMethods(List.of( "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); // 허용할 헤더 config.setAllowedHeaders(List.of("*")); // 모든 헤더 허용 // Authorization 헤더 노출 (프론트에서 읽을 수 있게) config.setExposedHeaders(List.of("Authorization")); // 쿠키 포함 여부 (Refresh Token을 쿠키로 관리할 때 true) config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); // 전체 URL에 적용 return source; } } // SecurityConfig에서 이 Bean을 참조해서 사용 // .cors(Customizer::withDefaults) → CorsConfigurationSource Bean을 자동으로 찾아 적용
Spring Security가 있으면 CORS 설정을 반드시 Security 레벨에서 해야 함.
@CrossOrigin 애너테이션이나 WebMvcConfigurer만으론 부족함.
Security Filter가 CORS 전에 동작해서 OPTIONS preflight 요청을 막아버릴 수 있음.
src/main/java/com/myapp/ ├── config/ │ ├── SecurityConfig.java // Security 설정 전체 │ └── CorsConfig.java // CORS 설정 │ ├── auth/ │ ├── controller/ │ │ └── AuthController.java // /api/auth/** 엔드포인트 │ ├── service/ │ │ └── AuthService.java // 회원가입, 로그인, 토큰 재발급 │ ├── dto/ │ │ ├── LoginRequest.java │ │ ├── LoginResponse.java │ │ └── SignupRequest.java │ └── jwt/ │ ├── JwtUtil.java // 토큰 생성/검증 │ └── JwtFilter.java // 요청마다 토큰 검사 │ ├── security/ │ ├── CustomUserDetails.java // UserDetails 구현 │ ├── CustomUserDetailsService.java // UserDetailsService 구현 │ ├── CustomAuthenticationEntryPoint.java // 401 처리 │ └── CustomAccessDeniedHandler.java // 403 처리 │ ├── user/ │ ├── controller/ │ │ └── UserController.java │ ├── service/ │ │ └── UserService.java │ ├── repository/ │ │ └── UserRepository.java │ ├── entity/ │ │ └── User.java │ └── dto/ │ ├── UserResponse.java │ └── UserUpdateRequest.java │ └── global/ └── exception/ ├── GlobalExceptionHandler.java ├── BusinessException.java ├── UserNotFoundException.java ├── DuplicateEmailException.java ├── TokenExpiredException.java └── InvalidTokenException.java
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
jwt:
secret: myDevSecretKey123456789012345678901234 # 32자 이상
expiration: 3600000 # 1시간
refresh-expiration: 604800000 # 7일
@Entity @Table(name = "users") @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id; @Column(nullable = false) private String name; @Column(unique = true, nullable = false) private String email; @Column(nullable = false) private String password; // BCrypt 암호화된 값 저장 @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; public enum Role { USER, ADMIN, MANAGER } }