Spring security/01.Security

08. JDBC를 이용한 인증

  • -

앞선 spring security에서는 memory 기반에 사용자 정보를 추가하고 동작시켜보았다. 하지만 일반 실무에서 그럴리는 거의 없을 것이다. 이번 포스트에서는 JDBC 기반으로 사용자를 관리하는 방법에 대해 알아보자.

 

AuthenticationManager

 

AuthenticationManager와 DaoAuthenticationManager

인증과 관련된 처리는 AuthenticationManager 인터페이스가 담당하는데 그 구현체는 ProviderManager이다.  말 그대로 인증 방식을 제공하는 Provider들(AuthenticationProvider)을 관리하는 객체이다. Provider는 상황에 따라 여러가지 형태가 가능하므로 ProviderManager는  AuthenticationProvider를 List 형태로 관리한다. AuthenticationProvider interface는 기본 구현체는 DaoAuthenticationProvider이다.

AuthenticationManager와 AuthenticationProvider

 

다음은 DaoAuthenticationProvider의 동작 흐름이다.

DaoAuthenticationProvider의 동작 흐름

  1. Authentication을 관리하는 filter는 username과 password를 UsernamePasswordAuthenticationToken에 담아서 AuthenticationManager의 구현체인 ProviderManager에 전달한다.
  2. ProviderManager는 관리하고 있던 Provider 중 하나인 DaoAuthenticationProvider에 token을 전달한다.
  3. DaoAuthenticationProvider는 UserDetailsService를 이용해 token의 username에 해당하는 사용자를 찾아본다.
  4. 사용자를 찾았다면 PasswordEncoder를 통해서 비밀번호가 valid 한지 검사한다.
  5. 인증에 성공하면 최종적으로 token에 UserDetails와 Authorities 객체가 담겨서 반환된다.

 

DaoAuthenticationProvider는 UserDetailsService를 갖는데 UserDetailsService는 UserDetails라는 객체를 이용해서 인증 정보를 처리한다. UserDetailsService의 구현체로는 메모리상에서 인증 정보를 관리하는 InMemoryUserDetailsManager, 데이터베이스에서 인증 정보를 관리하는 JdbcUserDetailsManager가 있다.

DaoAuthenticationProvider 연관 객체

 

JdbcUserDetailsManager

UserDetailsService를 구현하고 있는 JdbcDaoImpl은 JDBC를 이용해서 username-password 기반의 인증을 처리할 때 사용된다. JdbcUserDetailsManager는 다시 JdbcDaoImpl을 상속받고 있다.

JdbcUserDetailsManager는 Jdbc 기반으로 사용자 인증을 처리한다.

이제 기존의 InMemoryUserDetailsManager 대신 JdbcUserDetailsManager로 교체해주면 이제 JDBC 기반으로 사용자 인증이 이뤄지는데 JdbcUserDetailsManager는 DataSource 타입의 빈이 추가로 필요하다.

@Bean
public UserDetailsService userDetailsService(PasswordEncoder pe, DataSource ds) {
    UserDetails user = User.builder().username("user")
                                     .password(pe.encode("1234")).roles("USER").build();
    UserDetails staff = User.builder().username("staff")
                                      .password(pe.encode("1234")).roles("STAFF").build();
    UserDetails admin = User.builder().username("doding")
                                      .password(pe.encode("1234")).roles("ADMIN").build();

    // InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(user, staff);
    JdbcUserDetailsManager manager = new JdbcUserDetailsManager(ds);
    manager.createUser(user);
    manager.createUser(staff);
    manager.createUser(admin);
    return manager;
}

 

JDBC 기반의 인증 처리

 

JdbcDaoImpl의 기본 동작

JdbcDaoImpl은 사용자 계정등을 저장하기 위한 테이블이 있어야 하는데 기본 테이블 구조는 다음과 같다.

create table users(
	username varchar_ignorecase(50) not null primary key,
	password varchar_ignorecase(500) not null,
	enabled boolean not null
);

create table authorities (
	username varchar_ignorecase(50) not null,
	authority varchar_ignorecase(50) not null,
	constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);
참고로 default schema는 org/springframework/security/core/userdetails/jdbc/users.ddl에서 확인할 수 있다.  물론 테이블을 만들어주는 것은 아니다.

이 테이블에 조회하는 쿼리는 JdbcUserDetailsManager#setUsersByUsernameQuery 등으로 설정할 수 있다.

JdbcUserDetailsManager manager = new JdbcUserDetailsManager(ds);
manager.setUsersByUsernameQuery("select username, password, enabled from users where username = ?");
manager.setAuthoritiesByUsernameQuery("select username, authority from authorities where username = ?");

따라서 필요하다면 테이블도, 쿼리도 사용자 정의로 지정해서 변경할 수 있지만 Spring Security의 기본 구조에 따라 코드가 흘러갈 수 있도록 처리하는 것이 향후 Spring Security 업그레이드 시 호환성 유지나 유지 보수 측면에서 유리할 것 같다. 여기서는 위 테이블 구조에 맞춰서 Entity를 구성해보자.

 

Entity 만들기

이번 예제에서는 JPA의 create ddl 옵션을 create로 사용할 계획이므로 아래와 같이 entity만 만들어주면 된다. 관계는 User와 Authority를 1:N의 단방향으로 만들어주자.

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
    @Id
    @Column(length = 50)
    private String username;

    @Column(length = 500, nullable = false)
    private String password;

    @Column(nullable = false)
    private boolean enabled;

    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "username")
    private Set<Authority> authorities = new HashSet<>();

    @Builder
    public User(String username, String password, boolean enabled) {
        this.username = username;
        this.password = password;
        this.enabled = enabled;
    }

    public void addAuthority(String authority) {
        authorities.add(new Authority(authority));
    }

    public void removeAuthority(String authorityName) {
        authorities.removeIf(authority -> authority.getAuthority().equals(authorityName));
    }
}

