Skip to content

Latest commit

 

History

History
1003 lines (831 loc) · 39.3 KB

AC-1-1.md

File metadata and controls

1003 lines (831 loc) · 39.3 KB

AC-1-1 HarmonyAuth SMART Part 1

行动背景

第四章好像比前面几张都快……笔者一拖再拖,觉得这个项目似乎没法再拖下去了,毕竟我们讲完了数据库、邮件发送,貌似是时候完成这个项目了。

你可能已经注意到了,本节的编号不是以 EX 开头,而是 AC。这表示我们将要完成一次真正的行动了。所有 EX 开头的行动,笔者都已经做过实验,是直接给你正确的结果。而对于 AC 章节,笔者也不保证自己找到的就是最好的解决方案,还真有点紧张呢~

行动规划

行动名称:HarmonyAuth SMART

行动代号:AC-1

行动类别:作战

涉及章节:

  • AC-1-1
  • AC-1-2
  • AC-1-3

难度:闪电苦力怕

这次我们的任务很明确:改进「HarmonyAuth」。

我们大致要做出这么几个功能:

  • 使用 OP 审核来恢复密码
  • 自由地修改密码
  • 使用数据库存储数据,文件作为后备
  • 让玩家不再回弹

嗯,大致就是这些。

开始行动

首先下载(如果你之前没有下载的)mysql-connector-java-8.0.23.jar,版本无所谓。这一次考虑到邮件不太好用,就没有使用,而是采用了更为直观的管理员手动审核功能。

接下来和 EX-1-1 一样,创建新模块「HarmonyAuth SMART」,添加依赖。

回到代码界面,创建包,创建主类继承 JavaPlugin,这应该很熟练了。

配置文件

我们先把配置文件写好,这样在处理数据时就知道该做些什么。

创建 config.yml

mysql:
  enabled: false
  host: localhost
  port: 3306
  username: root
  password: 123456
  db-name: "harmony-auth-smart"

msg:
  hint-register: "请输入 /has <密码> <再输入一次密码> 进行或注册!"
  hint-login: "请输入 /has <密码> 进行登录!"
  login-failed: "密码错误!如忘记密码,使用 /iforgot 恢复密码!"
  login-success: "登录成功!"
  register-success: "注册成功!"
  register-failed: "两次输入密码不一致!"
  iforgot-newpwd: "请在聊天栏输入新密码。"
  iforgot-hint: "请在聊天栏向服服务器管理人员说明情况并提供证据。"
  iforgot-commit: "您的请求已经上报。"
  iforgot-accepted: "恢复请求已经通过,使用您设置的新密码登录。"
  iforgot-rejected: "恢复请求未通过,请尝试使用原先的密码登录或提交新的申请。"
  iforgot-no-available: "密码恢复功能已禁用。"
  command-handling: "上一条命令正在处理中,请稍后再试!"
  audit-in: "您已进入审核模式。"
  audit-out: "您已退出审核模式。"
  audit-uuid: "玩家 UUID:"
  audit-reason: "申请原因:"
  audit-hint: "输入 y 通过或输入 n 拒绝,输入 q 离开审核模式。"
  hint: "在登录前,您不能进行操作。"


iforgot: true # 允许 IForgot

auto-login: 300 # 自动登录间隔,单位秒

hook: # 用 ${playerName} 表示玩家名
  on-login-success: # 登录成功
    - ""
  on-login-failed: # 登录失败
    - ""
  on-register-success: # 注册成功
    - ""

为了使用文件进行后备存储,这里我们不能粗暴地写入 Bukkit 的默认配置(config.yml),而应该使用自定义配置文件

自定义配置文件和默认配置文件一样,都需要在 src 下创建。名字是无所谓的(不然为什么叫自定义呢)。

创建 data.yml,并留空(本来就是数据文件)。

自定义文件需要自己进行读取和保存,利用 Java 的接口特性,我们编写一个 IDataManager 接口,到时候分别用文件和数据库方式实现它们(自己给自己创建一个协议用)。

package rarityeg.harmonyauthsmart;

import javax.annotation.Nonnull;
import java.util.Date;
import java.util.UUID;

public interface IDataManager {
    void saveAll();

    void loadAll();

    @Nonnull
    String getPasswordHash(UUID id);

    boolean getIForgotState(UUID id);

    @Nonnull
    String getIForgotManualReason(UUID id);

    @Nonnull
    String getIForgotNewPasswordHash(UUID id);

    @Nonnull
    Date getLastLoginTime(UUID id);

    void setPasswordHash(UUID id, String hash);

    void setIForgotState(UUID id, boolean state);

    void setIForgotManualReason(UUID id, String reason);

    void setIForgotNewPasswordHash(UUID id, String hash);

    void setLastLoginTime(UUID id, Date date);
    
    boolean isExist(UUID id);

}

接口中只允许抽象方法。

然后我们先实现 IDataManager 的文件版本,在此之前先在主类上完成那个小技巧,将实例暴露出去,另外顺便也把数据库驱动注册上:

package rarityeg.harmonyauthsmart;

import org.bukkit.plugin.java.JavaPlugin;

public class HarmonyAuthSMART extends JavaPlugin {
    public static JavaPlugin instance;
    public static boolean dbError = false;
    @Override
    public void onEnable() {
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
        } catch (ClassNotFoundException e) {
            getLogger().log(Level.WARNING, "数据库驱动加载失败,将使用备用存储方法。");
            e.printStackTrace();
            dbError = true;
            // 如果驱动找不到就改用文件存储
        }
        saveDefaultConfig(); // config.yml
        saveResource("data.yml", false); // data.yml,false 表示不覆盖
        instance = this;
    }
}

saveResource 用于保存 src 下对应的文件,第二个参数是「文件存在时,是否覆盖」的意思。

接下来实现 FileDataManager 类:

package rarityeg.harmonyauthsmart;

import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;

import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;

public class FileDataManager implements IDataManager {
    static FileConfiguration data;
    final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    // Date 没有办法直接序列化,需要利用 DateFormat

    @Override
    public void saveAll() {
        try {
            File dataFile = new File(HarmonyAuthSMART.instance.getDataFolder(), "data.yml");
            data.save(dataFile);
            // 保存数据的标准方法
        } catch (IOException e) {
            HarmonyAuthSMART.instance.getLogger().log(Level.WARNING, "配置数据未能保存,可能产生回档问题!");
            // 这种错误还是说出来的好
            e.printStackTrace();
        }
    }

    @Override
    public void loadAll() {
        File dataFile = new File(HarmonyAuthSMART.instance.getDataFolder(), "data.yml");
        data = YamlConfiguration.loadConfiguration(dataFile);
        // 不需要 InputStream,直接 loadConfiguration
    }

    @Override
    @Nonnull
    public String getPasswordHash(UUID id) {
        return Objects.requireNonNull(data.getString("passwords." + id.toString(), ""));
        // 虽然 getString 提供了默认值就不会返回 null,但 IDEA 一直报警告很麻烦,就照它的建议做了
    }

    @Override
    public boolean getIForgotState(UUID id) {
        return data.getBoolean("iforgot-states." + id.toString());
        // boolean 不会返回 null,默认是 false
    }

    @Override
    @Nonnull
    public String getIForgotManualReason(UUID id) {
        return Objects.requireNonNull(data.getString("iforgot-reasons." + id.toString(), ""));
    }

    @Override
    @Nonnull
    public String getIForgotNewPasswordHash(UUID id) {
        // IForgot 会先向玩家要求一个新密码,用这个查询
        return Objects.requireNonNull(data.getString("iforgot-newpwd." + id.toString(), ""));
    }

    @Override
    @Nonnull
    public Date getLastLoginTime(UUID id) {
        String dstr = data.getString("last-login." + id.toString(), "1970-01-01 23:59:59");
        if (dstr == null) {
            // 实际上这里不可能执行到,getString 返回的不可能是 null
            try {
                return sdf.parse("1970-01-01 23:59:59");
            } catch (ParseException e) {
                HarmonyAuthSMART.instance.getLogger().log(Level.WARNING, "这不可能!不可能出现这个错误!日期的读取失败了?");
                e.printStackTrace();
                return new Date();
            }
        } else {
            try {
                return sdf.parse(dstr);
            } catch (ParseException e) {
                // 这里也不可能执行到,以防万一
                try {
                    return sdf.parse("1970-01-01 23:59:59");
                } catch (ParseException e2) {
                    HarmonyAuthSMART.instance.getLogger().log(Level.WARNING, "这不可能!不可能出现这个错误!日期的读取失败了?");
                    e2.printStackTrace();
                    return new Date();
                }
            }
        }
    }

