[스프링 인 액션] 4. 스프링 시큐리티

김하영·2021년 7월 20일
0

4.1 스프링 시큐리티 활성화하기

스프링 애플리케이션에서 스프링 시큐리티를 사용하기 위해서는 스프링 부트 스타터 시큐리티 의존성을 빌드 명세에 추가해야 한다.

<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)만 추가했을 경우

  • 모든 HTTP 요청 경로는 인증되어야 한다.
  • 어떤 특정 역할이나 권한이 없다.
  • 로그인 페이지가 따로 없다.
  • 스프링 시큐리티의 HTTP 기본 인증을 사용해서 인증된다.
  • 사용자는 하나만 있으며, 이름은 user이고 비밀번호는 암호화해준다.

최소한 필요한 스프링 시큐리티 구성

  • 스프링 시큐리티의 HTTP 인증 대화상자 대신 우리의 로그인 페이지로 인증한다.
  • 다수의 사용자를 제공하며, 새로운 타코 클라우드 고객이 사용자로 등록할 수 있는 페이지가 있어야 한다.
  • 서로 다른 HTTP 요청 경로마다 서로 다른 보안 규칙을 적용한다. (홈페이지와 사용자 등록 페이지는 인증이 필요하지 않다)

4.2 스프링 시큐리티 구성하기

SecurityConfig

SecurityConfig 클래스 (WebSecurityConfigurerAdapter의 서브 클래스)
보안 구성 클래스인 WebSecurityConfigurerAdapter의 서브 클래스이다.
사용자의 HTTP 요청 경로에 대해 접근 제한과 같은 보안 관련 처리 설정한다.
그리고 두개의 configure() 메서드를 오버라이딩하고 있다.

  • configure(HttpSecurity)는 HTTP 보안을 구성하는 메서드다.
  • configure(AutheticatoinManagerBuilder)는 사용자 인증 정보를 구성하는 메서드다.
@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();
    }

}

한 명 이상의 사용자를 처리할 수 있도록 사용자 정보를 유지/관리하는 사용자 스토어를 구성해야한다.
스프링 시큐리티에서는 여러 가지의 사용자 스토어 구성 방법을 제공한다.

  1. 인메모리 사용자 스토어
  2. JDBC 기반 사용자 스토어
  3. LDAP 기반 사용자 스토어
  4. 커스텀 사용자 명세 서비스

4.2.1 인메모리 사용자 스토어

사용자 정보를 유지/관리할 수 있는 곳 중 하나가 메모리다.
만일 변경이 필요 없는 사용자만 미리 정해 놓고 애플리케이션을 사용한다면 아예 보안 구성 코드 내부에 정의할 수 있을 것이다.

  • 테스트 목적이나 간단한 애플리케이션에서는 편리하다.
  • 사용자의 정보의 추가나 변경이 쉽지 않다. 즉, 사용자의 추가, 삭제, 변경을 해야 한다면
    보안 구성 코드를 변경한 후 애플리케이션을 다시 빌드하고 배포, 설치 해야한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...

    public SecurityConfig(UserDetailsService userRepositoryUserDetailsService) {
        this.userRepositoryUserDetailsService = userRepositoryUserDetailsService;
    }

    ...
}

4.2.2 JDBC 기반 사용자 스토어

사용자 정보는 관계형 데이터베이스로 유지/관리되는 경우가 많으므로 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 인터페이스를 구현하는 어떤 객체도 인자로 받을 수 있다.

  • BCryptPasswordEncoder : bcrypt를 해싱 암호화한다.
  • NoOpPasswordEncoder : 암호화하지 않는다.
  • Pbkdf2PasswordEncoder : PBKDF2를 암호화한다.
  • SCryptPasswordEncoder : scrypt를 해싱 암호화한다.
  • StandardPasswordEncoder : SHA-256을 해싱 암호화한다.

4.2.3 LDAP 기반 사용자 스토어

LDAP(Lightweight Directory Access Protocol) 이란?

