From 9bee361ea42aa1b2572e50a2307e36542d3afb4c Mon Sep 17 00:00:00 2001 From: Sashegdev Date: Tue, 7 Apr 2026 17:50:29 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D0=BE=D0=BA,=20=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2,=20=D0=B0=20?= =?UTF-8?q?=D1=82=D0=B0=D0=BA=20=D0=B6=D0=B5=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- launcher/dependency-reduced-pom.xml | 2 +- .../me/sashegdev/zernmc/launcher/Main.java | 24 +- .../zernmc/launcher/auth/AuthManager.java | 206 ++++++++ .../zernmc/launcher/menu/LaunchMenu.java | 40 +- .../zernmc/launcher/menu/LoginMenu.java | 167 +++++++ .../zernmc/launcher/utils/Config.java | 4 + .../zernmc/launcher/utils/Input.java | 48 +- .../zernmc/launcher/utils/ZHttpClient.java | 441 +++++++++--------- server/auth.py | 357 ++++++++++++++ server/main.py | 51 +- server/pass_manager.py | 77 +++ 11 files changed, 1198 insertions(+), 219 deletions(-) create mode 100644 launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java create mode 100644 launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java create mode 100644 server/auth.py create mode 100644 server/pass_manager.py diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml index 81984aa..24abee4 100644 --- a/launcher/dependency-reduced-pom.xml +++ b/launcher/dependency-reduced-pom.xml @@ -3,7 +3,7 @@ 4.0.0 me.sashegdev ZernMCLauncher - 1.0.5 + 1.0.6 diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java index a5bae5c..fb73deb 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java @@ -1,5 +1,6 @@ package me.sashegdev.zernmc.launcher; +import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.menu.*; import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import me.sashegdev.zernmc.launcher.utils.*; @@ -17,8 +18,12 @@ public class Main { private static final String CURRENT_VERSION = Version.getCurrentVersion(); - public static void main(String[] args) { + public static void main(String[] args) throws IOException { System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true"); + System.setProperty("file.encoding", "UTF-8"); + System.setProperty("sun.err.encoding", "UTF-8"); + System.setProperty("sun.stdout.encoding", "UTF-8"); + java.nio.charset.Charset.defaultCharset(); ZAnsi.install(); System.out.print("\033[H\033[2J"); @@ -29,6 +34,23 @@ public class Main { checkAndAutoUpdateLauncher(); + // === АВТОРИЗАЦИЯ === + System.out.println(ZAnsi.cyan("Проверка авторизации...")); + boolean sessionRestored = AuthManager.loadSavedSession(); + + if (!sessionRestored) { + LoginMenu loginMenu = new LoginMenu(); + boolean loggedIn = loginMenu.show(); + if (!loggedIn) { + System.out.println(ZAnsi.yellow("До свидания!")); + ZAnsi.uninstall(); + System.exit(0); + } + } else { + System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!")); + } + // === КОНЕЦ АВТОРИЗАЦИИ === + try { mainLoop(); } catch (Exception e) { diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java new file mode 100644 index 0000000..6fd7fea --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -0,0 +1,206 @@ +package me.sashegdev.zernmc.launcher.auth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +public class AuthManager { + + private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); + + private static AuthSession session = null; + + public static boolean loadSavedSession() { + if (!Files.exists(AUTH_FILE)) return false; + try { + String json = Files.readString(AUTH_FILE); + AuthSession loaded = GSON.fromJson(json, AuthSession.class); + if (loaded == null || loaded.accessToken == null) return false; + + session = loaded; + if (isAccessTokenExpired()) { + return tryRefresh(); + } + return true; + } catch (Exception e) { + return false; + } + } + + public static AuthResult login(String username, String password) { + return authRequest("/auth/login", username, password); + } + + public static AuthResult register(String username, String password) { + return authRequest("/auth/register", username, password); + } + + private static AuthResult authRequest(String endpoint, String username, String password) { + try { + String body = GSON.toJson(new LoginRequest(username, password)); + HttpResponse resp = post(endpoint, body); + + if (resp.statusCode() == 200) { + session = GSON.fromJson(resp.body(), AuthSession.class); + session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; + saveSession(); + return AuthResult.ok(); + } else { + return AuthResult.fail(extractError(resp.body())); + } + } catch (Exception e) { + return AuthResult.fail("Ошибка соединения: " + e.getMessage()); + } + } + + public static void logout() { + if (session != null && session.refreshToken != null) { + try { post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); } + catch (Exception ignored) {} + } + session = null; + try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} + } + + public static boolean isLoggedIn() { + return session != null && session.accessToken != null; + } + + public static String getUsername() { + return session != null ? session.username : "Player"; + } + + public static String getUuid() { + return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000"; + } + + public static String getAccessToken() { + if (session == null) return "0"; + if (isAccessTokenExpired()) tryRefresh(); + return session.accessToken != null ? session.accessToken : "0"; + } + + private static boolean isAccessTokenExpired() { + if (session == null) return true; + return System.currentTimeMillis() / 1000L >= session.expiresAt - 300; + } + + private static boolean tryRefresh() { + if (session == null || session.refreshToken == null) return false; + try { + String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; + HttpResponse resp = post("/auth/refresh", body); + + if (resp.statusCode() == 200) { + AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); + newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; + session = newSession; + saveSession(); + return true; + } + } catch (Exception ignored) {} + session = null; + try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} + return false; + } + + private static void saveSession() { + try { + Files.createDirectories(AUTH_FILE.getParent()); + Files.writeString(AUTH_FILE, GSON.toJson(session)); + } catch (IOException e) { + System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage())); + } + } + + private static HttpResponse post(String endpoint, String jsonBody) throws Exception { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(ZHttpClient.getBaseUrl() + endpoint)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(15)) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + return HTTP.send(req, HttpResponse.BodyHandlers.ofString()); + } + + private static String extractError(String body) { + try { + int idx = body.indexOf("\"detail\""); + if (idx != -1) { + int s = body.indexOf("\"", idx + 9) + 1; + int e = body.indexOf("\"", s); + if (e > s) return body.substring(s, e); + } + } catch (Exception ignored) {} + return "Неизвестная ошибка"; + } + + // ====================== ВНУТРЕННИЕ КЛАССЫ ====================== + + public static class AuthSession { + @SerializedName("access_token") public String accessToken; + @SerializedName("refresh_token") public String refreshToken; + @SerializedName("expires_in") public int expiresIn; + public long expiresAt; + public String username; + public String uuid; + } + + private static class LoginRequest { + final String username; + final String password; + LoginRequest(String u, String p) { this.username = u; this.password = p; } + } + + public static class AuthResult { + public final boolean success; + public final String error; + private AuthResult(boolean s, String e) { success = s; error = e; } + public static AuthResult ok() { return new AuthResult(true, null); } + public static AuthResult fail(String msg) { return new AuthResult(false, msg); } + } + + public static boolean hasActivePass() { + if (!isLoggedIn()) return false; + try { + String response = ZHttpClient.get("/auth/pass/my"); + // Простая проверка — есть ли хотя бы одна активная проходка + return response.contains("\"is_active\":true"); + } catch (Exception e) { + System.err.println("Не удалось проверить проходки: " + e.getMessage()); + return false; + } + } + + public static String activatePass(String passCode) { + try { + String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}"; + HttpResponse resp = post("/auth/pass/activate", json); + + if (resp.statusCode() == 200) { + return "Проходка успешно активирована!"; + } else { + String error = extractError(resp.body()); + return "Ошибка: " + error; + } + } catch (Exception e) { + return "Ошибка соединения: " + e.getMessage(); + } + } +} \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java index 4419d62..8c22af4 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java @@ -1,5 +1,6 @@ package me.sashegdev.zernmc.launcher.menu; +import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib; @@ -80,6 +81,32 @@ public class LaunchMenu { } private void installServerPack() throws Exception { + if (!AuthManager.hasActivePass()) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightRed("У вас нет активной проходки!")); + System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку.")); + System.out.println(); + System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: ")); + + String code = Input.readLine(); + if (code.isEmpty()) return; + + String result = AuthManager.activatePass(code); + System.out.println(ZAnsi.cyan(result)); + + if (!result.contains("успешно")) { + ConsoleUtils.pause(); + return; + } + + // Повторная проверка + if (!AuthManager.hasActivePass()) { + System.out.println(ZAnsi.brightRed("Не удалось активировать проходку.")); + ConsoleUtils.pause(); + return; + } + } + ConsoleUtils.clearScreen(); System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера...")); @@ -502,11 +529,22 @@ public class LaunchMenu { } private void launchExistingInstance(Instance instance) { + if (instance.isServerPack() && !AuthManager.hasActivePass()) { + ConsoleUtils.clearScreen(); + System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!")); + ConsoleUtils.pause(); + return; + } ConsoleUtils.clearScreen(); System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName())); MinecraftLib lib = new MinecraftLib(instance); - LaunchOptions options = new LaunchOptions(); + LaunchOptions options = new LaunchOptions(); + + // Авторизация Minecraft + options.setUsername(AuthManager.getUsername()); + options.setUuid(AuthManager.getUuid()); + options.setAccessToken(AuthManager.getAccessToken()); try { lib.launch(options); diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java new file mode 100644 index 0000000..d58f50f --- /dev/null +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java @@ -0,0 +1,167 @@ +package me.sashegdev.zernmc.launcher.menu; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; +import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; +import me.sashegdev.zernmc.launcher.utils.Input; +import me.sashegdev.zernmc.launcher.utils.ZAnsi; + +import java.io.IOException; +import java.util.List; + +/** + * Экран входа/регистрации. + * Показывается при старте лаунчера, если нет сохранённой сессии. + * + * show() возвращает true — пользователь вошёл/зарегистрировался + * false — пользователь выбрал выход из лаунчера + */ +public class LoginMenu { + + /** + * Главный экран выбора действия. + */ + public boolean show() throws IOException { + while (true) { + ConsoleUtils.clearScreen(); + printBanner(); + + List options = List.of( + "Войти в аккаунт", + "Создать аккаунт", + "Выйти из лаунчера" + ); + + ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options); + int choice = menu.show(); + + if (choice == -1 || choice == 2) return false; + + boolean success = switch (choice) { + case 0 -> doLogin(); + case 1 -> doRegister(); + default -> false; + }; + + if (success) return true; + // Если не успех — покажем меню снова (ошибка уже напечатана внутри методов) + } + } + + /** + * Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта. + */ + public void showAccountMenu() throws IOException { + ConsoleUtils.clearScreen(); + + System.out.println(ZAnsi.header("=== Аккаунт ===")); + System.out.println(); + System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername())); + System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid())); + System.out.println(); + + List options = List.of( + "Выйти из аккаунта", + "Назад" + ); + + ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options); + int choice = menu.show(); + + if (choice == 0) { + AuthManager.logout(); + System.out.println(ZAnsi.yellow("Вы вышли из аккаунта.")); + ConsoleUtils.pause(); + } + } + + // ====================== ПРИВАТНЫЕ МЕТОДЫ ====================== + + private boolean doLogin() throws IOException { + ConsoleUtils.clearScreen(); + printBanner(); + System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]")); + System.out.println(); + + String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); + if (username.isEmpty()) return false; + + String password = readPassword(" Пароль: "); + if (password.isEmpty()) return false; + + System.out.println(); + System.out.print(ZAnsi.cyan(" Выполняем вход...")); + + AuthResult result = AuthManager.login(username, password); + + if (result.success) { + System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! ")); + ConsoleUtils.pause(); + return true; + } else { + System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); + ConsoleUtils.pause(); + return false; + } + } + + private boolean doRegister() throws IOException { + ConsoleUtils.clearScreen(); + printBanner(); + System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]")); + System.out.println(); + System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _")); + System.out.println(ZAnsi.yellow(" Длина имени: 3–16 символов | Длина пароля: от 6 символов")); + System.out.println(); + + String username = Input.readLine(ZAnsi.white(" Имя пользователя: ")); + if (username.isEmpty()) return false; + + String password = readPassword(" Пароль: "); + if (password.isEmpty()) return false; + + String confirm = readPassword(" Повторите пароль: "); + if (!password.equals(confirm)) { + System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!")); + ConsoleUtils.pause(); + return false; + } + + System.out.println(); + System.out.print(ZAnsi.cyan(" Создаём аккаунт...")); + + AuthResult result = AuthManager.register(username, password); + + if (result.success) { + System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! ")); + ConsoleUtils.pause(); + return true; + } else { + System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " ")); + ConsoleUtils.pause(); + return false; + } + } + + /** + * Читаем пароль — стараемся скрыть вывод через Console, + * если недоступно (IDE/терминал без TTY) — читаем обычным способом. + */ + private String readPassword(String prompt) { + java.io.Console console = System.console(); + if (console != null) { + char[] chars = console.readPassword(prompt); + return chars != null ? new String(chars) : ""; + } + // Fallback: в IDE пароль будет виден + return Input.readLine(prompt); + } + + private void printBanner() { + System.out.println(ZAnsi.header("╔══════════════════════════════╗")); + System.out.println(ZAnsi.header("║ ZernMC Launcher ║")); + System.out.println(ZAnsi.header("╚══════════════════════════════╝")); + System.out.println(); + } +} diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java index 9c31311..942fc16 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java @@ -113,6 +113,10 @@ public class Config { return CONFIG_DIR.resolve("jre"); } + public static Path getConfigDir() { + return CONFIG_DIR; + } + /** * Полезная информация для пользователя */ diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java index ecfb442..7ece5e6 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java @@ -1,10 +1,18 @@ package me.sashegdev.zernmc.launcher.utils; +import me.sashegdev.zernmc.launcher.ui.ArrowMenu; + +import java.io.IOException; +import java.util.List; import java.util.Scanner; +/** + * Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu + */ public class Input { - private static final Scanner scanner = new Scanner(System.in); + // Используем UTF-8 явно — это помогает на Windows + private static final Scanner scanner = new Scanner(System.in, "UTF-8"); public static String readLine() { return scanner.nextLine().trim(); @@ -36,9 +44,39 @@ public class Input { } } - public static boolean confirm(String prompt) { - System.out.print(prompt + " (да/нет): "); - String answer = scanner.nextLine().trim().toLowerCase(); - return answer.equals("да") || answer.equals("y") || answer.equals("yes"); + /** + * Новый confirm через ArrowMenu + * @throws IOException + */ + public static boolean confirm(String question) throws IOException { + ConsoleUtils.clearScreen(); // опционально, можно убрать + + List options = List.of( + "Да", + "Нет" + ); + + ArrowMenu menu = new ArrowMenu(question, options); + int choice = menu.show(); + + return choice == 0; // 0 = "Да" + } + + /** + * Альтернативный confirm без очистки экрана + * @throws IOException + */ + public static boolean confirmInline(String question) throws IOException { + List options = List.of("Да", "Нет"); + ArrowMenu menu = new ArrowMenu(question, options); + int choice = menu.show(); + return choice == 0; + } + + /** + * Закрытие сканнера (вызывать при выходе из программы, если нужно) + */ + public static void close() { + scanner.close(); } } \ No newline at end of file diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java index a21e01d..3e0bff0 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -26,14 +26,14 @@ public class ZHttpClient { .build(); private static final String BASE_URL = "http://87.120.187.36:1582"; - + // Глобальный прокси режим (для обратной совместимости) private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false); - + // Умное проксирование по сервисам public enum ServiceType { - ZERN_SERVER(BASE_URL, true), // Всегда прямое подключение + ZERN_SERVER(BASE_URL, true), FABRIC_META("https://meta.fabricmc.net", false), FABRIC_MAVEN("https://maven.fabricmc.net", false), MOJANG_META("https://piston-meta.mojang.com", false), @@ -41,39 +41,34 @@ public class ZHttpClient { FORGE_MAVEN("https://maven.minecraftforge.net", false), GOOGLE("https://google.com", false), CLOUDFLARE("https://cloudflare.com", false); - + private final String baseUrl; private final boolean alwaysDirect; - + ServiceType(String baseUrl, boolean alwaysDirect) { this.baseUrl = baseUrl; this.alwaysDirect = alwaysDirect; } - - public String getBaseUrl() { - return baseUrl; - } - - public boolean isAlwaysDirect() { - return alwaysDirect; - } + + public String getBaseUrl() { return baseUrl; } + public boolean isAlwaysDirect() { return alwaysDirect; } } - + // Статусы сервисов private static final Map serviceProxyMode = new ConcurrentHashMap<>(); private static final Map serviceFailCount = new ConcurrentHashMap<>(); private static final Map serviceLastCheckTime = new ConcurrentHashMap<>(); private static final Map serviceHealthy = new ConcurrentHashMap<>(); - + private static final int MAX_FAILS_BEFORE_PROXY = 2; private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута - private static final long CHECK_TIMEOUT_MS = 5000; // 5 секунд на проверку - + private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку + // Статистика private static int directSuccessCount = 0; private static int proxySuccessCount = 0; private static int directFailCount = 0; - + static { for (ServiceType type : ServiceType.values()) { serviceProxyMode.put(type, false); @@ -81,34 +76,33 @@ public class ZHttpClient { serviceHealthy.put(type, false); } } - + /** - * Проверить все сервисы при старте + * Вызывать один раз при запуске лаунчера */ public static void checkAllServicesOnStartup() { if (proxyTested.get()) return; - + System.out.println(ZAnsi.cyan("Проверка доступности сервисов...")); - + List servicesToCheck = List.of( - ServiceType.ZERN_SERVER, - ServiceType.GOOGLE, - ServiceType.FABRIC_META, - ServiceType.FABRIC_MAVEN, - ServiceType.MOJANG_META, - ServiceType.FORGE_MAVEN + ServiceType.ZERN_SERVER, + ServiceType.GOOGLE, + ServiceType.FABRIC_META, + ServiceType.FABRIC_MAVEN, + ServiceType.MOJANG_META, + ServiceType.MOJANG_RESOURCES, + ServiceType.FORGE_MAVEN ); - + for (ServiceType service : servicesToCheck) { boolean isHealthy = checkServiceHealth(service); serviceHealthy.put(service, isHealthy); - + if (service.isAlwaysDirect()) { - if (!isHealthy) { - System.out.println(ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); - } else { - System.out.println(ZAnsi.green(" " + service.name() + " - OK")); - } + System.out.println(isHealthy ? + ZAnsi.green(" " + service.name() + " - OK") : + ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); } else { if (isHealthy) { System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); @@ -119,52 +113,70 @@ public class ZHttpClient { } } } - - // Проверяем, нужно ли включить глобальный прокси режим - boolean anyCriticalDown = !serviceHealthy.get(ServiceType.ZERN_SERVER); - if (anyCriticalDown) { + + if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); } - + proxyTested.set(true); - - // Запускаем фоновую проверку startHealthCheckThread(); - printStats(); } - + /** - * Проверить здоровье конкретного сервиса + * Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки) */ - private static boolean checkServiceHealth(ServiceType service) { - if (service.isAlwaysDirect()) { - return checkDirectConnection(service.getBaseUrl()); + public static void forceCheckMojangServices() { + System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов...")); + + for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) { + boolean healthy = checkServiceHealth(service); + serviceHealthy.put(service, healthy); + + if (healthy) { + System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую")); + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + } else { + System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован")); + serviceProxyMode.put(service, true); + serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); + } } - + } + + private static boolean checkServiceHealth(ServiceType service) { return checkDirectConnection(service.getBaseUrl()); } - + /** - * Проверить прямое подключение к URL + * Улучшенная проверка прямого подключения */ - private static boolean checkDirectConnection(String url) { + private static boolean checkDirectConnection(String baseUrl) { + String testUrl = baseUrl; + + if (baseUrl.contains("piston-meta.mojang.com") || baseUrl.contains("launchermeta.mojang.com")) { + testUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + } else if (baseUrl.contains("resources.download.minecraft.net")) { + testUrl = "https://resources.download.minecraft.net/00/0000000000000000000000000000000000000000"; + } + try { HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) + .uri(URI.create(testUrl)) .timeout(Duration.ofMillis(CHECK_TIMEOUT_MS)) - .HEAD() + .GET() + .header("User-Agent", "ZernMC-Launcher/HealthCheck") .build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding()); - return response.statusCode() < 500; + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int code = response.statusCode(); + return code == 200 || code == 404; // 404 для ресурсов — нормально } catch (Exception e) { return false; } } - - /** - * Запустить фоновый поток для периодической проверки - */ + private static void startHealthCheckThread() { Thread healthThread = new Thread(() -> { while (true) { @@ -179,30 +191,23 @@ public class ZHttpClient { healthThread.setDaemon(true); healthThread.start(); } - - /** - * Периодическая проверка здоровья сервисов - */ + private static void performHealthCheck() { for (ServiceType service : ServiceType.values()) { if (service.isAlwaysDirect()) continue; - + boolean isHealthy = checkServiceHealth(service); serviceHealthy.put(service, isHealthy); - + if (isHealthy && serviceProxyMode.get(service)) { - // Сервис восстановился - пробуем переключить обратно - Long lastCheck = serviceLastCheckTime.get(service); - if (lastCheck == null || System.currentTimeMillis() - lastCheck > HEALTH_CHECK_INTERVAL_MS) { - serviceProxyMode.put(service, false); - serviceFailCount.put(service, 0); - System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение")); - } + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение")); } else if (!isHealthy && !serviceProxyMode.get(service)) { int fails = serviceFailCount.getOrDefault(service, 0) + 1; serviceFailCount.put(service, fails); serviceLastCheckTime.put(service, System.currentTimeMillis()); - + if (fails >= MAX_FAILS_BEFORE_PROXY) { serviceProxyMode.put(service, true); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим")); @@ -210,14 +215,11 @@ public class ZHttpClient { } } } - - /** - * Определить тип сервиса по URL - */ + private static ServiceType detectService(String url) { if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META; if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN; - if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) + if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) return ServiceType.MOJANG_META; if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN; @@ -225,108 +227,158 @@ public class ZHttpClient { if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; return null; } - - /** - * Нужно ли использовать прокси для URL - */ + private static boolean shouldUseProxyForUrl(String url) { if (useProxyMode.get()) return true; - + ServiceType service = detectService(url); - if (service == null) return false; - if (service.isAlwaysDirect()) return false; - + if (service == null || service.isAlwaysDirect()) return false; + return serviceProxyMode.getOrDefault(service, false); } - + + private static boolean isConnectionError(Throwable e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + String msg = cause.getMessage() != null ? cause.getMessage().toLowerCase() : ""; + + return cause instanceof java.net.ConnectException || + cause instanceof java.net.UnknownHostException || + cause instanceof java.nio.channels.ClosedChannelException || + msg.contains("connection") || + msg.contains("timeout") || + msg.contains("refused") || + msg.contains("closed"); + } + + private static void markServiceAsBlocked(String url) { + ServiceType service = detectService(url); + if (service == null || service.isAlwaysDirect()) return; + + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + serviceLastCheckTime.put(service, System.currentTimeMillis()); + + if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { + serviceProxyMode.put(service, true); + System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси")); + } + } /** - * Получить URL через прокси если нужно + * Универсальный GET с умным прокси + автоматическим fallback */ public static String getWithSmartProxy(String url) throws IOException, InterruptedException { - if (shouldUseProxyForUrl(url)) { - String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); - return proxyGet("/download?url=" + encodedUrl); + // Попытка прямого подключения + if (!shouldUseProxyForUrl(url)) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(25)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + directSuccessCount++; + return response.body(); + } + + if (response.statusCode() >= 400) { + throw new IOException("HTTP " + response.statusCode()); + } + } catch (Exception e) { + if (isConnectionError(e)) { + directFailCount++; + markServiceAsBlocked(url); + } + // Если ошибка соединения — пробуем через прокси + } } - + + // Через прокси try { + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; + HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(30)) + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofSeconds(40)) .header("User-Agent", "ZernMC-Launcher/1.0") .GET() .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - + if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); + throw new IOException("Proxy HTTP " + response.statusCode()); } - - directSuccessCount++; + + proxySuccessCount++; return response.body(); - + } catch (Exception e) { - directFailCount++; - ServiceType service = detectService(url); - if (service != null && !service.isAlwaysDirect()) { - int fails = serviceFailCount.getOrDefault(service, 0) + 1; - serviceFailCount.put(service, fails); - if (fails >= MAX_FAILS_BEFORE_PROXY) { - serviceProxyMode.put(service, true); - } - } - throw e; + throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e); } } - + /** - * Скачать файл с умным прокси + * Скачивание файла с умным прокси + fallback */ public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { - if (shouldUseProxyForUrl(url)) { - downloadViaProxy(url, target); - return; - } - - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(30)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - - directSuccessCount++; - - } catch (Exception e) { - directFailCount++; - ServiceType service = detectService(url); - if (service != null && !service.isAlwaysDirect()) { - int fails = serviceFailCount.getOrDefault(service, 0) + 1; - serviceFailCount.put(service, fails); - if (fails >= MAX_FAILS_BEFORE_PROXY) { - serviceProxyMode.put(service, true); + if (!shouldUseProxyForUrl(url)) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(40)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() == 200) { + directSuccessCount++; + return; } + } catch (Exception e) { + if (isConnectionError(e)) { + directFailCount++; + markServiceAsBlocked(url); + } + // fallback на прокси ниже } - throw e; } + + // Скачивание через прокси + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofMinutes(5)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() != 200) { + throw new IOException("Proxy download failed: HTTP " + response.statusCode()); + } + + proxySuccessCount++; } - - // ====================== ОСНОВНЫЕ МЕТОДЫ (СОХРАНЕНЫ) ====================== - + + // ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ====================== + public static String get(String endpoint) throws IOException, InterruptedException { checkAllServicesOnStartup(); - + if (useProxyMode.get()) { return proxyGet(endpoint); } - + try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + endpoint)) @@ -334,20 +386,19 @@ public class ZHttpClient { .header("User-Agent", "ZernMC-Launcher/1.0") .GET() .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - + if (response.statusCode() != 200) { throw new IOException("HTTP " + response.statusCode()); } return response.body(); - } catch (Exception e) { directFailCount++; throw e; } } - + private static String proxyGet(String endpoint) throws IOException { try { HttpRequest request = HttpRequest.newBuilder() @@ -356,96 +407,64 @@ public class ZHttpClient { .header("User-Agent", "ZernMC-Launcher/1.0") .GET() .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - + if (response.statusCode() != 200) { throw new IOException("HTTP " + response.statusCode()); } - + proxySuccessCount++; return response.body(); - } catch (Exception e) { - throw new IOException("Ошибка прокси запроса: " + e.getMessage(), e); + throw new IOException("Ошибка прокси: " + e.getMessage(), e); } } - - private static void downloadViaProxy(String url, Path target) throws Exception { - String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8.toString()); - String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(proxyUrl)) - .timeout(Duration.ofMinutes(5)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); - } - - proxySuccessCount++; - } - + // ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ====================== - + public static List getFabricLoaderVersions() throws IOException, InterruptedException { String url = "https://meta.fabricmc.net/v2/versions/loader"; - String response = getWithSmartProxy(url); - return parseFabricVersionsFromJson(response); + return parseFabricVersionsFromJson(getWithSmartProxy(url)); } - + public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException { String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; String response = getWithSmartProxy(url); return new JSONObject(response); } - + public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { JSONObject manifest = getMojangVersionManifest(); JSONArray versions = manifest.getJSONArray("versions"); - - String versionUrl = null; + for (int i = 0; i < versions.length(); i++) { JSONObject v = versions.getJSONObject(i); if (v.getString("id").equals(versionId)) { - versionUrl = v.getString("url"); - break; + return new JSONObject(getWithSmartProxy(v.getString("url"))); } } - - if (versionUrl == null) { - throw new IOException("Version " + versionId + " not found"); - } - - String response = getWithSmartProxy(versionUrl); - return new JSONObject(response); + throw new IOException("Version " + versionId + " not found"); } - + public static String getForgeVersionsXml() throws IOException, InterruptedException { String url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"; return getWithSmartProxy(url); } - + public static void downloadFile(String url, Path target) throws Exception { downloadFileWithSmartProxy(url, target); } - + public static void downloadAsset(String hash, Path target) throws Exception { String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; downloadFileWithSmartProxy(url, target); } - + public static String downloadString(String url) throws IOException, InterruptedException { return getWithSmartProxy(url); } - - // ====================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ====================== - + private static List parseFabricVersionsFromJson(String json) { JSONArray array = new JSONArray(json); List versions = new ArrayList<>(); @@ -457,20 +476,22 @@ public class ZHttpClient { } return versions; } - + + // ====================== ВСПОМОГАТЕЛЬНЫЕ ====================== + public static String getBaseUrl() { return BASE_URL; } - + public static String getLauncherVersionInfo() throws IOException, InterruptedException { return get("/launcher/version"); } - + public static void forceProxyMode() { useProxyMode.set(true); System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим")); } - + public static void disableProxyMode() { useProxyMode.set(false); for (ServiceType type : ServiceType.values()) { @@ -481,23 +502,23 @@ public class ZHttpClient { } System.out.println(ZAnsi.green("Режим прокси выключен")); } - + public static boolean isProxyMode() { return useProxyMode.get(); } - + public static void printStats() { System.out.println(ZAnsi.cyan("\n=== Статистика сети ===")); - System.out.println(ZAnsi.white("Глобальный прокси режим: ") + (useProxyMode.get() ? "ВКЛЮЧЕН" : "ВЫКЛЮЧЕН")); + System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ")); System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount); System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount); System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount); - + System.out.println(ZAnsi.cyan("\nСтатус сервисов:")); for (ServiceType type : ServiceType.values()) { if (type.isAlwaysDirect()) continue; String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО"); - String health = serviceHealthy.get(type) ? ZAnsi.green("✓") : ZAnsi.red("✗"); + String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]"); System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); } } diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..5872ca8 --- /dev/null +++ b/server/auth.py @@ -0,0 +1,357 @@ +# auth.py +import base64 +import json +import sqlite3 +import hashlib +import hmac +import secrets +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import structlog +from fastapi import APIRouter, HTTPException, Request, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel, Field + +logger = structlog.get_logger(__name__) + +# ====================== КОНФИГ ====================== +AUTH_DB = Path("data/auth.db") +AUTH_DB.parent.mkdir(exist_ok=True) + +SECRET_KEY = Path("data/.secret_key") + +ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа +REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней + +# ====================== СЕКРЕТНЫЙ КЛЮЧ ====================== +def _get_secret() -> bytes: + if SECRET_KEY.exists(): + return SECRET_KEY.read_bytes() + key = secrets.token_bytes(64) + SECRET_KEY.write_bytes(key) + SECRET_KEY.chmod(0o600) + return key + +_SECRET = _get_secret() + +def create_jwt(payload: dict) -> str: + import base64, json + header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b'=').decode() + body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode() + msg = f"{header}.{body}".encode() + sig = hmac.new(_SECRET, msg, hashlib.sha256).digest() + return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}" + +def verify_jwt(token: str) -> Optional[dict]: + try: + parts = token.split(".") + if len(parts) != 3: + return None + header, body, sig = parts + msg = f"{header}.{body}".encode() + expected = hmac.new(_SECRET, msg, hashlib.sha256).digest() + if not hmac.compare_digest(base64.urlsafe_b64decode(sig + '==='[:3]), expected): + return None + payload = json.loads(base64.urlsafe_b64decode(body + '==='[:3])) + if payload.get("exp", 0) < time.time(): + return None + return payload + except: + return None + +# ====================== БАЗА ДАННЫХ ====================== +def get_db(): + conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + +def init_db(): + conn = get_db() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + uuid TEXT UNIQUE NOT NULL, + created_at REAL NOT NULL, + last_login REAL + ); + + CREATE TABLE IF NOT EXISTS passes ( -- НОВАЯ ТАБЛИЦА + code TEXT PRIMARY KEY, -- ZERN-XXXXXX + is_used BOOLEAN DEFAULT 0, + activated_by INTEGER REFERENCES users(id), + activated_at REAL, + expires_at REAL, -- NULL = без срока + max_uses INTEGER DEFAULT 1, -- пока 1, можно больше + uses INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS user_passes ( -- Связь пользователь-пасс + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + pass_code TEXT REFERENCES passes(code), + activated_at REAL NOT NULL, + PRIMARY KEY (user_id, pass_code) + ); + """) + conn.commit() + conn.close() + logger.info("Auth database initialized") + +# ====================== ХЕЛПЕРЫ ====================== +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + hash_obj = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 300000) + return f"{salt}${hash_obj.hex()}" + +def verify_password(password: str, stored: str) -> bool: + try: + salt, stored_hash = stored.split('$') + hash_obj = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 300000) + return hmac.compare_digest(hash_obj.hex(), stored_hash) + except: + return False + +def generate_uuid() -> str: + return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}" + +# ====================== МОДЕЛИ ====================== +class LoginRequest(BaseModel): + username: str + password: str + +class RegisterRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$") + password: str = Field(..., min_length=6, max_length=128) + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: int + username: str + uuid: str + +# ====================== ROUTER ====================== +router = APIRouter(prefix="/auth", tags=["auth"]) +bearer = HTTPBearer(auto_error=False) + +@router.post("/register", response_model=TokenResponse) +async def register(body: RegisterRequest, request: Request): + conn = get_db() + try: + if conn.execute("SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", (body.username,)).fetchone(): + raise HTTPException(409, "Имя пользователя уже занято") + + uuid = generate_uuid() + pw_hash = hash_password(body.password) + now = time.time() + + conn.execute( + "INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)", + (body.username, pw_hash, uuid, now) + ) + user_id = conn.lastrowid + conn.commit() + + return _issue_tokens(conn, user_id, body.username, uuid) + finally: + conn.close() + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest, request: Request): + conn = get_db() + try: + row = conn.execute( + "SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE", + (body.username,) + ).fetchone() + + if not row or not verify_password(body.password, row["password_hash"]): + raise HTTPException(401, "Неверное имя пользователя или пароль") + + conn.execute("UPDATE users SET last_login = ? WHERE id = ?", (time.time(), row["id"])) + conn.commit() + + return _issue_tokens(conn, row["id"], row["username"], row["uuid"]) + finally: + conn.close() + + +def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse: + now = time.time() + + access_token = create_jwt({ + "sub": user_id, + "username": username, + "uuid": uuid, + "type": "access", + "exp": now + ACCESS_TOKEN_EXPIRE_SECONDS + }) + + refresh_token = create_jwt({ + "sub": user_id, + "type": "refresh", + "exp": now + REFRESH_TOKEN_EXPIRE_SECONDS + }) + + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + + # Удаляем старые refresh-токены пользователя + conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,)) + conn.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)", + (user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS) + ) + conn.commit() + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=ACCESS_TOKEN_EXPIRE_SECONDS, + username=username, + uuid=uuid + ) + + +# Добавь этот эндпоинт, он используется в tryRefresh() +@router.post("/refresh") +async def refresh(body: dict): + refresh_token = body.get("refresh_token") + if not refresh_token: + raise HTTPException(400, "refresh_token обязателен") + + payload = verify_jwt(refresh_token) + if not payload or payload.get("type") != "refresh": + raise HTTPException(401, "Недействительный refresh token") + + conn = get_db() + try: + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + row = conn.execute( + "SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?", + (token_hash, time.time()) + ).fetchone() + + if not row: + raise HTTPException(401, "Refresh token истёк или недействителен") + + user_row = conn.execute( + "SELECT id, username, uuid FROM users WHERE id = ?", (row["user_id"],) + ).fetchone() + + if not user_row: + raise HTTPException(401, "Пользователь не найден") + + return _issue_tokens(conn, user_row["id"], user_row["username"], user_row["uuid"]) + finally: + conn.close() + +class ActivatePassRequest(BaseModel): + pass_code: str = Field(..., min_length=8, max_length=20, pattern=r"^ZERN-[A-Z0-9]+$") + +class PassInfo(BaseModel): + code: str + expires_at: Optional[float] = None + is_active: bool + +@router.post("/pass/activate") +async def activate_pass(body: ActivatePassRequest, credentials: HTTPAuthorizationCredentials = Depends(bearer)): + token = credentials.credentials if credentials else None + if not token: + raise HTTPException(401, "Требуется авторизация") + + payload = verify_jwt(token) + if not payload or payload.get("type") != "access": + raise HTTPException(401, "Недействительный токен") + + user_id = payload["sub"] + + conn = get_db() + try: + # Проверяем существование и доступность пасса + pass_row = conn.execute( + "SELECT code, expires_at, uses, max_uses FROM passes WHERE code = ?", + (body.pass_code.upper(),) + ).fetchone() + + if not pass_row: + raise HTTPException(404, "Проходка не найдена") + + if pass_row["expires_at"] and pass_row["expires_at"] < time.time(): + raise HTTPException(410, "Проходка истекла") + + if pass_row["uses"] >= pass_row["max_uses"]: + raise HTTPException(410, "Проходка уже использована") + + # Проверяем, не активировал ли уже этот пользователь + already = conn.execute( + "SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?", + (user_id, body.pass_code.upper()) + ).fetchone() + + if already: + raise HTTPException(409, "Эта проходка уже активирована на вашем аккаунте") + + now = time.time() + + # Активируем + conn.execute( + "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", + (user_id, body.pass_code.upper(), now) + ) + + # Увеличиваем счётчик использований + conn.execute( + "UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?", + (user_id, now, body.pass_code.upper()) + ) + + conn.commit() + + logger.info("Pass activated", user_id=user_id, pass_code=body.pass_code) + return {"success": True, "message": "Проходка успешно активирована!"} + + finally: + conn.close() + + +@router.get("/pass/my") +async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)): + token = credentials.credentials if credentials else None + if not token: + raise HTTPException(401, "Требуется авторизация") + + payload = verify_jwt(token) + if not payload: + raise HTTPException(401, "Недействительный токен") + + user_id = payload["sub"] + + conn = get_db() + try: + rows = conn.execute(""" + SELECT p.code, p.expires_at, up.activated_at + FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? + """, (user_id,)).fetchall() + + passes = [] + now = time.time() + for row in rows: + expires = row["expires_at"] + is_active = expires is None or expires > now + passes.append({ + "code": row["code"], + "activated_at": row["activated_at"], + "expires_at": expires, + "is_active": is_active + }) + + return {"passes": passes} + finally: + conn.close() \ No newline at end of file diff --git a/server/main.py b/server/main.py index 9dc25c4..cf9e0fa 100644 --- a/server/main.py +++ b/server/main.py @@ -9,7 +9,6 @@ import logging from datetime import datetime from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol -# Import local modules from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR from models import PackMeta from middleware import LoggingMiddleware @@ -20,6 +19,9 @@ import httpx import base64 from fastapi.responses import StreamingResponse +from auth import router as auth_router, init_db, verify_jwt +from pass_manager import activate_pass, has_active_pass, get_user_passes + logger = structlog.get_logger(__name__) # Cache for manifests - expires after 5 minutes @@ -50,6 +52,10 @@ async def lifespan(app: FastAPI): BUILDS_DIR.mkdir(exist_ok=True) PACKS_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True) + + init_db() + + app.include_router(auth_router) if args.test: await run_test_mode() @@ -820,6 +826,49 @@ async def proxy_status(): } +@app.post("/auth/pass/activate") +async def api_activate_pass(request: Request): + try: + body = await request.json() + pass_code = body.get("pass_code") + if not pass_code: + raise HTTPException(400, "pass_code обязателен") + except: + raise HTTPException(400, "Неверный JSON") + + # Получаем текущего пользователя из токена + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(401, "Требуется авторизация") + + token = auth_header.split(" ")[1] + payload = verify_jwt(token) # функция из auth.py + if not payload: + raise HTTPException(401, "Недействительный токен") + + result = activate_pass(pass_code, payload["username"], payload["sub"]) + + if result["success"]: + return result + else: + raise HTTPException(400, result["error"]) + + +@app.get("/auth/pass/my") +async def api_my_passes(request: Request): + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(401, "Требуется авторизация") + + token = auth_header.split(" ")[1] + payload = verify_jwt(token) + if not payload: + raise HTTPException(401, "Недействительный токен") + + passes = get_user_passes(payload["username"]) + return {"passes": passes, "has_active": any(p["is_active"] for p in passes)} + + # Cleanup on shutdown @app.on_event("shutdown") async def shutdown_proxy(): diff --git a/server/pass_manager.py b/server/pass_manager.py new file mode 100644 index 0000000..574fcb6 --- /dev/null +++ b/server/pass_manager.py @@ -0,0 +1,77 @@ +import json +from pathlib import Path +from datetime import datetime +import structlog + +logger = structlog.get_logger(__name__) + +PASSES_FILE = Path("data/passes.json") + +def load_passes(): + if not PASSES_FILE.exists(): + PASSES_FILE.parent.mkdir(exist_ok=True) + default = {"passes": {}} + PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False)) + return default + try: + return json.loads(PASSES_FILE.read_text(encoding="utf-8")) + except: + return {"passes": {}} + +def save_passes(data): + PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + +def activate_pass(pass_code: str, username: str, user_id: int) -> dict: + data = load_passes() + pass_code = pass_code.upper().strip() + + if pass_code not in data["passes"]: + return {"success": False, "error": "Проходка не найдена"} + + p = data["passes"][pass_code] + + if not p.get("is_active", True): + return {"success": False, "error": "Проходка деактивирована"} + + if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp(): + return {"success": False, "error": "Проходка истекла"} + + if p.get("owner") is not None: + if p.get("owner") != username: + return {"success": False, "error": "Проходка уже активирована другим пользователем"} + return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"} + + # Активация + now = datetime.now().timestamp() + p["owner"] = username + p["activated_at"] = now + p["uses"] = p.get("uses", 0) + 1 + + save_passes(data) + + logger.info("Pass activated", pass_code=pass_code, username=username) + return {"success": True, "message": "Проходка успешно активирована!"} + +def has_active_pass(username: str) -> bool: + data = load_passes() + for p in data["passes"].values(): + if p.get("owner") == username: + if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp(): + continue + if p.get("is_active", True): + return True + return False + +def get_user_passes(username: str) -> list: + data = load_passes() + result = [] + now = datetime.now().timestamp() + for p in data["passes"].values(): + if p.get("owner") == username: + result.append({ + "code": p["code"], + "activated_at": p.get("activated_at"), + "expires_at": p.get("expires_at"), + "is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now) + }) + return result \ No newline at end of file