    // 以下都是上面相应的 set 方法
    @Override
    public void setPasswordHash(UUID id, String hash) {
        data.set("passwords." + id.toString(), hash);
    }

    @Override
    public void setIForgotState(UUID id, boolean state) {
        data.set("iforgot-states." + id.toString(), state);
    }

    @Override
    public void setIForgotManualReason(UUID id, String reason) {
        data.set("iforgot-reasons." + id.toString(), reason);
    }

    @Override
    public void setIForgotNewPasswordHash(UUID id, String hash) {
        data.set("iforgot-newpwd." + id.toString(), hash);
    }

    @Override
    public void setLastLoginTime(UUID id, Date date) {
        data.set("last-login." + id.toString(), sdf.format(date));
    }
    @Override
    public boolean isExist(UUID id) {
        return data.contains("passwords." + id.toString());
    }
}

虽然看上去很多,实际上原理很简单啦,有注释应该看得懂。

getDataFolder 用于获取当前插件的数据目录。一般是 <服务器根目录>/plugins/<插件的名字>/

下面我们实现基于数据库的 DBDataManager。这里暂时不用异步(因为不好回调),我们在命令处理器中再使用异步。


在继续编写代码前,我们需要部署好数据库。下载安装好 MySQL 并启动它(如果你没有安装过)。

打开 MySQL 终端(希望你还记得怎么做,见 4-2),然后(在 MySQL 终端中)创建新的数据库用于测试:

CREATE DATABASE test;

名字是无所谓的。

回到 IDEA,在窗口的最右侧找到「Database」,单击「+」、「Data Source」、「MySQL」,打开「Data Sources and Drivers」窗口。

这里 IDEA 会提示你没有安装驱动程序,单击「Download Missing Drivers」即可。

DSRC

「User」填上用户名(一般是 root),「Password」输入密码,「Database」填入刚刚创建的数据库名(test),单击一下「Test Connection」,确认连接成功。

?> 到底怎么回事
作为一个成熟的开发工具,IDEA 认为我们都是针对特定的数据库进行开发的,因此它需要以一个数据库作为蓝本来进行语法检查。
但这次我们不需要这样的功能,不过报错放在那里总不是个办法,于是就部署一个数据库解决这个问题。
实际上上面这些操作即使现在不做,调试时也要做的,还是早点搞定的好。

然后我们就可以实现 DBDataManager 了。

package rarityeg.harmonyauthsmart;

import org.bukkit.configuration.file.FileConfiguration;

import javax.annotation.Nonnull;
import java.sql.*;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Objects;
import java.util.UUID;

public class DBDataManager implements IDataManager {
    static String db_url;
    static String username;
    static String password;
    final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    public void saveAll() {
    }

