first commit

This commit is contained in:
loki5512344 2026-06-10 22:59:27 +02:00
commit 32b1165bb3
32 changed files with 1763 additions and 0 deletions

20
plugin/build.gradle.kts Normal file
View file

@ -0,0 +1,20 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.gradleup.shadow")
}
dependencies {
implementation(project(":common"))
implementation(project(":discord")) {
exclude(group = "ch.qos.logback")
}
compileOnly("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
annotationProcessor("com.velocitypowered:velocity-api:3.5.0-SNAPSHOT")
}
tasks.named<ShadowJar>("shadowJar") {
archiveBaseName.set("loauth")
archiveClassifier.set("")
}

View file

@ -0,0 +1,104 @@
package dev.loki.loAuth.plugin;
import dev.loki.loAuth.plugin.cfg.PluginCfg;
import dev.loki.loAuth.plugin.listener.LoginListener;
import dev.loki.loAuth.common.db.DatabaseManager;
import dev.loki.loAuth.discord.Bot;
import dev.loki.loAuth.discord.cfg.BotConfig;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
@Plugin(id = "loauth", name = "loAuth", version = "0.0.1",
description = "Discord 2FA auth for Velocity", authors = {"loki"})
public final class FoxAuth {
private final ProxyServer server;
private final Logger log;
private final Path dataDir;
private DatabaseManager db;
private Bot discordBot;
@Inject
public FoxAuth(final ProxyServer server, final Logger log, @DataDirectory final Path dataDir) {
this.server = server;
this.log = log;
this.dataDir = dataDir;
}
@Subscribe
public void onInit(final ProxyInitializeEvent event) {
PluginCfg cfg;
try {
cfg = new PluginCfg(getOrCreateConfig("config.yml"));
} catch (IOException e) {
log.error("Failed to load config: {}", e.getMessage());
return;
}
db = new DatabaseManager(cfg, "loAuthPlugin", 5);
server.getEventManager().register(this, new LoginListener(server, db, cfg, log));
if ("embedded".equals(cfg.getMode())) {
startEmbeddedBot(cfg);
} else {
log.info("Mode: standalone — external Discord bot required.");
}
log.info("loAuth initialized.");
}
private void startEmbeddedBot(final PluginCfg cfg) {
String token = cfg.getToken();
if (token.isEmpty()) {
log.warn("Mode is 'embedded' but token is empty! Discord bot not started.");
return;
}
try {
discordBot = new Bot(new BotConfig() {
@Override public String getDbType() { return cfg.getDbType(); }
@Override public String getToken() { return token; }
@Override public String getDbHost() { return cfg.getDbHost(); }
@Override public int getDbPort() { return cfg.getDbPort(); }
@Override public String getDbName() { return cfg.getDbName(); }
@Override public String getDbUser() { return cfg.getDbUser(); }
@Override public String getDbPassword() { return cfg.getDbPassword(); }
@Override public int getAuthTimeoutSeconds() { return cfg.getAuthTimeoutSeconds(); }
@Override public int getVerificationCodeLength() { return cfg.getVerificationCodeLength(); }
});
log.info("Discord bot started in embedded mode.");
} catch (Exception e) {
log.error("Failed to start embedded Discord bot: {}", e.getMessage());
}
}
@Subscribe
public void onShutdown(final ProxyShutdownEvent event) {
if (discordBot != null) discordBot.shutdown();
if (db != null) db.close();
}
private Path getOrCreateConfig(final String fileName) throws IOException {
Files.createDirectories(dataDir);
Path target = dataDir.resolve(fileName);
if (Files.notExists(target)) {
try (InputStream in = getClass().getResourceAsStream("/" + fileName)) {
if (in == null) throw new IOException("Resource not found: " + fileName);
Files.copy(in, target);
}
}
return target;
}
}

View file

@ -0,0 +1,120 @@
package dev.loki.loAuth.plugin.cfg;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.TextColor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class MessageConfig {
private static final Pattern HEX_PATTERN = Pattern.compile("&#([0-9A-Fa-f]{6})");
private final Map<String, Object> raw;
@SuppressWarnings("unchecked")
public MessageConfig(final Map<String, Object> raw) {
this.raw = raw;
}
public Component header() { return text("first_login.header"); }
public Component returningHeader() { return text("returning_login.header"); }
public Component returningBody() { return text("returning_login.body"); }
public Component returningTimeout() { return text("returning_login.timeout"); }
public Component expiredHeader() { return text("expired.header"); }
public Component expiredBody() { return text("expired.body"); }
public Component success() { return text("success"); }
public Component code(final String code) {
return parse("first_login.code", Map.of("code", code))
.clickEvent(ClickEvent.copyToClipboard(code))
.hoverEvent(HoverEvent.showText(text("first_login.code_hover")));
}
public Component discordLink(final String link) {
String fullLink = link.startsWith("http") ? link : "https://" + link;
return parse("first_login.discord", Map.of("link", link))
.clickEvent(ClickEvent.openUrl(fullLink))
.hoverEvent(HoverEvent.showText(text("first_login.discord_hover")));
}
public Component expires(final int seconds) {
return text("first_login.expires", Map.of("time", String.valueOf(seconds)));
}
public Component react(final int seconds) {
return text("returning_login.react", Map.of("time", String.valueOf(seconds)));
}
// ─── Parser ───────────────────────────────────────────────────────────────
private Component text(final String path) {
return text(path, Map.of());
}
private Component text(final String path, final Map<String, String> placeholders) {
String rawStr = resolveRaw(path);
if (rawStr == null) return Component.text('[' + path + ']');
return parse(rawStr, placeholders);
}
private Component parse(final String rawStr, final Map<String, String> placeholders) {
String str = applyPlaceholders(rawStr, placeholders);
List<int[]> hexes = new ArrayList<>();
Matcher m = HEX_PATTERN.matcher(str);
while (m.find()) {
hexes.add(new int[]{m.start(), m.end(), Integer.parseInt(m.group(1), 16)});
}
TextComponent.Builder builder = Component.text();
int last = 0;
for (int i = 0; i < hexes.size(); i++) {
int[] h = hexes.get(i);
if (last < h[0]) {
builder.append(Component.text(str.substring(last, h[0])));
}
TextColor color = TextColor.color(h[2]);
int nextStart = (i + 1 < hexes.size()) ? hexes.get(i + 1)[0] : str.length();
builder.append(Component.text(str.substring(h[1], nextStart), color));
last = nextStart;
}
if (last < str.length()) {
builder.append(Component.text(str.substring(last)));
}
return builder.build();
}
@SuppressWarnings("unchecked")
private String resolveRaw(final String path) {
String[] parts = path.split("\\.");
Map<String, Object> current = raw;
for (int i = 0; i < parts.length - 1; i++) {
Object next = current.get(parts[i]);
if (next instanceof Map) {
current = (Map<String, Object>) next;
} else {
return null;
}
}
Object val = current.get(parts[parts.length - 1]);
return val instanceof String ? (String) val : null;
}
private static String applyPlaceholders(final String str, final Map<String, String> placeholders) {
String result = str;
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
result = result.replace("{" + entry.getKey() + "}", entry.getValue());
}
return result;
}
}