네트워크 상에서 조직이나 개인정보 혹은 파일이나 디바이스 정보 등을 찾아보는 것을 가능하게 만든 소프트웨어 프로토콜이다.

네트워크 상의 디렉토리 서비스 표준인 X.500의 DAP(Directory Access Protocol)를 기반으로한 경량화(Lightweight) 된 DAP 버전이다.

  • DAP는 OSI 전체 프로토콜 스택을 지원하며 운영에 매우 많은 컴퓨팅 자원을 필요로하는 아주 무거운 프로토콜
  • LDAP은 DAP의 복잡성을 줄이고 TCP/IP 레이어에서 더 적은 비용으로 DAP의 많은 기능적인 부분을 조작할 수 있도록 설계

디렉터리 서비스는?

  • 이름을 기준으로 대상을 찾아 조회하거나 편집할 수 있는 서비스
  • DNS도 디렉터리 서비스의 일종
  • DNS는 도메인 이름으로 IP 주소를 조회

Lightweight 하다.

  • 이 의미는 사용하기 간편하다는 의미가 아니라 통신 네트워크 대역폭 상의 가벼움을 의미
  • 인터넷 프로토콜로 데이터를 조금만 주고 받아도 되게끔 설계되었다고 함
  • LDAP의 요청의 99%는 검색에 대한 요청
  • 디렉토리 안에는 연락처, 사용자, 파일, code 등 무엇이든 넣을 수 있고, insert, update 보다는 검색 요청에 특화되어 있다.
  • 검색에 특화되다보니 트랜잭션이나 롤백이 없고 복잡한 관계 등을 설정할 수 없다.
  • 신뢰성이나 가용성을 개선하기 위해 쉽게 복제될 수 있는 아키텍처로 이루어져 있다.

기본적으로 바이너리 프로토콜이다.

  • ASN.1이라는 언어로 메시지를 표현
  • 메시지를 BER(Basic Encoding Rules)라는 포맷으로 인코딩하여 주고 받음
  • BER 인코딩이 바이너리라서 내용을 알아볼 순 없음

비동기 프로토콜이다.

  • 세션을 하나만 열어서 여러 메시지 요청을 보낼 수 있고, 각각의 요청에 대한 응답이 다른 시점에 올 수도 있음
  • 응답마다 어떤 요청의 응답인지 식별할 수 있는 아이디가 부여됨

용도

  • 사용자, 시스템, 네트워크, 서비스, 애플리케이션 등의 정보를 트리 구조로 저장하여 조회하거나 관리
  • 회사에서 구성원의 조직도나 팀별 이메일 주소 등도 LDAP 서비스로 관리
  • 특정 영역에서 이용자명과 패스워드를 확인하여 인증하는 용도로 쓰임
  • 인증이든 무엇이든 트리 구조로 검색하고 편집하기 좋은 데이터들은 LDAP을 많이 사용
  • LDAP은 서버에만 적용되는 프로토콜이 아니라 주소록 관리에 사용되거나 스마트폰 내에서도 LDAP 클라이언트가 포함되어 있음
  • 특정 데이터를 중앙에서 일괄 관리하는 일반적인 경우에 사용
  • 유저 권한 관리, 주소록, 조직도, 사용자 정보 관리, 어플리케이션/시스템 설정 정보, 공개 키 인프라스트럭쳐, DHCP나 DNS등의 저장소, 문서 관리, 이미지 저장소, Code 등

LDAP 기반 인증으로 스프링 시큐리티를 구성하기 위해서 ldapAuthentication() 메서드를 사용할 수 있다.

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
	auth
    .ldapAuthentication()
    .userSearchFilter("(uid={0})")
    .groupSearchFilter("(member={0})");
}

4.2.4 사용자 인증의 커스터마이징

사용자 도메인 객체와 퍼시스턴스 정의하기

