Spring Security
Spring Security는 Spring 기반 애플리케이션의 인증, 권한 부여 및 기타 보안 기능을 제공하는 프레임워크입니다.
Spring Security란?
Spring Security는 Java 애플리케이션에서 보안을 구현하기 위한 강력한 프레임워크입니다. 인증(Authentication)과 권한 부여(Authorization)를 중심으로 다양한 보안 기능을 제공합니다.
주요 기능
- 인증 (Authentication): 사용자가 누구인지 확인
- 권한 부여 (Authorization): 사용자가 어떤 리소스에 접근할 수 있는지 결정
- 세션 관리: 사용자 세션 관리 및 보안
- CSRF 보호: Cross-Site Request Forgery 공격 방지
- XSS 보호: Cross-Site Scripting 공격 방지
기본 설정
의존성 추가
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
기본 보안 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
사용자 인증
UserDetailsService 구현
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles().toArray(new String[0]))
.build();
}
}
사용자 엔티티
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String email;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
private boolean enabled = true;
// 생성자, Getter, Setter...
}
JWT 인증
JWT 유틸리티 클래스
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
}
JWT 필터
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
username = jwtTokenUtil.extractUsername(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}
인증 컨트롤러
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Autowired
private UserService userService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
)
);
} catch (BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("잘못된 사용자명 또는 비밀번호입니다.");
}
final UserDetails userDetails = userService.loadUserByUsername(loginRequest.getUsername());
final String token = jwtTokenUtil.generateToken(userDetails);
return ResponseEntity.ok(new JwtResponse(token));
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest registerRequest) {
if (userService.existsByUsername(registerRequest.getUsername())) {
return ResponseEntity.badRequest().body("이미 존재하는 사용자명입니다.");
}
User user = new User();
user.setUsername(registerRequest.getUsername());
user.setPassword(registerRequest.getPassword());
user.setEmail(registerRequest.getEmail());
user.setRoles(Set.of("USER"));
userService.save(user);
return ResponseEntity.ok("사용자가 성공적으로 등록되었습니다.");
}
}
메서드 레벨 보안
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
return userRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public User getUserByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
@PreAuthorize("hasRole('ADMIN') or #user.username == authentication.name")
public User updateUser(User user) {
return userRepository.save(user);
}
}
CORS 설정
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
보안 헤더 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.frameOptions().sameOrigin()
.contentTypeOptions().and()
.httpStrictTransportSecurity(hstsConfig -> hstsConfig
.maxAgeInSeconds(31536000)
.includeSubdomains(true)
)
.contentSecurityPolicy(cspConfig -> cspConfig
.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'")
)
)
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.formLogin(form -> form.permitAll());
return http.build();
}
}
테스트
@SpringBootTest
@AutoConfigureTestDatabase
class SecurityTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testPublicEndpoint() {
ResponseEntity<String> response = restTemplate.getForEntity("/public/hello", String.class);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
void testProtectedEndpoint() {
ResponseEntity<String> response = restTemplate.getForEntity("/api/users", String.class);
assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
}
@Test
void testLogin() {
LoginRequest loginRequest = new LoginRequest("admin", "password");
ResponseEntity<JwtResponse> response = restTemplate.postForEntity(
"/api/auth/login", loginRequest, JwtResponse.class);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody().getToken());
}
}