    @Override
    public void loadAll() {
        FileConfiguration fc = HarmonyAuthSMART.instance.getConfig();
        String port = fc.getString("mysql.port");
        username = fc.getString("mysql.username");
        String db_name = fc.getString("mysql.db-name");
        password = fc.getString("mysql.password");
        if (port == null || username == null || db_name == null || password == null) {
            HarmonyAuthSMART.dbError = true;
            HarmonyAuthSMART.instance.getLogger().warning("数据库配置不完全,将改用备用存储方式。");
            return;
        }
        db_url = "jdbc:mysql://localhost:" + port + "/" + db_name + "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            Statement statement = connection.createStatement();
            statement.execute("CREATE TABLE IF NOT EXISTS harmony_auth_data(UUID VARCHAR(255) PRIMARY KEY NOT NULL, PwdHash TEXT NOT NULL, IForgotState BOOLEAN NOT NULL, IForgotReason LONGTEXT NOT NULL, NewPwdHash TEXT NOT NULL, LastLogin TEXT NOT NULL);");
            statement.close();
            connection.close();
            HarmonyAuthSMART.instance.getLogger().info("成功与数据库建立连接!");
            // 先尝试一次连接
        } catch (SQLException e) {
            putError(e);
        }
    }

    private void putError(Exception e) {
        HarmonyAuthSMART.dbError = true;
        HarmonyAuthSMART.instance.getLogger().warning("数据库操作失败,将改用备用存储方式。");
        e.printStackTrace();
    }

    @Nonnull
    @Override
    public String getPasswordHash(UUID id) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("SELECT PwdHash FROM harmony_auth_data WHERE UUID=?;");
            // 查询
            preparedStatement.setString(1, id.toString());
            // SQL 的索引从 1 开始!!!
            ResultSet rs = preparedStatement.executeQuery();
            rs.first();
            // 按道理只应该返回一组,多的我们舍去
            preparedStatement.close();
            connection.close();
            return Objects.requireNonNullElse(rs.getString("PwdHash"), "");
        } catch (SQLException e) {
            putError(e);
            return "";
        }
    }

    @Override
    public boolean getIForgotState(UUID id) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("SELECT IForgotState FROM harmony_auth_data WHERE UUID=?;");
            preparedStatement.setString(1, id.toString());
            ResultSet rs = preparedStatement.executeQuery();
            rs.first();
            preparedStatement.close();
            connection.close();
            return Objects.requireNonNullElse(rs.getBoolean("IForgotState"), false);
        } catch (SQLException e) {
            putError(e);
            return false;
        }
    }

    @Nonnull
    @Override
    public String getIForgotManualReason(UUID id) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("SELECT IForgotReason FROM harmony_auth_data WHERE UUID=?;");
            preparedStatement.setString(1, id.toString());
            ResultSet rs = preparedStatement.executeQuery();
            rs.first();
            preparedStatement.close();
            connection.close();
            return Objects.requireNonNullElse(rs.getString("IForgotReason"), "");
        } catch (SQLException e) {
            putError(e);
            return "";
        }
    }

    @Nonnull
    @Override
    public String getIForgotNewPasswordHash(UUID id) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("SELECT NewPwdHash FROM harmony_auth_data WHERE UUID=?;");
            preparedStatement.setString(1, id.toString());
            ResultSet rs = preparedStatement.executeQuery();
            rs.first();
            preparedStatement.close();
            connection.close();
            return Objects.requireNonNullElse(rs.getString("NewPwdHash"), "");
        } catch (SQLException e) {
            putError(e);
            return "";
        }
    }

    @Nonnull
    @Override
    public Date getLastLoginTime(UUID id) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("SELECT LastLogin FROM harmony_auth_data WHERE UUID=?;");
            preparedStatement.setString(1, id.toString());
            ResultSet rs = preparedStatement.executeQuery();
            rs.first();
            preparedStatement.close();
            connection.close();
            String dateString = Objects.requireNonNullElse(rs.getString("LastLogin"), "1970-01-01 23:59:59");
            return sdf.parse(dateString);
        } catch (SQLException e) {
            putError(e);
            try {
                return sdf.parse("1970-01-01 23:59:59");
            } catch (ParseException e2) {
                HarmonyAuthSMART.instance.getLogger().warning("这不可能!不可能出现这个错误!日期的读取失败了?");
                e2.printStackTrace();
                return new Date();
            }
        } catch (ParseException e) {
            HarmonyAuthSMART.instance.getLogger().warning("这不可能!不可能出现这个错误!日期的读取失败了?");
            e.printStackTrace();
            return new Date();
        }
    }
    
    // 上面方法相应的 set 方法
    @Override
    public void setPasswordHash(UUID id, String hash) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO harmony_auth_data (UUID, PwdHash, IForgotState, IForgotReason, NewPwdHash, LastLogin) VALUES (?, ?, false, '', '', '1970-01-01 23:59:59') ON DUPLICATE KEY UPDATE PwdHash=?;");
            preparedStatement.setString(1, id.toString());
            preparedStatement.setString(2, hash);
            preparedStatement.setString(3, hash);
            preparedStatement.execute();
            preparedStatement.close();
            connection.close();
        } catch (SQLException e) {
            putError(e);
        }
    }

    @Override
    public void setIForgotState(UUID id, boolean state) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO harmony_auth_data (UUID, PwdHash, IForgotState, IForgotReason, NewPwdHash, LastLogin) VALUES (?, '', ?, '', '', '1970-01-01 23:59:59') ON DUPLICATE KEY UPDATE IForgotState=?;");
            preparedStatement.setString(1, id.toString());
            preparedStatement.setBoolean(2, state);
            preparedStatement.setBoolean(3, state);
            preparedStatement.execute();
            preparedStatement.close();
            connection.close();
        } catch (SQLException e) {
            putError(e);
        }
    }

    @Override
    public void setIForgotManualReason(UUID id, String reason) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO harmony_auth_data (UUID, PwdHash, IForgotState, IForgotReason, NewPwdHash, LastLogin) VALUES (?, '', false, ?, '', '1970-01-01 23:59:59') ON DUPLICATE KEY UPDATE IForgotReason=?;");
            preparedStatement.setString(1, id.toString());
            preparedStatement.setString(2, reason);
            preparedStatement.setString(3, reason);
            preparedStatement.execute();
            preparedStatement.close();
            connection.close();
        } catch (SQLException e) {
            putError(e);
        }
    }

    @Override
    public void setIForgotNewPasswordHash(UUID id, String hash) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO harmony_auth_data (UUID, PwdHash, IForgotState, IForgotReason, NewPwdHash, LastLogin) VALUES (?, '', false, '', ?, '1970-01-01 23:59:59') ON DUPLICATE KEY UPDATE NewPwdHash=?;");
            preparedStatement.setString(1, id.toString());
            preparedStatement.setString(2, hash);
            preparedStatement.setString(3, hash);
            preparedStatement.execute();
            preparedStatement.close();
            connection.close();
        } catch (SQLException e) {
            putError(e);
        }
    }

    @Override
    public void setLastLoginTime(UUID id, Date date) {

        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO harmony_auth_data (UUID, PwdHash, IForgotState, IForgotReason, NewPwdHash, LastLogin) VALUES (?, '', false, '', '', ?) ON DUPLICATE KEY UPDATE LastLogin=?;");
            preparedStatement.setString(1, id.toString());
            String dateString = sdf.format(date);
            preparedStatement.setString(2, dateString);
            preparedStatement.setString(3, dateString);
            preparedStatement.execute();
            preparedStatement.close();
            connection.close();
        } catch (SQLException e) {
            putError(e);
        }

    }

    @Override
    public boolean isExist(UUID id) {
        try {
            Connection connection = DriverManager.getConnection(db_url, username, password);
            PreparedStatement preparedStatement = connection.prepareStatement("SELECT COUNT(UUID) FROM harmony_auth_data WHERE UUID=?");
            preparedStatement.setString(1, id.toString());
            ResultSet rs = preparedStatement.executeQuery();
            rs.first();
            preparedStatement.close();
            connection.close();
            return rs.getInt(1) != 0;
        } catch (SQLException e) {
            putError(e);
            return false;
        }
    }
}

