MyBatisでデータを暗号化/複合化する

SpringBoot+MyBatisでデータを暗号化/複合化したので備忘録として残します。

## 目的

- エンティティの特定データを暗号化してDBに登録する。
- 暗号化/複合化の実行はMyBatisの機能で行う。
- MyBatisのMapperはXMLを使用する。

## ライブラリ等

- spring-boot-starter: 2.6.4
- mybatis-spring-boot-starter: 2.2.2
- lombok: 1.18.22

## 実装

- 以下、パッケージ名は `{package-name}`と表記する。

### データベースを作成

- ID、名前、Eメールアドレスを持つユーザ情報。
- 名前とEメールアドレスを暗号化する。

```/src/main/resource/schema.sql
DROP TABLE IF EXISTS user;

CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT NULL,
  `email` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`)
);
```

```/src/main/java/{package-name}/entity/User.java
@Data
public class User {
    private int id;
    private String name;
    private String email;

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
}
```

### 暗号化ロジックを作成

- サンプルとしてシーザー暗号を採用した。
- シーザー暗号は、アルファベットを決まった値だけシフトして暗号化する。
- 例えば、シフト数2で `Qiita` を暗号化すると `Tllwd` になる。

```/src/main/java/{package-name}/cipher/CaesarCipher.java
@Component
public class CaesarCipher {

    // シフトする値
    @Value("${caesar-cipher.shift:10}")
    private int shiftValue;

    // シフト
    private String shift(String str, int s) {
        if (s < 0) {
            s += 26;
        }

        StringBuilder sb = new StringBuilder();
        for (char ch : str.toCharArray()) {
            if (!Character.isAlphabetic(ch)) {
                sb.append(ch);
                continue;
            }

            int base = Character.isUpperCase(ch) ? 'A' : 'a';
            char newCh = (char) ((ch + s - base) % 26 + base);
            sb.append(newCh);
        }
        return sb.toString();
    }

    // 暗号化
    public String encipher(String text) {
        if (text == null) {
            return null;
        }

        return shift(text, shiftValue);
    }

    // 複合化
    public String decipher(String text) {
        if (text == null) {
            return null;
        }

        return shift(text, -shiftValue);
    }
}
```

### TypeHandlerを作成

- MyBatisのMapperでカラムに紐づけるためのTypeHandlerを作成する。
- DBからデータを取得してエンティティに格納する際に実行される。
- 暗号化するデータ型ごとに作成する必要がある。

```/src/main/java/{package-name}/typehandler/CaesarCipherStringType.java
// DBに格納する際のDataTypeを定義
// AliasはMapper内で参照する名前
@Alias("CaesarCipherString")
public class CaesarCipherStringType {
}
```

```/src/main/java/{package-name}/typehandler/CaesarCipherStringType.java
// 定義したDataTypeに紐づくTypeHandler
@MappedTypes(CaesarCipherStringType.class)
public class CaesarCipherStringTypeHandler extends BaseTypeHandler<String> {

    private final CaesarCipher caesarCipher;

    public CaesarCipherStringTypeHandler(CaesarCipher caesarCipher) {
        this.caesarCipher = caesarCipher;
    }

    // null以外の値をDBに保存（暗号化）
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
            throws SQLException {
        String str = caesarCipher.encipher(parameter);
        ps.setString(i, str);
    }

    // カラム名指定でDBから取得（複合化）
    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        try {
            String str = rs.getString(columnName);
            if (str == null) {
                return null;
            }
            return caesarCipher.decipher(str);
        } catch (Exception e) {
            throw new SQLException(e);
        }
    }

    // カラム番号指定でDBから取得（複合化）
    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        try {
            String str = rs.getString(columnIndex);
            if (str == null) {
                return null;
            }
            return caesarCipher.decipher(str);
        } catch (Exception e) {
            throw new SQLException(e);
        }
    }

    // カラム番号指定でDBから取得（複合化）
    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        try {
            String str = cs.getString(columnIndex);
            if (str == null) {
                return null;
            }
            return caesarCipher.decipher(str);
        } catch (Exception e) {
            throw new SQLException(e);
        }
    }
}
```

```/src/main/java/{package-name}/config/MyBatisConfig.java
@Configuration
@MapperScan("{package-name}.repository")
@RequiredArgsConstructor
public class MyBatisConfig {

