스프링 애플리케이션에서 스프링 시큐리티를 사용하기 위해서는 스프링 부트 스타터 시큐리티 의존성을 빌드 명세에 추가해야 한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
첫번째는 스프링 부트 보안 스타터 의존성이고, 두번째는 보안 테스트 의존성이다.
어떤 보안 구성이 자동으로 제공되는지 알아보자.
보안 스타터 의존성 (spring-boot-starter-security)만 추가했을 경우
최소한 필요한 스프링 시큐리티 구성
SecurityConfig 클래스 (WebSecurityConfigurerAdapter의 서브 클래스)
보안 구성 클래스인 WebSecurityConfigurerAdapter의 서브 클래스이다.
사용자의 HTTP 요청 경로에 대해 접근 제한과 같은 보안 관련 처리 설정한다.
그리고 두개의 configure() 메서드를 오버라이딩하고 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 사용자 인증 정보를 구성하는 메소드
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user1")
.password("{noop}password1")
.authorities("ROLE_USER")
.and()
.withUser("user2")
.password("{noop}password2")
.authorities("ROLE_USER");
}
// HTTP 보안을 구성하는 메소드
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "orders")
.access("hasRole('ROLE_USER')")
.antMatchers("/", "/**").access("permitAll")
.and()
.httpBasic();
}
}
한 명 이상의 사용자를 처리할 수 있도록 사용자 정보를 유지/관리하는 사용자 스토어를 구성해야한다.
스프링 시큐리티에서는 여러 가지의 사용자 스토어 구성 방법을 제공한다.
사용자 정보를 유지/관리할 수 있는 곳 중 하나가 메모리다.
만일 변경이 필요 없는 사용자만 미리 정해 놓고 애플리케이션을 사용한다면 아예 보안 구성 코드 내부에 정의할 수 있을 것이다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
public SecurityConfig(UserDetailsService userRepositoryUserDetailsService) {
this.userRepositoryUserDetailsService = userRepositoryUserDetailsService;
}
...
}
사용자 정보는 관계형 데이터베이스로 유지/관리되는 경우가 많으므로 JDBC 기반의 사용자 스토어가 적합해 보인다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource);
}
...
}
스프링 시큐리티의 것과 다른 데이터베이스를 사용한다면, 스프링 스큐리티의 SQL 쿼리를 커스터마이징 할 수 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery("select username, password, enabled from users where username=?")
.authoritiesByUsernameQuery("select username, authority from authorities where username=?")
}
...
}
스프링 시큐리티의 것과 다른 데이터베이스를 사용한다면, 스프링 스큐리티의 SQL 쿼리를 커스터마이징 할 수 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery("select username, password, enabled from users where username=?")
.authoritiesByUsernameQuery("select username, authority from authorities where username=?")
.passwordEncoder(new BCryptPasswordEncoder()); // 비밀번호 암호화(encoder)를 지정.
}
...
}
암호화된 비밀번호 사용하기
비밀번호를 데이터베이스에 저장할 때와 사용자가 입력한 비밀번호는 모두 같은 암호화 알고리즘을 사용해서 암호화해야 한다.
비밀번호를 암호화할 때는 다음과 같이 passwordEncoder() 메서드를 호출하여 비밀번호 인코더를 지정한다.
(위 소스 코드 참조)
passwordEncoder() 메소드는 스프링 시큐리티의 PasswordEncoder 인터페이스를 구현하는 어떤 객체도 인자로 받을 수 있다.
LDAP(Lightweight Directory Access Protocol) 이란?
네트워크 상에서 조직이나 개인정보 혹은 파일이나 디바이스 정보 등을 찾아보는 것을 가능하게 만든 소프트웨어 프로토콜이다.
네트워크 상의 디렉토리 서비스 표준인 X.500의 DAP(Directory Access Protocol)를 기반으로한 경량화(Lightweight) 된 DAP 버전이다.
디렉터리 서비스는?
Lightweight 하다.
기본적으로 바이너리 프로토콜이다.
비동기 프로토콜이다.
용도
LDAP 기반 인증으로 스프링 시큐리티를 구성하기 위해서 ldapAuthentication() 메서드를 사용할 수 있다.
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchFilter("(uid={0})")
.groupSearchFilter("(member={0})");
}
사용자 도메인 객체와 퍼시스턴스 정의하기
/**
* User 클래스는 스프링 시큐리티의 UserDetails 인터페이스를 구현.
*/
@Entity
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
@RequiredArgsConstructor
public class User implements UserDetails {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private final String username;
private final String password;
private final String fullname;
private final String street;
private final String city;
private final String state;
private final String zip;
private final String phoneNumber;
/**
* 해당 사용자에게 부여된 권한을 저장한 컬렉션을 반환
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
public interface UserRepository extends CrudRepository<User, Long> {
User findByUsername(String username);
}
커스텀 사용자 명세 서비스 정의
@Service // 스프링이 컴포넌트 스캔을 해준다는 것을 의미.
public class UserRepositoryUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// UserRepositoryUserDetailsService에 생성자를 통해 UserRepository 인스턴스가 주입된다.
public UserRepositoryUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username); // 주입된 UserRepository 인스턴스의 findByUsername()을 호출해 User을 찾는다.
// 유저를 찾지 못했을 경우
if (user == null) {
throw new UsernameNotFoundException(String.format("User %s not found", username));
}
// 유저를 찾은 경우 유저를 반환
return user;
}
}
Spring Security 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private UserDetailsService userRepositoryUserDetailsService;
@Bean
public PasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
public SecurityConfig(UserDetailsService userRepositoryUserDetailsService) {
this.userRepositoryUserDetailsService = userRepositoryUserDetailsService;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userRepositoryUserDetailsService)
.passwordEncoder(encoder());
}
}
encoder()에 @Bean 어노테이션이 지정되었으므로 encoder() 메소드가 생성한 BCryptPasswordEncoder 인스턴스가 스프링 애플리케이션 컨텍스트에 등록, 관리되며 이 인스턴스가 애플리케이션 컨텍스트로부터 주입되어 반환된다.
따라서 우리가 원하는 종류의 PasswordEncoder 빈 객체를 스프링의 관리하에 사용할 수 있다.
홈페이지, 로그인 등 특정 페이지는 인증되지 않은 모든 사용자가 사용할 수 있어야 한다.
이러한 보안 규칙을 구성하려면 configure(HttpSecurity http) 메소드를 오버라이딩해야 한다.
@Override
protected void configure(HttpSecurity http) throws Exception {
...
}
HttpSecurity를 사용해서 구성할 수 있는 것은 다음과 같다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "orders")
.hasRole("ROLE_USER")
.antMatchers("/", "/**").access("permitAll");
}
...
}
/design, /orders의 요청은 인증된 사용자(ROLE_USER)에게만 허용되고 나머지는 모든 사용자에게 허용
이런 규칙을 지정할 때는 순서가 중요하다.
antMatchers()에서 지정된 경로의 패턴 일치를 검사하므로 먼저 지정된 보안 규칙이 우선적으로 처리된다.
대부분의 메서드는 요청 처리의 기본적인 보안 규칙을 제공한다. 그
러나 각 메서드에 정의된 보안 규칙만 사용된다는 제약이 있다.
따라서 이의 대안으로 access() 메서드를 사용하면 더 풍부한 보안 규칙을 선언하기 위해 SpEl을 사용할 수 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
...
.formLogin()
.loginPage("/login") // 커스텀 로그인 페이지. (사용자가 인증되지 않아 로그인이 필요하다고 시큐리티가 판단할 떄 해당 경로로 연결해줌)
.and()
.logout()
.logoutSuccessUrl("/");
}
...
}
CSRF(Cross-Site Request Forgery, 크로스 사이트 요청 위조)는 많이 알려진 보안 공격이다.
사용자가 웹 사이트에 로그인 한 상태에서 악의적인 코드가 삽입된 페이지를 열명 공격 대상이 되는 웹 사이트에 자동으로 폼이 제출되고 이 사이트는 위조된 공격 명령이 믿을 수 있는 사용자로부터 제출된 것으로 판단하게 되어 공격에 노출된다.
예를 들어, 자동으로 해당 사용자의 거래 은행 웹사이트 URL로 다른 폼을 제출하는 공격자 웹사이트의 폼을 사용자가 볼 수 있다. 이 경우 사용자는 자신의 계사에서 돈이 인출되었는지 실제 확인하지 않는 한 공격이 이루어졌다는 것을 모를 수 있다.
CSRF 공격을 막기 위해 애플리케이션에서는 폼의 숨김 필드에 넣을 CSRF 토큰을 생성할 수 있다.
그리고 해당 필드에 토큰을 넣은 후, 나중에 서버에서 사용한다. 이후에 해당 폼이 제출될 때는 폼의 다른 데이터와 함께 토큰도 서버로 전송된다.
그리고 서버에서는 이 토큰을 원래 생성된 토큰과 비교하며 일치하면 해당 요청의 처리가 허용된다.
CSRF 지원을 비활성화지 말자.(단, REST API 서버로 실행되는 애플리케이션의 경우는 CSRF를 disable 해야 한다.)
사용자가 로그인되었음을 아는 정도로는 충분하지 않을 떄가 있다.
사용자 경험에 맞추려면 그들이 누구인지 아는 것도 중요하다.
사용자가 누구인지 결정하는 방법은 여러 가지가 있으며, 그중 많이 사용되는 방법은 다음과 같다.