iOS: DeviceCheckの参照、保存をJavaで実装。(アカウントBan,リセマラ防止対応など)

# iOSのdevice checkで出来ること。

- 端末から取得できるdevice tokenを元に、端末単位で2つのビット値をAppleのAPIに記録、参照できる。
- 端末を初期化、アプリのアンインストール&インストールしても保存されたビット値はリセットされない。

# どんなことに利用できるか。
### リセマラ防止。
- (初期インストール時、device checkのAPIに記録すると、再インストールされたことを判定できる。)

### 深刻な不正行為をするユーザーを端末単位で、アカウントbanする。
- device checkのAPIで、アカウントbanされた端末であることを記録。
- アプリの再インストール、端末の初期化後も、以前にアカウントbanされた端末である場合、利用できないようにする。

# 利用時の注意点
- 端末が中古市場等で別の人に渡った場合でも、保持される値なので慎重な運用が必要。
- 端末から取得したdevice tokenは時間的な有効期限があるみたい。(この部分の記憶は定かじゃないので、テストしてみてください。)アカウントbanで利用する場合は、banした後の次のアプリ起動時にdevice tokenをサーバーサイドに送って、そこですぐにdevice checkのAPIで更新するような工夫が必要。

# 事前準備、前提
- 認証キーのp8ファイルを取得。
- KEY_ID, TEAM_IDを取得
- DeviceTokenを端末で取得し、サーバーサイドに渡す。

### 参考記事
- iOS11で追加されたDeviceCheckについて
    - https://qiita.com/owen/items/85dff1e45083d2805140

- apple 公式：
    - https://developer.apple.com/documentation/devicecheck/accessing_and_modifying_per-device_data

# DeviceCheck APIのURL

## baseのAPI
### 開発
https://api.development.devicecheck.apple.com/v1/

### 本番運用時
https://api.devicecheck.apple.com/v1/

## APIの機能
### 参照：query_two_bits
https://api.devicecheck.apple.com/v1/query_two_bits

### 更新：update_two_bits
https://api.devicecheck.apple.com/v1/update_two_bits

### device tokenのバリデーション：validate_device_token
https://api.devicecheck.apple.com/v1/validate_device_token

## プログラム

### iOSアプリでdevice tokenを取得。
※シミュレーターでは取得できません。

```
import DeviceCheck

DCDevice.current.generateToken {
    (data, error) in
    guard let data = data else {
        return
    }

    let token = data.base64EncodedString()
    print(token)
}
```

### サーバーサイド(Java)

#### 取得、更新サンプル
```
// 取得
private void queryIOSDeviceCheckSample() {
    // この値で2つのbool値と最終更新日時を取得できる。
    Response response = postRequest(DEVELOPMENT_BASE_API_URL + "query_two_bits", "端末から取得したdevicetoken", null, null);
}

// 更新
private void updateIOSDeviceCheckSample(Boolean bit0, Boolean bit1) {
    Response response = postRequest(DEVELOPMENT_BASE_API_URL + "update_two_bits", "端末から取得したdevicetoken", null, null);
}
```

#### API呼び出し処理
- "Authorization"ヘッダーにJWTのトークンを設定。
- request bodyにjson形式で各パラメーターを設定。
    - device_token, transaction_id, timestamp, bit0, bit1

```
private static Response postRequest(String url, String deviceToken, Boolean bit0, Boolean bit1) throws IOException {
    MediaType JSON = MediaType.get("application/json; charset=utf-8");

    JSONObject jsonObject = new JSONObject();
    jsonObject.put("device_token", deviceToken);
    jsonObject.put("transaction_id", UUID.randomUUID().toString());
    jsonObject.put("timestamp", new Date().getTime());
    if (bit0 != null) {
        jsonObject.put("bit0", bit0);
    }
    if (bit1 != null) {
        jsonObject.put("bit1", bit1);
    }
    String json = jsonObject.toJSONString();

    RequestBody body = RequestBody.create(JSON, json);

    String jwt = getJWTStr();
    if (jwt == null) {
        return null;
    }
    Request request = new Request.Builder()
            .url(url)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .header("Content-Length", String.valueOf(json.length()))
            .header("Authorization", "Bearer " + jwt)
            .post(body)
            .build();
    OkHttpClient client = new OkHttpClient();
    return client.newCall(request).execute();
}
```

#### p8ファイルから、JWTトークンを取得。

##### プッシュ通知用ライブラリ「pushy」のソースを参考に実装：JWTトークンの取得処理
- pushyという、iOSのプッシュ通知用のライブラリがあり、
p8ファイルからJWTトークンを取得する処理がある。
- その部分を参考にして実装。
https://github.com/jchambers/pushy/blob/master/pushy/src/main/java/com/eatthepath/pushy/apns/auth/ApnsSigningKey.java#L124-L170

#### JTW(=JSON Web Token)
https://ja.wikipedia.org/wiki/JSON_Web_Token

```
private static String getJWTStr() {
    try {
        ECPrivateKey privateKey = getECPrivateKey(P8_SECRET_KEY_PATH);
        return Jwts.builder()
            .setHeaderParam("kid", KEY_ID)
            .setIssuer(TEAM_ID)
            .setIssuedAt(new Date())
            .signWith(privateKey, SignatureAlgorithm.ES256)
            .compact();
    } catch (Exception e) {
        return null;
    }
}

private static ECPrivateKey getECPrivateKey(String p8FilePath) throws Exception {

    final FileInputStream fileInputStream = new FileInputStream(new File(p8FilePath));
    final ECPrivateKey signingKey;
    {
        final String base64EncodedPrivateKey;
        {
            final StringBuilder privateKeyBuilder = new StringBuilder();

            final BufferedReader reader = new BufferedReader(new InputStreamReader(fileInputStream));
            boolean haveReadHeader = false;
            boolean haveReadFooter = false;

            for (String line; (line = reader.readLine()) != null; ) {
                if (!haveReadHeader) {
                    if (line.contains("BEGIN PRIVATE KEY")) {
                        haveReadHeader = true;
                    }
                } else {
                    if (line.contains("END PRIVATE KEY")) {
                        haveReadFooter = true;
                        break;
                    } else {
                        privateKeyBuilder.append(line);
                    }
                }
            }

            if (!(haveReadHeader && haveReadFooter)) {
                throw new IOException("Could not find private key header/footer");
            }

            base64EncodedPrivateKey = privateKeyBuilder.toString();
        }

        final byte[] keyBytes = Base64.getDecoder().decode(base64EncodedPrivateKey.getBytes(StandardCharsets.US_ASCII));

        final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
        final KeyFactory keyFactory = KeyFactory.getInstance("EC");

        try {
            signingKey = (ECPrivateKey) keyFactory.generatePrivate(keySpec);
        } catch (InvalidKeySpecException e) {
            throw new InvalidKeyException(e);
        }
    }
    return signingKey;
}
```