    private final CaesarCipher caesarCipher;

    // MyBatisのTypeHandlerにCaesarCipherStringTypeHandlerを登録
    @Bean
    ConfigurationCustomizer mybatisConfigurationCustomizer() {
        return configuration -> {
            configuration.getTypeHandlerRegistry().register(new CaesarCipherStringTypeHandler(caesarCipher));
        };
    }
}
```

```/src/main/resources/application.yml
mybatis:
  mapper-locations: classpath*:/mapper/*.xml
  type-aliases-package: {package-name}.typehandler
```

### MapperでTypeHandlerを紐付け

- 先ほど作成したTypeHandlerをMapperに紐づけて、暗号化/複合化する

```/src/main/java/{package-name}/repository/UserRepository.java
@Repository
public interface UserRepository {
    // 複合かして取得
    User findById(int id);

    // 複合化せずに取得
    User findByIdWithoutDecipher(int id);

    // 暗号化して登録
    void insert(User user);
}

```

```/src/main/resources/mapper/UserRepository.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="{name-space}.repository.UserRepository">

  <resultMap type="{name-space}.model.User" id="user">
    <id column="id" property="id" />
    <result column="name" property="name" javaType="CaesarCipherString" />    <!-- javaTypeで複合化方式を指定 -->
    <result column="email" property="email" javaType="CaesarCipherString" />
  </resultMap>

  <resultMap type="{name-space}.model.User" id="userWithoutDeCipher">
    <id column="id" property="id" />
    <result column="name" property="name" />
    <result column="email" property="email" />
  </resultMap>

  <select id="findById" resultMap="user">
    select id,
           name,
           email
    from user
    where id = #{id}
  </select>

  <select id="findByIdWithoutDecipher" resultMap="userWithoutDeCipher">
    select id,
           name,
           email
    from user
    where id = #{id}
  </select>

  <insert id="insert" useGeneratedKeys="true" keyProperty="id">
    insert into user (id,
                      name,
                      email)
    values (#{id},
            #{name, javaType=CaesarCipherString},    <!-- javaTypeで暗号化方式を指定 -->
            #{email, javaType=CaesarCipherString})
  </insert>
</mapper>
```

### テストで動作確認

```/test/resources/db/user.xml
<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
  <user
    id="1"
    name="QDPH"
    email="qdph@hadpsoh.frp" />
</dataset>
```

```/src/test/java/{name-space}/UserRepositoryTest.java
@SpringBootTest
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class
})
public class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    // 複合化して取得
    @Test
    @DatabaseSetup("/db/user.xml")
    public void findById() {
        User user = userRepository.findById(1);
        assertThat(user.getId()).isEqualTo(1);
        assertThat(user.getName()).isEqualTo("NAME");
        assertThat(user.getEmail()).isEqualTo("name@example.com");
    }

    // 複合化せずに取得
    @Test
    @DatabaseSetup("/db/user.xml")
    public void findByIdWithoutDecipher() {
        User user = userRepository.findByIdWithoutDecipher(1);
        assertThat(user.getId()).isEqualTo(1);
        assertThat(user.getName()).isEqualTo("QDPH");
        assertThat(user.getEmail()).isEqualTo("qdph@hadpsoh.frp");
    }

    // 暗号化して登録
    @Test
    @DatabaseSetup("/db/user.xml")
    public void insert() {
        User user1 = new User("NAME", "name@example.com");
        userRepository.insert(user1);

        User user2 = userRepository.findById(user1.getId());
        assertThat(user2.getName()).isEqualTo("QDPH");
        assertThat(user2.getEmail()).isEqualTo("qdph@hadpsoh.frp");
    }
}
```

## GitHub

https://github.com/yk-mura/spring-mybatis-encrypt