
Spring Security는 웹 애플리케이션 보안을 관리하기 위한 Spring Framework의 주요 컴포넌트로, 인증(Authentication)과 인가(Authorization) 기능을 제공한다. 인증은 사용자가 누구인지 확인하는 과정이며, 인가는 인증된 사용자가 애플리케이션의 특정 자원에 접근할 수 있는지 검증하는 과정이다. 이러한 보안 기능은 Security Filter Chain을 통해 요청을 필터링하는 방식으로 이루어진다.
주요 컴포넌트:
우리 프로젝트에서는 요구사항에 따라 Spring Security와 JWT(JSON Web Token)를 사용해 무상태(Stateless) 인증을 구현한다. 이를 통해 애플리케이션 서버가 세션을 관리하지 않고, 사용자는 토큰을 통해 자격 증명을 유지하게 된다.
build.gradle 파일에 Spring Security 의존성을 추가한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
SecurityConfig 클래스는 Spring Security의 주요 설정을 관리한다. 여기서는 JWT와 커스텀 필터, 역할 기반 접근 제어를 구성하여 보안을 강화했다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationConfiguration authenticationConfiguration;
private final UserService userService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(requests -> requests
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated())
.exceptionHandling(exception -> exception
.accessDeniedHandler(new CustomAccessDeniedHandler())
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()))
.addFilterBefore(customAuthenticationFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public RoleHierarchy roleHierarchy() {
return fromHierarchy(
"ROLE_MANAGER > ROLE_CUSTOMER\n" +
"ROLE_MANAGER > ROLE_OWNER\n" +
"ROLE_MASTER > ROLE_MANAGER");
}
private CustomAuthenticationFilter customAuthenticationFilter(AuthenticationManager authenticationManager) {
CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter();
customAuthenticationFilter.setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler(jwtTokenProvider));
customAuthenticationFilter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());
customAuthenticationFilter.setAuthenticationManager(authenticationManager);
return customAuthenticationFilter;
}
private JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider, userService);
}
}
AuthConfig에서는 비밀번호를 암호화하기 위해 BCryptPasswordEncoder를 사용한다. 이는 비밀번호를 안전하게 저장하고 인증 시 검증에 사용된다.
@Configuration
public class AuthConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
CustomAuthenticationProvider는 Spring Security의 AuthenticationProvider 인터페이스를 구현하여 사용자 자격 증명(아이디와 비밀번호)을 직접 확인하는 커스텀 인증 로직을 제공한다.
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String) authentication.getCredentials();
User user = getUser(username);
checkPassword(password, user);
return new UsernamePasswordAuthenticationToken(user, null, List.of(new SimpleGrantedAuthority(user.getRole().getAuthority())));
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
}
private User getUser(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Invalid username"));
}
private void checkPassword(String password, User user) {
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("Invalid password");
}
}
}
JwtTokenProvider는 JWT 토큰을 생성하고 검증하는 기능을 제공한다. 사용자의 인증 상태를 유지하고, 토큰에서 사용자 정보를 추출하는 데 사용된다.
@Component
public class JwtTokenProvider {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final Duration ACCESS_TOKEN_DURATION = Duration.ofHours(2);
private static final String AUTHORIZATION_TYPE = "Bearer ";
private static final String AUTHORITIES_KEY = "authorities";
@Value("${jwt.secret.key}")
private String secret;
private Key key;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secret);
key = Keys.hmacShaKeyFor(bytes);
}
public String createAccessToken(Long userId, String authority) {
Date now = new Date();
Date expiry = new Date(now.getTime() + ACCESS_TOKEN_DURATION.toMillis());
return AUTHORIZATION_TYPE + Jwts.builder()
.setSubject(userId.toString())
.claim(AUTHORITIES_KEY, authority)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key)
.compact();
}
public String substringToken(String authorizationHeaderValue) {
if (authorizationHeaderValue.startsWith(AUTHORIZATION_TYPE)) {
return authorizationHeaderValue.replace(AUTHORIZATION_TYPE, "");
}
return null;
}
public boolean isValidToken(String jwtToken) {
Jwts.parser().setSigningKey(secret).parseClaimsJws(jwtToken);
return true;
}
public String getSubject(String token) {
return getClaims(token).getSubject();
}
public String getAuthority(String token) {
return getClaims(token).get(AUTHORITIES_KEY, String.class);
}
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(token)
.getBody();
}
}
사실 이번 과제 팀 프로젝트에서 인증, 유저 도메인은 다른 팀원이 담당하게 되어 나는 작성된 코드를 분석하며 Spring Security 개념을 공부하며 이해해보는 시간을 가지게 되었다. 스프링 부트에서 스프링 시큐리티 사용하는 프로젝트를 맨바닥부터 구현해본 경험이 없어서 이번 주에는 이 부분을 심도있게 공부해보고자 한다. 그리고 위 글을 작성하며 배운 점은 아래와 같다.