Authority를 만들 때는 GrantedAuthority를 구현해주자.(나중에 사용자 정의 UserDetails에서 형 변환을 용이하게 하기 위해서이다.)

@Entity
@Table(name = "authorities")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Authority implements GrantedAuthority{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 50, nullable = false)
    private String authority;

    @Builder
    public Authority(String authority) {
        this.authority = "ROLE_" + authority;
    }
}

ROLE 기반의 권한 관리를 위해 생성자에서 authority를 설정할 때 ROLE_를 추가해주고 있다.

 

로그인 동작 확인

이제 in-memory 방식에서 jdbc 기반으로 변경해봤으니 기존의 로그인 동작이 잘 동작하는지 확인해보자. 기존의 SecurityWebTest는 단순히 @WebMvcTest를 해서 DataSource 등 빈이 로딩되지 않는다. 따라서 다음과 같이 에너테이션을 교체해서 테스트 해보자.

@SpringBootTest
@AutoConfigureMockMvc

기존의 단위테스트는 물론 실제 웹 브라우저를 이용해서도 생각대로 잘 동작해야 한다. 

 

테스트 수정

 

JPA의 개입에 따른 UserDetailsService 모킹 

기존의 단위테스트는 @ExtendWith(SpringExtension.class)를 이용해서 처리했는데 이는 JPA 관련 빈들을 로딩하지 못한다. 따라서 JPA를 사용해야 하는 UserDetailsService#loadUserByUsername을 재정의 해주어야 한다. 다음과 같이 추상 클래스를 말들고 UserDetailsService에 대한 mock을 생성해보자.

@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
@Import(SecurityConfig.class)
public class AbstractSecurityTest {
    @Autowired
    PasswordEncoder passwordEncoder;

    @MockitoBean
    UserDetailsService userDetailsService;

    @BeforeEach
    void setUp() {
        // UserDetails 모킹
        UserDetails mockUser = User.builder()
                .username("doding")
                .password(passwordEncoder.encode("1234"))
                .roles("ADMIN")
                .build();

        when(userDetailsService.loadUserByUsername("doding"))
                .thenReturn(mockUser);
    }
}

이후 연관된 테스트들을 수정한다.

 

UserDetailsTest

먼저 UserDetailsTest는 JdbcUserDetailsService가 잘 동작하는지 확인하기 위한 부분이므로 JPA의 동작이 필요하다. 따라서 @ExtendWith를 다음과 같이 @DataJpaTest로 변경해주자.

@DataJpaTest  // @ExtendWith(SpringExtension.class)
@Import(SecurityConfig.class)
@ActiveProfiles("test")
public class UserDetailsTest {
    @Autowired
    UserDetailsService userDetailsService;
    ...
}

 

다른 테스트들

다른 테스트들은 UserDetailsService의 동작을 테스트 하는 것은 아니기 때문에 이 부분이 mock으로 처리된 AbstractSecurityTest를 상속받아서 사용하자.

@WebMvcTest
//@Import(SecurityConfig.class)
public class WebSecurityFilterChainTest extends AbstractSecurityTest { ...}

@WebMvcTest
// @ActiveProfiles("test")
// @Import(SecurityConfig.class)
public class SecurityWebTest extends AbstractSecurityTest {...}

 

WebSecurityCustomizer

 

H2-Console 사용 문제

그런데 H2-Console을 이용해서 실제 DB를 확인하려고 하면 약간의 문제가 발생한다.H2-Console을 웹에서 접근하다보니 security가 호출을 허용하지 않기 때문이다. 크게 h2-console을 사용하지 못하는 이유는 3가지 정도로 요약해볼 수 있다.(처리하지는 않을 계획이므로 언급만 하고 가자.)

  • 권한 필요: /h2-console 경로에 대해 permitAll()의 적용이 필요하다.
  • CSRF 제외: CSRF 공격을 방어하기 위해서 csrf_token을 전달해야 하는데 이것이 불가하므로 예외가 필요하다.
  • X-Frame-Options 설정: h2-console은 iframe을 사용하는데 이 역시 보안상 좋지 않다. 따라서 X-Frame-Options헤더에 DENY, SAMEORIGIN 등의 설정이 필요하다.

위의 번거로움을 간단히 처리해줄 수 있는 방법으로 WebSecurityCustomizer를 사용할 수 있다. 

 

WebSecurityCustomizer

WebSecurityCustomizer는 특정 리소스나 경로에 대해 보안 설정을 무시하고 싶을 때 사용할 수 있는 빈으로 주로 CSS, JavaScript, 이미지 등에 대한 보안 설정을 제외할 때 사용한다. 여기에 h2-console의 경로를 추가해주면 된다.

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring()
                      .requestMatchers("/css/**", "/js/**", "/images/**",
                                       "/favicon.ico", "/error");
}

@Bean
@ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true")
public WebSecurityCustomizer h2ConsoleSecurityCustomizer() {
    // h2-console을 하드코딩하지 않고 PathRequest.toH2Console 사용
    return web -> web.ignoring().requestMatchers(PathRequest.toH2Console());
}

@ConditionalOnProperty는 속성이 있을 때 조건에 따라서 등록되는 빈 설정이다.

눈 여겨 볼만한 부분은 PathRequest.toH2Console()인데 이 메서드는 아래처럼 h2-console의 경로를 하드코딩하지 않게 도와준다.

return web -> web.ignoring().requestMatchers("/h2-console/**");
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.