Spring Security 적용하기

김해나·2024년 11월 11일

Spring Security 개념

Spring Security는 웹 애플리케이션 보안을 관리하기 위한 Spring Framework의 주요 컴포넌트로, 인증(Authentication)인가(Authorization) 기능을 제공한다. 인증은 사용자가 누구인지 확인하는 과정이며, 인가는 인증된 사용자가 애플리케이션의 특정 자원에 접근할 수 있는지 검증하는 과정이다. 이러한 보안 기능은 Security Filter Chain을 통해 요청을 필터링하는 방식으로 이루어진다.

주요 컴포넌트:

  • SecurityFilterChain: 요청을 처리할 여러 필터를 포함하며, UsernamePasswordAuthenticationFilter와 같은 인증 필터와 JwtAuthenticationFilter를 통해 인증 절차가 진행된다.
  • AuthenticationManagerAuthenticationProvider: 사용자 자격 증명(예: 아이디/비밀번호)을 확인하는 메커니즘을 제공하며, 커스텀 인증 로직을 구현할 수 있다.
  • UserDetailsService: 인증 과정에서 사용자의 정보를 로드하며, 주로 DB에서 사용자 정보를 불러오는 역할을 한다.
  • PasswordEncoder: 비밀번호를 안전하게 암호화하는 인터페이스로, 주로 BCryptPasswordEncoder를 사용한다.

우리 프로젝트에서는 요구사항에 따라 Spring Security와 JWT(JSON Web Token)를 사용해 무상태(Stateless) 인증을 구현한다. 이를 통해 애플리케이션 서버가 세션을 관리하지 않고, 사용자는 토큰을 통해 자격 증명을 유지하게 된다.

Spring Security 적용하기

의존성 추가

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 클래스 작성

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);
	}
}
  • httpBasic과 csrf, formLogin을 비활성화하여 JWT 기반 인증에 맞는 무상태(Stateless) 보안 설정을 구성했다.
  • 세션 관리: SessionCreationPolicy.STATELESS로 설정하여 서버에서 세션을 사용하지 않고, 클라이언트가 JWT를 통해 인증 상태를 유지하도록 한다.
  • 인가 정책: /api/auth/login 경로는 누구나 접근할 수 있게 허용하고, 그 외의 모든 요청은 인증된 사용자만 접근할 수 있도록 구성했다.
  • 예외 처리: CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint를 통해 접근 권한이 없거나 인증되지 않은 사용자가 접근할 때 발생하는 예외를 처리한다.
  • 필터 추가: customAuthenticationFilter와 jwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여, 사용자 자격 증명 및 JWT 인증을 수행한다.

AuthConfig 설정

AuthConfig에서는 비밀번호를 암호화하기 위해 BCryptPasswordEncoder를 사용한다. 이는 비밀번호를 안전하게 저장하고 인증 시 검증에 사용된다.

@Configuration
public class AuthConfig {

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

CustomAuthenticationProvider 구현

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");
		}
	}
}
  • authenticate 메서드는 사용자 이름과 비밀번호를 검증하여 유효한 사용자일 경우 UsernamePasswordAuthenticationToken을 반환한다.
  • getUser 메서드는 DB에서 사용자 정보를 가져오며, 유효하지 않은 사용자명일 경우 UsernameNotFoundException을 발생시킨다.
  • checkPassword 메서드는 입력된 비밀번호와 저장된 비밀번호를 비교하여 일치하지 않을 경우 BadCredentialsException을 발생시킨다.

JwtTokenProvider 구현

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();
	}
}
  • createAccessToken 메서드는 JWT 토큰을 생성하여 사용자의 userId와 역할 정보를 담아 반환한다.
  • substringToken은 Authorization 헤더에서 Bearer 타입의 토큰 값을 추출한다. 만약 헤더가 “Bearer “로 시작한다면 이를 제거하고 JWT 값만 반환한다.
  • isValidToken: 전달받은 토큰이 유효한지 검증하며, 서명이 올바른지 확인하여 유효한 토큰만 인증되도록 한다.
  • getSubject: 토큰에서 사용자의 ID(또는 사용자명)를 추출한다.
  • getAuthority: 토큰에서 사용자의 역할을 나타내는 authorities 값을 추출한다.
  • getClaims: 토큰의 Claims 객체를 반환하여, 토큰에 저장된 다양한 정보를 불러올 수 있다.

TIL

사실 이번 과제 팀 프로젝트에서 인증, 유저 도메인은 다른 팀원이 담당하게 되어 나는 작성된 코드를 분석하며 Spring Security 개념을 공부하며 이해해보는 시간을 가지게 되었다. 스프링 부트에서 스프링 시큐리티 사용하는 프로젝트를 맨바닥부터 구현해본 경험이 없어서 이번 주에는 이 부분을 심도있게 공부해보고자 한다. 그리고 위 글을 작성하며 배운 점은 아래와 같다.

  1. JWT 기반 인증: JWT는 사용자 인증 상태를 관리하기 위한 효율적인 방법으로, 무상태 서버에서 사용하기에 적합하다. 프로젝트에서는 JWT를 사용해 사용자 인증과 인가 과정을 무상태로 구현함으로써 API 서버의 확장성과 보안성을 높일 수 있었다.
  2. Spring Security의 커스터마이징: Spring Security의 SecurityConfig와 AuthenticationProvider를 커스터마이징하여 필요한 보안 정책과 필터를 자유롭게 추가할 수 있음을 배웠다. 특히 CustomAuthenticationProvider를 통해 사용자 자격 증명을 직접 검증하고, 예외 처리를 통해 사용자 경험을 개선할 수 있었다.
  3. 무상태 인증 설정: SessionCreationPolicy.STATELESS와 같은 설정을 통해 세션을 사용하지 않는 방식으로 서버의 부담을 줄였으며, 클라이언트가 인증된 요청을 지속적으로 보낼 수 있게 했다.
  4. PasswordEncoder의 중요성: BCryptPasswordEncoder를 통해 비밀번호를 안전하게 암호화하여 저장할 수 있었다. 이는 사용자 정보를 안전하게 관리하고 인증 과정을 보다 신뢰성 있게 유지하기 위해 필수적이다.

0개의 댓글