🦔

Spring Security で RDB にユーザを永続化した際の挙動をソースコードとともに確認する

2023/01/25に公開約18,200字

はじめに

これまで、 Spring Security の挙動をソースコードを追いながら見ていきました。

https://zenn.dev/kiyotatakeshi/articles/fc593c768ad7e0
https://zenn.dev/kiyotakeshi/articles/cf198ab5e6c735/

今回の記事では、サンプルコード をもとに Spring Security の認証時に使用するユーザを RDB に永続化する方法を確認していきます。

今回の作業ブランチは persist-to-DB です。

Spring Security が設定してくれている内容と、フレームワーク内部の実装を掘り下げることで、
セキュリティを意識する上で必要な実装についての理解を深められればと思います。

ライブラリ バージョン Maven central URL
spring-boot-starter-web 3.0.1 https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web/3.0.1
spring-boot-starter-security 3.0.1 https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security/3.0.1
spring-core 6.0.3 https://mvnrepository.com/artifact/org.springframework/spring-core/6.0.3
spring-security-web 6.0.1 https://mvnrepository.com/artifact/org.springframework.security/spring-security-web/6.0.1

JdbcUserDetailsManager を使用したユーザの管理

前回の記事で、「Spring Security が認証を行う流れ」を確認しました。

ログインのリクエストからユーザを取得し、パスワードの有効性を確認しているのは、
DaoAuthenticationProvider であり、ドキュメント に前後の処理が図示されておりわかりやすいです。
(実際のソースコードリーディングは前回の記事で実施)

hoge

ポイントとしては、③の UserDetailsService の実装を差し替えることで、ユーザの取得先(取得方法)を変更できるということです

UserDetailsServiceドキュメントに記載のように、Spring Security が提供する実装として、
インメモリと JDBC のサポートがあります。

Spring Security provides in-memory and JDBC implementations of UserDetailsService.

もしくは、 UserDetailsService interface を自身で実装し、それをDIコンテナに登録することでユーザの取得の処理をカスタマイズできます。

インメモリの実装は、 InMemoryUserDetailsManager であり、前回の記事のこちらで挙動を確認済みです。

まずは JDBC の実装である JdbcUserDetailsManager を使用してみます。

こちらの commit 時点に移動すると実際に動かして試せます。

docker-compose.yaml を使用し、 Postgres をコンテナ起動します。

services:
  postgres:
    image: postgres:13.9
    container_name: spring-security-demo-postgres
    ports:
      - 15432:5432
    volumes:
      - ./.docker/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: testdb
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
    restart: always
$ docker compose up -d

application.yaml にも接続先の情報を記載します。

spring:
  datasource:
    url: jdbc:postgresql://localhost:15432/testdb
    driverClassName: org.postgresql.Driver
    username: postgres
    password: password

Spring Security が指定する schema がドキュメントに記載されています。

Postgres 用に書き換えた以下の DDL を流します。
(自分は IntelliJ のデータベースクライアントで流しました)

drop table if exists authorities;
drop table if exists users;

drop sequence if exists user_id_seq;
drop sequence if exists authority_id_seq;

create sequence user_id_seq;
create sequence authority_id_seq;

create table users
(
    id int not null default nextval('user_id_seq') primary key,
    username varchar(50)  not null unique,
    password varchar(500) not null,
    enabled  boolean                 not null
);

create table authorities
(
    id int not null default nextval('authority_id_seq') primary key,
    username  varchar(50) not null,
    authority varchar(50) not null,
    constraint fk_authorities_users foreign key (username) references users (username)
);

create unique index ix_auth_username on authorities (username, authority);

あとは、UserDetailsService の実装として、JdbcUserDetailsManager を使用するためにDIコンテナに登録します。

ドキュメントを参考にサンプルのユーザを作成済みの状態にします。

この先、ユーザの登録時に BCryptPasswordEncoder を DI コンテナから引っ張っていきたいため、Bean定義します。

