Spring 09: Spring Security 기초
인증과 인가의 핵심 개념
1. Spring Security 개요
1.1 Spring Security란?
Spring Security는 Spring 기반 애플리케이션에 보안 기능을 제공하는 프레임워크입니다. 인증(Authentication)과 인가(Authorization)를 중심으로 포괄적인 보안 솔루션을 제공합니다.
주요 특징
- 선언적 보안 설정
- 다양한 인증 방식 지원 (폼, HTTP Basic, OAuth2 등)
- 메서드 레벨 보안
- CSRF, 세션 고정 공격 등 보안 취약점 방어
- Spring Boot와의 완벽한 통합
1.2 인증(Authentication) vs 인가(Authorization)
인증 (Authentication)
"당신이 누구인가?"를 확인하는 과정
- 사용자 신원 확인
- 로그인 과정
- 자격 증명 검증
- 예: 아이디/패스워드, JWT 토큰
인가 (Authorization)
"당신이 무엇을 할 수 있는가?"를 결정하는 과정
- 권한 및 역할 확인
- 리소스 접근 제어
- 기능별 권한 관리
- 예: ROLE_USER, ROLE_ADMIN
// 인증과 인가의 흐름 예시
1. 사용자가 로그인 시도 (인증 요청)
2. Spring Security가 자격 증명 검증 (인증 처리)
3. 인증 성공 시 SecurityContext에 Authentication 객체 저장
4. 보호된 리소스 접근 시 권한 확인 (인가 처리)
5. 권한이 있으면 접근 허용, 없으면 접근 거부1.3 SecurityFilterChain 이해
Spring Security는 서블릿 필터 체인을 기반으로 동작합니다. 각 HTTP 요청은 여러 보안 필터를 거쳐 처리됩니다.
주요 Security 필터들
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.build();
}
}1.4 Spring Security 아키텍처
핵심 컴포넌트
SecurityContext
현재 인증된 사용자의 정보를 담고 있는 컨텍스트. Authentication 객체를 포함합니다.
Authentication
사용자의 인증 정보를 나타내는 인터페이스. Principal, Credentials, Authorities를 포함합니다.
AuthenticationManager
인증 처리를 담당하는 인터페이스. 실제 인증 로직을 AuthenticationProvider에 위임합니다.
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(
"User not found: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.accountExpired(!user.isAccountNonExpired())
.accountLocked(!user.isAccountNonLocked())
.credentialsExpired(!user.isCredentialsNonExpired())
.disabled(!user.isEnabled())
.build();
}
}1.5 Spring Boot에서의 Auto Configuration
Spring Boot는 Spring Security에 대한 자동 설정을 제공하여 최소한의 설정으로도 기본적인 보안 기능을 사용할 수 있습니다.
기본 자동 설정 내용
- 모든 HTTP 요청에 대한 인증 요구
- 기본 로그인 페이지 제공
- 기본 사용자 계정 생성 (user/랜덤 패스워드)
- CSRF 보호 활성화
- 세션 고정 공격 방어
# application.yml - 기본 사용자 설정
spring:
security:
user:
name: admin
password: secret
roles: ADMIN
# 또는 application.properties
spring.security.user.name=admin
spring.security.user.password=secret
spring.security.user.roles=ADMIN// 의존성 추가만으로 기본 보안 활성화
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
// Maven pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>1.6 보안 설정 우선순위
Spring Security에서는 여러 설정 방식이 있으며, 각각의 우선순위가 있습니다.
⚠️ 주의사항
사용자 정의 SecurityFilterChain 빈을 등록하면 Spring Boot의 자동 설정이 비활성화됩니다. 따라서 필요한 모든 보안 설정을 명시적으로 구성해야 합니다.
2. 기본 설정
2.1 SecurityConfig 클래스 생성
Spring Security 설정을 위한 Configuration 클래스를 생성합니다. @EnableWebSecurity 어노테이션을 사용하여 웹 보안을 활성화합니다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // 메서드 보안 활성화
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
public SecurityConfig(CustomUserDetailsService userDetailsService,
CustomAuthenticationEntryPoint authenticationEntryPoint,
CustomAccessDeniedHandler accessDeniedHandler) {
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(this::configureAuthorization)
.formLogin(this::configureFormLogin)
.logout(this::configureLogout)
.sessionManagement(this::configureSessionManagement)
.csrf(this::configureCsrf)
.exceptionHandling(this::configureExceptionHandling)
.build();
}
// 각 설정을 별도 메서드로 분리하여 가독성 향상
private void configureAuthorization(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry auth) {
auth
.requestMatchers("/", "/home", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/posts/**").hasRole("USER")
.anyRequest().authenticated();
}
}2.2 HttpSecurity 상세 설정
HttpSecurity 객체를 통해 다양한 보안 설정을 구성할 수 있습니다.
2.2.1 요청 권한 설정
private void configureAuthorization(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry auth) {
auth
// 정적 리소스는 인증 없이 접근 허용
.requestMatchers("/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll()
// 공개 페이지
.requestMatchers("/", "/home", "/about", "/contact").permitAll()
.requestMatchers("/auth/login", "/auth/register").permitAll()
// API 엔드포인트별 권한 설정
.requestMatchers(HttpMethod.GET, "/api/posts").permitAll()
.requestMatchers(HttpMethod.POST, "/api/posts").hasRole("USER")
.requestMatchers(HttpMethod.PUT, "/api/posts/**").hasRole("USER")
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
// 관리자 전용 영역
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/management/**").hasAnyRole("ADMIN", "MANAGER")
// 사용자 영역
.requestMatchers("/user/**", "/profile/**").hasRole("USER")
// 나머지 모든 요청은 인증 필요
.anyRequest().authenticated();
}2.2.2 폼 로그인 설정
private void configureFormLogin(FormLoginConfigurer<HttpSecurity> form) {
form
.loginPage("/auth/login") // 커스텀 로그인 페이지
.loginProcessingUrl("/auth/authenticate") // 로그인 처리 URL
.usernameParameter("email") // 사용자명 파라미터 (기본: username)
.passwordParameter("password") // 패스워드 파라미터
.defaultSuccessUrl("/dashboard", true) // 로그인 성공 후 이동할 URL
.failureUrl("/auth/login?error=true") // 로그인 실패 시 이동할 URL
.successHandler(customAuthenticationSuccessHandler()) // 커스텀 성공 핸들러
.failureHandler(customAuthenticationFailureHandler()) // 커스텀 실패 핸들러
.permitAll(); // 로그인 관련 URL은 모두 허용
}
@Bean
public AuthenticationSuccessHandler customAuthenticationSuccessHandler() {
return new SavedRequestAwareAuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 로그인 성공 로그 기록
String username = authentication.getName();
logger.info("User {} logged in successfully", username);
// 사용자 역할에 따른 리다이렉트
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
if (authorities.stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
getRedirectStrategy().sendRedirect(request, response, "/admin/dashboard");
} else {
super.onAuthenticationSuccess(request, response, authentication);
}
}
};
}2.2.3 로그아웃 설정
private void configureLogout(LogoutConfigurer<HttpSecurity> logout) {
logout
.logoutUrl("/auth/logout") // 로그아웃 처리 URL
.logoutSuccessUrl("/auth/login?logout=true") // 로그아웃 성공 후 이동할 URL
.logoutRequestMatcher(new AntPathRequestMatcher("/auth/logout", "POST"))
.invalidateHttpSession(true) // 세션 무효화
.deleteCookies("JSESSIONID", "remember-me") // 쿠키 삭제
.clearAuthentication(true) // 인증 정보 클리어
.logoutSuccessHandler(customLogoutSuccessHandler()) // 커스텀 로그아웃 핸들러
.permitAll();
}
@Bean
public LogoutSuccessHandler customLogoutSuccessHandler() {
return (request, response, authentication) -> {
if (authentication != null) {
String username = authentication.getName();
logger.info("User {} logged out successfully", username);
}
response.sendRedirect("/auth/login?logout=true");
};
}2.3 권한 설정 패턴
다양한 권한 설정 패턴을 활용하여 세밀한 접근 제어를 구현할 수 있습니다.
URL 패턴 매칭
// Ant 패턴 사용
.requestMatchers("/admin/**").hasRole("ADMIN") // /admin으로 시작하는 모든 경로
.requestMatchers("/user/*/profile").hasRole("USER") // 중간에 임의 경로가 있는 패턴
.requestMatchers("/api/v*/posts/**").permitAll() // 버전별 API 경로
// 정규식 패턴 사용
.requestMatchers(RegexRequestMatcher.regexMatcher("/api/v[0-9]+/.*")).permitAll()
// HTTP 메서드별 설정
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/posts/**").hasRole("USER")
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")복합 권한 설정
// 여러 역할 중 하나라도 가지고 있으면 접근 허용
.requestMatchers("/management/**").hasAnyRole("ADMIN", "MANAGER")
// 특정 권한을 가진 사용자만 접근 허용
.requestMatchers("/reports/**").hasAuthority("READ_REPORTS")
// 여러 권한 중 하나라도 가지고 있으면 접근 허용
.requestMatchers("/data/**").hasAnyAuthority("READ_DATA", "WRITE_DATA")
// 익명 사용자만 접근 허용 (로그인하지 않은 사용자)
.requestMatchers("/guest/**").anonymous()
// 인증된 사용자만 접근 허용 (역할 상관없이)
.requestMatchers("/authenticated/**").authenticated()
// Remember-Me로 인증된 사용자는 제외
.requestMatchers("/secure/**").fullyAuthenticated()2.4 CSRF 보호 설정
Cross-Site Request Forgery(CSRF) 공격을 방어하기 위한 설정입니다. Spring Security는 기본적으로 CSRF 보호를 활성화합니다.
CSRF란?
사용자가 의도하지 않은 요청을 악의적인 웹사이트가 대신 전송하는 공격입니다. Spring Security는 CSRF 토큰을 사용하여 이를 방어합니다.
private void configureCsrf(CsrfConfigurer<HttpSecurity> csrf) {
csrf
// 특정 URL에 대해서는 CSRF 보호 비활성화 (주로 API)
.ignoringRequestMatchers("/api/**", "/h2-console/**")
// CSRF 토큰 저장소 설정
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// CSRF 실패 시 처리할 핸들러
.accessDeniedHandler(new AccessDeniedHandlerImpl())
// 세션 기반 토큰 저장소 사용 (기본값)
.csrfTokenRepository(new HttpSessionCsrfTokenRepository());
}
// REST API의 경우 CSRF 비활성화
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
@Order(1) // 우선순위 설정
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**") // API 경로에만 적용
.csrf(csrf -> csrf.disable()) // API는 CSRF 비활성화
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 무상태
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults()) // HTTP Basic 인증 사용
.build();
}
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.csrf(Customizer.withDefaults()) // 웹은 CSRF 보호 유지
.build();
}
}Thymeleaf에서 CSRF 토큰 사용
<!-- 폼에 CSRF 토큰 자동 추가 -->
<form th:action="@{/user/update}" method="post">
<!-- Thymeleaf가 자동으로 CSRF 토큰을 hidden 필드로 추가 -->
<input type="text" name="username"/>
<button type="submit">Update</button>
</form>
<!-- 수동으로 CSRF 토큰 추가 -->
<form action="/user/update" method="post">
<input type="hidden" name="_csrf" th:value="csrfToken"/>
<input type="text" name="username"/>
<button type="submit">Update</button>
</form>2.5 세션 관리 설정
사용자 세션의 생성, 관리, 보안에 대한 설정을 구성합니다.
private void configureSessionManagement(SessionManagementConfigurer<HttpSecurity> session) {
session
// 세션 생성 정책 설정
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 필요시에만 생성
// 동시 세션 제어
.maximumSessions(1) // 동시 로그인 세션 수 제한
.maxSessionsPreventsLogin(false) // 새 로그인 시 기존 세션 만료 (true면 새 로그인 차단)
.sessionRegistry(sessionRegistry()) // 세션 레지스트리 설정
.expiredUrl("/auth/login?expired=true") // 세션 만료 시 이동할 URL
.and()
// 세션 고정 공격 방어
.sessionFixation().changeSessionId() // 로그인 시 세션 ID 변경
// 잘못된 세션 ID 처리
.invalidSessionUrl("/auth/login?invalid=true");
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher(); // 세션 이벤트 발행을 위해 필요
}
// 세션 생성 정책 옵션들
public enum SessionCreationPolicy {
ALWAYS, // 항상 세션 생성
NEVER, // 세션을 생성하지 않지만 기존 세션은 사용
IF_REQUIRED, // 필요시에만 생성 (기본값)
STATELESS // 세션을 사용하지 않음 (JWT 등 토큰 기반 인증에 사용)
}세션 관리 모니터링
@RestController
@RequestMapping("/admin/sessions")
public class SessionController {
@Autowired
private SessionRegistry sessionRegistry;
@GetMapping("/active")
public List<String> getActiveSessions() {
return sessionRegistry.getAllPrincipals().stream()
.map(principal -> principal.toString())
.collect(Collectors.toList());
}
@PostMapping("/invalidate/{username}")
public ResponseEntity<String> invalidateUserSessions(@PathVariable String username) {
sessionRegistry.getAllPrincipals().stream()
.filter(principal -> principal.toString().equals(username))
.forEach(principal -> {
sessionRegistry.getAllSessions(principal, false)
.forEach(SessionInformation::expireNow);
});
return ResponseEntity.ok("Sessions invalidated for user: " + username);
}
}2.6 보안 헤더 설정
다양한 보안 헤더를 설정하여 웹 애플리케이션의 보안을 강화할 수 있습니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
// X-Frame-Options 헤더 설정 (클릭재킹 방어)
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
// Content-Type-Options 헤더 설정
.contentTypeOptions(Customizer.withDefaults())
// X-XSS-Protection 헤더 설정
.xssProtection(xss -> xss.and())
// Referrer-Policy 헤더 설정
.referrerPolicy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
// Content Security Policy 설정
.contentSecurityPolicy("default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
// HTTP Strict Transport Security (HTTPS 강제)
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000) // 1년
.includeSubdomains(true)
.preload(true)
)
)
// ... 다른 설정들
.build();
}보안 헤더 설명
3. 사용자 인증
3.1 UserDetails 인터페이스
UserDetails는 Spring Security에서 사용자 정보를 나타내는 핵심 인터페이스입니다. 사용자의 인증 정보와 권한 정보를 포함합니다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities(); // 사용자 권한
String getPassword(); // 패스워드
String getUsername(); // 사용자명
boolean isAccountNonExpired(); // 계정 만료 여부
boolean isAccountNonLocked(); // 계정 잠금 여부
boolean isCredentialsNonExpired(); // 자격증명 만료 여부
boolean isEnabled(); // 계정 활성화 여부
}
// 커스텀 UserDetails 구현
public class CustomUserDetails implements UserDetails {
private final User user;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(User user) {
this.user = user;
this.authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName().toUpperCase()))
.collect(Collectors.toList());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail(); // 이메일을 사용자명으로 사용
}
@Override
public boolean isAccountNonExpired() {
return user.getAccountExpiryDate() == null ||
user.getAccountExpiryDate().isAfter(LocalDateTime.now());
}
@Override
public boolean isAccountNonLocked() {
return !user.isLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return user.getPasswordExpiryDate() == null ||
user.getPasswordExpiryDate().isAfter(LocalDateTime.now());
}
@Override
public boolean isEnabled() {
return user.isEnabled();
}
// 추가 사용자 정보 접근 메서드
public User getUser() {
return user;
}
public String getFullName() {
return user.getFirstName() + " " + user.getLastName();
}
}3.2 UserDetailsService 구현
UserDetailsService는 사용자 정보를 로드하는 인터페이스입니다. 데이터베이스나 다른 저장소에서 사용자 정보를 가져와 UserDetails 객체로 변환합니다.
@Service
@Transactional(readOnly = true)
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final LoginAttemptService loginAttemptService;
public CustomUserDetailsService(UserRepository userRepository,
LoginAttemptService loginAttemptService) {
this.userRepository = userRepository;
this.loginAttemptService = loginAttemptService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 로그인 시도 횟수 확인
if (loginAttemptService.isBlocked(username)) {
throw new AccountStatusException("Account is temporarily blocked due to too many failed attempts") {};
}
// 사용자 조회 (이메일 또는 사용자명으로)
User user = userRepository.findByEmailOrUsername(username, username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
// 사용자 상태 확인
validateUserStatus(user);
return new CustomUserDetails(user);
}
private void validateUserStatus(User user) {
if (!user.isEnabled()) {
throw new DisabledException("User account is disabled");
}
if (user.isLocked()) {
throw new LockedException("User account is locked");
}
if (user.getAccountExpiryDate() != null &&
user.getAccountExpiryDate().isBefore(LocalDateTime.now())) {
throw new AccountExpiredException("User account has expired");
}
if (user.getPasswordExpiryDate() != null &&
user.getPasswordExpiryDate().isBefore(LocalDateTime.now())) {
throw new CredentialsExpiredException("User credentials have expired");
}
}
}
// 로그인 시도 관리 서비스
@Service
public class LoginAttemptService {
private final int MAX_ATTEMPT = 3;
private final LoadingCache<String, Integer> attemptsCache;
public LoginAttemptService() {
super();
attemptsCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.DAYS)
.build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) {
return 0;
}
});
}
public void loginSucceeded(String key) {
attemptsCache.invalidate(key);
}
public void loginFailed(String key) {
int attempts = attemptsCache.getUnchecked(key);
attempts++;
attemptsCache.put(key, attempts);
}
public boolean isBlocked(String key) {
try {
return attemptsCache.get(key) >= MAX_ATTEMPT;
} catch (ExecutionException e) {
return false;
}
}
}3.3 PasswordEncoder 설정
패스워드 암호화를 위한 PasswordEncoder를 설정합니다. Spring Security는 다양한 암호화 알고리즘을 지원합니다.
⚠️ 보안 주의사항
패스워드는 반드시 암호화하여 저장해야 합니다. 평문으로 저장하면 심각한 보안 위험이 있습니다. BCrypt는 현재 가장 권장되는 암호화 방식입니다.
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt 암호화 사용 (권장)
return new BCryptPasswordEncoder(12); // strength: 4~31 (기본값: 10)
}
// 여러 암호화 방식을 지원하는 DelegatingPasswordEncoder
@Bean
public PasswordEncoder delegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
}
// 패스워드 암호화 서비스
@Service
public class PasswordService {
private final PasswordEncoder passwordEncoder;
public PasswordService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public String encodePassword(String rawPassword) {
return passwordEncoder.encode(rawPassword);
}
public boolean matches(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
// 패스워드 강도 검증
public boolean isValidPassword(String password) {
if (password == null || password.length() < 8) {
return false;
}
boolean hasUpper = password.chars().anyMatch(Character::isUpperCase);
boolean hasLower = password.chars().anyMatch(Character::isLowerCase);
boolean hasDigit = password.chars().anyMatch(Character::isDigit);
boolean hasSpecial = password.chars().anyMatch(ch -> "!@#$%^&*()_+-=[]{}|;:,.<>?".indexOf(ch) >= 0);
return hasUpper && hasLower && hasDigit && hasSpecial;
}
// 패스워드 변경
@Transactional
public void changePassword(String username, String oldPassword, String newPassword) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
throw new BadCredentialsException("Current password is incorrect");
}
if (!isValidPassword(newPassword)) {
throw new IllegalArgumentException("New password does not meet requirements");
}
user.setPassword(passwordEncoder.encode(newPassword));
user.setPasswordExpiryDate(LocalDateTime.now().plusDays(90)); // 90일 후 만료
userRepository.save(user);
}
}3.4 AuthenticationProvider 커스터마이징
AuthenticationProvider를 커스터마이징하여 특별한 인증 로직을 구현할 수 있습니다.
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final LoginAttemptService loginAttemptService;
public CustomAuthenticationProvider(CustomUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder,
LoginAttemptService loginAttemptService) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.loginAttemptService = loginAttemptService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
try {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 패스워드 검증
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
loginAttemptService.loginFailed(username);
throw new BadCredentialsException("Invalid credentials");
}
// 추가 검증 로직 (예: 2FA, IP 제한 등)
performAdditionalChecks(userDetails, authentication);
// 로그인 성공 처리
loginAttemptService.loginSucceeded(username);
return new UsernamePasswordAuthenticationToken(
userDetails,
password,
userDetails.getAuthorities()
);
} catch (UsernameNotFoundException e) {
loginAttemptService.loginFailed(username);
throw new BadCredentialsException("Invalid credentials");
}
}
private void performAdditionalChecks(UserDetails userDetails, Authentication authentication) {
// IP 주소 기반 접근 제한
String clientIP = getClientIP(authentication);
if (!isAllowedIP(clientIP)) {
throw new BadCredentialsException("Access denied from this IP address");
}
// 시간 기반 접근 제한
if (!isAllowedTime()) {
throw new BadCredentialsException("Access denied at this time");
}
// 사용자별 추가 검증
CustomUserDetails customUser = (CustomUserDetails) userDetails;
if (customUser.getUser().isTwoFactorEnabled()) {
// 2FA 검증 로직 (별도 구현 필요)
validateTwoFactorAuth(customUser, authentication);
}
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
private String getClientIP(Authentication authentication) {
if (authentication.getDetails() instanceof WebAuthenticationDetails) {
return ((WebAuthenticationDetails) authentication.getDetails()).getRemoteAddress();
}
return "unknown";
}
private boolean isAllowedIP(String ip) {
// IP 화이트리스트 검증 로직
List<String> allowedIPs = Arrays.asList("127.0.0.1", "192.168.1.*");
return allowedIPs.stream().anyMatch(allowed ->
ip.matches(allowed.replace("*", ".*")));
}
private boolean isAllowedTime() {
LocalTime now = LocalTime.now();
LocalTime startTime = LocalTime.of(9, 0); // 오전 9시
LocalTime endTime = LocalTime.of(18, 0); // 오후 6시
return now.isAfter(startTime) && now.isBefore(endTime);
}
}3.5 사용자 등록 및 관리
사용자 등록, 수정, 삭제 등의 관리 기능을 구현합니다.
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
public UserService(UserRepository userRepository,
RoleRepository roleRepository,
PasswordEncoder passwordEncoder,
EmailService emailService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordEncoder = passwordEncoder;
this.emailService = emailService;
}
public User registerUser(UserRegistrationDto registrationDto) {
// 중복 사용자 확인
if (userRepository.existsByEmail(registrationDto.getEmail())) {
throw new UserAlreadyExistsException("Email already registered");
}
if (userRepository.existsByUsername(registrationDto.getUsername())) {
throw new UserAlreadyExistsException("Username already taken");
}
// 새 사용자 생성
User user = new User();
user.setUsername(registrationDto.getUsername());
user.setEmail(registrationDto.getEmail());
user.setFirstName(registrationDto.getFirstName());
user.setLastName(registrationDto.getLastName());
user.setPassword(passwordEncoder.encode(registrationDto.getPassword()));
user.setEnabled(false); // 이메일 인증 후 활성화
user.setCreatedAt(LocalDateTime.now());
// 기본 역할 할당
Role userRole = roleRepository.findByName("USER")
.orElseThrow(() -> new RuntimeException("Default role not found"));
user.setRoles(Set.of(userRole));
// 이메일 인증 토큰 생성
String verificationToken = UUID.randomUUID().toString();
user.setEmailVerificationToken(verificationToken);
user.setEmailVerificationExpiry(LocalDateTime.now().plusHours(24));
User savedUser = userRepository.save(user);
// 인증 이메일 발송
emailService.sendVerificationEmail(user.getEmail(), verificationToken);
return savedUser;
}
public void verifyEmail(String token) {
User user = userRepository.findByEmailVerificationToken(token)
.orElseThrow(() -> new InvalidTokenException("Invalid verification token"));
if (user.getEmailVerificationExpiry().isBefore(LocalDateTime.now())) {
throw new TokenExpiredException("Verification token has expired");
}
user.setEnabled(true);
user.setEmailVerified(true);
user.setEmailVerificationToken(null);
user.setEmailVerificationExpiry(null);
userRepository.save(user);
}
public void initiatePasswordReset(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String resetToken = UUID.randomUUID().toString();
user.setPasswordResetToken(resetToken);
user.setPasswordResetExpiry(LocalDateTime.now().plusHours(1));
userRepository.save(user);
emailService.sendPasswordResetEmail(email, resetToken);
}
public void resetPassword(String token, String newPassword) {
User user = userRepository.findByPasswordResetToken(token)
.orElseThrow(() -> new InvalidTokenException("Invalid reset token"));
if (user.getPasswordResetExpiry().isBefore(LocalDateTime.now())) {
throw new TokenExpiredException("Reset token has expired");
}
user.setPassword(passwordEncoder.encode(newPassword));
user.setPasswordResetToken(null);
user.setPasswordResetExpiry(null);
user.setPasswordExpiryDate(LocalDateTime.now().plusDays(90));
userRepository.save(user);
}
}4. 폼 로그인
4.1 formLogin() 기본 설정
Spring Security의 formLogin()을 사용하여 폼 기반 로그인을 구성합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/home", "/register", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/auth/login") // 커스텀 로그인 페이지
.loginProcessingUrl("/auth/authenticate") // 로그인 처리 URL
.usernameParameter("email") // 사용자명 파라미터명
.passwordParameter("password") // 패스워드 파라미터명
.defaultSuccessUrl("/dashboard", true) // 로그인 성공 후 기본 URL
.failureUrl("/auth/login?error=true") // 로그인 실패 시 URL
.permitAll() // 로그인 관련 URL 모두 허용
)
.logout(logout -> logout
.logoutUrl("/auth/logout")
.logoutSuccessUrl("/auth/login?logout=true")
.permitAll()
)
.build();
}
}기본 로그인 페이지 vs 커스텀 로그인 페이지
4.2 커스텀 로그인 페이지 구현
Thymeleaf를 사용하여 커스텀 로그인 페이지를 구현합니다.
로그인 컨트롤러
@Controller
@RequestMapping("/auth")
public class AuthController {
@GetMapping("/login")
public String loginPage(@RequestParam(value = "error", required = false) String error,
@RequestParam(value = "logout", required = false) String logout,
@RequestParam(value = "expired", required = false) String expired,
Model model) {
if (error != null) {
model.addAttribute("errorMessage", "아이디 또는 비밀번호가 올바르지 않습니다.");
}
if (logout != null) {
model.addAttribute("logoutMessage", "성공적으로 로그아웃되었습니다.");
}
if (expired != null) {
model.addAttribute("expiredMessage", "세션이 만료되었습니다. 다시 로그인해주세요.");
}
return "auth/login";
}
@GetMapping("/register")
public String registerPage(Model model) {
model.addAttribute("userRegistrationDto", new UserRegistrationDto());
return "auth/register";
}
@PostMapping("/register")
public String registerUser(@Valid @ModelAttribute UserRegistrationDto registrationDto,
BindingResult result,
Model model,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "auth/register";
}
try {
userService.registerUser(registrationDto);
redirectAttributes.addFlashAttribute("successMessage",
"회원가입이 완료되었습니다. 이메일을 확인해주세요.");
return "redirect:/auth/login";
} catch (UserAlreadyExistsException e) {
model.addAttribute("errorMessage", e.getMessage());
return "auth/register";
}
}
}로그인 페이지 템플릿 (login.html)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="text-center">로그인</h3>
</div>
<div class="card-body">
<!-- 에러 메시지 -->
<div th:if="${errorMessage}" class="alert alert-danger" role="alert">
<span th:text="${errorMessage}"></span>
</div>
<!-- 로그아웃 메시지 -->
<div th:if="${logoutMessage}" class="alert alert-success" role="alert">
<span th:text="${logoutMessage}"></span>
</div>
<!-- 세션 만료 메시지 -->
<div th:if="${expiredMessage}" class="alert alert-warning" role="alert">
<span th:text="${expiredMessage}"></span>
</div>
<!-- 로그인 폼 -->
<form th:action="@{/auth/authenticate}" method="post">
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember-me" name="remember-me">
<label class="form-check-label" for="remember-me">로그인 상태 유지</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">로그인</button>
</div>
<!-- CSRF 토큰은 Thymeleaf가 자동으로 추가 -->
</form>
<div class="text-center mt-3">
<a th:href="@{/auth/register}">회원가입</a> |
<a th:href="@{/auth/forgot-password}">비밀번호 찾기</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>4.3 로그인/로그아웃 처리 커스터마이징
로그인 성공/실패 및 로그아웃 처리를 커스터마이징할 수 있습니다.
커스텀 인증 성공 핸들러
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final UserService userService;
private final AuditService auditService;
public CustomAuthenticationSuccessHandler(UserService userService, AuditService auditService) {
this.userService = userService;
this.auditService = auditService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String username = authentication.getName();
String clientIP = getClientIP(request);
// 로그인 성공 로그 기록
auditService.logLoginSuccess(username, clientIP);
// 마지막 로그인 시간 업데이트
userService.updateLastLoginTime(username);
// 사용자 역할에 따른 리다이렉트
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
String redirectUrl = determineTargetUrl(authorities, request);
// 세션에 사용자 정보 저장
HttpSession session = request.getSession();
session.setAttribute("userFullName", getUserFullName(authentication));
session.setAttribute("loginTime", LocalDateTime.now());
// 리다이렉트 실행
new DefaultRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
private String determineTargetUrl(Collection<? extends GrantedAuthority> authorities,
HttpServletRequest request) {
// 이전에 접근하려던 URL이 있으면 그곳으로 리다이렉트
String savedRequest = (String) request.getSession().getAttribute("SPRING_SECURITY_SAVED_REQUEST");
if (savedRequest != null) {
return savedRequest;
}
// 역할별 기본 페이지
boolean isAdmin = authorities.stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
boolean isManager = authorities.stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_MANAGER"));
if (isAdmin) {
return "/admin/dashboard";
} else if (isManager) {
return "/manager/dashboard";
} else {
return "/user/dashboard";
}
}
private String getClientIP(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0];
}
}커스텀 인증 실패 핸들러
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final AuditService auditService;
private final LoginAttemptService loginAttemptService;
public CustomAuthenticationFailureHandler(AuditService auditService,
LoginAttemptService loginAttemptService) {
this.auditService = auditService;
this.loginAttemptService = loginAttemptService;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String username = request.getParameter("email");
String clientIP = getClientIP(request);
// 실패 원인별 처리
String errorMessage = getErrorMessage(exception);
// 로그인 실패 로그 기록
auditService.logLoginFailure(username, clientIP, exception.getClass().getSimpleName());
// 실패 횟수 증가
if (username != null) {
loginAttemptService.loginFailed(username);
}
// 에러 메시지를 세션에 저장
request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", errorMessage);
// 로그인 페이지로 리다이렉트
new DefaultRedirectStrategy().sendRedirect(request, response, "/auth/login?error=true");
}
private String getErrorMessage(AuthenticationException exception) {
if (exception instanceof BadCredentialsException) {
return "아이디 또는 비밀번호가 올바르지 않습니다.";
} else if (exception instanceof DisabledException) {
return "계정이 비활성화되었습니다. 이메일 인증을 완료해주세요.";
} else if (exception instanceof LockedException) {
return "계정이 잠겨있습니다. 관리자에게 문의하세요.";
} else if (exception instanceof AccountExpiredException) {
return "계정이 만료되었습니다.";
} else if (exception instanceof CredentialsExpiredException) {
return "비밀번호가 만료되었습니다. 비밀번호를 변경해주세요.";
} else {
return "로그인에 실패했습니다. 다시 시도해주세요.";
}
}
}커스텀 로그아웃 핸들러
@Component
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
private final AuditService auditService;
public CustomLogoutSuccessHandler(AuditService auditService) {
this.auditService = auditService;
}
@Override
public void onLogoutSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
if (authentication != null) {
String username = authentication.getName();
String clientIP = getClientIP(request);
// 로그아웃 로그 기록
auditService.logLogoutSuccess(username, clientIP);
}
// 세션 완전 무효화
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
// 보안 헤더 추가
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
// 로그인 페이지로 리다이렉트
response.sendRedirect("/auth/login?logout=true");
}
}4.4 Remember-Me 기능
Remember-Me 기능을 통해 사용자가 브라우저를 닫아도 로그인 상태를 유지할 수 있습니다.
Remember-Me 동작 방식
- 해시 기반: 사용자명, 만료시간, 패스워드를 해시하여 쿠키에 저장
- 토큰 기반: 데이터베이스에 토큰을 저장하고 쿠키에는 토큰만 저장 (더 안전)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private DataSource dataSource;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.formLogin(form -> form
.loginPage("/auth/login")
.permitAll()
)
.rememberMe(remember -> remember
.key("mySecretKey") // 해시 생성용 키
.tokenValiditySeconds(86400 * 7) // 7일간 유효
.userDetailsService(userDetailsService()) // UserDetailsService 설정
.tokenRepository(persistentTokenRepository()) // 토큰 저장소 (선택사항)
.rememberMeParameter("remember-me") // 파라미터명 (기본값)
.rememberMeCookieName("remember-me-cookie") // 쿠키명
.useSecureCookie(true) // HTTPS에서만 쿠키 전송
)
.build();
}
// 토큰 기반 Remember-Me를 위한 저장소
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
}
// Remember-Me 토큰 테이블 생성 SQL
/*
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
);
*/커스텀 Remember-Me 서비스
@Service
public class CustomRememberMeService extends PersistentTokenBasedRememberMeServices {
private final AuditService auditService;
public CustomRememberMeService(String key,
UserDetailsService userDetailsService,
PersistentTokenRepository tokenRepository,
AuditService auditService) {
super(key, userDetailsService, tokenRepository);
this.auditService = auditService;
}
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request,
HttpServletResponse response) {
UserDetails user = super.processAutoLoginCookie(cookieTokens, request, response);
// Remember-Me 로그인 로그 기록
auditService.logRememberMeLogin(user.getUsername(), getClientIP(request));
return user;
}
@Override
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication successfulAuthentication) {
super.onLoginSuccess(request, response, successfulAuthentication);
// Remember-Me 토큰 생성 로그
auditService.logRememberMeTokenCreated(successfulAuthentication.getName());
}
}Remember-Me 보안 고려사항
- HTTPS 환경에서만 사용 (useSecureCookie(true))
- 토큰 기반 방식 사용 권장 (해시 기반보다 안전)
- 적절한 만료 시간 설정 (너무 길면 보안 위험)
- 중요한 작업 전에는 재인증 요구
- 정기적인 토큰 정리 작업 필요
4.5 다중 로그인 페이지 지원
서로 다른 사용자 그룹을 위한 별도의 로그인 페이지를 구성할 수 있습니다.
@Configuration
@EnableWebSecurity
public class MultiLoginSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/admin/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("ADMIN")
)
.formLogin(form -> form
.loginPage("/admin/login")
.loginProcessingUrl("/admin/authenticate")
.defaultSuccessUrl("/admin/dashboard")
.failureUrl("/admin/login?error=true")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/admin/logout")
.logoutSuccessUrl("/admin/login?logout=true")
)
.build();
}
@Bean
@Order(2)
public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/user/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("USER")
)
.formLogin(form -> form
.loginPage("/user/login")
.loginProcessingUrl("/user/authenticate")
.defaultSuccessUrl("/user/dashboard")
.failureUrl("/user/login?error=true")
.permitAll()
)
.build();
}
@Bean
@Order(3)
public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/home", "/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.build();
}
}5. 권한 관리
5.1 @PreAuthorize 어노테이션
@PreAuthorize는 메서드 실행 전에 권한을 검사하는 어노테이션입니다. SpEL(Spring Expression Language)을 사용하여 복잡한 권한 로직을 표현할 수 있습니다.
@EnableMethodSecurity 설정 필요
메서드 보안을 사용하려면 @EnableMethodSecurity(prePostEnabled = true) 어노테이션을 설정 클래스에 추가해야 합니다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // 메서드 보안 활성화
public class SecurityConfig {
// SecurityFilterChain 설정...
}
@RestController
@RequestMapping("/api/posts")
public class PostController {
// 인증된 사용자만 접근 가능
@PreAuthorize("isAuthenticated()")
@GetMapping("/my")
public List<Post> getMyPosts() {
return postService.getPostsByCurrentUser();
}
// ADMIN 역할을 가진 사용자만 접근 가능
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.deletePost(id);
return ResponseEntity.ok().build();
}
// 여러 역할 중 하나라도 가지면 접근 가능
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@PutMapping("/{id}/approve")
public ResponseEntity<Post> approvePost(@PathVariable Long id) {
Post approvedPost = postService.approvePost(id);
return ResponseEntity.ok(approvedPost);
}
// 특정 권한을 가진 사용자만 접근 가능
@PreAuthorize("hasAuthority('WRITE_POSTS')")
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody @Valid PostDto postDto) {
Post createdPost = postService.createPost(postDto);
return ResponseEntity.ok(createdPost);
}
// 복합 조건: ADMIN이거나 자신의 게시물인 경우
@PreAuthorize("hasRole('ADMIN') or @postService.isOwner(#id, authentication.name)")
@PutMapping("/{id}")
public ResponseEntity<Post> updatePost(@PathVariable Long id,
@RequestBody @Valid PostDto postDto) {
Post updatedPost = postService.updatePost(id, postDto);
return ResponseEntity.ok(updatedPost);
}
// 메서드 파라미터를 사용한 권한 검사
@PreAuthorize("#username == authentication.name or hasRole('ADMIN')")
@GetMapping("/user/{username}")
public List<Post> getPostsByUser(@PathVariable String username) {
return postService.getPostsByUsername(username);
}
// 객체의 속성을 사용한 권한 검사
@PreAuthorize("@postService.isOwner(#postDto.id, authentication.name)")
@PutMapping("/update")
public ResponseEntity<Post> updatePostByDto(@RequestBody PostDto postDto) {
Post updatedPost = postService.updatePost(postDto);
return ResponseEntity.ok(updatedPost);
}
}커스텀 권한 검사 서비스
@Service
public class PostService {
@Autowired
private PostRepository postRepository;
// @PreAuthorize에서 사용할 권한 검사 메서드
public boolean isOwner(Long postId, String username) {
return postRepository.findById(postId)
.map(post -> post.getAuthor().getUsername().equals(username))
.orElse(false);
}
public boolean canEdit(Long postId, String username) {
Post post = postRepository.findById(postId).orElse(null);
if (post == null) return false;
// 작성자이거나 관리자인 경우 수정 가능
return post.getAuthor().getUsername().equals(username) ||
hasRole(username, "ADMIN");
}
public boolean canView(Long postId, String username) {
Post post = postRepository.findById(postId).orElse(null);
if (post == null) return false;
// 공개 게시물이거나 작성자인 경우 조회 가능
return post.isPublic() || post.getAuthor().getUsername().equals(username);
}
private boolean hasRole(String username, String role) {
// 현재 사용자의 역할 확인 로직
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("ROLE_" + role));
}
}5.2 @PostAuthorize 어노테이션
@PostAuthorize는 메서드 실행 후 반환값을 기준으로 권한을 검사합니다. 메서드의 반환값에 접근할 수 있어 더 세밀한 권한 제어가 가능합니다.
@RestController
@RequestMapping("/api/posts")
public class PostController {
// 반환된 게시물의 작성자가 현재 사용자이거나 ADMIN인 경우만 접근 허용
@PostAuthorize("returnObject.author.username == authentication.name or hasRole('ADMIN')")
@GetMapping("/{id}")
public Post getPost(@PathVariable Long id) {
return postService.findById(id);
}
// 반환된 사용자 정보가 현재 사용자 본인이거나 ADMIN인 경우만 접근 허용
@PostAuthorize("returnObject.username == authentication.name or hasRole('ADMIN')")
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
// 컬렉션 필터링: 현재 사용자가 볼 수 있는 게시물만 반환
@PostFilter("filterObject.isPublic() or filterObject.author.username == authentication.name")
@GetMapping("/all")
public List<Post> getAllPosts() {
return postService.findAll();
}
// 복합 조건을 사용한 권한 검사
@PostAuthorize("(returnObject.isPublic() and hasRole('USER')) or " +
"(returnObject.author.username == authentication.name) or " +
"hasRole('ADMIN')")
@GetMapping("/{id}/details")
public PostDetails getPostDetails(@PathVariable Long id) {
return postService.getPostDetails(id);
}
}@PreAuthorize vs @PostAuthorize
5.3 @Secured 어노테이션
@Secured는 간단한 역할 기반 권한 검사를 위한 어노테이션입니다. SpEL을 지원하지 않아 @PreAuthorize보다 제한적이지만 더 간단합니다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // @Secured 활성화
public class SecurityConfig {
// 설정...
}
@RestController
@RequestMapping("/api/admin")
public class AdminController {
// ADMIN 역할만 접근 가능
@Secured("ROLE_ADMIN")
@GetMapping("/users")
public List<User> getAllUsers() {
return userService.findAll();
}
// 여러 역할 중 하나라도 가지면 접근 가능
@Secured({"ROLE_ADMIN", "ROLE_MODERATOR"})
@PostMapping("/posts/{id}/approve")
public ResponseEntity<Void> approvePost(@PathVariable Long id) {
postService.approvePost(id);
return ResponseEntity.ok().build();
}
// 단일 역할 검사
@Secured("ROLE_SUPER_ADMIN")
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.ok().build();
}
}
// JSR-250 어노테이션 사용 (@RolesAllowed, @PermitAll, @DenyAll)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true) // JSR-250 활성화
public class SecurityConfig {
// 설정...
}
@RestController
@RequestMapping("/api/reports")
public class ReportController {
// JSR-250 @RolesAllowed 사용
@RolesAllowed({"ADMIN", "MANAGER"})
@GetMapping("/sales")
public SalesReport getSalesReport() {
return reportService.getSalesReport();
}
// 모든 사용자 접근 허용
@PermitAll
@GetMapping("/public")
public PublicReport getPublicReport() {
return reportService.getPublicReport();
}
// 모든 사용자 접근 거부
@DenyAll
@GetMapping("/restricted")
public RestrictedReport getRestrictedReport() {
return reportService.getRestrictedReport();
}
}5.4 메서드 보안 고급 기능
Spring Security는 메서드 보안을 위한 다양한 고급 기능을 제공합니다.
@PreFilter와 @PostFilter
@Service
public class PostService {
// 입력 파라미터 필터링: 현재 사용자가 수정할 수 있는 게시물만 처리
@PreFilter("@postService.canEdit(filterObject.id, authentication.name)")
public List<Post> updatePosts(List<Post> posts) {
return posts.stream()
.map(this::updatePost)
.collect(Collectors.toList());
}
// 반환값 필터링: 현재 사용자가 볼 수 있는 게시물만 반환
@PostFilter("filterObject.isPublic() or filterObject.author.username == authentication.name")
public List<Post> getPostsByCategory(String category) {
return postRepository.findByCategory(category);
}
// 복합 필터링
@PreFilter("hasRole('ADMIN') or filterObject.author.username == authentication.name")
@PostFilter("filterObject.status == T(com.example.PostStatus).APPROVED")
public List<Post> processPostsForPublication(List<Post> posts) {
return posts.stream()
.map(this::processForPublication)
.collect(Collectors.toList());
}
}커스텀 Permission Evaluator
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private PostRepository postRepository;
@Autowired
private UserRepository userRepository;
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
if (authentication == null || targetDomainObject == null || !(permission instanceof String)) {
return false;
}
String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();
return hasPrivilege(authentication, targetType, permission.toString(), targetDomainObject);
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
if (authentication == null || targetType == null || !(permission instanceof String)) {
return false;
}
return hasPrivilege(authentication, targetType.toUpperCase(), permission.toString(), targetId);
}
private boolean hasPrivilege(Authentication auth, String targetType, String permission, Object target) {
String username = auth.getName();
switch (targetType) {
case "POST":
return hasPostPermission(username, permission, target);
case "USER":
return hasUserPermission(username, permission, target);
default:
return false;
}
}
private boolean hasPostPermission(String username, String permission, Object target) {
Post post = null;
if (target instanceof Post) {
post = (Post) target;
} else if (target instanceof Long) {
post = postRepository.findById((Long) target).orElse(null);
}
if (post == null) return false;
switch (permission) {
case "READ":
return post.isPublic() || post.getAuthor().getUsername().equals(username) || hasRole(username, "ADMIN");
case "WRITE":
return post.getAuthor().getUsername().equals(username) || hasRole(username, "ADMIN");
case "DELETE":
return post.getAuthor().getUsername().equals(username) || hasRole(username, "ADMIN");
default:
return false;
}
}
private boolean hasRole(String username, String role) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("ROLE_" + role));
}
}
// PermissionEvaluator 등록
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
CustomPermissionEvaluator permissionEvaluator) {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(permissionEvaluator);
return expressionHandler;
}
}
// 사용 예시
@RestController
public class PostController {
// hasPermission 사용
@PreAuthorize("hasPermission(#id, 'POST', 'READ')")
@GetMapping("/posts/{id}")
public Post getPost(@PathVariable Long id) {
return postService.findById(id);
}
@PreAuthorize("hasPermission(#post, 'WRITE')")
@PutMapping("/posts")
public Post updatePost(@RequestBody Post post) {
return postService.update(post);
}
}5.5 역할 계층 (Role Hierarchy)
역할 계층을 설정하여 상위 역할이 하위 역할의 권한을 자동으로 포함하도록 할 수 있습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String hierarchy = """
ROLE_SUPER_ADMIN > ROLE_ADMIN
ROLE_ADMIN > ROLE_MANAGER
ROLE_MANAGER > ROLE_USER
ROLE_USER > ROLE_GUEST
""";
roleHierarchy.setHierarchy(hierarchy);
return roleHierarchy;
}
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy);
return expressionHandler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN") // SUPER_ADMIN도 접근 가능
.requestMatchers("/manager/**").hasRole("MANAGER") // ADMIN, SUPER_ADMIN도 접근 가능
.requestMatchers("/user/**").hasRole("USER") // MANAGER, ADMIN, SUPER_ADMIN도 접근 가능
.anyRequest().authenticated()
)
.build();
}
}
// 역할 계층을 활용한 서비스
@Service
public class UserManagementService {
// ADMIN 이상의 역할만 접근 가능 (SUPER_ADMIN도 포함)
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
return userRepository.findAll();
}
// MANAGER 이상의 역할만 접근 가능 (ADMIN, SUPER_ADMIN도 포함)
@PreAuthorize("hasRole('MANAGER')")
public List<User> getTeamUsers() {
return userRepository.findByTeam(getCurrentUserTeam());
}
// USER 이상의 역할만 접근 가능 (모든 인증된 사용자)
@PreAuthorize("hasRole('USER')")
public User getCurrentUser() {
return userRepository.findByUsername(getCurrentUsername());
}
}역할 계층의 장점
- 권한 관리 단순화: 상위 역할이 하위 역할의 모든 권한을 자동 상속
- 코드 중복 감소: 각 메서드마다 모든 역할을 나열할 필요 없음
- 유지보수성 향상: 역할 구조 변경 시 한 곳에서만 수정
- 직관적인 권한 구조: 조직의 계층 구조와 일치
5.6 동적 권한 관리
데이터베이스 기반의 동적 권한 관리 시스템을 구현할 수 있습니다.
// 권한 관리 엔티티
@Entity
public class Permission {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name; // 권한 이름 (예: READ_POSTS, WRITE_POSTS)
private String resource; // 리소스 (예: POST, USER)
private String action; // 액션 (예: READ, WRITE, DELETE)
// getters, setters...
}
@Entity
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
// getters, setters...
}
// 동적 권한 검사 서비스
@Service
public class DynamicPermissionService {
@Autowired
private UserRepository userRepository;
@Autowired
private PermissionRepository permissionRepository;
public boolean hasPermission(String username, String resource, String action) {
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) return false;
return user.getRoles().stream()
.flatMap(role -> role.getPermissions().stream())
.anyMatch(permission ->
permission.getResource().equals(resource) &&
permission.getAction().equals(action));
}
public boolean hasAnyPermission(String username, String resource, String... actions) {
return Arrays.stream(actions)
.anyMatch(action -> hasPermission(username, resource, action));
}
public Set<String> getUserPermissions(String username) {
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) return Collections.emptySet();
return user.getRoles().stream()
.flatMap(role -> role.getPermissions().stream())
.map(permission -> permission.getResource() + ":" + permission.getAction())
.collect(Collectors.toSet());
}
}
// 동적 권한을 사용한 컨트롤러
@RestController
@RequestMapping("/api/posts")
public class PostController {
@Autowired
private DynamicPermissionService permissionService;
@PreAuthorize("@dynamicPermissionService.hasPermission(authentication.name, 'POST', 'READ')")
@GetMapping("/{id}")
public Post getPost(@PathVariable Long id) {
return postService.findById(id);
}
@PreAuthorize("@dynamicPermissionService.hasPermission(authentication.name, 'POST', 'WRITE')")
@PostMapping
public Post createPost(@RequestBody PostDto postDto) {
return postService.createPost(postDto);
}
@PreAuthorize("@dynamicPermissionService.hasAnyPermission(authentication.name, 'POST', 'DELETE', 'ADMIN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.deletePost(id);
return ResponseEntity.ok().build();
}
}6. 예외 처리
6.1 Spring Security 예외 처리 개요
Spring Security에서 발생하는 보안 관련 예외를 적절히 처리하여 사용자에게 명확한 피드백을 제공하고 보안을 강화할 수 있습니다.
주요 보안 예외 유형
// 예외 처리 흐름
1. 보안 예외 발생
2. ExceptionTranslationFilter가 예외를 캐치
3. AuthenticationException → AuthenticationEntryPoint 호출
4. AccessDeniedException → AccessDeniedHandler 호출
5. 적절한 응답 생성 (리다이렉트, JSON 응답 등)6.2 AuthenticationEntryPoint 구현
AuthenticationEntryPoint는 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출되는 인터페이스입니다.
기본 AuthenticationEntryPoint
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
String requestURI = request.getRequestURI();
// API 요청인 경우 JSON 응답
if (requestURI.startsWith("/api/")) {
handleApiRequest(request, response, authException);
} else {
// 웹 요청인 경우 로그인 페이지로 리다이렉트
handleWebRequest(request, response, authException);
}
}
private void handleApiRequest(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(401)
.error("Unauthorized")
.message("인증이 필요합니다.")
.path(request.getRequestURI())
.build();
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
private void handleWebRequest(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 원래 요청 URL을 세션에 저장
HttpSession session = request.getSession();
session.setAttribute("SPRING_SECURITY_SAVED_REQUEST", request.getRequestURL().toString());
// 로그인 페이지로 리다이렉트
response.sendRedirect("/auth/login?error=authentication_required");
}
}
// ErrorResponse DTO
@Data
@Builder
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
}JWT 기반 AuthenticationEntryPoint
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
logger.error("Unauthorized error: {}", authException.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
String errorMessage = determineErrorMessage(authException);
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("timestamp", LocalDateTime.now().toString());
errorDetails.put("status", 401);
errorDetails.put("error", "Unauthorized");
errorDetails.put("message", errorMessage);
errorDetails.put("path", request.getRequestURI());
// JWT 관련 추가 정보
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
errorDetails.put("tokenPresent", true);
errorDetails.put("tokenValid", false);
} else {
errorDetails.put("tokenPresent", false);
}
response.getWriter().write(objectMapper.writeValueAsString(errorDetails));
}
private String determineErrorMessage(AuthenticationException authException) {
if (authException instanceof BadCredentialsException) {
return "잘못된 자격증명입니다.";
} else if (authException instanceof DisabledException) {
return "계정이 비활성화되었습니다.";
} else if (authException instanceof LockedException) {
return "계정이 잠겨있습니다.";
} else if (authException instanceof AccountExpiredException) {
return "계정이 만료되었습니다.";
} else if (authException instanceof CredentialsExpiredException) {
return "자격증명이 만료되었습니다.";
} else {
return "인증이 필요합니다.";
}
}
}6.3 AccessDeniedHandler 구현
AccessDeniedHandler는 인증된 사용자가 권한이 없는 리소스에 접근할 때 호출되는 인터페이스입니다.
기본 AccessDeniedHandler
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication != null ? authentication.getName() : "anonymous";
logger.warn("Access denied for user '{}' to resource '{}': {}",
username, request.getRequestURI(), accessDeniedException.getMessage());
String requestURI = request.getRequestURI();
if (requestURI.startsWith("/api/")) {
handleApiAccessDenied(request, response, accessDeniedException, username);
} else {
handleWebAccessDenied(request, response, accessDeniedException, username);
}
}
private void handleApiAccessDenied(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException,
String username) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("timestamp", LocalDateTime.now().toString());
errorDetails.put("status", 403);
errorDetails.put("error", "Forbidden");
errorDetails.put("message", "접근 권한이 없습니다.");
errorDetails.put("path", request.getRequestURI());
errorDetails.put("user", username);
// 필요한 권한 정보 추가 (선택사항)
String requiredRole = extractRequiredRole(request);
if (requiredRole != null) {
errorDetails.put("requiredRole", requiredRole);
}
response.getWriter().write(objectMapper.writeValueAsString(errorDetails));
}
private void handleWebAccessDenied(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException,
String username) throws IOException {
// 접근 거부 페이지로 리다이렉트
String errorPage = "/error/403?path=" + URLEncoder.encode(request.getRequestURI(), StandardCharsets.UTF_8);
response.sendRedirect(errorPage);
}
private String extractRequiredRole(HttpServletRequest request) {
// 요청 경로를 기반으로 필요한 역할 추출 (예시)
String path = request.getRequestURI();
if (path.startsWith("/admin/")) {
return "ROLE_ADMIN";
} else if (path.startsWith("/manager/")) {
return "ROLE_MANAGER";
}
return null;
}
}역할별 AccessDeniedHandler
@Component
public class RoleBasedAccessDeniedHandler implements AccessDeniedHandler {
private final AuditService auditService;
public RoleBasedAccessDeniedHandler(AuditService auditService) {
this.auditService = auditService;
}
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
// 인증되지 않은 사용자 - 로그인 페이지로 리다이렉트
response.sendRedirect("/auth/login");
return;
}
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// 접근 거부 로그 기록
auditService.logAccessDenied(username, request.getRequestURI(),
authorities.toString(), accessDeniedException.getMessage());
// 사용자 역할에 따른 다른 처리
if (hasRole(authorities, "ROLE_USER")) {
handleUserAccessDenied(request, response);
} else if (hasRole(authorities, "ROLE_MANAGER")) {
handleManagerAccessDenied(request, response);
} else {
handleDefaultAccessDenied(request, response);
}
}
private void handleUserAccessDenied(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (request.getRequestURI().startsWith("/api/")) {
sendJsonError(response, "일반 사용자는 이 기능을 사용할 수 없습니다. 관리자에게 문의하세요.");
} else {
response.sendRedirect("/user/access-denied");
}
}
private void handleManagerAccessDenied(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (request.getRequestURI().startsWith("/api/")) {
sendJsonError(response, "관리자 권한이 필요한 기능입니다.");
} else {
response.sendRedirect("/manager/access-denied");
}
}
private void handleDefaultAccessDenied(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (request.getRequestURI().startsWith("/api/")) {
sendJsonError(response, "접근 권한이 없습니다.");
} else {
response.sendRedirect("/error/403");
}
}
private boolean hasRole(Collection<? extends GrantedAuthority> authorities, String role) {
return authorities.stream()
.anyMatch(authority -> authority.getAuthority().equals(role));
}
private void sendJsonError(HttpServletResponse response, String message) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now().toString());
error.put("status", 403);
error.put("error", "Forbidden");
error.put("message", message);
ObjectMapper mapper = new ObjectMapper();
response.getWriter().write(mapper.writeValueAsString(error));
}
}6.4 글로벌 예외 처리
@ControllerAdvice를 사용하여 Spring Security 예외를 포함한 모든 예외를 중앙에서 처리할 수 있습니다.
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Spring Security 관련 예외 처리
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(
AuthenticationException ex, HttpServletRequest request) {
log.error("Authentication error: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(401)
.error("Unauthorized")
.message(getAuthenticationErrorMessage(ex))
.path(request.getRequestURI())
.build();
return ResponseEntity.status(401).body(errorResponse);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(
AccessDeniedException ex, HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth != null ? auth.getName() : "anonymous";
log.error("Access denied for user '{}' to resource '{}': {}",
username, request.getRequestURI(), ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(403)
.error("Forbidden")
.message("접근 권한이 없습니다.")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(403).body(errorResponse);
}
// 메서드 보안 예외 처리
@ExceptionHandler(MethodSecurityException.class)
public ResponseEntity<ErrorResponse> handleMethodSecurityException(
MethodSecurityException ex, HttpServletRequest request) {
log.error("Method security error: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(403)
.error("Method Access Denied")
.message("메서드 실행 권한이 없습니다.")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(403).body(errorResponse);
}
// 사용자 정의 보안 예외 처리
@ExceptionHandler(UserAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleUserAlreadyExistsException(
UserAlreadyExistsException ex, HttpServletRequest request) {
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(409)
.error("Conflict")
.message(ex.getMessage())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(409).body(errorResponse);
}
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ErrorResponse> handleInvalidTokenException(
InvalidTokenException ex, HttpServletRequest request) {
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(400)
.error("Invalid Token")
.message(ex.getMessage())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(400).body(errorResponse);
}
// 일반적인 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex, HttpServletRequest request) {
log.error("Unexpected error: ", ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(500)
.error("Internal Server Error")
.message("서버 내부 오류가 발생했습니다.")
.path(request.getRequestURI())
.build();
return ResponseEntity.status(500).body(errorResponse);
}
private String getAuthenticationErrorMessage(AuthenticationException ex) {
if (ex instanceof BadCredentialsException) {
return "아이디 또는 비밀번호가 올바르지 않습니다.";
} else if (ex instanceof DisabledException) {
return "계정이 비활성화되었습니다.";
} else if (ex instanceof LockedException) {
return "계정이 잠겨있습니다.";
} else if (ex instanceof AccountExpiredException) {
return "계정이 만료되었습니다.";
} else if (ex instanceof CredentialsExpiredException) {
return "자격증명이 만료되었습니다.";
} else {
return "인증에 실패했습니다.";
}
}
}6.5 커스텀 예외 클래스
애플리케이션 특화된 보안 예외 클래스를 정의하여 더 세밀한 예외 처리를 구현할 수 있습니다.
// 사용자 정의 예외 클래스들
public class UserAlreadyExistsException extends RuntimeException {
public UserAlreadyExistsException(String message) {
super(message);
}
}
public class InvalidTokenException extends RuntimeException {
public InvalidTokenException(String message) {
super(message);
}
}
public class TokenExpiredException extends RuntimeException {
public TokenExpiredException(String message) {
super(message);
}
}
public class InsufficientPrivilegeException extends AccessDeniedException {
private final String requiredPrivilege;
private final String currentPrivilege;
public InsufficientPrivilegeException(String message, String requiredPrivilege, String currentPrivilege) {
super(message);
this.requiredPrivilege = requiredPrivilege;
this.currentPrivilege = currentPrivilege;
}
public String getRequiredPrivilege() {
return requiredPrivilege;
}
public String getCurrentPrivilege() {
return currentPrivilege;
}
}
// 보안 감사 예외
public class SecurityAuditException extends RuntimeException {
private final String username;
private final String action;
private final String resource;
public SecurityAuditException(String message, String username, String action, String resource) {
super(message);
this.username = username;
this.action = action;
this.resource = resource;
}
// getters...
}
// 예외 사용 예시
@Service
public class UserService {
public User createUser(UserRegistrationDto dto) {
if (userRepository.existsByEmail(dto.getEmail())) {
throw new UserAlreadyExistsException("이미 등록된 이메일입니다: " + dto.getEmail());
}
if (userRepository.existsByUsername(dto.getUsername())) {
throw new UserAlreadyExistsException("이미 사용중인 사용자명입니다: " + dto.getUsername());
}
// 사용자 생성 로직...
}
public void verifyEmailToken(String token) {
User user = userRepository.findByEmailVerificationToken(token)
.orElseThrow(() -> new InvalidTokenException("유효하지 않은 인증 토큰입니다."));
if (user.getEmailVerificationExpiry().isBefore(LocalDateTime.now())) {
throw new TokenExpiredException("인증 토큰이 만료되었습니다.");
}
// 이메일 인증 처리...
}
}6.6 예외 처리 설정 통합
SecurityFilterChain에서 예외 처리 핸들러들을 통합하여 설정합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
public SecurityConfig(CustomAuthenticationEntryPoint authenticationEntryPoint,
CustomAccessDeniedHandler accessDeniedHandler) {
this.authenticationEntryPoint = authenticationEntryPoint;
this.accessDeniedHandler = accessDeniedHandler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/home", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/auth/login")
.permitAll()
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(authenticationEntryPoint) // 인증 실패 처리
.accessDeniedHandler(accessDeniedHandler) // 인가 실패 처리
)
.build();
}
}
// 예외 처리 테스트를 위한 컨트롤러
@RestController
@RequestMapping("/api/test")
public class SecurityTestController {
@GetMapping("/public")
public ResponseEntity<String> publicEndpoint() {
return ResponseEntity.ok("Public endpoint - no authentication required");
}
@GetMapping("/authenticated")
public ResponseEntity<String> authenticatedEndpoint(Authentication authentication) {
return ResponseEntity.ok("Authenticated endpoint - user: " + authentication.getName());
}
@GetMapping("/admin")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> adminEndpoint() {
return ResponseEntity.ok("Admin endpoint - admin access required");
}
@GetMapping("/user-or-admin")
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public ResponseEntity<String> userOrAdminEndpoint() {
return ResponseEntity.ok("User or Admin endpoint");
}
}예외 처리 모범 사례
- API와 웹 요청을 구분하여 적절한 응답 형식 제공
- 보안 관련 로그를 상세히 기록하되 민감한 정보는 제외
- 사용자에게는 명확하고 도움이 되는 오류 메시지 제공
- 공격자에게 시스템 정보를 노출하지 않도록 주의
- 예외 발생 시 적절한 감사 로그 기록
7. 정리
7.1 Spring Security 설정 체크리스트
Spring Security를 프로덕션 환경에 적용하기 전에 확인해야 할 필수 항목들입니다.
✅ 기본 보안 설정
🔐 인증 설정
🛡️ 인가 설정
📊 모니터링 및 로깅
7.2 실전 가이드
실제 프로젝트에서 Spring Security를 효과적으로 활용하기 위한 실전 가이드입니다.
7.2.1 프로젝트 초기 설정
// 1. 의존성 추가 (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
// 테스트
testImplementation 'org.springframework.security:spring-security-test'
}
// 2. 기본 보안 설정 클래스 생성
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/home", "/register", "/css/**", "/js/**", "/images/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}7.2.2 사용자 엔티티 및 리포지토리
// User 엔티티
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
private boolean enabled = true;
private boolean accountNonExpired = true;
private boolean accountNonLocked = true;
private boolean credentialsNonExpired = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
// constructors, getters, setters...
}
// Role 엔티티
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String name;
// constructors, getters, setters...
}
// UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
Optional<User> findByEmailOrUsername(String email, String username);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
}7.2.3 완전한 UserDetailsService 구현
@Service
@Transactional(readOnly = true)
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByEmailOrUsername(username, username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.accountExpired(!user.isAccountNonExpired())
.accountLocked(!user.isAccountNonLocked())
.credentialsExpired(!user.isCredentialsNonExpired())
.disabled(!user.isEnabled())
.build();
}
}7.2.4 컨트롤러 구현
@Controller
public class AuthController {
private final UserService userService;
public AuthController(UserService userService) {
this.userService = userService;
}
@GetMapping("/login")
public String loginPage() {
return "auth/login";
}
@GetMapping("/register")
public String registerPage(Model model) {
model.addAttribute("user", new UserRegistrationDto());
return "auth/register";
}
@PostMapping("/register")
public String registerUser(@Valid @ModelAttribute UserRegistrationDto userDto,
BindingResult result,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "auth/register";
}
try {
userService.registerUser(userDto);
redirectAttributes.addFlashAttribute("success", "회원가입이 완료되었습니다.");
return "redirect:/login";
} catch (Exception e) {
result.rejectValue("email", "error.user", e.getMessage());
return "auth/register";
}
}
}
@Controller
public class DashboardController {
@GetMapping("/dashboard")
public String dashboard(Model model, Authentication authentication) {
model.addAttribute("username", authentication.getName());
return "dashboard";
}
@GetMapping("/admin")
@PreAuthorize("hasRole('ADMIN')")
public String adminPage() {
return "admin/dashboard";
}
}7.3 성능 최적화 팁
Spring Security 애플리케이션의 성능을 최적화하기 위한 팁들입니다.
세션 관리 최적화
// 세션 클러스터링 설정
@Configuration
public class SessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("JSESSIONID");
serializer.setCookiePath("/");
serializer.setDomainNamePattern("^.+?\.(\w+\.[a-z]+)$");
serializer.setHttpOnly(true);
serializer.setSecure(true); // HTTPS 환경에서만
return serializer;
}
@Bean
public SessionRepository sessionRepository() {
// Redis 기반 세션 저장소
return new RedisSessionRepository(redisTemplate());
}
}캐싱 전략
@Service
@Transactional(readOnly = true)
public class CachedUserDetailsService implements UserDetailsService {
@Cacheable(value = "users", key = "#username")
@Override
public UserDetails loadUserByUsername(String username) {
// 사용자 정보 로드 로직
return loadUser(username);
}
@CacheEvict(value = "users", key = "#username")
public void evictUserCache(String username) {
// 사용자 정보 변경 시 캐시 무효화
}
}데이터베이스 최적화
// 인덱스 추가
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
// 연관관계 최적화
@Entity
public class User {
@ManyToMany(fetch = FetchType.LAZY) // EAGER 대신 LAZY 사용
private Set<Role> roles;
// 필요시에만 조인 페치
@Query("SELECT u FROM User u JOIN FETCH u.roles WHERE u.username = :username")
Optional<User> findByUsernameWithRoles(@Param("username") String username);
}7.4 보안 모범 사례
Spring Security를 사용할 때 반드시 지켜야 할 보안 모범 사례들입니다.
🚨 보안 주의사항
- 패스워드를 평문으로 저장하지 말 것
- 기본 사용자 계정을 프로덕션에서 사용하지 말 것
- 보안 관련 정보를 로그에 노출하지 말 것
- HTTPS 없이 인증 정보를 전송하지 말 것
- 세션 ID를 URL에 포함하지 말 것
패스워드 보안
- BCrypt 또는 Argon2 사용
- 최소 8자 이상, 복합 문자 조합
- 패스워드 히스토리 관리
- 정기적인 패스워드 변경 권장
- 브루트 포스 공격 방어
세션 보안
- 세션 고정 공격 방어
- 적절한 세션 타임아웃 설정
- 동시 세션 수 제한
- 로그아웃 시 세션 완전 무효화
- 보안 쿠키 설정 (HttpOnly, Secure)
권한 관리
- 최소 권한 원칙 적용
- 역할 기반 접근 제어
- 정기적인 권한 검토
- 임시 권한 자동 만료
- 권한 변경 감사 로그
7.5 테스트 전략
Spring Security 기능을 효과적으로 테스트하기 위한 전략입니다.
// 보안 테스트 예시
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional
class SecurityIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testPublicEndpointAccess() {
ResponseEntity<String> response = restTemplate.getForEntity("/public", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void testProtectedEndpointWithoutAuth() {
ResponseEntity<String> response = restTemplate.getForEntity("/protected", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@WithMockUser(roles = "USER")
void testProtectedEndpointWithAuth() {
ResponseEntity<String> response = restTemplate.getForEntity("/protected", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@WithMockUser(roles = "USER")
void testAdminEndpointWithUserRole() {
ResponseEntity<String> response = restTemplate.getForEntity("/admin", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}
// 메서드 보안 테스트
@SpringBootTest
class MethodSecurityTest {
@Autowired
private UserService userService;
@Test
@WithMockUser(roles = "ADMIN")
void testAdminMethodAccess() {
assertDoesNotThrow(() -> userService.deleteUser(1L));
}
@Test
@WithMockUser(roles = "USER")
void testUserMethodAccessDenied() {
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(1L));
}
}7.6 마무리
Spring Security는 강력하고 유연한 보안 프레임워크입니다. 이 세션에서 학습한 내용을 바탕으로 안전하고 효율적인 웹 애플리케이션을 구축할 수 있습니다.
핵심 요약
- 인증과 인가: 사용자 신원 확인과 권한 관리의 기본 개념
- SecurityFilterChain: 보안 필터 체인을 통한 요청 처리
- UserDetailsService: 사용자 정보 로드 및 관리
- PasswordEncoder: 안전한 패스워드 암호화
- 메서드 보안: @PreAuthorize, @PostAuthorize를 통한 세밀한 권한 제어
- 예외 처리: 보안 예외에 대한 적절한 응답 처리
다음 단계
- OAuth2와 JWT를 활용한 토큰 기반 인증
- 마이크로서비스 환경에서의 보안 구현
- Spring Security와 React/Vue.js 연동
- 보안 테스트 자동화
- 성능 모니터링 및 최적화