상세 컨텐츠

본문 제목

Spring Boot Security - DB를 이용한 인증

개발

by 목장주 2018. 11. 21. 04:45

본문

지난 글에서 스프링 앱에 최소한의 인증과 접근 관리를 추가해봤습니다. 권한별로 접근할 수 있는 url을 정의해 놓고 인증 여부, 권한 여부에 따라 테스트를 작성했습니다. 이번에는 지난 번 앱의 인증 부분을 좀 더 고쳐보도록 하죠. 최종 소스는 github의 step-02 브랜치에 있습니다.


H2 데이타베이스

실제 운용할 때는 오라클 같은 상용 디비를 쓰겠지만, 테스트니까 간단하게 H2를 사용하겠습니다. H2는 메모리/파일 기반 데이터베이스입니다. MySQL처럼 설치하고, 사용자 설정하고, 서버 시작하고 등등 이런 번거로움 없이 간단하게 테스트가 가능합니다. H2를 사용하려면 H2에 대한 의존성과 Spring Data JPA에 대한 의존성을 추가해줘야 합니다. 


implementation('org.springframework.boot:spring-boot-starter-data-jpa')
implementation('com.h2database:h2')


우리가 사용할 테이블 정의는 간단합니다. 사용자명, 비밀번호, 이메일, 권한 이렇게 네 개의 컬럼이 존재하는 User 테이블입니다. src/main/resources에 schema.sql이라는 파일을 만들어 다음과 같이 저장합니다. 


CREATE TABLE IF NOT EXISTS User (
    username VARCHAR(16) NOT NULL,
    password VARCHAR(16) NOT NULL,
    email VARCHAR(64) NOT NULL,
    role VARCHAR(16) NOT NULL,
    PRIMARY KEY (username)
);


매번 DB에 접속해서 사용자 레코드를 생성하기는 귀찮으니 src/main/resources/data.sql을 하나 생성해서 다음과 같이 저장해 두면 스프링 시작 때 자동으로 DB에 SQL을 실행해줍니다. 


INSERT INTO User VALUES ('user', 'password', 'user@email', 'USER');
INSERT INTO User VALUES ('admin', 'password', 'admin@email', 'ADMIN');


schema.sql과 data.sql이 스프링 시작 때 실행되려면 설정을 좀 바꿔줘야 합니다. application.properties 파일을 열어 다음과 같이 바꿔줍니다.


spring.jpa.hibernate.ddl-auto=none
spring.h2.console.enabled=true


  • line 1: hibernate가 @Entity 주석이 붙은 클래스를 읽어서 자동으로 테이블을 만들어 줄 수 있습니다. 이 기능이 활성화 되면 schema.sql에 있는 SQL DDL은 무시됩니다. schema.sql 파일을 읽어서 테이블 생성을 원한다면 hibernate이 제공하는 기능을 꺼야합니다. 
  • line 2: h2 서버가 실행되고 있을 때, DB에 SQL을 실행하고 싶다면 범용 DB 클라이언트를 통해 접속을 하거나, 웹 기반 GUI를 통해 접속한 후에 가능합니다. true로 설정이 되면 웹 기반 GUI를 사용할 수 잇씁니다.

User 클래스

User 테이블은 위의 schema.sql이 생성을 하고, 데이터는 미리 data.sql이 넣어줍니다. User 테이블에서 자료를 받아오면 저장할 User 클래스를 만들어 봅시다. username, password, email, role이라는 필드를 넣어주고, getter, setter, constructor 생성해 주면 됩니다.


@Entity
public class User {
    @Id
    private String username;
    private String password;
    private String email;
    private String role;

    public User() {
    }

    public User(String username, String password, String email, String role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    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;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getRole() {
        return role;
    }

    public void setRole(String role) {
        this.role = role;
    }
}


  • line 1: @Entity는 User 클래스가 DB에 있는 User 테이블에 상응하는 클래스임을 표시하는 주석입니다.
  • line 3: @Id 밑에 오는 username이 User 테이블의 키임을 표시하는 주석입니다.


UserRepository

User 테이블에 자료를 읽고 쓰는 클래스를 만들어 봅시다. JpaRepository는 테이블에 대응하는 클래스와 테이블의 키 타입을 알려주면 기본적인 CRUD 기능을 수행해줍니다. WHERE 절의 조건을 바꾸고 싶다면 메소드 이름만 바꿔주면 됩니다. username으로 사용자를 검색하고 싶으면 findByUsername이라고 메소드만 정의해 주면 JpaRepository가 나머지는 알아서 처리해줍니다.


@Repository
public interface UserRepository extends JpaRepository {
    User findByUsername(String username);
}