@Configuration
class SecurityConfig {
    @Bean
    fun users(dataSource: DataSource): UserDetailsManager {
        val user: UserDetails = User.builder()
            .username("admin")
            // encode with Spring Boot CLI
            // https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-boot-cli
            // $ spring encodepassword 1qazxsw2
	    // delete "{bcrypt}\"
            .password("$2a\$10\$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy")
            .authorities("USER", "ADMIN")
            .build()
        val admin: UserDetails = User.builder()
            .username("user")
            // $ spring encodepassword 2wsxzaq1
            .password("$2a\$10\$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6")
            .authorities("USER")
            .build()

        val users = JdbcUserDetailsManager(dataSource)
        users.createUser(user)
        users.createUser(admin)
        return users
    }

    @Bean
    fun passwordEncoder() : PasswordEncoder = BCryptPasswordEncoder()    
}

この状態で、アプリケーションをデバック実行してみると、
JdbcUserDetailsManagercreateUser メソッドが呼ばれ

	@Override
	public void createUser(final UserDetails user) {
		validateUserDetails(user);
		getJdbcTemplate().update(this.createUserSql, (ps) -> {
			ps.setString(1, user.getUsername());
			ps.setString(2, user.getPassword());
			ps.setBoolean(3, user.isEnabled());

// (略)

以下の DML が発行されることで、

	private String createUserSql = DEF_CREATE_USER_SQL;
	public static final String DEF_CREATE_USER_SQL = "insert into users (username, password, enabled) values (?,?,?)";

DB にユーザが作成されます。
作成されたことを確認します。

### どっちでも
### $ docker compose exec postgres bash -c 'psql -Upostgres -d testdb -c "select * from users"'
$ docker compose exec -T postgres /bin/bash <<EOF
psql -Upostgres -d testdb -c "select * from users"
EOF

 id | username |                           password                           | enabled 
----+----------+--------------------------------------------------------------+---------
  1 | admin    | $2a$10$1gHHMqYmv7spE.896lYtKuenhXSRGyZ0FK.JTzAOSD6qgRKtPl5wy | t
  2 | user     | $2a$10$saAFPwyIghNePc0C4sKuUOBUIQBs6xnC8sUh2OvLW6fuU57oJ1tp6 | t
(2 rows)

では、Basic認証が通ることを確認します。

$ admin_encoded_credential=$(echo -n "admin:1qazxsw2" | base64)
$ curl --location --request GET 'localhost:9080/public' --header "Authorization: Basic $admin_encoded_credential"

hello public world

$ user_encoded_credential=$(echo -n "user:2wsxzaq1" | base64)
$ curl --location --request GET 'localhost:9080/public' --header "Authorization: Basic $user_encoded_credential"

hello public world

この際、デバック実行し stacktrace を確認すると、
org/springframework/security/authentication/dao/DaoAuthenticationProvider.javaUserDetailsService を呼び出す際に、
実装として org/springframework/security/provisioning/JdbcUserDetailsManager.javaloadUsersByUsername が呼ばれている事がわかります。

12-zenn-spring-security

発行されている SQL を確認すると、
先程作成したテーブルからリクエストの username で検索をかけていることが分かります。

	@Override
	protected List<UserDetails> loadUsersByUsername(String username) {
		return getJdbcTemplate().query(getUsersByUsernameQuery(), this::mapToUser, username);
	}
	private String usersByUsernameQuery;
		this.usersByUsernameQuery = DEF_USERS_BY_USERNAME_QUERY;
	public static final String DEF_USERS_BY_USERNAME_QUERY = "select username,password,enabled "
			+ "from users "
			+ "where username = ?";

その後、 loadUserAuthorities メソッドで authorities テーブルにも検索をかけています。

13-zenn-spring-security

その後の流れは、前回の記事で確認したものと同じで、
DB から取得したユーザの encode 済みのパスワードと、リクエストのパスワードを encoding したものを比較し、パスワードが有効であるかを確認しログインの認証の判定をしています。

ユーザの取得方法の挙動だけが変わった ことが確認できました。以下が確認できたわけです。

UserDetailsService の実装を差し替えることで、ユーザの取得先(取得方法)を変更できるということです

UserDetailsService を独自に実装する

JdbcUserDetailsManager を使用することで、
手軽に Spring Security が提供する実装を活用して DB にユーザを永続化できることが分かりました。
一方で、テーブルの構造が Spring Security が指定するものに縛られてしまうというデメリットがあります。

別の属性を持った Entity を認証する対象として DB にて管理したい場合や、
権限自体を単独でデータベースで管理したい場合(後述のように、ユーザと権限と多対多関係にする)など、
認証情報を要件に合わせたテーブル構成にしてそこからデータを取得してきたい場合は、
UserDetailsService interface を自身で実装し、それをDIコンテナに登録すればよいです。

では、カスタマイズしていきます。
最終的なコードは branch persist-to-DB に checkout することで実際に動かして確認できます。
(差分は こちらの merge commit より確認できます)

まず、 build.gradle.kts の定義を変更し、
jOOQ を使用して別のテーブルの情報でログインの判定を行うようにしてみます。

	// implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
	implementation("org.springframework.boot:spring-boot-starter-jooq")

認証情報を保持する Entity は Customer というクラス、および DB のテーブルとします。

class Customer private constructor(
    id: Int?, email: String, password: String, roles: Set<Role>?
) {
    var id: Int? = id
    val email = email
    val password = password
    val roles: Set<Role>? = roles

// 略

また、権限情報を表す Entity および DB のテーブルは Role とします。
上記の Customer の属性にあるように、 CustomerRole を重複なく複数持てるようにします。

class Role private constructor(
    id: Int?, name: String
){
    var id: Int? = id
    val name = name

DDL としては以下のものを使用します。
customer は複数の role を持てて、role は複数の customer に紐づくため、
多対多(N:N)のリレーションとし中間テーブルで customer_role を作成しています。

create sequence customer_id_seq;
create table customers
(
    id       int          not null default nextval('customer_id_seq') primary key,
    email    varchar(50)  not null unique,
    password varchar(500) not null
);

drop table if exists roles;
drop sequence if exists role_id_seq;

create sequence role_id_seq;
create table roles
(
    id   int         not null default nextval('role_id_seq') primary key,
    name varchar(50) not null unique
);

create sequence customer_role_id_seq;
create table customer_role
(
    id          int not null default nextval('customer_role_id_seq') primary key,
    customer_id int not null,
    role_id     int not null,
    constraint fk_customer_role_customer_id foreign key (customer_id) references customers (id),
    constraint fk_customer_role_role_id foreign key (role_id) references roles (id)
);

DB からデータを所得する箇所のコードは、
domain layer に repository の interface を定義し、

interface CustomerRepository {
    fun findByEmail(email: String): Customer?
    fun save(customer: Customer): Customer
    fun findAll(): List<Customer>
    fun attachRoles(customerId: Int, roles: List<String>): Customer
}

interface RoleRepository {
    fun findByName(name: String): Role?
    fun save(role: Role): Role
    fun findAll(): List<Role>
}

infrastructure layer に実装(今回だと jOOQ を使用したデータベースへのアクセス)を記載します。
(onion archtecture で記載していますが、 Spring Security の話とは関係がないので今回は割愛)

customers, customer_role(中間テーブル), roles を結合して取得し、
customer に role を持たせてインスタンス化したものを返しています。

@Repository
class CustomerJooqRepository(private val create: DSLContext) : CustomerRepository {

    val c: Customers = CUSTOMERS.`as`("c")
    val cr: CustomerRole = CUSTOMER_ROLE.`as`("cr")
    val r: Roles = ROLES.`as`("r")

    override fun findByEmail(email: String): Customer? {

        val records = create.select()
            .from(c.leftOuterJoin(cr).on(cr.CUSTOMER_ID.eq(c.ID)))
            .leftOuterJoin(r).on(r.ID.eq(cr.ROLE_ID))
            .where(c.EMAIL.eq(email))
            .fetch()

        if (records.isEmpty()) {
            return null
        }

        val roles = records.map {
            Role.reconstruct(
                id = it.getValue(r.ID)!!,
                name = it.getValue(r.NAME)!!
            )
        }.toSet()

        val first: Record = records.first()

        return Customer.reconstruct(
            id = first.getValue(c.ID)!!,
            email = first.getValue(c.EMAIL)!!,
            password = first.getValue(c.PASSWORD)!!,
            roles = roles
        );
    }

そして、 UserDetailsService interface を自身で実装した CustomerDetails を追加します。
こちらは上記の CustomerRepositoryfindByEmail メソッドで認証情報を保持する customer を取得し Spring Security がユーザを扱う形式である UserDetails をインスタンス化して返すものです。
stereotype annotation である @Service を使用してDIコンテナへの登録をしています。

@Service
class CustomerDetails(
    private val customerRepository: CustomerRepository,
    private val roleRepository: RoleRepository,
    private val passwordEncoder: PasswordEncoder,
) : UserDetailsService {

    override fun loadUserByUsername(email: String): UserDetails {
        val customer = customerRepository.findByEmail(email)
            ?: throw UsernameNotFoundException("User details not found for the user : $email")

        val authorities: MutableList<GrantedAuthority> = ArrayList()
        customer.roles?.map {
            authorities.add(SimpleGrantedAuthority("ROLE_${it.name}"))
        }
        return User(customer.email, customer.password, authorities)
    }
}

DB に初期データを流しておきます。

-- initial data for admin user
insert into roles (id, name)
values (default, 'ADMIN'),
       (default, 'USER');

insert into customers (id, email, password)
values (default, 'admin@example.com', '$2a$10$ancDG4fEZY31a9OtuqSbs./SPUu7s00qam5sXinI5NrTLSGlCy/BK');

insert into customer_role (customer_id, role_id)
values ((select c.id from customers c where c.email = 'admin@example.com'),
        (select r.id from roles r where r.name = 'ADMIN')),
       ((select c.id from customers c where c.email = 'admin@example.com'),
        (select r.id from roles r where r.name = 'USER'));

select c.*, r.name
from customers c
       left join customer_role cr on c.id = cr.customer_id
       left join roles r on cr.role_id = r.id;
       
/*
+--+-----------------+------------------------------------------------------------+-----+
|id|email            |password                                                    |name |
+--+-----------------+------------------------------------------------------------+-----+
|1 |admin@example.com|$2a$10$ancDG4fEZY31a9OtuqSbs./SPUu7s00qam5sXinI5NrTLSGlCy/BK|ADMIN|
|1 |admin@example.com|$2a$10$ancDG4fEZY31a9OtuqSbs./SPUu7s00qam5sXinI5NrTLSGlCy/BK|USER |
+--+-----------------+------------------------------------------------------------+-----+
*/

この状態でアプリケーションをデバック実行してリクエストすると、

$ admin_encoded_credential=$(echo -n "admin:1qazxsw2" | base64)
$ curl --location --request GET 'localhost:9080/public' --header "Authorization: Basic $admin_encoded_credential"

hello public world

$ user_encoded_credential=$(echo -n "user:2wsxzaq1" | base64)
$ curl --location --request GET 'localhost:9080/public' --header "Authorization: Basic $user_encoded_credential"

hello public world

org/springframework/security/authentication/dao/DaoAuthenticationProvider.javaUserDetailsService を呼び出す際に、
実装として com/example/zenn/security/CustomerDetails.ktloadUsersByUsername が呼ばれている事がわかります。

14-zenn-spring-security

つまりは以下の点が検証できたわけです。

ポイントとしては、③の UserDetailsService の実装を差し替えることで、ユーザの取得先(取得方法)を変更できるということです

もしくは、 UserDetailsService interface を自身で実装し、それをDIコンテナに登録することでユーザの取得の処理をカスタマイズできます。

ユーザの新規作成

ユーザーの新規追加、 role の付与も branch persist-to-DB にて追加しています。

新規追加時には、 DIコンテナから BCryptPasswordEncoder を引っ張ってきて、
リクエストのあったパスワードを hash 化し、
USER をいう初期設定用の role を付与してユーザをインスタンス化して DB に保存しています。

    fun register(customer: Customer): Customer {
        val hashedPassword = passwordEncoder.encode(customer.password)
        val userRole: Role? = roleRepository.findByName("USER")
        val newCustomer = Customer.create(customer.email, hashedPassword, setOfNotNull(userRole))
        return customerRepository.save(newCustomer)
    }

おわりに

Spring Security の認証時に使用するユーザを RDB に永続化する方法を確認しました。
UserDetailsService の実装を差し替えることで、
ユーザの取得先(取得方法)を変更できる柔軟性の高さにより、要件に合わせたカスタマイズができますね。

次回は、独自の処理を差し込む Filter を追加する方法と、
認可についても言及できればと思います。
また、認証後は JWT(Json Web Token) を使用して認可の判定ができるようにシステムを作る方法も見ていきます。

つづき

https://zenn.dev/kiyotatakeshi/articles/d12a850f232d71

Discussion

ログインするとコメントできます