/**
 * 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 빈 객체를 스프링의 관리하에 사용할 수 있다.

4.3 웹 요청 보안 처리하기

홈페이지, 로그인 등 특정 페이지는 인증되지 않은 모든 사용자가 사용할 수 있어야 한다.
이러한 보안 규칙을 구성하려면 configure(HttpSecurity http) 메소드를 오버라이딩해야 한다.

@Override
protected void configure(HttpSecurity http) throws Exception {
	...
}

HttpSecurity를 사용해서 구성할 수 있는 것은 다음과 같다.

  • HTTP 요청 처리를 허용하기 전에 충족되어야 할 특정 보안 조건을 구성한다.
  • 커스텀 로그인 페이지를 구성한다.
  • 사용자가 애플리케이션의 로그아웃을 할 수 있도록 한다.
  • CSRF 공격으로부터 보호하도록 구성한다.

4.3.1 웹 요청 보안 처리하기

@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을 사용할 수 있다.

4.3.2 ~ 3 커스텀 로그인 / 로그아웃 페이지 생성하기

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
        .formLogin()
                .loginPage("/login") // 커스텀 로그인 페이지. (사용자가 인증되지 않아 로그인이 필요하다고 시큐리티가 판단할 떄 해당 경로로 연결해줌)
                .and()
                .logout()
                .logoutSuccessUrl("/");
    }
    ...

}

4.3.4 CSRF 공격 방어

CSRF(Cross-Site Request Forgery, 크로스 사이트 요청 위조)는 많이 알려진 보안 공격이다.
사용자가 웹 사이트에 로그인 한 상태에서 악의적인 코드가 삽입된 페이지를 열명 공격 대상이 되는 웹 사이트에 자동으로 폼이 제출되고 이 사이트는 위조된 공격 명령이 믿을 수 있는 사용자로부터 제출된 것으로 판단하게 되어 공격에 노출된다.

예를 들어, 자동으로 해당 사용자의 거래 은행 웹사이트 URL로 다른 폼을 제출하는 공격자 웹사이트의 폼을 사용자가 볼 수 있다. 이 경우 사용자는 자신의 계사에서 돈이 인출되었는지 실제 확인하지 않는 한 공격이 이루어졌다는 것을 모를 수 있다.

CSRF 공격을 막기 위해 애플리케이션에서는 폼의 숨김 필드에 넣을 CSRF 토큰을 생성할 수 있다.
그리고 해당 필드에 토큰을 넣은 후, 나중에 서버에서 사용한다. 이후에 해당 폼이 제출될 때는 폼의 다른 데이터와 함께 토큰도 서버로 전송된다.
그리고 서버에서는 이 토큰을 원래 생성된 토큰과 비교하며 일치하면 해당 요청의 처리가 허용된다.

CSRF 지원을 비활성화지 말자.(단, REST API 서버로 실행되는 애플리케이션의 경우는 CSRF를 disable 해야 한다.)

  • .csrf().disable()로 비활성 가능.

4.4 사용자 인지하기

사용자가 로그인되었음을 아는 정도로는 충분하지 않을 떄가 있다.
사용자 경험에 맞추려면 그들이 누구인지 아는 것도 중요하다.

사용자가 누구인지 결정하는 방법은 여러 가지가 있으며, 그중 많이 사용되는 방법은 다음과 같다.

  • Principal 객체를 컨트롤러 메소드에 주입한다.
  • Authentication 객체를 컨트롤러 메소드에 주입한다.
  • SecurityContextHolder를 사용해서 보안 컨텍스트를 얻는다.
  • @AuthenticationPrincipal 애노테이션을 메서드에 지정한다.

요약

  • 스프링 시큐리티의 자동-구성은 보안을 시작하는 데 좋은 방법이다.
    그러나 대부분의 애플리케이션에서는 나름의 보안 요구사항을 충족하기 위해 보안 구성이 필요하다.
  • 사용자 정보는 여러 종류의 사용자 스토어에 저장되고 관리될 수 있다. (관계형 데이터베이스, LDAP 등)
  • 스프링 시큐리티는 자동으로 CSRF 공격을 방어한다.
  • 인증된 사용자에 관한 정보는 SecurityContext 객체를 통해 얻거나, @AuthenticationPrincipal을 사용해서 컨트롤러에 주입하면 된다.
profile
Back-end Developer

0개의 댓글