StatementPreparedStatement 的简化版本,使用它只是我的习惯(都是笔者不好,真是对不起),各位在自己的插件中一定要尽量使用 PreparedStatement 啊。

我在 4-2 中提到过,executeQuery 中需要额外的参数,这里让你看看,如果不加,会有什么后果。

上面涉及到了许多 SQL 语句,实际上只有三种啦:创建表、读取、插入。

要注意的时,SQL 插值时,位置编号是从 1 开始的,这和数组从 0 开始不太一样。

需要注意的是,这里没有 USE test 这样的指令。

?> 到底怎么回事
JDBC 在连接时就已经(为我们)选好了数据库。因为 USE 指令是管理员指令,应用程序不应该调用,我们也不用管那么多,总之,JDBC 连接成功后,就已经处于 USE <数据库名> 后的状态了。一切都已经准备好,我们直接操作数据表即可。

实现好这个接口后,数据存储就会很轻松啦!

每次重新创建 IDataManager 是为了避免 BukkitRunnable 异步执行可能发生的阻塞和访问冲突。

运行时数据管理器

请原谅我起了这么长一个名字,这个管理器主要是管理像限制列表、OP 审核的进度、申请找回密码的玩家对话之类的信息,它们不用保存到数据库或文件中,需要在运行时管理。

创建类 RuntimeDataManager

package rarityeg.harmonyauthsmart;

import java.util.*;

public class RuntimeDataManager {
    private static final List<UUID> RESTRICTS = new ArrayList<>();
    private static final Map<UUID, Integer> IFORGOT_SETUP_MAP = new HashMap<>();
    private static final List<UUID> READ_MODE_LIST = new ArrayList<>();

    public synchronized static void addRestrictUUID(UUID id) {
        RESTRICTS.add(id);
    }

    public synchronized static void removeRestrictUUID(UUID id) {
        RESTRICTS.remove(id);
    }

    public synchronized static boolean hasRestrictUUID(UUID id) {
        return RESTRICTS.contains(id);
    }

    public synchronized static void toReadMode(UUID id) {
        READ_MODE_LIST.add(id);
    }

