Spring Security를 활용한 인증과 권한 관리 🔐
안녕하세요, 재능넷 커뮤니티 여러분! 오늘은 Java 개발자들에게 매우 중요한 주제인 'Spring Security를 활용한 인증과 권한 관리'에 대해 깊이 있게 알아보겠습니다. 이 글을 통해 여러분은 Spring Security의 기본 개념부터 고급 기능까지 상세히 이해할 수 있을 것입니다. 🚀
Spring Security는 Spring 기반 애플리케이션의 보안을 담당하는 강력한 프레임워크입니다. 인증(Authentication)과 권한 부여(Authorization)를 쉽게 구현할 수 있게 해주며, 다양한 보안 위협으로부터 애플리케이션을 보호합니다. 특히 재능넷과 같은 사용자 중심의 플랫폼에서는 보안이 매우 중요하죠. 그럼 지금부터 Spring Security의 세계로 깊이 들어가 봅시다!
1. Spring Security 소개 및 기본 개념 🌟
1.1 Spring Security란?
Spring Security는 Spring 프레임워크의 하위 프로젝트로, 애플리케이션의 보안을 담당하는 강력한 도구입니다. 주요 기능으로는 다음과 같은 것들이 있습니다:
- 인증 (Authentication): 사용자가 누구인지 확인
- 권한 부여 (Authorization): 인증된 사용자가 어떤 작업을 수행할 수 있는지 결정
- 보안 취약점 방어: CSRF, XSS 등의 공격으로부터 보호
- 세션 관리: 사용자 세션의 생성, 유지, 만료 처리
- 암호화: 비밀번호 등 중요 정보의 안전한 저장
1.2 Spring Security의 핵심 컴포넌트
Spring Security의 구조를 이해하기 위해서는 다음과 같은 핵심 컴포넌트들을 알아야 합니다:
- SecurityContextHolder: 보안 주체의 세부 정보를 포함하는 SecurityContext를 저장
- SecurityContext: 현재 애플리케이션을 사용중인 주체의 인증 정보를 보유
- Authentication: 현재 인증된 사용자의 정보를 나타내는 인터페이스
- UserDetails: 애플리케이션의 사용자 정보를 담는 인터페이스
- UserDetailsService: UserDetails 객체를 검색하기 위한 핵심 인터페이스
- GrantedAuthority: 사용자에게 부여된 권한을 나타내는 인터페이스
1.3 Spring Security의 동작 원리
Spring Security는 서블릿 필터 체인을 기반으로 동작합니다. 주요 동작 과정은 다음과 같습니다:
- 클라이언트가 요청을 보냅니다.
- 서블릿 컨테이너가 FilterChain을 생성합니다.
- Spring Security의 DelegatingFilterProxy가 요청을 가로챕니다.
- SecurityFilterChain에 등록된 필터들이 차례로 요청을 처리합니다.
- 인증 및 권한 부여 과정을 거칩니다.
- 최종적으로 요청이 컨트롤러에 도달하거나, 보안 정책에 따라 거부됩니다.
📌 참고: Spring Security의 필터 체인은 매우 유연하게 구성할 수 있습니다. 개발자는 필요에 따라 커스텀 필터를 추가하거나 기존 필터의 순서를 변경할 수 있습니다.
2. Spring Security 설정하기 ⚙️
2.1 의존성 추가
Spring Security를 사용하기 위해서는 먼저 프로젝트에 필요한 의존성을 추가해야 합니다. Maven을 사용하는 경우, pom.xml 파일에 다음과 같이 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Gradle을 사용하는 경우, build.gradle 파일에 다음 줄을 추가합니다:
implementation 'org.springframework.boot:spring-boot-starter-security'
2.2 기본 설정 클래스 생성
Spring Security의 기본 설정을 위해 설정 클래스를 생성합니다. 이 클래스는 @Configuration과 @EnableWebSecurity 어노테이션을 사용합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
return http.build();
}
}
이 기본 설정은 다음과 같은 내용을 포함합니다:
- 홈 페이지("/", "/home")는 모든 사용자에게 접근 허용
- 그 외의 모든 요청은 인증된 사용자만 접근 가능
- 로그인 페이지는 "/login"으로 설정하고 모든 사용자에게 접근 허용
- 로그아웃 기능 활성화
2.3 사용자 정보 설정
Spring Security는 기본적으로 인메모리 사용자를 제공합니다. 실제 애플리케이션에서는 데이터베이스와 연동하여 사용자 정보를 관리하는 것이 일반적이지만, 테스트를 위해 인메모리 사용자를 설정해 보겠습니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class UserConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails user =
User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
⚠️ 주의: 위의 예제에서 사용된 User.withDefaultPasswordEncoder()
는 테스트 목적으로만 사용해야 합니다. 실제 운영 환경에서는 더 안전한 암호화 방식을 사용해야 합니다.
2.4 커스텀 로그인 페이지 생성
Spring Security의 기본 로그인 페이지 대신 커스텀 로그인 페이지를 사용하고 싶다면, 다음과 같이 HTML 파일을 생성할 수 있습니다:
<!-- src/main/resources/templates/login.html -->
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인</h1>
<form th:action="@{/login}" method="post">
<div>
<label> 사용자명 : <input type="text" name="username"/> </label>
</div>
<div>
<label> 비밀번호 : <input type="password" name="password"/> </label>
</div>
<div>
<input type="submit" value="로그인"/>
</div>
</form>
</body>
</html>
이렇게 설정하면 기본적인 Spring Security 구성이 완료됩니다. 이제 애플리케이션은 보안 기능을 갖추게 되었고, 설정된 규칙에 따라 인증과 권한 관리가 이루어집니다.
3. 인증(Authentication) 구현하기 🔑
인증은 사용자가 자신이 주장하는 본인임을 증명하는 과정입니다. Spring Security에서는 다양한 인증 방식을 지원합니다. 여기서는 가장 일반적인 폼 기반 인증과 JWT(JSON Web Token) 기반 인증에 대해 알아보겠습니다.
3.1 폼 기반 인증
폼 기반 인증은 사용자가 웹 폼을 통해 아이디와 비밀번호를 입력하여 인증하는 방식입니다. 이는 가장 기본적이고 널리 사용되는 인증 방식입니다.
3.1.1 SecurityConfig 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}
3.1.2 UserDetailsService 구현
실제 사용자 정보를 데이터베이스에서 가져오기 위해 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 new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.getAuthorities()
);
}
}
3.2 JWT 기반 인증
JWT(JSON Web Token)는 클레임이라고 불리는 정보를 JSON 객체로 안전하게 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준(RFC 7519)입니다. JWT는 특히 RESTful API에서 많이 사용됩니다.
3.2.1 JWT 의존성 추가
Maven 프로젝트의 경우 pom.xml에 다음 의존성을 추가합니다:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
3.2.2 JWT 유틸리티 클래스 생성
import io.jsonwebtoken.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtUtil {
private String SECRET_KEY = "secret";
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <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_KEY).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
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() + 1000 * 60 * 60 * 10))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY).compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
3.2.3 JWT 필터 생성
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@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 = jwtUtil.extractUsername(jwt);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}
3.2.4 SecurityConfig 수정
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests().antMatchers("/authenticate").permitAll()
.anyRequest().authenticated()
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
💡 Tip: JWT를 사용할 때는 토큰의 만료 시간을 적절히 설정하고, 필요한 경우 토큰 갱신 메커니즘을 구현하는 것이 좋습니다. 또한, 토큰의 보안을 위해 HTTPS를 사용하는 것이 중요합니다.
이렇게 설정하면 JWT를 이용한 인증 시스템이 구축됩니다. 클라이언트는 로그인 후 받은 JWT를 이용해 이후의 요청에 대한 인증을 수행할 수 있습니다.
4. 권한 관리(Authorization) 구현하기 🛡️
권한 관리는 인증된 사용자가 특정 리소스에 접근하거나 특정 작업을 수행할 수 있는지를 결정하는 프로세스입니다. Spring Security는 강력하고 유연한 권한 관리 기능을 제공합니다.
4.1 권한(Authorities)과 역할(Roles)
Spring Security에서 권한과 역할은 다음과 같이 구분됩니다:
- 권한(Authority): 특정 작업을 수행할 수 있는 권리를 나타냅니다. 예: "READ_USER", "WRITE_USER"
- 역할(Role): 관련된 권한들의 그룹입니다. 관례적으로 "ROLE_" 접두사를 사용합니다. 예: "ROLE_ADMIN", "ROLE_USER"
4.2 메소드 수준의 보안
Spring Security는 메소드 수준에서 보안을 적용할 수 있는 강력한 기능을 제공합니다. 이를 위해 @PreAuthorize, @PostAuthorize, @Secured 등의 어노테이션을 사용할 수 있습니다.
4.2.1 설정 활성화
메소드 수준의 보안을 사용하기 위해 @EnableGlobalMethodSecurity 어노테이션을 추가합니다:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ... 기존 설정 ...
}
4.2.2 @PreAuthorize 사용 예
@RestController
@RequestMapping("/api/users")
public class UserController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping
public List<User> getAllUsers() {
// 모든 사용자 정보를 반환하는 로직
}
@PreAuthorize("hasAuthority('READ_USER') or #username == authentication.principal.username")
@GetMapping("/{username}")
public User getUser(@PathVariable String username) {
// 특정 사용자 정보를 반환하는 로직
}
@PreAuthorize("hasAuthority('WRITE_USER')")
@PostMapping
public User createUser(@RequestBody User user) {
// 새로운 사용자를 생성하는 로직
}
}
4.3 URL 기반 보안
URL 패턴에 따라 접근 권한을 설정할 수 있습니다. 이는 SecurityConfig 클래스에서 구성합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/users/**").hasRole("USER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}
4.4 커스텀 권한 평가자(Custom Permission Evaluator)
더 복잡한 권한 로직이 필요한 경우, 커스텀 권한 평가자를 구현할 수 있습니다.
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
if ((auth == null) || (targetDomainObject == null) || !(permission instanceof String)) {
return false;
}
String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();
return hasPrivilege(auth, targetType, permission.toString().toUpperCase());
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
if ((auth == null) || (targetType == null) || !(permission instanceof String)) {
return false;
}
return hasPrivilege(auth, targetType.toUpperCase(), permission.toString().toUpperCase());
}
private boolean hasPrivilege(Authentication auth, String targetType, String permission) {
for (GrantedAuthority grantedAuth : auth.getAuthorities()) {
if (grantedAuth.getAuthority().startsWith(targetType) &&
grantedAuth.getAuthority().contains(permission)) {
return true;
}
}
return false;
}
}
이 커스텀 권한 평가자를 Spring Security 설정에 등록합니다:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler =
new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
return expressionHandler;
}
}
📌 참고: 권한 관리를 구현할 때는 최소 권한의 원칙(Principle of Least Privilege)을 따르는 것이 좋습니다. 사용자에게 필요한 최소한의 권한만을 부여하여 보안 위험을 최소화하세요.
5. 고급 Spring Security 기능 🚀
Spring Security는 기본적인 인증과 권한 관리 외에도 다양한 고급 기능을 제공합니다. 이러한 기능들을 활용하면 더욱 강력하고 유연한 보안 시스템을 구축할 수 있습니다.
5.1 Remember Me 기능
"Remember Me" 기능은 사용자가 브라우저를 닫았다가 다시 열어도 로그인 상태를 유지할 수 있게 해줍니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter { <pre><code>
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ... 기존 설정 ...
.rememberMe()
.key("uniqueAndSecret")
.tokenValiditySeconds(86400) // 24시간
}
}
5.2 세션 관리
Spring Security는 세션 고정 보호, 동시 세션 제어 등 다양한 세션 관리 기능을 제공합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ... 기존 설정 ...
.sessionManagement()
.sessionFixation().migrateSession()
.maximumSessions(1).maxSessionsPreventsLogin(true)
}
}
5.3 CORS(Cross-Origin Resource Sharing) 설정
다른 도메인의 리소스에 접근할 수 있도록 CORS를 설정할 수 있습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ... 기존 설정 ...
.cors()
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("https://example.com"));
configuration.setAllowedMethods(Arrays.asList("GET","POST"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
5.4 CSRF(Cross-Site Request Forgery) 보호
CSRF 공격을 방지하기 위한 설정을 할 수 있습니다. 기본적으로 활성화되어 있지만, RESTful API에서는 비활성화하는 경우도 있습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ... 기존 설정 ...
.csrf().disable() // CSRF 보호 비활성화
}
}
5.5 OAuth2 / OpenID Connect 지원
Spring Security는 OAuth2와 OpenID Connect를 지원합니다. 이를 통해 소셜 로그인 등을 구현할 수 있습니다.
@Configuration
@EnableWebSecurity
public class OAuth2LoginConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorize-client")
.authorizationRequestRepository(authorizationRequestRepository())
.and()
.tokenEndpoint()
.accessTokenResponseClient(accessTokenResponseClient())
.and()
.userInfoEndpoint()
.userService(oauth2UserService())
}
@Bean
public OAuth2UserService<oauth2userrequest oauth2user=""> oauth2UserService() {
// OAuth2 사용자 서비스 구현
}
// ... 기타 필요한 빈 설정 ...
}
</oauth2userrequest>
5.6 암호화
Spring Security는 비밀번호 암호화를 위한 다양한 인코더를 제공합니다. 가장 권장되는 방식은 BCrypt입니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// ... 기타 설정 ...
}
5.7 커스텀 필터 추가
특정 요구사항을 충족시키기 위해 커스텀 필터를 추가할 수 있습니다.
public class CustomFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 커스텀 로직 구현
chain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ... 기존 설정 ...
.addFilterBefore(new CustomFilter(), UsernamePasswordAuthenticationFilter.class)
}
}
💡 Tip: Spring Security의 고급 기능을 사용할 때는 항상 보안과 사용자 경험 사이의 균형을 고려해야 합니다. 너무 엄격한 보안 설정은 사용자 경험을 해칠 수 있으므로, 적절한 수준의 보안을 유지하면서도 사용자 친화적인 시스템을 구축하는 것이 중요합니다.