
오늘은 백엔드 개발의 기초가 되는 Spring Security에 대해 공부하며 정리해보았습니다.
웹 애플리케이션의 보안 위협들:
Spring Security 없이 개발한다면?
// 매번 이런 코드를 모든 컨트롤러에 작성해야 함 😱
@GetMapping("/admin/users")
public List<User> getUsers(HttpSession session) {
if (session.getAttribute("user") == null) {
throw new RuntimeException("로그인이 필요합니다");
}
User user = (User) session.getAttribute("user");
if (!user.getRole().equals("ADMIN")) {
throw new RuntimeException("관리자 권한이 필요합니다");
}
return userService.getAllUsers();
}
Spring Security 사용하면?
// 깔끔하게 @PreAuthorize 어노테이션 하나로 해결! ✨
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/users")
public List<User> getUsers() {
return userService.getAllUsers();
}
예시:
// 로그인 과정
사용자: "이메일: user@test.com, 비밀번호: 123456"
서버: "이 정보가 맞는지 확인해보자"
↓
서버: "아, 맞네! 이 사람은 user@test.com 사용자구나"
예시:
// 권한 확인 과정
사용자: "관리자 페이지 접근하고 싶어요"
서버: "이 사용자가 관리자 권한이 있나요?"
↓
서버: "아, 일반 사용자네요. 관리자 페이지는 접근 불가"
// 인증: 로그인 성공 여부
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
// 인증 과정: 이메일/비밀번호가 맞는지 확인
User user = userService.authenticate(request.getEmail(), request.getPassword());
if (user == null) {
return ResponseEntity.badRequest().body("로그인 실패");
}
return ResponseEntity.ok("로그인 성공");
}
// 권한: 특정 기능 사용 가능 여부
@PreAuthorize("hasRole('ADMIN')") // 권한 확인
@GetMapping("/admin/users")
public List<User> getUsers() {
// 이미 인증된 사용자만 이 메서드에 접근 가능
// 추가로 ADMIN 권한이 있는지 확인
return userService.getAllUsers();
}
❌ 위험한 방식:
// 데이터베이스에 평문으로 저장
User user = new User();
user.setEmail("user@test.com");
user.setPassword("user123"); // 평문 저장! 😱
userRepository.save(user);
문제점:
BCrypt의 특징:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// 1. 데이터베이스에서 사용자 찾기
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email));
// 2. Spring Security 형식으로 변환
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword()) // 이미 BCrypt로 암호화된 비밀번호
.roles(user.getRoles().toArray(new String[0]))
.build();
}
}
// BCrypt 사용 예시
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 사용법
String rawPassword = "user123";
String encodedPassword = passwordEncoder.encode(rawPassword); // 암호화
boolean matches = passwordEncoder.matches("user123", encodedPassword); // 검증
// 역할 기반 권한
SimpleGrantedAuthority adminRole = new SimpleGrantedAuthority("ROLE_ADMIN");
SimpleGrantedAuthority userRole = new SimpleGrantedAuthority("ROLE_USER");
// 세부 권한
SimpleGrantedAuthority readPermission = new SimpleGrantedAuthority("READ_USER");
SimpleGrantedAuthority writePermission = new SimpleGrantedAuthority("WRITE_USER");
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. CSRF 설정
.csrf(csrf -> csrf.disable())
// 2. 세션 관리
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 3. URL 권한 설정
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// 4. JWT 필터 추가
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
CSRF 비활성화
.csrf(csrf -> csrf.disable())
세션 관리
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(authz -> authz
// 1. 정적 리소스 (누구나 접근 가능)
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
// 2. 공개 API (인증 불필요)
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
// 3. 관리자 전용 (ADMIN 역할 필요)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 4. 사용자 전용 (USER 역할 필요)
.requestMatchers("/api/user/**").hasRole("USER")
// 5. 여러 역할 중 하나 (ADMIN 또는 MODERATOR)
.requestMatchers("/api/moderator/**").hasAnyRole("ADMIN", "MODERATOR")
// 6. 나머지 모든 요청 (로그인만 하면 접근 가능)
.anyRequest().authenticated()
)
.authorizeHttpRequests(authz -> authz
// GET 요청은 누구나 가능
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
// POST, PUT, DELETE는 로그인 필요
.requestMatchers(HttpMethod.POST, "/api/posts/**").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/posts/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
)
// ❌ 잘못된 예시 (anyRequest가 먼저 나오면 뒤의 설정이 무시됨)
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated() // 이게 먼저 나오면
.requestMatchers("/admin/**").hasRole("ADMIN") // 이 설정이 무시됨
)
// ✅ 올바른 예시 (구체적인 패턴을 먼저 작성)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/admin/**").hasRole("ADMIN") // 구체적인 패턴 먼저
.anyRequest().authenticated() // 마지막에 작성
)
@RestController
@RequestMapping("/api/users")
public class UserController {
// 역할 기반 권한
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/all")
public List<User> getAllUsers() {
return userService.getAllUsers();
}
// 여러 역할 중 하나
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@GetMapping("/moderate")
public List<User> getUsersForModeration() {
return userService.getUsersForModeration();
}
// 세부 권한
@PreAuthorize("hasAuthority('READ_USER')")
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUserById(id);
}
}
// 현재 사용자와 요청 대상이 같은 경우만 허용
@PreAuthorize("#id == authentication.principal.id")
@GetMapping("/profile/{id}")
public User getProfile(@PathVariable Long id) {
return userService.getUserById(id);
}
// 복합 조건
@PreAuthorize("hasRole('ADMIN') or (#id == authentication.principal.id)")
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.updateUser(id, user);
}
// 메서드 파라미터 활용
@PreAuthorize("#user.email == authentication.principal.username")
@PostMapping("/validate")
public boolean validateUser(@RequestBody User user) {
return userService.validateUser(user);
}
@RestControllerAdvice
public class SecurityExceptionHandler {
// 인증 실패 (로그인 안됨)
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e) {
ErrorResponse error = new ErrorResponse("AUTH001", "로그인이 필요합니다");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
// 권한 부족 (로그인은 됐지만 권한 없음)
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException e) {
ErrorResponse error = new ErrorResponse("AUTH002", "접근 권한이 없습니다");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
}
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ErrorResponse error = new ErrorResponse("AUTH001", "유효하지 않은 토큰입니다");
String json = new ObjectMapper().writeValueAsString(error);
response.getWriter().write(json);
}
}