    public synchronized static void exitReadMode(UUID id) {
        READ_MODE_LIST.remove(id);
    }

    public synchronized static boolean isInReadMode(UUID id) {
        return READ_MODE_LIST.contains(id);
    }

    public synchronized static void toIForgotMode(UUID id, int mode) {
        IFORGOT_SETUP_MAP.put(id, mode);
    }

    public synchronized static void exitIForgotMode(UUID id) {
        IFORGOT_SETUP_MAP.remove(id);
    }

    public synchronized static int getIForgotMode(UUID id) {
        return Objects.requireNonNullElse(IFORGOT_SETUP_MAP.get(id), 0);
    }
}

READ_MODE_LIST 记录哪些 OP 正在审核玩家的密码恢复请求。IFORGOT_SETUP_MAP 记录哪些玩家正在请求密码恢复以及恢复到哪一步了。

审核模式下,OP 可以审核玩家的请求,此时禁用命令。

「IForgot」模式有两步,输入新密码和输入理由,此时禁用命令。

基于此创建了上面这些代码,应该非常简单。

这次我们基于 UUID 来管理玩家。

唯一出现的新知识点就是 synchronized,它的意思是「同步」,也就是说,阻止多个线程同时访问一个对象,这很明显。ArrayList 不是线程安全(Thread Safe)的,因此要阻止它们同时被多个线程写入,稍微牺牲了一点性能但增加了安全性。至于 IDataManager,它的实例属于各个线程,因此不影响。

命令设计

我们暂且设计这些命令:

  • /hl <密码> <重复密码> 以注册,如果已经注册,则使用第一个密码登录。
  • /hl <密码> 以登录,如果没有注册,认为「两次输入的密码不一致」。
  • /iforgot 对普通玩家和登录前的 OP 是恢复密码,对登录后的 OP 是审核。

那么我们先来实现这些命令。创建类 plugin.yml

main: rarityeg.harmonyauthsmart.HarmonyAuthSMART
api-version: 1.16
version: 1.0
name: HarmonyAuth-SMART
database: true
description: "A third-party login plugin."
author: RarityEG
commands:
  hl:
    aliases:
      - "l"
      - "L"
      - "reg"
      - "register"
      - "login"
      - "log"
    usage: "/hl <PASSWORD> [<PASSWORD>]"
    description: "Login or register, judged automatically."
  iforgot:
    aliases:
      - "ifg"
    usage: "/iforgot"
    description: "Create a IForgot request or (for OPs) start auditing."

这次由于是制作成品插件,我们为命令设定了很多别名。此外,我们设定了 database 选项。虽然没必要告知 Bukkit,但我觉得既然有这个选项,还是老老实实说出来比较好。


接下来我们需要完成命令处理器。还是和之前一样啦……

package rarityeg.harmonyauthsmart;

import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitRunnable;

import javax.annotation.ParametersAreNonnullByDefault;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;

public class CommandHandler implements CommandExecutor {

    public static List<UUID> NoInterruptList = new ArrayList<>();
    // 哪些玩家的命令正在执行中

    @Override
    @ParametersAreNonnullByDefault
    public boolean onCommand(CommandSender commandSender, Command command, String label, String[] args) {
        if (!(commandSender instanceof Player)) {
            // 控制台登录个鬼
            return false;
        }
        UUID id = ((Player) commandSender).getUniqueId();
        // 强制转换并获取 UUID
        if (RuntimeDataManager.getIForgotMode(id) != 0 || RuntimeDataManager.isInReadMode(id)) {
            // 在审核模式或者恢复模式中,禁止命令
            return true;
        }
        if (getIF(id)) {
            commandSender.sendMessage(Util.getAndTranslate("msg.command-handling"));
            // 当前尚有命令没有处理完成,拒绝处理
            return true;
        }
        cli(id); // 暂时禁止该玩家执行命令
        if (command.getName().equals("hl")) {
            return onLoginCommand(commandSender, args);
            // 分配到 onLoginCommand 中
        } else if (command.getName().equals("iforgot")) {
            if (!HarmonyAuthSMART.instance.getConfig().getBoolean("iforgot")) {
                commandSender.sendMessage(Util.getAndTranslate("msg.iforgot-no-available"));
                // 如果恢复模式被禁用
                sti(id); // 允许该玩家继续执行命令
                return true;
            }
            return onIForgotCommand(commandSender);
            // 切换到 onIForgotCommand 继续执行
        } else {
            // 后备操作
            sti(id);
            // 开放命令
            return false;
        }
    }

