spring.svg

【Spring Security】DBによる認証

SpringBoot

はじめに

過去記事でインメモリによる認証を用いて、Spring Secutityの認証の流れなどを説明しています。 この記事ではインメモリをDBに置き換えた場合の例を示すのみで、認証の仕組み等には詳しく触れません。 仕組みを知りたい方は過去記事を参照してください。

またDBはMySQL、DB処理にはSpring Data JPAを使用します。 こちらも詳しくは触れませんので、こちらなどを参照してください。

build.gradle
dependencies {
  ...
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  runtimeOnly 'com.mysql:mysql-connector-j'
  ...
}
application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb
    username: user
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver

今回データとして使用するのは、以下の user テーブルになります。 使用しているDBにテーブルの作成とデータ登録をしておいてください。

  • テーブル定義(user)
    項目名PK備考
    idINT自動インクリメント
    usernamevarcharユニーク
    passwordvarcharBCryptでハッシュ化
    rolevarcharADMIN, USERのどちらかを設定
    emailvarchar
  • 登録データ
    idusernamepasswordroleemail
    1admin$2a$08$h0qbchHHG86Yd6xb8VImjuuaIAnfls7.9RLwcxJpVYfnsEiHDXTZGADMINadmin@example.com
    2user$2a$08$h0qbchHHG86Yd6xb8VImjuuaIAnfls7.9RLwcxJpVYfnsEiHDXTZGUSERuser@example.com

    passwordはどちらも"password"をBCryptでハッシュ化した値

DB認証

UserDetails

ユーザー情報としてUserDetailsインターフェースを実装するUserクラスを作成します。 今回はSpring Data JPAを使用するため、Entityとして実装していきます。

User.java
@Entity
@Table(name = "user")
public class User implements UserDetails {

    @Id private int id;
    private String username;
    private String password;
    private String email;
    private String role;

    public String getUsername() {
      return username;
    }

    public String getPassword() {
      return password;
    }

    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
        return authorities;
    }
    
    public boolean isAccountNonExpired() {
        return true;
    }
    
    public boolean isCredentialsNonExpired() {
        return true;
    }
    
    public boolean isEnabled() {
        return true;
    }

    public boolean isAccountNonLocked() {
        return true;
    }
}

今回は、権限の判定をRoleで行う想定のため、getAuthorities()ROLE_XXXとなるように値を返しています。 RoleとAuthorityの違いについては過去記事を参照してください。

isAccountNonExpired()などのユーザーの有効性を判定するための項目は、今回使用しないため明示的にtrueを返しています。 必要な場合は、テーブルに該当項目を追加して値を返してください。

UserDetailsService

次にUserDetailsServiceを実装するUserServiceクラスを作成します。 UserDetailsServiceの役割は、usernameからユーザー情報(User)を取得することです。 今回は user テーブルからデータを取得する必要があるため、事前に以下のようなUserRepositoryを作成しておきます。

UserRepository
@Repository
public interface UserRepository extends JpaRepository<User, Integer>{
    public Optional<User> findByUsername(String username);
}
UserService.java
public class UserService implements UserDetailsService {

    @Autowired private UserRepository repository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = repository.findByUsername(username)
                              .orElseThrow(() -> 
                                new UsernameNotFoundException(username + " not found."));
        return user;
    }
}

Config

最後にConfigとして認証の設定を行います。

WebSecurityConfig
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService users() {
        return new UserService();
    }

    @Bean
    public SecurityFilterChain secutiryFilterChain(HttpSecurity http) throws Exception {
        http.httpBasic(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/fuga").hasRole("USER")
            .requestMatchers("/piyo").hasRole("ADMIN")
            .anyRequest().authenticated());
        return http.build();
    }
}

今回パスワードはBCryptでハッシュ化するため、PasswordEncoderにはBCryptPasswordEncoderを使用します。 ユーザーの参照部分には先ほど定義したUserServiceを使用します。

動作確認用に、securityFilterChain()で定義したパスの処理を作成します。

TestController.java
@RestController
public class TestController {

    @GetMapping("hoge")
    public String getHoge() {
        return "hoge";
    }

    @GetMapping("fuga")
    public String getFuga() {
        return "fuga";
    }

    @GetMapping("piyo")
    public String getPiyo() {
        return "piyo";
    }
}

今回認証としてBasic認証を採用しているため、リクエスト送信時にAuthorizationヘッダーにusername:passwordをBASE64でエンコードした値を設定します (パスワードは平文)。

Authorization: Basic YWRtaW46cGFzc3dvcmQ=

結果として以下のHTTPステータスが返ってくることを確認します。

adminuser
/hoge200200
/fuga403200
/piyo200403

前述していますが、基本的には過去記事のインメモリの認証からユーザー参照の部分をDBに置き換えただけです。 UserDetailsUserDetailsServiceの役割がわかっていれば、それほど難しくはないと思います。

JdbcUserDetailsManager

上記ではUserDetailsServiceを独自に実装しましたが、あらかじめ実装されているJdbcUserDetailsManagerを使用することができます。 ただし、JdbcUserDetailsManager(JdbcDaoImpl)の実装にデータ設計を合わせる必要があります。 というのも、あらかじめ使用するSQLが内部で定義されています。

JdbcUserDetailsManager
public class JdbcUserDetailsManager extends JdbcDaoImpl implements UserDetailsManager, GroupManager {
    //...
    @Override
    protected List<UserDetails> loadUsersByUsername(String username) {
      return getJdbcTemplate().query(getUsersByUsernameQuery(), this::mapToUser, username);
    }
    //...
}
JdbcDaoImple
public class JdbcDaoImpl extends JdbcDaoSupport implements UserDetailsService, MessageSourceAware {
    //...
    public static final String DEF_USERS_BY_USERNAME_QUERY = "select username,password,enabled "
            + "from users "
            + "where username = ?";
    //...
    public JdbcDaoImpl() {
      this.usersByUsernameQuery = DEF_USERS_BY_USERNAME_QUERY;
      this.authoritiesByUsernameQuery = DEF_AUTHORITIES_BY_USERNAME_QUERY;
      this.groupAuthoritiesByUsernameQuery = DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY;
    }
    //...
    public String getUsersByUsernameQuery() {
      return this.usersByUsernameQuery;
    }
    //...
}

上記の通り、JdbcUserDetailsManagerloadUsersByUsername()では、JdbcDaoImplgetUsersByUsernameQuery()で取得したSQLを実行します。 これを辿ると、JdbcDaoImplDEF_USERS_BY_USERNAME_QUERYで定義されたSQLであることがわかります。

JdbcUserDetailsManager及びJdbcDaoImplには他にもユーザー操作に関する様々なSQLが内部で定義されています。 実例等は割愛しますが、使用する場合はこのSQLに従ってデータ構造を作成する必要があります。 おそらく使用頻度はそれほど高くないとは思います。

JdbcUserDetailsManagerを使用する場合は、以下のようにDatasourceをコンストラクタに渡します。

WebSercurityConfig.java
@Bean
public UserDetailsService userService(DataSource dataSource) {
    return new JdbcUserDetailsManager(dataSource);
}