• 自作の認証プラグインが、普段はちゃんと動いている
  • でもDBが壊れたときや起動に失敗したとき、どうなるかは試したことがない
  • 気づいたら誰でも入れる状態になっていた、は避けたい

マイクラサーバーの参加許可をDiscordで管理していると、認証プラグインが門番そのものになります。

僕のサーバーでは9月末から、Discordに荒らしが来るようになりました。Discordの人間確認は突破され、BANしても入り直してきます。自作のBotに連投や大量メンションの自動タイムアウトを入れても追いつかず、招待リンクを12時間止めたほどです。

そこで運営で話し合い、Discordのチャンネルを見る権限を、マイクラのアカウントで認証した人だけに付けることにしました。Java版で入るにはゲームを購入したMicrosoftアカウント、統合版でもMicrosoftアカウントでのサインインが必要なので、使い捨てのアカウントで来るたびに手間とお金がかかります。

こうなると、認証プラグインはマイクラとDiscordの両方の門番です。

門番が倒れたときに門が開くのか閉じるのか。ここを確かめていないと、エラーひとつでサーバーが誰でも入れる状態になります。

僕のサーバーで使っている自作のDiscord認証プラグインを監査したら、まさにその穴が見つかりました。

この記事では、見つかった4つの不具合と直し方、起動に失敗したときに全員を締め出すロックダウンの作り、本番で全員に再認証してもらった手順をまとめます。

自分のプラグインのどこを見直せばいいか、判断しやすくなるはずです。

目次

MCAuthの仕組み

MCAuthは、Paper向けに自作したDiscord認証プラグインです。

項目内容
サーバーPaper 26.2(Java版と統合版の両方から接続)
実装Java 21向けにビルド(サーバーはJava 25で動作)、Discord側はJDA 5
保存先SQLite(mcauth.db)
認証の流れ未認証で接続するとキック画面に4桁のコード、Discordのボタンからそのコードを入力

入場の判定はAsyncPlayerPreLoginEvent(ログイン直前に非同期で呼ばれるイベント)で行います。認証済みならそのまま通し、未認証ならdisallowでキックしてコードを見せる作りです。

統合版の人も、Floodgate(統合版のプレイヤーをJava版サーバーに入れるプラグイン)がXboxのIDから作る固定のUUIDを持つので、同じ流れで認証できます。

例外が出ると誰でも入れる

例外が出たときの入場判定。直す前は例外でdisallowされずALLOWEDのままになり誰でも入れる。直した後はcatchしてdisallowし接続を拒否する。起動に失敗したらロックダウンで全員を拒否する

いちばん危なかったのがこれ。

Paperのイベント処理は、リスナーの中で例外が起きても「Could not pass event」とログに出して先へ進みます。disallowを呼ぶ前に例外で抜けると、ログインの結果は初期値のALLOWEDのまま残ります。

つまり、認証コードの発行で例外が出ると、未認証の人がそのまま入れてしまう状態でした。

直し方はシンプルで、判定に関わる処理を全部tryで包み、失敗したら拒否します。

String code;
try {
    code = createCode(event.getName(), uuid);
} catch (RuntimeException exception) {
    getLogger().log(Level.SEVERE, "Failed to create verification code", exception);
    event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER,
            Component.text("認証コードを発行できませんでした。しばらく待ってから再接続してください。"));
    return;
}

認証済みかどうかをDBに問い合わせる部分も同じです。DBを読めなかったら、認証済みの人も含めて拒否します。

迷ったら閉じる。これがフェイルクローズ(異常時は安全側に倒す)という考え方です。

既存の連携を上書きできた

保存のSQLがINSERT OR REPLACEになっていました。

INSERT OR REPLACEは、主キーやUNIQUE制約がぶつかると古い行を消して新しい行を入れます。エラーにはなりません。

保存の前に、同じDiscordアカウントがすでに連携済みかは確かめていました。ところが、同じマイクラアカウントがすでに別のDiscordアカウントと連携済みかは確かめていなかったのです。

そのため、連携済みのマイクラアカウントに別のDiscordアカウントから認証が通ると、前の連携が黙って上書きされます。元の持ち主は/unlinkで解除できなくなり、Discordのロールだけが残ってしまう状態でした。

マイクラ側の重複も先に確かめ、SQLもふつうのINSERTに変えています。どちらの重複で止まったかは、結果として返すようにしました。

enum AuthenticationResult {
    SAVED,
    DISCORD_ALREADY_LINKED,
    PLAYER_ALREADY_LINKED
}

連携済みのDiscordアカウントでコードを入力した人には、コードの誤りではなく「先に/unlinkで解除してください」と案内します。Discordアカウント1つにマイクラアカウント1つの1対1です。

/unlinkは、Discordサーバー内のどのチャンネルからでも実行できるようにしています。認証が終わると認証チャンネルが見えなくなる設定なので、チャンネルで制限すると、肝心の解除ができなくなるためです。

別のDiscordサーバーを抜けても認証が消えた

Discordサーバーから抜けた人の認証は、GuildMemberRemoveEventで取り消しています。

ところがBotが複数のサーバーに入っていると、認証と関係ないサーバーを抜けただけでも取り消されていました。