    public boolean onLoginCommand(CommandSender commandSender, String[] args) {
        Player player = (Player) commandSender;
        UUID id = player.getUniqueId();
        if (RuntimeDataManager.hasRestrictUUID(id)) {
            // 进入异步处理
            new BukkitRunnable() {
                @Override
                public void run() {
                    IDataManager idm; // 仅「占个位置」,下面按需赋值
                    if (HarmonyAuthSMART.instance.getConfig().getBoolean("mysql.enabled") && !HarmonyAuthSMART.dbError) {
                        // 数据库可用
                        idm = new DBDataManager();
                    } else {
                        // 数据库不可用
                        idm = new FileDataManager();
                    }
                    if (idm.isExist(id)) {
                        // 存在 ID
                        // 你看这样多方便,无论是数据库还是文件,都只需要调用 isExist
                        if (args[0] == null) {
                            // 没输密码
                            player.sendMessage(Util.getAndTranslate("msg.login-failed"));
                            sti(id); // 开放命令
                            List<String> hooks = Util.generateHooks("hook.on-login-failed", player.getName());
                            for (String cmd : hooks) {
                                // 按顺序循环 hooks 中的每项
                                Util.dispatchCommandAsServer(cmd);
                                // 以服务器身份执行命令
                            }
                            // 执行钩子
                            return;
                        }
                        if (idm.getPasswordHash(id).equals(Util.calculateMD5(args[0]))) {
                            RuntimeDataManager.removeRestrictUUID(id);
                            player.sendMessage(Util.getAndTranslate("msg.login-success"));
                            idm.setIForgotManualReason(id, "");
                            idm.setIForgotState(id, false);
                            idm.setLastLoginTime(id, new Date());
                            // 登录成功,重设日期,取消恢复请求
                            sti(id);
                            List<String> hooks = Util.generateHooks("hook.on-login-success", player.getName());
                            for (String cmd : hooks) {
                                Util.dispatchCommandAsServer(cmd);
                            }
                            return;
                        }
                        player.sendMessage(Util.getAndTranslate("msg.login-failed"));
                        List<String> hooks = Util.generateHooks("hook.on-login-failed", player.getName());
                        for (String cmd : hooks) {
                            Util.dispatchCommandAsServer(cmd);
                        }
                        sti(id);
                        return;
                    }
                    if (args.length < 2 || !args[0].equals(args[1])) {
                        player.sendMessage(Util.getAndTranslate("msg.register-failed"));
                        sti(id);
                        return;
                    }
                    idm.setPasswordHash(id, Util.calculateMD5(args[0]));
                    RuntimeDataManager.removeRestrictUUID(id);
                    player.sendMessage(Util.getAndTranslate("msg.register-success"));
                    idm.setIForgotManualReason(id, "");
                    idm.setIForgotState(id, false);
                    idm.setLastLoginTime(id, new Date());
                    sti(id);
                    List<String> hooks = Util.generateHooks("hook.on-register-success", player.getName());
                    for (String cmd : hooks) {
                        Util.dispatchCommandAsServer(cmd);
                    }
                }
            }.runTaskAsynchronously(HarmonyAuthSMART.instance);
        } else {
            player.sendMessage(Util.getAndTranslate("msg.login-success"));
            // 已经登录
            sti(id);
        }
        return true;
    }

