티스토리 뷰

Etc.

Spring Security

ccc124213131 2023. 3. 24. 23:03
728x90

인증: 사용자를 식별하는 것

인가: 자원에 대한 접근을 통제하는 것(특정 자원에 접근할 때 적절한 권한이 있는지 확인하는 과정)

어떤 API에 접근하려고 할 때, 인증과 인가를 위해 각 API에 대응되는 메소드마다 로직을 추가할 수 있지만 이러면 중복된 코드가 발생할 것임

따라서 모든 요청이 공통적으로 통과해야하는 서블릿 필터에 이 로직을 한 번만 구현해주면 들어오는 모든 요청을 지정한 조건에 따라 인증과 인가 절차를 진행할 수 있을 것

스프링 부트에서 시큐리티 스타터 의존성을 설치 시 관련 의존성들이 추가되고 자동설정들도 적용됨

그 중 하나로, 메모리에 인증에 필요한 사용자가 자동으로 등록됨. 아이디는 user, 비밀번호는 콘솔에 Using generated security password 라는 텍스트와 함께 출력되는 긴 문자열.

또한 API에 접근하려고 하면 로그인 폼이 존재하는 /login 경로로 리다이렉트됨

언급한 user, password로 로그인하면 성공적으로 로그인이 되는 것을 볼 수 있음

이제부터는 이렇게 인증된 사용자만 자원에 접근할 수 있음. 단지 스프링 시큐리티 스타터만 추가했을 뿐인데도 이렇게 인증을 사용할 수 있게 된 것

참고: 스프링 부트는 기본적으로 INFO 레벨 이상의 로그만 출력하도록 설정되어 있음. 시큐리티 관련한 더 많은 로그 메시지를 확인하려면 로그 레벨을 DEBUG로 변경 해야함

application.properties
logging.level.org.springframework.security=debug

시큐리티 커스터마이징

기본적으로 제공해주는 기능을 넘어 우리가 원하는 기능을 구현하기 위해서는 커스터마이징 필요

가장 먼저 시큐리티 관련 설정을 위한 환경 설정 클래스 작성

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.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    }
}

@EnableWebSecurity: 이 클래스로부터 생성된 객체가 시큐리티 설정 파일임을 의미하고, 동시에 시큐리티를 사용하는데 필요한 수많은 객체 생성

매개변수로 받는 HttpSecurity 객체: 이 객체를 이용해 앱 자원에 대한 인증과 인가 제어 가능

위와 같은 코드를 작성만 해도 더 이상 앱은 로그인을 강제하지 않음.

filterChain 메서드 내에 URL 경로와 권한 설정 가능

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
			http.authorizeRequests()
				.antMatchers("/").permitAll()
				.antMatchers("/member/**").authenticated()
				.antMatchers("/manager/**").hasRole("MANAGER")
				.antMatchers("/admin/**").hasRole("ADMIN");
				.csrf().disable();
    }
}

AuthorizedUrl는 빌더 패턴을 사용하기 때문에 위와 같이 작성할 수 있음

특정 경로에 대한 권한을 가진 사용자만 접근이 가능하게끔 하려면 AuthorizedUrl 객체의 메서드를 이용해야함

AuthorizedUrl는 HttpSecurity의 authorizeRequests() 메서드를 호출했을 때 리턴된 ExpressionInterceptUrlRegistry의 antMatchers() 메서드를 통해 얻을 수 있음

위 코드의 의미는 다음과 같음

/: 인증이 필요하지 않음,

"/member/**": 인증이 필요

"/manager/**": MANAGER Role 필요

"/admin/**": ADMIN Role 필요

이제 다시 사용자를 인증하기 위해, 로그인 시켜야함

http.authorizeRequests()에 .formLogin()을 추가하면 <form> 태그 기반의 로그인을 지원할 수 있음

이제 다시 /member, /admin, /manager로 접근하면 로그인 화면으로 이동하는 것을 볼 수 있음

부트가 제공하는 로그인 화면이 아니라 직접 작성한 로그인 화면을 사용하고 싶으면 loginPage()를 추가할 수 있음

