/* * Copyright (C) 2021 - 2023 Elytrium * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package net.elytrium.limboauth.handler; import at.favre.lib.crypto.bcrypt.BCrypt; import com.google.common.primitives.Longs; import com.j256.ormlite.dao.Dao; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.proxy.protocol.packet.PluginMessage; import dev.samstevens.totp.code.CodeVerifier; import dev.samstevens.totp.code.DefaultCodeGenerator; import dev.samstevens.totp.code.DefaultCodeVerifier; import dev.samstevens.totp.time.SystemTimeProvider; import io.netty.buffer.ByteBuf; import io.whitfin.siphash.SipHasher; import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.text.MessageFormat; import java.util.List; import java.util.Locale; import java.util.UUID; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import net.elytrium.commons.kyori.serialization.Serializer; import net.elytrium.limboapi.api.Limbo; import net.elytrium.limboapi.api.LimboSessionHandler; import net.elytrium.limboapi.api.player.LimboPlayer; import net.elytrium.limboauth.LimboAuth; import net.elytrium.limboauth.Settings; import net.elytrium.limboauth.event.PostAuthorizationEvent; import net.elytrium.limboauth.event.PostRegisterEvent; import net.elytrium.limboauth.event.TaskEvent; import net.elytrium.limboauth.migration.MigrationHash; import net.elytrium.limboauth.model.RegisteredPlayer; import net.elytrium.limboauth.model.SQLRuntimeException; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; import org.checkerframework.checker.nullness.qual.Nullable; public class AuthSessionHandler implements LimboSessionHandler { private static final CodeVerifier TOTP_CODE_VERIFIER = new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider()); private static final BCrypt.Verifyer HASH_VERIFIER = BCrypt.verifyer(); private static final BCrypt.Hasher HASHER = BCrypt.withDefaults(); private static BossBar.Color bossbarColor; private static BossBar.Overlay bossbarOverlay; private static Component ipLimitKick; private static Component databaseErrorKick; private static String wrongNicknameCaseKick; private static Component timesUp; private static Component registerSuccessful; @Nullable private static Title registerSuccessfulTitle; private static Component[] loginWrongPassword; private static Component loginWrongPasswordKick; private static Component totp; @Nullable private static Title totpTitle; private static Component register; @Nullable private static Title registerTitle; private static Component[] login; @Nullable private static Title loginTitle; private static Component registerDifferentPasswords; private static Component registerPasswordTooLong; private static Component registerPasswordTooShort; private static Component registerPasswordUnsafe; private static Component loginSuccessful; private static Component sessionExpired; @Nullable private static Title loginSuccessfulTitle; @Nullable private static MigrationHash migrationHash; private final Dao playerDao; private final Player proxyPlayer; private final LimboAuth plugin; private final long joinTime = System.currentTimeMillis(); private final BossBar bossBar = BossBar.bossBar( Component.empty(), 1.0F, bossbarColor, bossbarOverlay ); private final boolean loginOnlyByMod = Settings.IMP.MAIN.MOD.ENABLED && Settings.IMP.MAIN.MOD.LOGIN_ONLY_BY_MOD; @Nullable private RegisteredPlayer playerInfo; private ScheduledFuture authMainTask; private LimboPlayer player; private int attempts = Settings.IMP.MAIN.LOGIN_ATTEMPTS; private boolean totpState; private String tempPassword; private boolean tokenReceived; public AuthSessionHandler(Dao playerDao, Player proxyPlayer, LimboAuth plugin, @Nullable RegisteredPlayer playerInfo) { this.playerDao = playerDao; this.proxyPlayer = proxyPlayer; this.plugin = plugin; this.playerInfo = playerInfo; } @Override public void onSpawn(Limbo server, LimboPlayer player) { this.player = player; if (Settings.IMP.MAIN.DISABLE_FALLING) { this.player.disableFalling(); } else { this.player.enableFalling(); } Serializer serializer = LimboAuth.getSerializer(); if (this.playerInfo == null) { try { String ip = this.proxyPlayer.getRemoteAddress().getAddress().getHostAddress(); List alreadyRegistered = this.playerDao.queryForEq(RegisteredPlayer.IP_FIELD, ip); if (alreadyRegistered != null) { int sizeOfValidRegistrations = alreadyRegistered.size(); if (Settings.IMP.MAIN.IP_LIMIT_VALID_TIME > 0) { for (RegisteredPlayer registeredPlayer : alreadyRegistered.stream() .filter(registeredPlayer -> registeredPlayer.getRegDate() < System.currentTimeMillis() - Settings.IMP.MAIN.IP_LIMIT_VALID_TIME) .collect(Collectors.toList())) { registeredPlayer.setIP(""); this.playerDao.update(registeredPlayer); --sizeOfValidRegistrations; } } if (sizeOfValidRegistrations >= Settings.IMP.MAIN.IP_LIMIT_REGISTRATIONS) { this.proxyPlayer.disconnect(ipLimitKick); return; } } } catch (SQLException e) { this.proxyPlayer.disconnect(databaseErrorKick); throw new SQLRuntimeException(e); } } else { if (!this.proxyPlayer.getUsername().equals(this.playerInfo.getNickname())) { this.proxyPlayer.disconnect(serializer.deserialize( MessageFormat.format(wrongNicknameCaseKick, this.playerInfo.getNickname(), this.proxyPlayer.getUsername())) ); return; } } boolean bossBarEnabled = !this.loginOnlyByMod && Settings.IMP.MAIN.ENABLE_BOSSBAR; int authTime = Settings.IMP.MAIN.AUTH_TIME; float multiplier = 1000.0F / authTime; this.authMainTask = this.player.getScheduledExecutor().scheduleWithFixedDelay(() -> { if (System.currentTimeMillis() - this.joinTime > authTime) { this.proxyPlayer.disconnect(timesUp); } else { if (bossBarEnabled) { float secondsLeft = (authTime - (System.currentTimeMillis() - this.joinTime)) / 1000.0F; this.bossBar.name(serializer.deserialize(MessageFormat.format(Settings.IMP.MAIN.STRINGS.BOSSBAR, (int) secondsLeft))); // It's possible, that the progress value can overcome 1, e.g. 1.0000001. this.bossBar.progress(Math.min(1.0F, secondsLeft * multiplier)); } } }, 0, 1, TimeUnit.SECONDS); if (bossBarEnabled) { this.proxyPlayer.showBossBar(this.bossBar); } if (!this.loginOnlyByMod) { this.sendMessage(true); } } @Override public void onChat(String message) { if (this.loginOnlyByMod) { return; } String[] args = message.split(" "); if (args.length != 0 && this.checkArgsLength(args.length)) { Command command = Command.parse(args[0]); if (command == Command.REGISTER && !this.totpState && this.playerInfo == null) { String password = args[1]; if (this.checkPasswordsRepeat(args) && this.checkPasswordLength(password) && this.checkPasswordStrength(password)) { this.saveTempPassword(password); RegisteredPlayer registeredPlayer = new RegisteredPlayer(this.proxyPlayer).setPassword(password); try { this.playerDao.create(registeredPlayer); this.playerInfo = registeredPlayer; } catch (SQLException e) { this.proxyPlayer.disconnect(databaseErrorKick); throw new SQLRuntimeException(e); } this.proxyPlayer.sendMessage(registerSuccessful); if (registerSuccessfulTitle != null) { this.proxyPlayer.showTitle(registerSuccessfulTitle); } this.plugin.getServer().getEventManager() .fire(new PostRegisterEvent(this::finishAuth, this.player, this.playerInfo, this.tempPassword)) .thenAcceptAsync(this::finishAuth); } // {@code return} placed here (not above), because // AuthSessionHandler#checkPasswordsRepeat, AuthSessionHandler#checkPasswordLength, and AuthSessionHandler#checkPasswordStrength methods are // invoking Player#sendMessage that sends its own message in case if the return value is false. // If we don't place {@code return} here, an another message (AuthSessionHandler#sendMessage) will be sent. return; } else if (command == Command.LOGIN && !this.totpState && this.playerInfo != null) { String password = args[1]; this.saveTempPassword(password); if (password.length() > 0 && checkPassword(password, this.playerInfo, this.playerDao)) { if (this.playerInfo.getTotpToken().isEmpty()) { this.finishLogin(); } else { this.totpState = true; this.sendMessage(true); } } else if (--this.attempts != 0) { this.proxyPlayer.sendMessage(loginWrongPassword[this.attempts - 1]); this.checkBruteforceAttempts(); } else { this.proxyPlayer.disconnect(loginWrongPasswordKick); } return; } else if (command == Command.TOTP && this.totpState && this.playerInfo != null) { if (TOTP_CODE_VERIFIER.isValidCode(this.playerInfo.getTotpToken(), args[1])) { this.finishLogin(); return; } else { this.checkBruteforceAttempts(); } } } this.sendMessage(false); } @Override public void onGeneric(Object packet) { if (Settings.IMP.MAIN.MOD.ENABLED && packet instanceof PluginMessage) { PluginMessage pluginMessage = (PluginMessage) packet; String channel = pluginMessage.getChannel(); if (channel.equals("MC|Brand") || channel.equals("minecraft:brand")) { // Minecraft can't handle the plugin message immediately after going to the PLAY // state, so we have to postpone sending it if (Settings.IMP.MAIN.MOD.ENABLED) { this.proxyPlayer.sendPluginMessage(this.plugin.getChannelIdentifier(this.proxyPlayer), new byte[0]); } } else if (channel.equals(this.plugin.getChannelIdentifier(this.proxyPlayer).getId())) { if (this.tokenReceived) { this.checkBruteforceAttempts(); this.proxyPlayer.disconnect(Component.empty()); return; } this.tokenReceived = true; if (this.playerInfo == null) { return; } ByteBuf data = pluginMessage.content(); if (data.readableBytes() < 16) { this.checkBruteforceAttempts(); this.proxyPlayer.sendMessage(sessionExpired); return; } long issueTime = data.readLong(); long hash = data.readLong(); if (this.playerInfo.getTokenIssuedAt() > issueTime) { this.proxyPlayer.sendMessage(sessionExpired); return; } byte[] lowercaseNicknameSerialized = this.playerInfo.getLowercaseNickname().getBytes(StandardCharsets.UTF_8); long correctHash = SipHasher.init(Settings.IMP.MAIN.MOD.VERIFY_KEY) .update(lowercaseNicknameSerialized) .update(Longs.toByteArray(issueTime)) .digest(); if (hash != correctHash) { this.checkBruteforceAttempts(); this.proxyPlayer.sendMessage(sessionExpired); return; } this.finishAuth(); } } } private void checkBruteforceAttempts() { this.plugin.incrementBruteforceAttempts(this.proxyPlayer.getRemoteAddress().getAddress()); if (this.plugin.getBruteforceAttempts(this.proxyPlayer.getRemoteAddress().getAddress()) >= Settings.IMP.MAIN.BRUTEFORCE_MAX_ATTEMPTS) { this.proxyPlayer.disconnect(loginWrongPasswordKick); } } private void saveTempPassword(String password) { this.tempPassword = password; } @Override public void onDisconnect() { if (this.authMainTask != null) { this.authMainTask.cancel(true); } this.proxyPlayer.hideBossBar(this.bossBar); } private void sendMessage(boolean sendTitle) { if (this.totpState) { this.proxyPlayer.sendMessage(totp); if (sendTitle && totpTitle != null) { this.proxyPlayer.showTitle(totpTitle); } } else if (this.playerInfo == null) { this.proxyPlayer.sendMessage(register); if (sendTitle && registerTitle != null) { this.proxyPlayer.showTitle(registerTitle); } } else { this.proxyPlayer.sendMessage(login[this.attempts - 1]); if (sendTitle && loginTitle != null) { this.proxyPlayer.showTitle(loginTitle); } } } private boolean checkArgsLength(int argsLength) { if (this.playerInfo == null && Settings.IMP.MAIN.REGISTER_NEED_REPEAT_PASSWORD) { return argsLength == 3; } else { return argsLength == 2; } } private boolean checkPasswordsRepeat(String[] args) { if (!Settings.IMP.MAIN.REGISTER_NEED_REPEAT_PASSWORD || args[1].equals(args[2])) { return true; } else { this.proxyPlayer.sendMessage(registerDifferentPasswords); return false; } } private boolean checkPasswordLength(String password) { int length = password.length(); if (length > Settings.IMP.MAIN.MAX_PASSWORD_LENGTH) { this.proxyPlayer.sendMessage(registerPasswordTooLong); return false; } else if (length < Settings.IMP.MAIN.MIN_PASSWORD_LENGTH) { this.proxyPlayer.sendMessage(registerPasswordTooShort); return false; } else { return true; } } private boolean checkPasswordStrength(String password) { if (Settings.IMP.MAIN.CHECK_PASSWORD_STRENGTH && this.plugin.getUnsafePasswords().contains(password)) { this.proxyPlayer.sendMessage(registerPasswordUnsafe); return false; } else { return true; } } private void finishLogin() { this.proxyPlayer.sendMessage(loginSuccessful); if (loginSuccessfulTitle != null) { this.proxyPlayer.showTitle(loginSuccessfulTitle); } this.plugin.clearBruteforceAttempts(this.proxyPlayer.getRemoteAddress().getAddress()); this.plugin.getServer().getEventManager() .fire(new PostAuthorizationEvent(this::finishAuth, this.player, this.playerInfo, this.tempPassword)) .thenAcceptAsync(this::finishAuth); } private void finishAuth(TaskEvent event) { if (event.getResult() == TaskEvent.Result.CANCEL) { this.proxyPlayer.disconnect(event.getReason()); return; } else if (event.getResult() == TaskEvent.Result.WAIT) { return; } this.finishAuth(); } private void finishAuth() { if (Settings.IMP.MAIN.CRACKED_TITLE_SETTINGS.CLEAR_AFTER_LOGIN) { this.proxyPlayer.clearTitle(); } try { this.plugin.updateLoginData(this.proxyPlayer); } catch (SQLException e) { throw new SQLRuntimeException(e); } catch (Throwable e) { e.printStackTrace(); } this.plugin.cacheAuthUser(this.proxyPlayer); this.player.disconnect(); } public static void reload() { Serializer serializer = LimboAuth.getSerializer(); bossbarColor = Settings.IMP.MAIN.BOSSBAR_COLOR; bossbarOverlay = Settings.IMP.MAIN.BOSSBAR_OVERLAY; ipLimitKick = serializer.deserialize(Settings.IMP.MAIN.STRINGS.IP_LIMIT_KICK); databaseErrorKick = serializer.deserialize(Settings.IMP.MAIN.STRINGS.DATABASE_ERROR_KICK); wrongNicknameCaseKick = Settings.IMP.MAIN.STRINGS.WRONG_NICKNAME_CASE_KICK; timesUp = serializer.deserialize(Settings.IMP.MAIN.STRINGS.TIMES_UP); registerSuccessful = serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_SUCCESSFUL); if (Settings.IMP.MAIN.STRINGS.REGISTER_SUCCESSFUL_TITLE.isEmpty() && Settings.IMP.MAIN.STRINGS.REGISTER_SUCCESSFUL_SUBTITLE.isEmpty()) { registerSuccessfulTitle = null; } else { registerSuccessfulTitle = Title.title( serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_SUCCESSFUL_TITLE), serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_SUCCESSFUL_SUBTITLE), Settings.IMP.MAIN.CRACKED_TITLE_SETTINGS.toTimes() ); } int loginAttempts = Settings.IMP.MAIN.LOGIN_ATTEMPTS; loginWrongPassword = new Component[loginAttempts]; for (int i = 0; i < loginAttempts; ++i) { loginWrongPassword[i] = serializer.deserialize(MessageFormat.format(Settings.IMP.MAIN.STRINGS.LOGIN_WRONG_PASSWORD, i + 1)); } loginWrongPasswordKick = serializer.deserialize(Settings.IMP.MAIN.STRINGS.LOGIN_WRONG_PASSWORD_KICK); totp = serializer.deserialize(Settings.IMP.MAIN.STRINGS.TOTP); if (Settings.IMP.MAIN.STRINGS.TOTP_TITLE.isEmpty() && Settings.IMP.MAIN.STRINGS.TOTP_SUBTITLE.isEmpty()) { totpTitle = null; } else { totpTitle = Title.title( serializer.deserialize(Settings.IMP.MAIN.STRINGS.TOTP_TITLE), serializer.deserialize(Settings.IMP.MAIN.STRINGS.TOTP_SUBTITLE), Settings.IMP.MAIN.CRACKED_TITLE_SETTINGS.toTimes() ); } register = serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER); if (Settings.IMP.MAIN.STRINGS.REGISTER_TITLE.isEmpty() && Settings.IMP.MAIN.STRINGS.REGISTER_SUBTITLE.isEmpty()) { registerTitle = null; } else { registerTitle = Title.title( serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_TITLE), serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_SUBTITLE), Settings.IMP.MAIN.CRACKED_TITLE_SETTINGS.toTimes() ); } login = new Component[loginAttempts]; for (int i = 0; i < loginAttempts; ++i) { login[i] = serializer.deserialize(MessageFormat.format(Settings.IMP.MAIN.STRINGS.LOGIN, i + 1)); } if (Settings.IMP.MAIN.STRINGS.LOGIN_TITLE.isEmpty() && Settings.IMP.MAIN.STRINGS.LOGIN_SUBTITLE.isEmpty()) { loginTitle = null; } else { loginTitle = Title.title( serializer.deserialize(MessageFormat.format(Settings.IMP.MAIN.STRINGS.LOGIN_TITLE, loginAttempts)), serializer.deserialize(MessageFormat.format(Settings.IMP.MAIN.STRINGS.LOGIN_SUBTITLE, loginAttempts)), Settings.IMP.MAIN.CRACKED_TITLE_SETTINGS.toTimes() ); } registerDifferentPasswords = serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_DIFFERENT_PASSWORDS); registerPasswordTooLong = serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_PASSWORD_TOO_LONG); registerPasswordTooShort = serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_PASSWORD_TOO_SHORT); registerPasswordUnsafe = serializer.deserialize(Settings.IMP.MAIN.STRINGS.REGISTER_PASSWORD_UNSAFE); loginSuccessful = serializer.deserialize(Settings.IMP.MAIN.STRINGS.LOGIN_SUCCESSFUL); sessionExpired = serializer.deserialize(Settings.IMP.MAIN.STRINGS.MOD_SESSION_EXPIRED); if (Settings.IMP.MAIN.STRINGS.LOGIN_SUCCESSFUL_TITLE.isEmpty() && Settings.IMP.MAIN.STRINGS.LOGIN_SUCCESSFUL_SUBTITLE.isEmpty()) { loginSuccessfulTitle = null; } else { loginSuccessfulTitle = Title.title( serializer.deserialize(Settings.IMP.MAIN.STRINGS.LOGIN_SUCCESSFUL_TITLE), serializer.deserialize(Settings.IMP.MAIN.STRINGS.LOGIN_SUCCESSFUL_SUBTITLE), Settings.IMP.MAIN.CRACKED_TITLE_SETTINGS.toTimes() ); } migrationHash = Settings.IMP.MAIN.MIGRATION_HASH; } public static boolean checkPassword(String password, RegisteredPlayer player, Dao playerDao) { String hash = player.getHash(); boolean isCorrect = HASH_VERIFIER.verify( password.getBytes(StandardCharsets.UTF_8), hash.replace("BCRYPT$", "$2a$").getBytes(StandardCharsets.UTF_8) ).verified; if (!isCorrect && migrationHash != null) { isCorrect = migrationHash.checkPassword(hash, password); if (isCorrect) { player.setPassword(password); try { playerDao.update(player); } catch (SQLException e) { throw new SQLRuntimeException(e); } } } return isCorrect; } public static RegisteredPlayer fetchInfo(Dao playerDao, UUID uuid) { try { List playerList = playerDao.queryForEq(RegisteredPlayer.PREMIUM_UUID_FIELD, uuid.toString()); return (playerList != null ? playerList.size() : 0) == 0 ? null : playerList.get(0); } catch (SQLException e) { throw new SQLRuntimeException(e); } } public static RegisteredPlayer fetchInfo(Dao playerDao, String nickname) { try { List playerList = playerDao.queryForEq(RegisteredPlayer.LOWERCASE_NICKNAME_FIELD, nickname.toLowerCase(Locale.ROOT)); return (playerList != null ? playerList.size() : 0) == 0 ? null : playerList.get(0); } catch (SQLException e) { throw new SQLRuntimeException(e); } } /** * Use {@link RegisteredPlayer#genHash(String)} or {@link RegisteredPlayer#setPassword} */ @Deprecated() public static String genHash(String password) { return HASHER.hashToString(Settings.IMP.MAIN.BCRYPT_COST, password.toCharArray()); } public static CodeVerifier getTotpCodeVerifier() { return TOTP_CODE_VERIFIER; } private enum Command { INVALID, REGISTER, LOGIN, TOTP; static Command parse(String command) { if (Settings.IMP.MAIN.REGISTER_COMMAND.contains(command)) { return Command.REGISTER; } else if (Settings.IMP.MAIN.LOGIN_COMMAND.contains(command)) { return Command.LOGIN; } else if (Settings.IMP.MAIN.TOTP_COMMAND.contains(command)) { return Command.TOTP; } else { return Command.INVALID; } } } }