退出したサーバーが、認証チャンネルのあるサーバーと同じかどうかを比べてから取り消すように直しています。

GuildChannel channel = event.getJDA().getGuildChannelById(channelId);
if (channel == null || channel.getGuild().getIdLong() != event.getGuild().getIdLong()) {
    return;
}
plugin.revokeByDiscordUserId(event.getUser().getId());

連携済みの人の入力が失敗回数に数えられた

総当たり対策として、コードを5回間違えると60秒入力できなくしています。

ところが、連携済みのDiscordアカウントでコードを入れると「無効なコード」扱いになり、この失敗回数まで増えていました。本人は正しいコードを入れているのにロックされるので、かなり不親切です。

連携済みかどうかを先に確かめ、コードは消費せずに案内だけ返すようにしました。

起動に失敗したら全員を締め出す

ここまで直しても、まだ穴が残ります。プラグインの起動そのものに失敗した場合です。

onEnableで例外が出ると、Paperはそのプラグインを無効にし、登録済みのリスナーも外します。標準のホワイトリストを切っているサーバーでは、入場のチェックがなくなり、誰でも入れます。

そこで、起動処理の順番を変えました。

  1. 最初にリスナーを登録する
  2. DBを開く、設定を読む、Botを起動する、をまとめてtryで包む
  3. 失敗したらプラグインを止めず、ロックダウンに入る
@Override
public void onEnable() {
    saveDefaultConfig();
    Bukkit.getPluginManager().registerEvents(this, this);

    try {
        setUp();
    } catch (RuntimeException exception) {
        getLogger().log(Level.SEVERE, "MCAuth failed to start. All players will be denied until the server restarts.", exception);
        enterLockdown();
    }
}

ロックダウン中は、認証済みかどうかに関係なく全員の接続を拒否し、接続中の人もキックします。サーバーを再起動して起動に成功するまで、この状態が続きます。

管理者も入れなくなりますが、誰でも入れるよりはずっとまし。ログを見て直してから再起動すれば戻ります。

ただし、これでも防げないケースが2つあります。

  • MCAuthのJARがそもそも読み込まれない
  • 別のプラグインやコマンドでMCAuthが無効にされる

どちらもMCAuthの外で起きるので、プラグインの中からは止められません。起動ログでMCAuthが有効になっているかを見るのが確実です。

テストで「拒否されること」を確かめる

直した内容は、mvn test packageで毎回確かめられるようにテストを5件足しました。

  • コードの発行に失敗したら拒否される
  • DBが開けないときは全員拒否され、接続中の人もキックされる
  • ロックダウン中は認証済みの人も拒否される
  • 連携済みのDiscordアカウントには解除の案内が返る
  • 別のサーバーからの退出では取り消されない

フェイルクローズは、正常系のテストだけでは確かめられません。わざと失敗させて、拒否されることを見るテストが必要です。

本番では全員に再認証してもらった

保存先をSQLiteに変えたので、古い認証データは引き継がず、全員に再認証してもらうことにしました。移行スクリプトを書くより、認証をやり直してもらうほうが、1対1のルールで全員がそろいます。

本番への反映は、次の順で進めました。

  1. メンテナンスの前に、ゲーム内で告知する
  2. 停止した状態でバックアップを取る
  3. 新しいイメージを先にビルドしておく
  4. サーバーを止めて、Discordの認証ロールを全員から外す(41人)
  5. マイクラ標準のホワイトリストを空にして、オフにする
  6. 新しいMCAuthで起動する

ロールは、Discord APIでロールを持つメンバーを一覧にして、1人ずつ外しました。外したあとにもう一度一覧を取り、残りが0人なのを確かめています。

ホワイトリストを切ったのは、MCAuthが入場をDBだけで判定するようにしたからです。white-list=trueのままだと、MCAuthで認証済みでもwhitelist.jsonにいない人は入れません。

起動したあとは、次の点を確かめました。

  • 起動ログでMCAuthが有効になり、ロックダウンに入っていない
  • Botが認証チャンネルにボタン付きの案内を置いている
  • /unlinkがスラッシュコマンドとして登録されている
  • 未認証のプレイヤーが接続すると、コードを表示してキックされる

まとめ:認証プラグインは「壊れたときに閉じるか」で確かめる

認証プラグインは、正しく動いているときより、壊れたときの振る舞いのほうが大事です。

見直すところは、次の3つに絞れます。

  • AsyncPlayerPreLoginEventの中で例外が出たら、必ずdisallowしているか
  • onEnableが失敗したとき、リスナーが外れて素通しにならないか
  • 連携の保存が、既存の行を黙って上書きしていないか

全部を一度に直さなくても大丈夫です。まずは1つ目だけでも、わざと例外を投げて、接続が拒否されるかを手元のサーバーで試してみてください。

Discord側でBotを選ぶときの考え方は、こちらにまとめています。

関連記事【日本語対応あり】Discordボットおすすめ16選!管理・読み上げなどDiscordボットのおすすめを日本語対応ありで紹介。管理、荒らし対策、ロール付与、読み上げ、チケット、翻訳、レベル機能など、用途別の選び方です。