  • line 1: DB를 접근하는 레이어임을 알려줍니다. 
  • line 2: User 테이블에 상응하는 클래스(User)와 키 타입(String)을 넘겨줍니다.
  • line 3: Where 절 조건은 username 컬럼이 같은 지 여부입니다.


이제 데이터베이스 접근에 관한 부분은 완료가 되었습니다. 


UserDetailsService

Spring Security는 기본적으로 user라는 사용자명을 가진 사용자를 하나 생성합니다. 비밀번호는 앱 시작할 때 마다 무작위로 생성해줍니다. 그래서 지난 번 글에서는 application.properties을 고쳐서 사용자명 user, 비밀번호 password를 가지는 사용자를 만들도록 바꿨습니다.  


Spring Security 문서에 의하면 스프링 시큐리티에서 기본적으로 제공하는 사용자는 한 명 밖에 없습니다. 따라서 DB에 사용자 정보를 저장해 두고 인증을 해서 등록된 사용자인지, 관리자인지 권한 관리를 해야합니다.


The default AuthenticationManager has a single user (‘user’ username and random password, printed at INFO level when the application starts up)


인증을 위해 사용자 정보를 불러오는 일은 UserDetailsService가 합니다. 이미 스프링은 테스트용으로 클래스를 하나 구현해 뒀습니다. InMemoryUserDetailsManager 라는 이름에서 유추할 수 있다시피, db가 아닌 메모리에 사용자 정보를 저장해 둡니다. 따라서 서버가 멈추면 사용자 정보도 사라집니다. 


먼저 application.properties에 아래와 같이 설정해뒀던 사용자 정보를 지웁니다. 


spring.security.user.name=user
spring.security.user.password=password


그 다음은 UserDetailsService 를 구현하는 생성하는 코드를 작성합니다. loadUserByUsername 메소드만 구현하면 됩니다.


@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);
        UserDetails userDetails = null;
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

        org.springframework.security.core.userdetails.User.UserBuilder builder = 
            org.springframework.security.core.userdetails.User.builder()
            .passwordEncoder(encoder::encode);

        if (user != null) {
            userDetails = builder.username(user.getUsername())
                    .password(user.getPassword())
                    .roles(user.getRole())
                    .build();
        }

        return userDetails;
    }
}


  • line 5: 먼저 정의해 둔 UserRepository로 DB에 접근합니다.
  • line 9: 주어진 username을 가지고 테이블에서 검색을 합니다. 
  • line 10: Spring Security 5 부터는 평문을 패스워드로 사용할 수 없게 되었습니다. 대신 패스워드를 인코딩/디코딩 된 후에 사용해야 합니다. 기본으로 제공하는 인코더를 사용하도록 합니다.
  • line 13: UserDetails 인스턴스를 만들기 위한 빌더를 사용합니다. build 함수를 들어가보면 new User(...)를 호출합니다. 직접 new User를 해도 되고 builder를 사용해도 됩니다. 여기서 User는 우리가 위해서 생성한 클래스가 아니라 스프링에서 제공하는 User(UserDetails 구현체) 입니다. 따라서 패키지 이름을 길게 다 써줘야 컴파일러가 혼동하지 않습니다. 
  • line 15: 빌더에게 line 10에서 생성한 패스워드 인코더를 넘겨줍니다.
  • line 17: 테이블에서 읽어온 정보를 토대로 UserDetails 인스턴스를 생성합니다. 


테스트

이번에는 실제 DB에 접속해서 사용자 정보를 가져와서 인증까지 하는 테스트를 해보도록 하겠습니다. 이전 글에서는 @WebMvcTest 주석을 이용해서 테스트를 했습니다. 실제 인증을 하지 않고 인증이 되었다는 가정하에 컨트롤러만 테스트 하는 것이었기에 @WebMvcTest로 충분했습니다. @WebMvcTest는 @Service, @Repository 빈을 불러들이지 않기 때문에 다른 주석을 달아야 합니다.


Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests (i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).


If you are looking to load your full application configuration and use MockMVC, you should consider @SpringBootTest combined with @AutoConfigureMockMvc rather than this annotation.


먼저 이전에 있던 SpringSecurityApplicationTests의 이름을 WebSecurityControllerTest라 바꿔놓고, WebSecurityIntegratinoTest를 하나 아래와 같이 생성합니다. 


@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class WebSecurityIntegrationTest {

    @Autowired
    private MockMvc mvc;

    @Test
    public void givenRequestOnPrivatePageWithRealCredential_shouldReturn200() throws Exception {
        mvc.perform(
                get("/private")
                        .with(httpBasic("user", "wrong-password"))
        ).andExpect(
                status().isUnauthorized()
        );

        mvc.perform(
                get("/private")
                        .with(httpBasic("admin", "password"))
        ).andExpect(
                status().isOk()
        );

        mvc.perform(
                get("/private")
                        .with(httpBasic("user", "password"))
        ).andExpect(
                status().isOk()
        );
    }
}
  • line 10: 패스워드가 잘못 되었기 때문에 인증 자체가 되지 않았습니다. 따라서 401 코드를 반납해야 합니다.

실제 localhost:8080/admin, localhost:8080/private 페이지를 방문하면 아이디와 비번을 묻는 새 창이 뜹니다. BasicAuth를 사용하도록 되어있기 때문에 새 창이 뜨는데, 이 부분은 나중에 고치면 됩니다. 

다음 글에서는 OAuth 2를 적용해 보도록 하겠습니다.



관련글 더보기