ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [31] 스프링 시큐리티
    Spring/Spring boot 2020. 9. 10. 09:04
    반응형

    예제코드: github.com/camel-master/SpringSecurityExample.git

    1. 스프링 시큐리티의 정의와 적용을 위한 기본 설정

    스프링 시큐리티(Spring Security)란

    스프링 시큐리티는 사용자의 필요에 따라 정의하여 사용할 수 있는 인증과 접근 제어를 위한 프레임워크로 인증과 권한 부여를 모두 제공하는데 초점을 맞추고 있습니다. 스프링 시큐리티의 강점은 사용자의 요구사항을 충족시키기 위한 확장성을 제공하는 것입니다.

     

     

    스프링 부트에서 스프링 시큐리티를 사용의 예

    루트 페이지와 hello 페이지는 누구나 접속할 수 있고 my 페이지는 로그인 처리가 되어야 접속할 수 있는 예제를 작성하여 어떻게 스프링 부트에서 스프링 시큐리티를 사용할 수 있는지 살펴보도록 하겠습니다.

     

    먼저 스프링 부트 프로젝트를 생성하고 pom.xml 파일에서 추가해야 할 의존성은 다음과 같습니다.

    <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
            
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-thymeleaf</artifactId>
            </dependency>
            
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-security</artifactId>
            </dependency>
            
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-test</artifactId>
                <version>${spring-security.version}</version>
                <scope>test</scope>
            </dependency>
            
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-jpa</artifactId>
            </dependency>
            
            <dependency>
                <groupId>com.h2database</groupId>
                <artifactId>h2</artifactId>
            </dependency>
        </dependencies>

    본 예제의 데이터 관리는 spring jpa와 h2 db를 사용하며 템플릿엔진은 thymleaf를 사용할 것이기 때문에 스프링 시큐리티 의존성 외에 jpa, h2db, thymleaf와 관련한 의존성도 추가했습니다. 스프링 시큐리티 테스트를 위해서 spring-security-test도 추가했는데 버전 관리를 안 해주기 때문에 버전을 변수 형태로 지정해 줍니다.

     

    /resources/templates 아래에 index.html, hello.html, my.html 파일을 각각 생성해주고 그 내용을 다음과 같이 작성해주세요.

     

    [index.html]

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <h1>Welcome</h1>
    <a href="/hello">hello</a>
    <br />
    <a href="/my">my page</a>
    </body>
    </html>

    루트 페이지의 내용으로 hello.html과 my.html로 이동할 수 있는 링크를 만들어 둡니다.

     

    [hello.html]

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <h1>Hello</h1>
    </body>
    </html>

     

    [my.html]

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <h1>My</h1>
    </body>
    </html>

     

     

    위에 만들어 놓은 페이지에 대한 요청이 들어올 경우 각 페이지를 연결해주는 컨트롤로를 등록합니다.

    package me.dave;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class HomeController {
    
        @GetMapping("/hello")
        public String hello() {
            return "hello";
        }
    
        @GetMapping("/my")
        public String my() {
            return "my";
        }
    }
    

     

    스프링 시큐리티의 기본 설정은 모든 페이지에 접속 시 인증한 사용자만 허용하도록 되어있기 때문에 이 상태로 애플리케이션을 실행해보면 모든 어떤 페이지를 접속하더라도 인증하지 않은 상태이기 때문에 401(unauthorized) 응답을 하게 됩니다. 

    즉, 모든 요청이 스프링 시큐리티로 인해 인증을 필요로 하게 됩니다.

    basic authentication과 form 인증이 모두 적용 됩니다.

     

    (참고) accept header의 media type에 따른 동작

    더보기

    스프링 부트에 스프링 시큐리티를 적용했을 때 모든 요청에 대해 인증이 필요하도록 설정되는 것이 default이기 때문에 이상태로 서비스를 실행하면 우리가 만들어 둔 모든 페이지에 접근할 수 없습니다. 

    accept header에 요청이 원하는 응답의 형태를 지정하지 않았기 때문에 form인증에 대한 응답이 아닌 basic authenticate 응답을 보내게 됩니다. 브라우저에서 Basic Authenticate 응답을  받으면 다음과 같이 브라우저에서 제공되는 기본 인증 창이 뜨게 됩니다.

    accept header에 media type을 TEXT_HTML로 보내면 ~/login으로 리다이렉션 시킵니다.

    기본 사용자명은 user이고 password는 console 창에서 확인할 수 있습니다. password는 UserDetailsServiceAutoconfiguration을 통해 만들어집니다.

     

    로그인 이후에는 모든 페이지에 접근할 수 있습니다.

     

     

    스프링 시큐리티 의존성이 주입된 경우 적용되는 주요 설정 파일은 다음과 같습니다.

     

    1. SecurityAutoConfiguration class: 이 설정 파일에 ApplicationEventPublisher가 등록되어 있어서 비밀번호가 틀리거나, 존재하지 않는 사용자명이거나, 계정이 만료되는 등과 같은 여러 가지 이벤트를 발생시키고 우리는 이벤트에 대한 핸들러를 등록해서 사용자 상태를 등록하는 것과 같은 작업을 할 수 있습니다. ApplicationEventPublisher를 bean으로 등록만 하면 굳이 스프링 부트 설정을 사용하지 않고도 직접 설정할 수 있습니다.

     

    2. SpringBootWebSecurityConfiguration class: 등록된 WebSecurityConfigurerAdapter.class bean이 없으면 WebSecurityConfigurerAdapter를 상속받은 DefaultConfigurerAdapter를 bean으로 등록하게 되는데 별다른 작업은 하지 않고 SecurityProperties에 대한 우선순위만 기본보다 조금 더 높은 BASIC_AUTH_ORDER로 설정합니다. WebSecurityConfigurerAdapter는 스프링 시큐리티 설정의 기반이 되는 class입니다. 사용자 정의 스프링 시큐리티를 만들 때 이 class를 상속받아 만듭니다. WebSecurityConfigurerAdapter의 gethttp() 매서드를 확인해보면 기본적인 시큐리티 설정이 다음과 같이 되어 있는 것을 확인해볼 수 있습니다.

    ...
    //HTTP Security 객체를 생성하고 기본 설정 진행
    
        http = new HttpSecurity(objectPostProcessor, autheticationBuilder, sharedObjects);
        if(!disableDefaults) {
            http
                .csrf().and()
                .addFilter(new WebAsyncManagerIntegrationFilter()
                .exceptionHandling().and()
                .headers().and()
                .sessionManagement().and()
                .securityContext().and()
                .requestCache().and()
                .anonymous().and()
                .servletApi().and()
                .apply(new DefaultLoginPageConfigurer()).and()
                .logout();
    
    ...
    
    //Http Security 설정 변경
    protected void configure(HttpSecurity http) throws Exception {
    	http
        	.authorizeRequest()
                .anyRequest().authenticated()
                .and()
            .formLogin().and()
            .httpBasic()
    }

     

    3. UserDetailsServiceAutoConfiguration class: 스프링 부트 애플리케이션 실행 시 InMemoryUserDetailsManager를 만들어서 기본 유저 객체를 생성하여  제공해줍니다. AuthenticationManager, AuthenticationProvider, UserDetailService bean이 없을 때 이 설정 file이 적용됩니다.

     

     

     


    2. 나만의 스프링 시큐리티 설정 적용하기

    이제 my.html 페이지만 로그인을 필요로 하도록 만들어봅시다.

     

    1. 웹 시큐리티 설정

    SecurityConfig 설정 파일을 생성합니다.

    SecurityConfig의 내용을 다음과 같이 작성합니다.

    package me.dave.config;
    
    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.WebSecurityConfigurerAdapter;
    import org.springframework.security.crypto.factory.PasswordEncoderFactories;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        //WebSecurityConfigurerAdapter 타입을 정의하여 bean으로 등록하는 순간 스프링 시큐리티에서 제공하는 기본 기능 대신 사용자 정의 기능으로 동작
    
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                    .antMatchers("/", "/hello").permitAll() //	①
                    .anyRequest().authenticated()	//	②
                    .and()
                .formLogin()	//	③
                    .and()
                .httpBasic();	//	④
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
    }
    

    루트와 /hello는 접근 가능하도록 설정합니다.

    ② 나머지 모든 요청은 인증이 필요하도록 설정합니다.

    ③ accept header에 media type이 TEXT_HTML인 경우 formLogin()에 걸려 처리됨.

    ④ accept header에 media type이 지정되지 않은 경우 httpBasic()에 걸려 처리됨.

     

     

    2. UserDetailsService 구현

    먼저 사용자 계정 정보를 담을 Account 클래스를 만듭니다.

    Account 클래스는 사용자 id, 사용자 이름, 패스워드를 갖도록 다음과 같이 작성합니다.

    package me.dave.accunt;
    
    import javax.persistence.Entity;
    import javax.persistence.GeneratedValue;
    import javax.persistence.Id;
    
    @Entity
    public class Account {
    
        @Id
        @GeneratedValue
        private Long id;
    
        private String username;
    
        private String password;
    
        public Long getId() {
            return id;
        }
    
        public void setId(Long id) {
            this.id = id;
        }
    
        public String getUsername() {
            return username;
        }
    
        public void setUsername(String username) {
            this.username = username;
        }
    
        public String getPassword() {
            return password;
        }
    
        public void setPassword(String password) {
            this.password = password;
        }
    }
    

     

    이제 DB와 연동하여 계정 정보를 읽고 쓰기 위한 JPA respository interface를 하나 만들어 줍니다.

    JPA repository insterface의 코드는 다음과 같습니다.

    package me.dave.accunt;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    import java.util.Optional;
    
    public interface AccountRepository extends JpaRepository<Account, Long> {
        Optional<Account> findByUsername(String username);
    }
    

    db는 H2 in-memory database를 사용해 간단히 연동할 수 있도록 했습니다. (위에서 이미 의존성을 추가했습니다.)

     

     

     

    repository를 받아와서 기능을 정의할 AccountService를 만들어줍니다.

    AccountService의 코드는 다음과 같습니다.

    package me.dave.accunt;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    
    import java.util.Arrays;
    import java.util.Collection;
    import java.util.Optional;
    
    @Service
    public class AccountService implements UserDetailsService {	//	①
    
        @Autowired
        AccountRepository accountRepository;
    
        @Autowired
        private PasswordEncoder passwordEncoder;
    
        //	②
        public Account createAccount(String username, String password) {
            Account account = new Account();
            account.setUsername(username);
            account.setPassword(passwordEncoder.encode(password));
            return accountRepository.save(account);
        }
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {	// ③
            Optional<Account> byUsername = accountRepository.findByUsername(username);	//	④
            Account account = byUsername.orElseThrow(() -> new UsernameNotFoundException(username));	//	⑤
            return new User(account.getUsername(), account.getPassword(), authorities());   //	⑥
        }
    
    
        private Collection<? extends GrantedAuthority> authorities() {
            return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));	//	⑦
        }
    }
    

    ① UserDetailsService를 구현합니다. 보통은 서비스 계층에 UserDetailsService 인터페이스를 구현합니다. 별도의 클래스를 만들어 이 인터페이스를 구현하도록 해도 상관은 없습니다. 중요한 것은 UserDetailsService 타입의 bean이 등록되어 있어야 스프링부트에서 사용자 정보를 생성하지 않고 사용자 서비스를 통해 사용자 정보를 생성하게 됩니다. 

    사용자의 이름과 패스워드를 받아서 계정을 생성합니다.  생성과 동시에 생성한 쪽에서 계정 객체를 받아 사용할 수 있도록 리턴해줍니다.

    로그인 처리를 할 때 UserDetailsService의 loadUserByUsername() 메서드가 호출됩니다. form에서 입력받은 사용자명이 매개변수로 들어오게 되고 UserDetails 타입의 실제 유저 정보를 확인합니다. 정보 중에는 패스워드가 있어서 입력정보의 패스워드와 비교하고 같으면 로그인 다르면 예외를 던지게 됩니다.

    ④ 사용자명으로 Account를 찾습니다.

    ⑤ Account 객체가 있으면  account로 저장하고 없으면 예외 발생시킵니다.

    ⑥ UserDetails 인터페이스의 구현체는 User 타입으로 생성 시 username, password, authority(권한종류)를 필요로 합니다. authority는 GrantedAuthority를 상속받은 객체의 Collection 제네릭 타입으로 제공하면 됩니다.

    ⑦ 권한 이름을 "ROLE_USER"로 하여 허가된 권한 객체를 생성하고 array list에 넣어 반환합니다.

     

    (Tip)

    더보기

    IDE의 자동완성 기능을 최대한 활용하여 되도록 타이핑하지 않도록 하면 실수를 줄일 수 있습니다. findByUsername이 AccountRepository에 정의되지 않은 상태에서 반환 타입을 포함하여 사용 코드를 먼저 입력하고 IDE의 기능을 통해 AccountRepository에 findByUsername 메서드 정의를 추가합니다.

     

     

     

    로그인을 테스트하기 위해 application runner를 사용하여 사용자 정보를 하나 추가합니다.

    AccountRunner 클래스의 코드는 다음과 같습니다.

    package me.dave;
    
    import me.dave.accunt.Account;
    import me.dave.accunt.AccountService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.ApplicationArguments;
    import org.springframework.boot.ApplicationRunner;
    import org.springframework.stereotype.Component;
    
    @Component
    public class AccountRunner implements ApplicationRunner {
    
        @Autowired
        AccountService accountService;
    
        @Override
        public void run(ApplicationArguments args) throws Exception {
            Account dave = accountService.createAccount("dave", "1234");
        }
    }
    

     

     

     

    3. password encorder 설정

    여기까지 작성 후 Application을 실행해서 테스트해보면 여전히 로그인이 불가합니다. 로그인 시도 후 실행창에서 로그를 보면 다음과 같이 There is no passwordEncoder mapped for the id "null" 이란 메시지를 찾을 수 있습니다.

    스프링 시큐리티는 password를 인코딩하지 않으면 디코딩 과정 중 예외를 발생하게 설계되어 있으며 로그인 기능이 동작하지 않습니다. 패스워드를 인코딩하지 않는 경우는 패스워드 앞에 {noop}이 붙은 형태의 password로 바꿔주면 통과할 수 있는데, 이를 위해  NoOpPasswordEncoder.getInstance() 메서드를 통해 생성된 객체를 bean으로 추가합니다. SecurityConfig 클래스의 내용에 다음을 추가해서 실행하면 Application Runner 클래스에서 등록한 계정 정보로 로그인이 가능합니다. 하지만 이 방식은 보안 이슈가 발생하게 되므로 절대 사용하면 안 됩니다. 

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    	...
        
        @Bean
        public PasswordEncoder passwordEncoder() {
        	return NoOpPasswordEncoder.getInstatnce();
        }
        
        ...
       }

    만약 위와 같은 코드를 발견했다면 즉시 수정해야 합니다.

     

    NoOpPasswordEncoder 대신 스프링 시큐리티에서 권장하는 패스워드 인코더를 추가합니다. SecurityConfig 클래스에서 NoOpPasswordEncoder 관련 코드를 다음과 같이 변경합니다.

    @Configuration
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        ...
        
        @Bean
        public PasswordEncoder passwordEncoder() {
        	return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
        
        ...
       }

    새로운 패스워드 인코더를 추가했으니 사용자 계정 추가 시 패스워드가 인코딩 되도록 수정해줘야 합니다. AccountService에 다음의 내용을 추가해줍니다.

    ...
    
    @Service
    public class AccountService implements UserDetailsService {
    
    ...
    
        @Autowired
        private PasswordEncoder passwordEncoder;	//	①
    
        public Account createAccount(String username, String password) {
            Account account = new Account();
            account.setUsername(username);
            account.setPassword(passwordEncoder.encode(password));	//	②
            return accountRepository.save(account);
        }
    
    ...
    
    }
    

    ① 추가한 패스워드 인코더를 주입받고

    ② 인코더를 사용하여 인코딩한 패스워드를 사용자 정보에 추가합니다.

     

     

     

    시큐리티 인증을 통과하기 위한 가사용자 데이터를 생성할 수 있는 방법이 여러 가지 있습니다. 스프링 시큐리티 페이지에서 확인 가능합니다.

     

    단순하게 view를 return만 하는 controller라면 WebMvcConfiguer.addViewControllers() 를 재정의 하여 사용할 수도 있습니다. 

    package me.dave;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            registry.addViewController("/hello").setViewName("hello");
        }
    }
    

     

     


    3. 추가로 공부할 내용

    • WebSecurityConfigurerAdapter configure 전 처리에 대해.
    • 다양한 WebSecurityConfigurerAdapter HttpSecurity 객체 설정 항목.
    • OAuth 인증방식에 대해.
    • 컨트롤러나 폼, RestAPI를 통해 입력받은 값을 사용해서 유저 정보를 만드는 기능 추가.
    • 폼 인증 화면 커스터마이징.

    댓글

Designed by Tistory.