first commit
This commit is contained in:
commit
32b1165bb3
32 changed files with 1763 additions and 0 deletions
20
plugin/build.gradle.kts
Normal file
20
plugin/build.gradle.kts
Normal 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("")
|
||||
}
|
||||
104
plugin/src/main/java/dev/loki/loAuth/plugin/FoxAuth.java
Normal file
104
plugin/src/main/java/dev/loki/loAuth/plugin/FoxAuth.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
32
plugin/src/main/resources/config.yml
Normal file
32
plugin/src/main/resources/config.yml
Normal 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Код: 7FFFF{code}"
|
||||
code_hover: "Нажми чтобы скопировать код"
|
||||
discord: "&#AAAAAADiscord: &#FFFFFF{link}"
|
||||
discord_hover: "Открыть Discord"
|
||||
expires: "򇨣Код действителен {time} сек."
|
||||
returning_login:
|
||||
header: "&#FFAA00Подтверди вход в Discord!"
|
||||
body: "&#AAAAAABот отправил тебе личное сообщение в Discord."
|
||||
react: "&#AAAAAAПоставь 9F287✅ &#AAAAAAчтобы войти."
|
||||
timeout: "򇨣Таймаут: {time} сек."
|
||||
expired:
|
||||
header: "&#FF4444Время ожидания истекло."
|
||||
body: "&#AAAAAAЗайди снова и подтверди вход в Discord."
|
||||
success: "7FF55Авторизация успешна!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue