Spring Security - Username and Password Storage

July 25, 2021

사용자 스토어 관리

WebSecurityConfigurerAdapter에서 configure(AuthenticationManagerBuilder auth) 메소드는 유저를 관리하는 설정 메소드이며, 파라미터인 AuthenticationManagerBuilder 는 빌더 형태로 인증 명세를 구성할 수 있게 한다. 그리고 이를 통해 인증 정보를 관리하는 방법은 여러 방법이 존재할 수 있다.

  • In-Memory Store
  • 관계형 데이터베이스를 위한 JDBC 기반 Store
  • LDAP 기반 스토어
  • 커스텀 사용자 명세

In-Memory 기반 스토어

사용자의 정보 변경이 전혀 발생하지 않고, 오직 사전 정의된 계정으로만 관리가 된다면 저장 위치는 메모리가 될 수 있다. 이 때는 In-Memory 기반 스토어를 사용할 수 있다.

@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");
    }
}

AuthenticationManagerBuilder에서는 메모리에 유저 정보를 관리할 수 있게 등록하는 inMemoryAuthentication 메소드를 제공한다. withUser, password, authorities를 통해 아이디, 비밀번호, 권한을 지정할 수 있다.

그리고, 스프링5에서 부터는 비밀번호는 반드시 암호화되어야 한다. 하지만 간단한 구현을 위해 암호화를 피하는 구문으로 {noop}을 지정하였다.

JDBC 기반 스토어

사용자 정보는 사실 관계형 데이터베이스 안에서 관리되는 경우가 제일 많다. 그리고 이를 활용하기 위해 JDBC 기반으로 스토어 관리를 지정할 수 있다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final DataSource dataSource;

    @Autowired
    public SecurityConfig(DataSource dataSource){
        this.dataSource = dataSource;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .jdbcAuthentication()
            .dataSource(dataSource)
            .passwordEncoder(new NoEncodingPasswordEncoder());
    }
}

설정 값은 이게 전부이다. 스프링 시큐리티 내부에 이미 사용자 정보에 대한 스키마와 이에 관한 쿼리가 정의되어 있으며 이 스펙을 준수한다면 DataSource만 지정하면 된다.

-- 사용자 존재유무, 계정 활성화 여부 확인
SELECT
	username,
	password,
	enabled
FROM
	users
WHERE
	username = ?
-- 사용자의 권한 확인
SELECT
	username,
	enabled
FROM
	authorities
WHERE
	username = ?
-- 사용자의 그룹과 그룹의 권한 확인
SELECT
	g.id,
	g.group_name,
	ga.authority
FROM
	authorities g,
	group_members gm,
	group_authorities ga
WHERE
	gm.username = ? AND
	g.id = ga.group_id AND
	g.id = gm.group_id

그리고, 지정된 옵션이 passwordEncoder이다. 스프링 5에서 부터는 비밀번호에 대한 암호화를 안해주면 에러가 난다. 이 메소드는 아래 인터페이스를 구현한 클래스를 통해 비밀번호의 인코딩 방식을 지정한다.

public interface PasswordEncoder{
	String encode(CharSequence rawPassword);
	boolean matches(CharSequence rawPassword, String encodedPassword);
}
public class NoEncodingPasswordEncoder implements PasswordEncoder {
    @Override
    public String encode(CharSequence rawPassword) {
        return rawPassword.toString();
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return rawPassword.toString().equals(encodedPassword);
    }
}

위 예시에는 평문을 그대로 사용하기 위해 임의로 클래스를 구성했다. 원하는 암호화 알고리즘을 사용하여 구현하면 되며, 이미 내장 클래스로 구현되어 있는 것을 사용해도 무방하다.

Class Description
BCryptPasswordEncoder bcrypt를 해싱 암호화
Pbkdf2PasswordEncoder PBKDF2 암호화
SCryptPasswordEncoder scrypt를 해싱 암호화
StandardPasswordEncoder SHA-256을 해싱 암호화

그리고 스프링 시큐리티에서 제공하는 스키마와 쿼리에 맞춰 구성되어 있지 않아도, 쿼리를 직접 정의하면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final DataSource dataSource;

    @Autowired
    public SecurityConfig(DataSource dataSource){
        this.dataSource = dataSource;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .jdbcAuthentication()
            .dataSource(dataSource)
            .usersByUsernameQuery(
                    "select username, password, enalbed from users " +
                    "where username=?"
            )
            .passwordEncoder(new NoEncodingPasswordEncoder());
    }
}

LDAP 기반 스토어

LDAP는 Lightweight Directory Access Protocol의 약자이며 이를 기반으로 인증을 설정할 수 있다. 디폴트로는 ‘localhost:33389’를 바라보도록 되어있다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .ldapAuthentication()
                .userSearchFilter("(uid={0}")
                .groupSearchFilter("(member={0}");
    }
}

사용자 커스텀

관계형 데이터베이스를 사용하는 경우에는 JDBC 기반 인증으로 처리할 수 있다. 하지만 다른 데이터를 처리하기 위해 MyBatis, JPA와 같은 Persistence Framework를 사용하고 있다면 그에 맞게 구성할 수도 있다. 이는 UserDetailService 인터페이스를 구현한다. 앞서 봤던 다른 인증 정보 저장 메커니즘은 이를 자동으로 생성해주는데, 이에 반해 커스텀 방식에서는 직접 구성하는 것이다.

아래는 Spring Data 기반으로 작성된 예제이다.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserRepositoryUserDetailService userDetailsService;

    @Autowired
    public SecurityConfig(UserRepositoryUserDetailService userDetailsService){
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .userDetailsService(userDetailsService);
    }
}

먼저 User를 정의하는 엔티티를 UserDetails를 구현하여 작성한다.

public enum UserRole implements GrantedAuthority {
    SUPER_ADMIN,
    ADMIN,
    USER;

    @Override
    public String getAuthority() {
        return "ROLE_" + name();
    }
}
@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 String username;
    private String password;
    private UserRole role;

	// 사용자 권한을 리스트로 반환 
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Arrays.asList(new SimpleGrantedAuthority(this.role.getAuthority()));
    }

    // 계정 만료 여부
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정 잠김 여부
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 비밀번호 만료 여부
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 유효 계정 여부
    @Override
    public boolean isEnabled() {
        return true;
    }

}

그리고 UserDetailsServiceloadUserByUsername 를 구현하면 된다.

@Service
public class UserRepositoryUserDetailService implements UserDetailsService {
    private final UserRepository userRepository;

    @Autowired
    public UserRepositoryUserDetailService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository
                .findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}

참고


songmk 🙁