formLogin().loginPage("/signin").defaultSuccessUrl("/main", true);

defaultSuccessUrl: 로그인 성공 시 이동할 URL 지정

지금까지는 시큐리티가 제공하는 ‘user’를 이용해 로그인 인증을 진행했으나 실제로는 데이터베이스에 저장된 회원 정보를 이용하여 로그인 인증을 처리함

인증과 관련된 메소드 추가

@Autowired DataSource dataSource;

@Autowired
public void authenticate(AuthenticationManagerBuilder auth) throws Exception {
	String query1 = "select id username, concat('{noop}', password) password from member where id=?";
	String query2 = "select id, role from member where id=?";
	
	auth.jdbcAuthentication()
		.dataSource(dataSource)
		.usersByUsernameQuery(query1)
		.authoritiesByUsername(query2);
}

AuthenticationManagerBuilder 객체를 의존성 주입 받아 인증에 필요한 사용자 정보 생성

먼저 데이터 소스를 멤버 변수로 선언하고, 의존성 주입

첫 번째 쿼리로 사용자가 입력한 아이디로 사용자 정보를 조회

두 번째 쿼리로 권한 정보 조회

중요: 시큐리티는 조회한 사용자 정보를, 내부에서 사용하는 org.springframework.security.core.userdetails.User 객체에 자동 매핑

이 때 아이디는 username, 비밀번호는 password 변수에 저장하는데, 조회 결과의 컬럼 이름이 username, password로 일치해야 자동으로 매핑됨

비밀번호는 암호화를 적용하지 않기 위해 문자열 앞에 {noop} 추가한 것

usersByUsernameQuery()로 인증 처리 수행. 인증에 실패하면 다음 단계로 진행하지 않음

인증에 통과했다면 authoritiesByUsername로 사용자의 권한 검증

AuthenticationManagerBuilder가 지원하는 인증 방식은 메모리, JDBC, LDAP가 있음

로그아웃 처리

시큐리티가 인증을 처리하는 기본적인 방식은 HttpSession을 기반으로 함

때문에 브라우저가 종료되면 자동으로 브라우저와 함께 세션도 사라짐

브라우저를 종료하지 않고도 세션을 종료하려면? 사용자가 로그아웃 요청을 하고 서버는 해당 세션을 종료해야함

이 기능을 위해 SecurityConfig에 로그아웃 관련 설정 추가

.logout().invalidateHttpSession(true).logoutSuccessUrl("/login");

invalidateHttpSession(true): 현재 브라우저와 연관된 세션을 강제 종료

logoutSuccessUrl: 로그아웃 후 이동할 화면 리다이렉트 지정

쿠키 정보까지 삭제하고 싶으면 .deleteCookies() 추가

시큐리티 이해 및 데이터베이스 연동

스프링 시큐리티의 구조와 용어, 동작 원리에 대한 이해

스프링 시큐리티 원리 이해

시큐리티 필터

스프링 시큐리티는 서블릿 필터(javax.servlet.Filter)로 개발한 시큐리티 필터를 기반으로 함

서블릿 필터는 클라이언트의 요청을 가로채서 서블릿이 수행되기 전후에 전처리, 후처리를 수행하거나 요청을 리다이렉트하는 용도로 사용

일반적으로 필터 한 개당 하나의 기능을 처리하기 때문에 여러 기능이 필요한 경우 여러 필터를 만들어 필터 체인을 형성하여 사용함

스프링 시큐리티는 시큐리티와 관련된 다양한 기능들을 필터 체인으로 제공

인증되지 않은 사용자가 어떤 URL을 요청하거나, 로그인 화면을 보여주는 기능같은 것이 시큐리티 필터 중 하나에 의해 처리된 것

스프링 시큐리티에서 제공하는 필터 중 몇 가지는 다음과 같음

SecurityContextPersistenceFilter

  • SecurityContextRepository에서 SecurityContext 객체를 꺼내와 SecurityContextHolder에 저장하고, 요청 처리가 끝나면 제거

UsernamePasswordAuthenticationFilter

  • 로그인 요청이 들어오면, 아이디/비밀번호 기반의 인증 수행. 인증에 성공하면 지정한 페이지로 이동하고, 실패하면 로그인 화면을 보여줌