    public boolean onIForgotCommand(CommandSender commandSender) {
        Player player = (Player) commandSender;
        UUID id = player.getUniqueId();

        new BukkitRunnable() {
            @Override
            public void run() {
                IDataManager idm;
                if (HarmonyAuthSMART.instance.getConfig().getBoolean("mysql.enabled") && !HarmonyAuthSMART.dbError) {
                    idm = new DBDataManager();
                } else {
                    idm = new FileDataManager();
                }
                if (!RuntimeDataManager.hasRestrictUUID(id)) {
                    if (!player.isOp()) {
                        idm.setIForgotState(id, false);
                        idm.setIForgotManualReason(id, "");
                        RuntimeDataManager.toIForgotMode(id, 1);

                        player.sendMessage(Util.getAndTranslate("msg.iforgot-newpwd"));
                    } else {
                        RuntimeDataManager.toReadMode(id);
                        player.sendMessage(Util.getAndTranslate("msg.audit-in"));
                        UUID firstId = idm.getNextRequest();
                        // 先获取一个请求,开始这个链式反应,参见 AC-1-2
                        if (firstId.equals(UUID.fromString("00000000-0000-0000-0000-000000000000"))) {
                            RuntimeDataManager.exitReadMode(id);
                            player.sendMessage(Util.getAndTranslate("msg.audit-out"));
                        } else {
                            player.sendMessage(Util.getAndTranslate("audit-uuid" + firstId.toString()));
                            player.sendMessage(Util.getAndTranslate("audit-reason" + idm.getIForgotManualReason(firstId)));
                            player.sendMessage(Util.getAndTranslate("audit-hint"));
                        }
                    }

                } else {
                    RuntimeDataManager.toIForgotMode(id, 1);
                    player.sendMessage(Util.getAndTranslate("msg.iforgot-newpwd"));
                }
                sti(id);
            }
        }.runTaskAsynchronously(HarmonyAuthSMART.instance);
        return true;
    }

    private static synchronized void cli(UUID id) {
        NoInterruptList.add(id);
    }

    private static synchronized void sti(UUID id) {
        NoInterruptList.remove(id);
    }

    private static synchronized boolean getIF(UUID id) {
        return NoInterruptList.contains(id);
    }

}

就这么多……啊不,不是。为了方便编写,我还创建了 Util 类:

package rarityeg.harmonyauthsmart;

import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.craftbukkit.libs.org.apache.commons.codec.binary.Hex;

import javax.annotation.Nonnull;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;

public final class Util {
    @Nonnull
    public static String getAndTranslate(@Nonnull String key) {
        String str = Objects.requireNonNullElse(HarmonyAuthSMART.instance.getConfig().getString(key, ""), "");
        return ChatColor.translateAlternateColorCodes('&', str);
        // 用于替换 & 为 §,方便服主配置
    }

    @Nonnull
    public static String calculateMD5(@Nonnull String origin) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(origin.getBytes(StandardCharsets.UTF_8));
            // 加码
            return String.valueOf(Hex.encodeHex(md.digest()));
            // 转换为十六进制
        } catch (NoSuchAlgorithmException e) {
            HarmonyAuthSMART.instance.getLogger().severe("必要的 MD5 哈希算法不可用,正在禁用本插件……。");
            // 现代 CentOS 和 Windows Server 都有这个算法,应该不会有问题
            e.printStackTrace();
            Bukkit.getPluginManager().disablePlugin(HarmonyAuthSMART.instance);
            // 紧急停止插件,此时返回任何值都不明智
            return "";
        }
    }
    
    // 用于生成命令钩子,后面会用到
    public static List<String> generateHooks(@Nonnull String key, @Nonnull String playerName) {
        List<String> origin = HarmonyAuthSMART.instance.getConfig().getStringList(key);
        List<String> output = new ArrayList<>();
        for (String cmd : origin) {
            if (cmd != null && !cmd.equals("")) {
                output.add(cmd.replaceAll("\\$\\{playerName}", playerName)); // 模板替换
            }
        }
        return output;
    }
    
    public static synchronized void dispatchCommandAsServer(String cmd) {
        new BukkitRunnable() {
            @Override
            public void run() {
                Bukkit.dispatchCommand(Bukkit.getConsoleSender(), cmd);
            }
        }.runTask(HarmonyAuthSMART.instance);
    } // 执行命令
}

比较简单。有注释应该也比较容易看懂。

另外我们还将钩子读了出来,进行运行。

disablePlugin 用于停止插件,当出现不可恢复错误时才使用。

配置文件中不容易输入 § 符号,我们允许服主用 & 代替。

clisti 分别禁止命令执行和允许命令执行,我这么命名只是因为它们的功能和汇编中的 CLI(Clear Interrupt,禁止中断)和 STI(Set Interrupt,允许中断)指令很像啦。

这里我们借用了 Apache Commons CodeC 包中的工具。本来我们需要自己导入它,但该包已经成为 CraftBukkit 的一个库了,并且看上去无需反射就可以使用,我们就不麻烦 Maven 了。


笔者写到这里时已经有 5052 词了,本章节似乎太长啦,看来事件处理只能放在下一节了,有点可惜……

建议你在阅读之前把这些代码手动敲一遍,不长,你可以的!