View file

@ -0,0 +1,40 @@
package dev.loki.loAuth.plugin.cfg;
import dev.loki.loAuth.common.cfg.DbConfig;
import dev.loki.loAuth.common.cfg.YamlConfig;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
public final class PluginCfg implements DbConfig {
private final YamlConfig cfg;
public PluginCfg(final Path configPath) throws IOException {
cfg = new YamlConfig(configPath);
}
public String getMode() { return cfg.getRequired("mode", String.class); }
public String getToken() { return cfg.get("token", ""); }
public String getDiscordLink() { return cfg.get("discord_link", "discord.gg/ЗАМЕНИ_НА_СВОЙ"); }
@Override public String getDbType() { return cfg.getRequired("db_type", String.class); }
@Override public String getDbHost() { return cfg.getRequired("db_host", String.class); }
@Override public int getDbPort() { return cfg.getRequired("db_port", Integer.class); }
@Override public String getDbName() { return cfg.getRequired("db_name", String.class); }
@Override public String getDbUser() { return cfg.getRequired("db_user", String.class); }
@Override public String getDbPassword() { return cfg.getRequired("db_password", String.class); }
@Override public int getAuthTimeoutSeconds() { return cfg.getRequired("auth_timeout_seconds", Integer.class); }
@Override public int getVerificationCodeLength(){ return cfg.getRequired("verification_code_length",Integer.class); }
public MessageConfig getMessages() {
Object msgs = cfg.get("messages", null);
if (msgs instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) msgs;
return new MessageConfig(map);
}
return new MessageConfig(Map.of());
}
}

View file