SessionManagementFilter

  • 세션 타임아웃, 동시 접근 제어, 세션 고정 공격 등을 처리

ExceptionTranslationFilter

  • FilterSecurityInterceptor에서 발생한 예외를 웹에 맞는 응답으로 변환. 403 상태 코드를 응답하거나 로그인 페이지로 이동하는 작업 처리

FilterSecurityInterceptor

  • 권한 검사 하는 곳. 권한이 없으면 예외를 발생

각 필터는 등록된 순서대로 동작함

스프링 시큐리티를 구성하는 필터 중 가장 중요한 것중 하나가 UsernamePasswordAuthenticationFilter.

이 필터가 실제로 사용자가 입력한 인증 정보를 이용해 인증 처리하기 때문

또한 FilterSecurityInterceptor도 중요. 이 필터는 인증에 성공한 사용자가 해당 리소스에 접근할 권한이 있는지를 검증하기 때문

요청 → 인증 관리 Filter [사용자 정보 접근] → 권한 관리 Filter [리소스 권한 목록 접근] → 리소스

흐름

  • 사용자가 리소스 요청 시 가장 먼저 인증 관리 필터가 요청을 가로채서, 파일이나 데이터베이스에 저장되어 있는 사용자 정보를 읽어 입력한 인증 정보를 검증
  • 인증 성공 시 리소스에 대한 접근 권한 검사. 리소스 별 권한 목록을 참조해 검증

JPA 연동

엔티티 클래스와 리포지터리 작성

만약 회원이 가질 수 있는 권한을 ROLE_ADMIN, ROLE_MANAGER, ROLE_MEMBER로 설정했다고 가정하면, 이러한 권한에 대한 문자열을 직접 저장할 수도 있지만 일반적으로 Enum 타입으로 지정함

public enum Role {
	ROLE_ADMIN, ROLE_MANAGER, ROLE_MEMBER
}

Role을 사용하는 회원 엔티티

@Entity @Getter @Setter @ToString
public class Member {
	@Id
	private String id;
	private String password;
	private String name;
	@Enumerated(EnumType.STRING)
	private Role role;
}

role 변수는 EnumType.STRING으로 설정했기 때문에 권한에 해당하는 값이 문자열로 지정됨

이 엔티티를 이용해 데이터베이스 연동을 처리하기 위해 리포지터리 인터페이스 작성

public interface MemberRepository extends JpaRepository<Member, String> {
}

사용자 정의 UserDetailsService 구현

인증 관리 필터가 사용자가 입력한 정보를 토대로 기능을 처리하기 위해 사용자 정보가 저장된 UserDetails 객체가 필요함

그리고 이 객체에 실제 데이터베이스에서 검색한 사용자 정보를 저장하는 UserDetailsService 객체도 필요

인증 관리자는 UserDetailsService 객체를 통해 UserDetails 객체를 얻고 이 객체에서 인증과 인가에 필요한 정보들을 추출해 사용

스프링 부트는 UserDetailsService를 구현한 클래스를 기본적으로 제공

그리고 이 클래스가 제공하는 UserDetails 객체는 아이디가 user, 비밀번호는 콘솔에 출력되었던 문자열

UserDetailsService를 커스터마이징 하고 싶으면 UserDetailsService 인터페이스를 구현한 클래스를 직접 작성하여 등록하면 됨

@Service
public BoardUserDetailsService implements UserDetailsService {
	@Autowired
	private MemberRepository memberRepository;

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		// MemberRepository로 회원 정보 조회 후
		// UserDetails 타입의 객체로 리턴
	}
}

스프링이 제공하는 org.springframework.security.core.userdetails.User 클래스가 바로 UserDetails 인터페이스를 구현한 클래스

문제는 loadUserByUsername()의 리턴 타입이 UserDetails 이기 때문에 MemberRepository로 검색한 Member 엔티티를 리턴할 수 없다는 것

따라서, 이를 UserDetails로 변환하기 위해 Member 클래스를 UserDetails 인터페이스를 구현하게 하거나, 이미 UserDetails 인터페이스를 구현한 User 클래스를 상속해야함.

하지만 전자 같은 경우 재정의 해야할 추상 메서드들이 생기고, 후자는 복잡한 생성자를 추가해야함

무엇보다 두 방법 모두 Member를 순수한 엔티티로 사용하지 않게 되기 떄문에 적절한 방법이 아님

따라서 Member 클래스는 그대로 두고 스프링이 제공하는 User 클래스를 상속하여 새로운 클래스를 정의하여 구현, Member 클래스를 User 클래스로 매핑하는 방식을 사용

import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;

public class SecurityUser extends User {
	private static final long serialVersionUID = 1L;
	
	public SecurityUser(Member member) {
		super(member.getId(), "{noop}" + member.getPassword(), AuthorityUtils.createAuthorityList(member.getRole().toString()));
	}
}

org.springframework.security.core.userdetails.User를 상속하였고, User 클래스의 생성자를 호출할 때, 검색 결과로 얻은 Member 객체의 값을 전달

이제 SecurityUser를 이용하도록 BoardUserDetailsService 클래스 수정

@Service
public BoardUserDetailsService implements UserDetailsService {
	@Autowired
	private MemberRepository memberRepository;

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Optional<Member> optional = memberRepo.findById(username);
		if (!optional.isPresent()) {
			throw new UsernameNotFoundException(username + " 사용자 없음");
		} else {
			Member member = optional.get();
			return new SecurityUser(member);
		}
	}
}

이제 마지막으로 사용자가 정의한 UserDetailsService를 사용하도록 SecurityConfig 수정

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
		@Autowired
		private BoardUserDetailService boardUserDetailService;
	
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
			http.authorizeRequests()
				.antMatchers("/").permitAll()
				.antMatchers("/member/**").authenticated()
				.antMatchers("/manager/**").hasRole("MANAGER")
				.antMatchers("/admin/**").hasRole("ADMIN");
				.csrf().disable()
				.userDetailsService(boardUserDetailsService);
    }
}

기존에 사용했던 DataSource 변수와 authenticate() 삭제.

그리고 기본 UserDetailsService가 아닌 사용자 정의 UserDetailsService를 사용할 수 있도록 userDetailsService()를 이용하여 변경

PasswordEncoder 사용하기

지금까지 작성한 코드의 문제는 비밀번호를 암호화하지 않은 평문으로 저장한 것

이렇게 하면 나중에 보안 사고가 발생했을 때 사용자의 비밀번호가 노출되어 문제가 생길 수 있고, 이런 사고 여부와 무관하게 개인 정보 보호법에 위배됨

따라서 반드시 암호화 처리해야함

시큐리티는 패스워드를 쉽게 암호화 할 수 있도록 PasswordEncoder라는 인터페이스를 구현한 클래스들을 제공함

따라서 몇 가지 암호화 관련 설정만 추가하면 사용자가 입력한 비밀번호를 암호화해 처리할 수 있음

PasswordEncoder 객체를 생성할 때 사용하는 PasswordEncoderFactories 클래스

public static PasswordEncoder createDelegatingPasswordEncoder() {
	String encodingId = "bcrypt";
	Map<String, PasswordEncoder> encoders = new HashMap<>();
	encoders.put(encodingId, new BCryptPasswordEncoder());
	...
	return new DelegatingPasswordEncoder(encodingId, encoders);
}

PasswordEncoderFactories의 createDelegatingPasswordEncoder()로 PasswordEncoder 객체를 요청하면 기본적으로 BCryptPasswordEncoder 객체를 리턴함.

나머지 PasswordEncoder들은 지원 중단되어 사용하지 않음

BCryptPasswordEncoder는 비밀번호 암호화에 특화되어 있으면서 가장 안전한 해시 알고리즘인 BCrypt를 사용

암호화 적용

매우 간단함. SecurityConfig 클래스에 BCryptPasswordEncoder 객체를 리턴하는 PasswordEncoder() 메서드만 추가하면 됨

@Bean
public PasswordEncoder passwordEncoder() {
	return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

23213

댓글