@ -0,0 +1,134 @@
package dev.loki.loAuth.plugin.listener;
import dev.loki.loAuth.plugin.cfg.MessageConfig;
import dev.loki.loAuth.plugin.cfg.PluginCfg;
import dev.loki.loAuth.common.db.DatabaseManager;
import dev.loki.loAuth.common.model.User;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.proxy.ProxyServer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.*;
public final class LoginListener {
private final ProxyServer server;
private final DatabaseManager db;
private final PluginCfg cfg;
private final Logger log;
private final MessageConfig msg;
private final Map<String, ScheduledFuture<?>> pendingKicks = new ConcurrentHashMap<>();
private final Map<String, String> pendingTokens = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, r -> {
Thread t = new Thread(r, "foxauth-scheduler");
t.setDaemon(true);
return t;
});
public LoginListener(final ProxyServer server, final DatabaseManager db,
final PluginCfg cfg, final Logger log) {
this.server = server;
this.db = db;
this.cfg = cfg;
this.log = log;
this.msg = cfg.getMessages();
}
@Subscribe
public void onLogin(final LoginEvent event) {
String nick = event.getPlayer().getUsername();
if (!db.isRegistered(nick)) {
handleFirstLogin(event, nick);
} else {
handleReturningLogin(event, nick);
}
}
private void handleFirstLogin(final LoginEvent event, final String nick) {
String code = db.createVerificationCode(nick);
int ttl = cfg.getAuthTimeoutSeconds();
String discordLink = cfg.getDiscordLink();
event.getPlayer().sendMessage(Component.text()
.append(msg.header()).appendNewline()
.append(msg.code(code)).appendNewline()
.append(msg.discordLink(discordLink)).appendNewline()
.append(msg.expires(ttl))
.build()
);
log.info("First login for {}, verification code issued.", nick);
}
private void handleReturningLogin(final LoginEvent event, final String nick) {
Optional<String> discordId = db.findByMinecraftNick(nick).map(User::discordId);
if (discordId.isEmpty()) {
event.getPlayer().sendMessage(Component.text("Ошибка авторизации. Обратись к администратору.", NamedTextColor.RED));
return;
}
String token = db.createLoginToken(nick, discordId.get());
pendingTokens.put(nick, token);
pollApproval(nick, token);
log.info("Returning login for {}, awaiting Discord approval.", nick);
}
private void pollApproval(final String nick, final String token) {
int ttl = cfg.getAuthTimeoutSeconds();
Component awaitMessage = Component.text()
.append(msg.returningHeader()).appendNewline()
.append(msg.returningBody()).appendNewline()
.append(msg.react(ttl)).appendNewline()
.append(msg.returningTimeout())
.build();
ScheduledFuture<?>[] pollRef = new ScheduledFuture<?>[1];
pollRef[0] = scheduler.scheduleAtFixedRate(() -> {
try {
if (db.isTokenConsumed(token)) {
pollRef[0].cancel(false);
cancelKick(nick);
pendingTokens.remove(nick);
server.getPlayer(nick).ifPresent(p ->
p.sendMessage(msg.success())
);
log.info("Login approved for {}.", nick);
}
} catch (Exception e) {
log.error("Poll error for {}: {}", nick, e.getMessage());
}
}, 2, 2, TimeUnit.SECONDS);
ScheduledFuture<?> timeoutFuture = scheduler.schedule(() -> {
pollRef[0].cancel(false);
pendingTokens.remove(nick);
db.deleteLoginToken(token);
server.getPlayer(nick).ifPresent(p ->
p.sendMessage(Component.text()
.append(msg.expiredHeader()).appendNewline()
.append(msg.expiredBody())
.build())
);
log.info("Login timeout for {}.", nick);
}, ttl, TimeUnit.SECONDS);
pendingKicks.put(nick, timeoutFuture);
server.getPlayer(nick).ifPresent(p -> p.sendMessage(awaitMessage));
}
private void cancelKick(final String nick) {
ScheduledFuture<?> f = pendingKicks.remove(nick);
if (f != null) f.cancel(false);
}
}

View file

@ -0,0 +1,32 @@
mode: "standalone" # standalone | embedded
token: ""
db_type: "postgresql" # postgresql | sqlite
db_host: "localhost"
db_port: 5432
db_name: "mydb"
db_user: "postgres"
db_password: "secret"
auth_timeout_seconds: 120
verification_code_length: 6
discord_link: "discord.gg/ЗАМЕНИ_НА_СВОЙ"
messages:
first_login:
header: "&#FF4444Ты не зарегистрирован!"
code: "&#FFAA00Код: &#55FFFF{code}"
code_hover: "Нажми чтобы скопировать код"
discord: "&#AAAAAADiscord: &#FFFFFF{link}"
discord_hover: "Открыть Discord"
expires: "&#555555Код действителен {time} сек."
returning_login:
header: "&#FFAA00Подтверди вход в Discord!"
body: "&#AAAAAABот отправил тебе личное сообщение в Discord."
react: "&#AAAAAAПоставь &#57F287✅ &#AAAAAAчтобы войти."
timeout: "&#555555Таймаут: {time} сек."
expired:
header: "&#FF4444Время ожидания истекло."
body: "&#AAAAAAЗайди снова и подтверди вход в Discord."
success: "&#55FF55Авторизация успешна!"