diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml index 41197c7..15896e4 100644 --- a/launcher/dependency-reduced-pom.xml +++ b/launcher/dependency-reduced-pom.xml @@ -56,7 +56,7 @@ ${project.version}.0 ${project.version} - ZernMC Launcher — самописный Minecraft лаунчер + ZernMC Launcher — A Little Minecraft Launcher ${project.version}.0 ${project.version} ZernMC Launcher diff --git a/launcher/pom.xml b/launcher/pom.xml index e51e2fa..a08d5b7 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -110,7 +110,7 @@ ${project.version}.0 ${project.version} - ZernMC Launcher — самописный Minecraft лаунчер + ZernMC Launcher — A Little Minecraft Launcher ${project.version}.0 ${project.version} ZernMC Launcher 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 index f6cd5b3..5e732ae 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -1,25 +1,39 @@ package me.sashegdev.zernmc.launcher.auth; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; +import com.google.gson.*; 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.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; +import java.util.List; 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_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); private static volatile AuthSession session = null; + private static volatile UserInfo userInfo = null; + + // Роли + public static final int ROLE_USER = 0; + public static final int ROLE_PASS_HOLDER = 1; + public static final int ROLE_MODERATOR = 2; + public static final int ROLE_ELDER = 3; + public static final int ROLE_CREATOR = 4; public static boolean loadSavedSession() { if (!Files.exists(AUTH_FILE)) return false; @@ -29,6 +43,12 @@ public class AuthManager { if (loaded == null || loaded.accessToken == null) return false; session = loaded; + + // Получаем информацию о пользователе + if (session.username != null) { + userInfo = fetchUserInfo(); + } + if (isAccessTokenExpired()) { return tryRefresh(); } @@ -48,38 +68,59 @@ public class AuthManager { private static AuthResult authRequest(String endpoint, String username, String password) { try { - String body = GSON.toJson(new LoginRequest(username, password)); + JsonObject body = new JsonObject(); + body.addProperty("username", username); + body.addProperty("password", password); - //System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint)); + String jsonBody = GSON.toJson(body); - SimpleHttpResponse resp = post(endpoint, body); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ZHttpClient.getBaseUrl() + endpoint)) + .timeout(Duration.ofSeconds(15)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", "ZernMC-Launcher/1.0") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); - //System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode())); - - if (resp.statusCode() == 200) { - session = GSON.fromJson(resp.body(), AuthSession.class); + if (response.statusCode() == 200) { + session = GSON.fromJson(response.body(), AuthSession.class); session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; saveSession(); + + // Получаем информацию о пользователе + userInfo = fetchUserInfo(); + return AuthResult.ok(); - } else if (resp.statusCode() == 422) { - return AuthResult.fail("Ошибка валидации: " + extractError(resp.body())); } else { - return AuthResult.fail(extractError(resp.body())); + String error = extractError(response.body()); + return AuthResult.fail(error); } } catch (Exception e) { - //System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage())); - e.printStackTrace(); return AuthResult.fail("Ошибка соединения: " + e.getMessage()); } } public static void logout() { if (session != null && session.refreshToken != null) { - try { - post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); + try { + JsonObject body = new JsonObject(); + body.addProperty("refresh_token", session.refreshToken); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ZHttpClient.getBaseUrl() + "/auth/logout")) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) + .build(); + + HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); } catch (Exception ignored) {} } session = null; + userInfo = null; try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} } @@ -95,12 +136,59 @@ public class AuthManager { return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000"; } + public static int getRole() { + return session != null ? session.role : ROLE_USER; + } + + public static String getRoleName() { + return session != null ? session.roleName : "Игрок"; + } + + public static boolean hasPass() { + return getRole() >= ROLE_PASS_HOLDER; + } + + public static boolean isModerator() { + return getRole() >= ROLE_MODERATOR; + } + + public static boolean isElder() { + return getRole() >= ROLE_ELDER; + } + + public static boolean isCreator() { + return getRole() == ROLE_CREATOR; + } + public static String getAccessToken() { - if (session == null) return "0"; + if (session == null) return null; if (isAccessTokenExpired()) { tryRefresh(); } - return session != null && session.accessToken != null ? session.accessToken : "0"; + return session != null ? session.accessToken : null; + } + + private static UserInfo fetchUserInfo() { + if (!isLoggedIn()) return null; + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ZHttpClient.getBaseUrl() + "/admin/me")) + .timeout(Duration.ofSeconds(10)) + .header("Authorization", "Bearer " + session.accessToken) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return GSON.fromJson(response.body(), UserInfo.class); + } + } catch (Exception e) { + System.err.println("Не удалось получить информацию о пользователе: " + e.getMessage()); + } + return null; } private static boolean isAccessTokenExpired() { @@ -110,19 +198,34 @@ public class AuthManager { private static boolean tryRefresh() { if (session == null || session.refreshToken == null) return false; + try { - String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; - SimpleHttpResponse resp = post("/auth/refresh", body); + JsonObject body = new JsonObject(); + body.addProperty("refresh_token", session.refreshToken); - if (resp.statusCode() == 200) { - AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); - newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; - session = newSession; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ZHttpClient.getBaseUrl() + "/auth/refresh")) + .timeout(Duration.ofSeconds(15)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body))) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String newAccessToken = json.get("access_token").getAsString(); + int expiresIn = json.get("expires_in").getAsInt(); + + session.accessToken = newAccessToken; + session.expiresAt = System.currentTimeMillis() / 1000L + expiresIn; saveSession(); return true; } } catch (Exception ignored) {} + session = null; + userInfo = null; try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} return false; } @@ -136,50 +239,6 @@ public class AuthManager { } } - private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { - String fullUrl = ZHttpClient.getBaseUrl() + endpoint; - - java.net.HttpURLConnection conn = null; - try { - java.net.URL url = java.net.URI.create(fullUrl).toURL(); - conn = (java.net.HttpURLConnection) url.openConnection(); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); - conn.setRequestProperty("Accept", "application/json"); - conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); - - // Добавляем токен авторизации, если есть сессия - if (session != null && session.accessToken != null) { - conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); - } - - conn.setDoOutput(true); - conn.setConnectTimeout(15000); - conn.setReadTimeout(15000); - - try (java.io.OutputStream os = conn.getOutputStream()) { - byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); - } - - int statusCode = conn.getResponseCode(); - - java.io.InputStream is = (statusCode >= 200 && statusCode < 300) - ? conn.getInputStream() - : conn.getErrorStream(); - - String responseBody; - try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { - responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; - } - - return new SimpleHttpResponse(statusCode, responseBody); - - } finally { - if (conn != null) conn.disconnect(); - } - } - private static String extractError(String body) { try { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); @@ -199,38 +258,6 @@ public class AuthManager { return body.length() > 200 ? body.substring(0, 200) + "..." : body; } - 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() + "\"}"; - SimpleHttpResponse resp = post("/auth/pass/activate", json); - - System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode())); - - if (resp.statusCode() == 200) { - return "Проходка успешно активирована!"; - } else if (resp.statusCode() == 401) { - return "Ошибка: Требуется авторизация. Перезайдите в аккаунт."; - } else { - String error = extractError(resp.body()); - return "Ошибка: " + error; - } - } catch (Exception e) { - e.printStackTrace(); - return "Ошибка соединения: " + e.getMessage(); - } - } - // ====================== ВНУТРЕННИЕ КЛАССЫ ====================== public static class AuthSession { @@ -240,12 +267,20 @@ public class AuthManager { public transient long expiresAt; public String username; public String uuid; + public int role; + @SerializedName("role_name") public String roleName; } - private static class LoginRequest { - final String username; - final String password; - LoginRequest(String u, String p) { this.username = u; this.password = p; } + public static class UserInfo { + public int id; + public String username; + public String uuid; + public int role; + public String role_name; + public long created_at; + public Long last_login; + public boolean has_pass; + public List permissions; } public static class AuthResult { @@ -255,18 +290,4 @@ public class AuthManager { public static AuthResult ok() { return new AuthResult(true, null); } public static AuthResult fail(String msg) { return new AuthResult(false, msg); } } -} - -// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ====================== -class SimpleHttpResponse { - final int statusCode; - final String body; - - SimpleHttpResponse(int statusCode, String body) { - this.statusCode = statusCode; - this.body = body; - } - - int statusCode() { return statusCode; } - String body() { return body; } } \ 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 8c22af4..3c03dad 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 @@ -81,30 +81,15 @@ public class LaunchMenu { } private void installServerPack() throws Exception { - if (!AuthManager.hasActivePass()) { + // Проверяем наличие проходки + if (!AuthManager.hasPass()) { 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; - } + System.out.println(ZAnsi.white("Обратитесь к администратору для получения проходки.")); + ConsoleUtils.pause(); + return; } ConsoleUtils.clearScreen(); @@ -529,7 +514,7 @@ public class LaunchMenu { } private void launchExistingInstance(Instance instance) { - if (instance.isServerPack() && !AuthManager.hasActivePass()) { + if (instance.isServerPack() && !AuthManager.hasPass()) { ConsoleUtils.clearScreen(); System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!")); ConsoleUtils.pause(); diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java index 85d42b5..4118207 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -6,6 +6,8 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; + +import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZHttpClient; @@ -37,70 +39,83 @@ public class PackDownloader { * Получить список доступных паков с сервера */ public List getAvailablePacks() throws Exception { - String response = ZHttpClient.get("/packs"); - - // Для отладки - выведем ответ сервера - System.out.println(ZAnsi.cyan("Ответ сервера: " + response)); - - JsonObject root = JsonParser.parseString(response).getAsJsonObject(); - - // Проверяем, есть ли поле "packs" - if (!root.has("packs")) { - System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа")); - return new ArrayList<>(); + String accessToken = AuthManager.getAccessToken(); + if (accessToken == null) { + throw new IOException("Не авторизован. Требуется проходка для просмотра сборок."); } - + + // Используем HttpURLConnection для GET с авторизацией + java.net.HttpURLConnection connection = null; + try { + java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs"); + connection = (java.net.HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + + int responseCode = connection.getResponseCode(); + + if (responseCode == 403) { + throw new IOException("Для просмотра сборок требуется активная проходка"); + } + + StringBuilder response = new StringBuilder(); + try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); + java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + + if (responseCode != 200) { + throw new IOException("HTTP " + responseCode); + } + + return parsePacksResponse(response.toString()); + + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private List parsePacksResponse(String responseBody) { + JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject(); JsonArray packsArray = root.getAsJsonArray("packs"); List result = new ArrayList<>(); for (JsonElement elem : packsArray) { JsonObject pack = elem.getAsJsonObject(); - // Пропускаем паки с ошибками - if (pack.has("error")) { - System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString())); - continue; - } - - // Пропускаем паки со статусом not_scanned - if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) { - System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере")); + if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) { continue; } try { - // Пробуем получить name или pack_name (разные форматы) - String name = null; - if (pack.has("name")) { - name = pack.get("name").getAsString(); - } else if (pack.has("pack_name")) { - name = pack.get("pack_name").getAsString(); - } else { - continue; // Пропускаем если нет имени - } - + String name = pack.get("name").getAsString(); int version = pack.has("version") ? pack.get("version").getAsInt() : 0; - - // Получаем остальные поля (могут отсутствовать) String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown"; String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla"; - String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() ? pack.get("loader_version").getAsString() : ""; + String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() + ? pack.get("loader_version").getAsString() : ""; int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0; - // Парсим дату, если есть LocalDateTime updatedAt = null; if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) { try { - updatedAt = parseDateTime(pack.get("updated_at").getAsString()); - } catch (Exception e) { - // Игнорируем ошибки парсинга даты - } + updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(), + DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception ignored) {} } - result.add(new ServerPack(name, version, minecraftVersion, - loaderType, loaderVersion, updatedAt, filesCount)); + result.add(new ServerPack(name, version, minecraftVersion, loaderType, + loaderVersion, updatedAt, filesCount)); } catch (Exception e) { - System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage())); + System.err.println("Ошибка парсинга пака: " + e.getMessage()); } } @@ -292,22 +307,16 @@ public class PackDownloader { */ private DiffResponse getDiff(String packName, Map localFiles) throws Exception { String json = gson.toJson(localFiles); - - System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName)); - System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт")); - System.out.println(ZAnsi.cyan("JSON тело: " + json)); - - String baseUrl = ZHttpClient.getBaseUrl(); - if (baseUrl.endsWith("/")) { - baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + + // Получаем токен авторизации + String accessToken = AuthManager.getAccessToken(); + if (accessToken == null) { + throw new IOException("Не авторизован. Требуется проходка для скачивания сборок."); } - String url = baseUrl + "/pack/" + packName + "/diff"; - - System.out.println(ZAnsi.cyan("URL: " + url)); - - // ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding - // РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient - + + String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; + + // Используем HttpURLConnection для полного контроля java.net.HttpURLConnection connection = null; try { java.net.URL urlObj = new java.net.URL(url); @@ -315,21 +324,21 @@ public class PackDownloader { connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length)); connection.setDoOutput(true); connection.setConnectTimeout(30000); connection.setReadTimeout(30000); - + // Отправляем JSON try (java.io.OutputStream os = connection.getOutputStream()) { byte[] input = json.getBytes("UTF-8"); os.write(input, 0, input.length); os.flush(); } - + int responseCode = connection.getResponseCode(); - System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode)); - + // Читаем ответ StringBuilder response = new StringBuilder(); try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); @@ -339,16 +348,19 @@ public class PackDownloader { response.append(line); } } - + String responseBody = response.toString(); - System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody)); - - if (responseCode != 200) { - throw new IOException("HTTP " + responseCode + ": " + responseBody); + + if (responseCode == 403) { + throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору."); } - + + if (responseCode != 200) { + throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody)); + } + return gson.fromJson(responseBody, DiffResponse.class); - + } finally { if (connection != null) { connection.disconnect(); @@ -356,6 +368,16 @@ public class PackDownloader { } } + private String extractErrorFromResponse(String body) { + try { + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + if (json.has("detail")) { + return json.get("detail").getAsString(); + } + } catch (Exception ignored) {} + return body.length() > 200 ? body.substring(0, 200) + "..." : body; + } + /** * Применить diff (скачать новые файлы, удалить старые) */ diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java index 86e7b9c..6575ca2 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZAnsi.java @@ -27,6 +27,10 @@ public class ZAnsi { return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString(); } + public static String brightCyan(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString(); + } + public static String yellow(String text) { return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString(); } @@ -47,6 +51,27 @@ public class ZAnsi { return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString(); } + public static String brightBlue(String text) { + return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString(); + } + + public static String magenta(String text) { + return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString(); + } + + public static String brightMagenta(String text) { + return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString(); + } + + // Пурпурный как brightPurple (используем magenta) + public static String purple(String text) { + return brightMagenta(text); + } + + public static String brightPurple(String text) { + return brightMagenta(text); + } + public static String white(String text) { return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString(); } @@ -55,7 +80,28 @@ public class ZAnsi { return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString(); } - // Стили + public static String black(String text) { + return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString(); + } + + // === Фоновые цвета === + public static String bgGreen(String text) { + return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString(); + } + + public static String bgRed(String text) { + return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString(); + } + + public static String bgYellow(String text) { + return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString(); + } + + public static String bgBlue(String text) { + return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString(); + } + + // === Стили === public static String bold(String text) { return Ansi.ansi().bold().a(text).reset().toString(); } @@ -64,17 +110,73 @@ public class ZAnsi { return Ansi.ansi().reset().toString(); } - // Комбинированные удобные методы + // === Комбинированные удобные методы === public static String header(String text) { return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString(); } + public static String success(String text) { + return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString(); + } + + public static String error(String text) { + return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString(); + } + + public static String warning(String text) { + return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString(); + } + + public static String info(String text) { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString(); + } + public static String selected(String text) { return Ansi.ansi() .bgBright(Ansi.Color.WHITE) - .fgBlack() + .fg(Ansi.Color.BLACK) + .bold() .a(" > " + text + " ") .reset() .toString(); } + + public static String dim(String text) { + return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString(); + } + + // === Цветной текст для ролей === + public static String roleUser(String text) { + return white(text); + } + + public static String rolePassHolder(String text) { + return brightGreen(text); + } + + public static String roleModerator(String text) { + return brightBlue(text); + } + + public static String roleElder(String text) { + return brightPurple(text); + } + + public static String roleCreator(String text) { + return brightRed(text); + } + + // === Очистка экрана === + public static String clearScreen() { + return Ansi.ansi().eraseScreen().cursor(1, 1).toString(); + } + + // === Прогресс бар символы === + public static String progressChar() { + return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString(); + } + + public static String progressEmpty() { + return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString(); + } } \ No newline at end of file diff --git a/server/admin_router.py b/server/admin_router.py new file mode 100644 index 0000000..97eab17 --- /dev/null +++ b/server/admin_router.py @@ -0,0 +1,590 @@ +# admin_router.py +from fastapi import APIRouter, HTTPException, Depends, Request, status +from pydantic import BaseModel, Field +from typing import Optional, List +import structlog +import time +import secrets +from datetime import datetime + +from auth import get_db, require_role, log_audit, get_current_user +from roles import ( + ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions, + ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR +) + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix="/admin", tags=["admin"]) + +# ====================== МОДЕЛИ ====================== +class UpdateRoleRequest(BaseModel): + user_id: int + role: int = Field(..., ge=0, le=4) + +class PassRequest(BaseModel): + username: str + reason: Optional[str] = None + +class PassDecision(BaseModel): + request_id: int + approved: bool + reason: Optional[str] = None + +class CreatePassDirectRequest(BaseModel): + username: str + expires_days: Optional[int] = Field(None, ge=1, le=365) + max_uses: int = Field(1, ge=1, le=10) + +class BanUserRequest(BaseModel): + user_id: int + days: int = Field(..., ge=1, le=365) + reason: str + +# ====================== ЭНДПОИНТЫ ====================== + +@router.get("/users") +async def list_users( + current_user: dict = Depends(require_role(ROLE_MODERATOR)), + search: Optional[str] = None +): + """Список пользователей (модераторы видят всех, но без sensitive данных)""" + with get_db() as conn: + query = "SELECT id, username, uuid, role, created_at, last_login, is_active" + params = [] + + if current_user["role"] < ROLE_ELDER: + # Модераторы не видят забаненных + query += " FROM users WHERE is_active = 1" + else: + query += " FROM users" + + if search: + query += " AND (username LIKE ? OR email LIKE ?)" + params.extend([f"%{search}%", f"%{search}%"]) + + query += " ORDER BY role DESC, username" + + rows = conn.execute(query, params).fetchall() + + users = [] + for row in rows: + user_data = { + "id": row["id"], + "username": row["username"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), + "created_at": row["created_at"], + "last_login": row["last_login"], + } + + # Elder и Creator видят больше информации + if current_user["role"] >= ROLE_ELDER: + user_data["is_active"] = row["is_active"] + # Получаем информацию о проходке + pass_info = conn.execute(""" + SELECT code, expires_at, activated_at + FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + LIMIT 1 + """, (row["id"], time.time())).fetchone() + + if pass_info: + user_data["has_pass"] = True + user_data["pass_expires"] = pass_info["expires_at"] + + users.append(user_data) + + return {"users": users, "total": len(users)} + + +@router.get("/users/{user_id}") +async def get_user_detail( + user_id: int, + current_user: dict = Depends(require_role(ROLE_MODERATOR)) +): + """Детальная информация о пользователе""" + with get_db() as conn: + row = conn.execute(""" + SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until + FROM users WHERE id = ? + """, (user_id,)).fetchone() + + if not row: + raise HTTPException(404, "Пользователь не найден") + + # Модераторы не видят email обычных пользователей + if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR: + email = None + else: + email = row["email"] + + # Получаем активную проходку + pass_info = None + if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER: + pass_row = 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 = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + LIMIT 1 + """, (user_id, time.time())).fetchone() + + if pass_row: + pass_info = { + "code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"], + "expires_at": pass_row["expires_at"], + "activated_at": pass_row["activated_at"] + } + + # Логи действий (только для Elder+) + actions = [] + if current_user["role"] >= ROLE_ELDER: + action_rows = conn.execute(""" + SELECT action, details, timestamp FROM audit_log + WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20 + """, (user_id,)).fetchall() + actions = [dict(row) for row in action_rows] + + return { + "id": row["id"], + "username": row["username"], + "email": email, + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), + "created_at": row["created_at"], + "last_login": row["last_login"], + "is_active": row["is_active"], + "banned_until": row["banned_until"], + "has_pass": pass_info is not None, + "pass_info": pass_info, + "recent_actions": actions if current_user["role"] >= ROLE_ELDER else None + } + + +@router.put("/users/{user_id}/role") +async def update_user_role( + user_id: int, + body: UpdateRoleRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Изменение роли пользователя""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + (user_id,) + ).fetchone() + + if not target: + raise HTTPException(404, "Пользователь не найден") + + # Проверки прав + if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Нельзя изменить роль создателя") + + if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем") + + if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})") + + # Elder не может создавать других Elder (только Creator) + if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Только создатель может назначать Elder Moderator") + + # Проверяем, нужно ли выдать/отозвать проходку + old_role = target["role"] + new_role = body.role + + conn.execute( + "UPDATE users SET role = ? WHERE id = ?", + (new_role, user_id) + ) + + # Управление проходками при изменении роли + now = time.time() + + if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER: + # Выдаем проходку если её нет + existing = conn.execute(""" + SELECT 1 FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (user_id, now)).fetchone() + + if not existing: + # Создаем автоматическую проходку + pass_code = f"AUTO_{secrets.token_hex(8).upper()}" + conn.execute(""" + INSERT INTO passes (code, owner, expires_at, max_uses, is_active) + VALUES (?, ?, NULL, 1, 1) + """, (pass_code, target["username"])) + + conn.execute(""" + INSERT INTO user_passes (user_id, pass_code, activated_at) + VALUES (?, ?, ?) + """, (user_id, pass_code, now)) + + logger.info("Auto-pass issued", user=target["username"], role=new_role) + + elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER: + # Отзываем проходку + conn.execute(""" + UPDATE passes SET is_active = 0 + WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?) + """, (user_id,)) + + logger.info("Auto-pass revoked", user=target["username"]) + + conn.commit() + + log_audit( + current_user["id"], + "role_change", + f"Changed role of {target['username']} from {old_role} to {new_role}", + ip + ) + + logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role) + + return { + "success": True, + "user_id": user_id, + "username": target["username"], + "old_role": old_role, + "old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"), + "new_role": new_role, + "new_role_name": ROLE_NAMES.get(new_role, "Неизвестно") + } + + +@router.post("/pass/grant") +async def grant_pass( + body: CreatePassDirectRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Выдача проходки пользователю (Elder+ могут выдавать)""" + ip = request.client.host if request.client else "unknown" + + # Проверяем право на прямую выдачу + if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS): + raise HTTPException(403, "Недостаточно прав для выдачи проходки") + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE", + (body.username,) + ).fetchone() + + if not target: + raise HTTPException(404, f"Пользователь {body.username} не найден") + + # Проверяем, есть ли уже активная проходка + existing = conn.execute(""" + SELECT p.code FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (target["id"], time.time())).fetchone() + + if existing: + raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка") + + # Создаем проходку + pass_code = secrets.token_hex(12).upper() + now = time.time() + expires_at = now + (body.expires_days * 86400) if body.expires_days else None + + conn.execute(""" + INSERT INTO passes (code, owner, expires_at, max_uses, is_active) + VALUES (?, ?, ?, ?, 1) + """, (pass_code, target["username"], expires_at, body.max_uses)) + + conn.execute(""" + INSERT INTO user_passes (user_id, pass_code, activated_at) + VALUES (?, ?, ?) + """, (target["id"], pass_code, now)) + + # Обновляем роль если нужно + if target["role"] < ROLE_PASS_HOLDER: + conn.execute( + "UPDATE users SET role = ? WHERE id = ?", + (ROLE_PASS_HOLDER, target["id"]) + ) + + conn.commit() + + log_audit( + current_user["id"], + "grant_pass", + f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})", + ip + ) + + logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code) + + return { + "success": True, + "pass_code": pass_code, + "username": target["username"], + "expires_at": expires_at, + "expires_days": body.expires_days, + "max_uses": body.max_uses + } + + +@router.delete("/pass/revoke/{username}") +async def revoke_pass( + username: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Отзыв проходки у пользователя""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE", + (username,) + ).fetchone() + + if not target: + raise HTTPException(404, f"Пользователь {username} не найден") + + # Отзываем проходку + conn.execute(""" + UPDATE passes SET is_active = 0 + WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?) + """, (target["id"],)) + + # Понижаем роль если она была только из-за проходки + if target["role"] == ROLE_PASS_HOLDER: + conn.execute( + "UPDATE users SET role = ? WHERE id = ?", + (ROLE_USER, target["id"]) + ) + + conn.commit() + + log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip) + logger.info("Pass revoked", admin=current_user["username"], target=username) + + return {"success": True, "message": f"Проходка {username} отозвана"} + + +@router.post("/user/ban") +async def ban_user( + body: BanUserRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Бан пользователя (Elder+ могут банить)""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + (body.user_id,) + ).fetchone() + + if not target: + raise HTTPException(404, "Пользователь не найден") + + # Нельзя забанить создателя + if target["role"] == ROLE_CREATOR: + raise HTTPException(403, "Нельзя забанить создателя") + + # Elder не может банить других Elder + if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Недостаточно прав для бана этого пользователя") + + banned_until = time.time() + (body.days * 86400) + + conn.execute( + "UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?", + (banned_until, target["id"]) + ) + + # Отзываем проходку при бане + conn.execute(""" + UPDATE passes SET is_active = 0 + WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?) + """, (target["id"],)) + + conn.commit() + + log_audit( + current_user["id"], + "ban_user", + f"Banned {target['username']} for {body.days} days. Reason: {body.reason}", + ip + ) + + logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days) + + return { + "success": True, + "username": target["username"], + "banned_until": banned_until, + "days": body.days + } + + +@router.post("/user/unban/{user_id}") +async def unban_user( + user_id: int, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None +): + """Разбан пользователя""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + target = conn.execute( + "SELECT id, username FROM users WHERE id = ?", + (user_id,) + ).fetchone() + + if not target: + raise HTTPException(404, "Пользователь не найден") + + conn.execute( + "UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?", + (user_id,) + ) + + conn.commit() + + log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip) + logger.info("User unbanned", admin=current_user["username"], target=target["username"]) + + return {"success": True, "username": target["username"]} + + +@router.get("/audit") +async def get_audit_log( + current_user: dict = Depends(require_role(ROLE_ELDER)), + limit: int = 50, + offset: int = 0, + user_id: Optional[int] = None +): + """Просмотр логов аудита (только Elder+)""" + with get_db() as conn: + query = """ + SELECT al.*, u.username + FROM audit_log al + LEFT JOIN users u ON al.user_id = u.id + """ + params = [] + + if user_id: + query += " WHERE al.user_id = ?" + params.append(user_id) + + query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + + total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"] + + return { + "logs": [dict(row) for row in rows], + "total": total, + "limit": limit, + "offset": offset + } + + +@router.get("/stats") +async def get_admin_stats( + current_user: dict = Depends(require_role(ROLE_MODERATOR)) +): + """Статистика для админов""" + with get_db() as conn: + # Общая статистика + total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"] + + # Статистика по ролям + role_stats = conn.execute(""" + SELECT role, COUNT(*) as count + FROM users + GROUP BY role + ORDER BY role DESC + """).fetchall() + + # Активные проходки + active_passes = conn.execute(""" + SELECT COUNT(*) as count FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE p.expires_at IS NULL OR p.expires_at > ? + """, (time.time(),)).fetchone()["count"] + + # Забаненные пользователи + banned_users = conn.execute(""" + SELECT COUNT(*) as count FROM users + WHERE is_active = 0 AND banned_until > ? + """, (time.time(),)).fetchone()["count"] + + # Недавние регистрации (последние 7 дней) + week_ago = time.time() - (7 * 86400) + recent_registrations = conn.execute(""" + SELECT COUNT(*) as count FROM users WHERE created_at > ? + """, (week_ago,)).fetchone()["count"] + + return { + "total_users": total_users, + "active_passes": active_passes, + "banned_users": banned_users, + "recent_registrations_7d": recent_registrations, + "roles_distribution": [ + {"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]} + for r in role_stats + ], + "my_info": { + "role": current_user["role"], + "role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"), + "username": current_user["username"] + } + } + + +@router.get("/me") +async def get_my_info(current_user: dict = Depends(get_current_user)): + """Информация о текущем пользователе с правами""" + with get_db() as conn: + row = conn.execute(""" + SELECT id, username, email, uuid, role, created_at, last_login + FROM users WHERE id = ? + """, (current_user["id"],)).fetchone() + + # Проверяем наличие активной проходки + has_pass = False + if row["role"] >= ROLE_PASS_HOLDER: + pass_row = conn.execute(""" + SELECT 1 FROM user_passes up + JOIN passes p ON up.pass_code = p.code + WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?) + """, (current_user["id"], time.time())).fetchone() + has_pass = pass_row is not None + + permissions = list(ROLE_PERMISSIONS.get(row["role"], set())) + + return { + "id": row["id"], + "username": row["username"], + "email": row["email"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), + "created_at": row["created_at"], + "last_login": row["last_login"], + "has_pass": has_pass, + "permissions": permissions + } \ No newline at end of file diff --git a/server/auth.py b/server/auth.py index eef9827..69522db 100644 --- a/server/auth.py +++ b/server/auth.py @@ -8,25 +8,33 @@ import time from datetime import datetime from pathlib import Path from typing import Optional +from contextlib import contextmanager import structlog -from fastapi import APIRouter, HTTPException, Request, Depends +from fastapi import APIRouter, HTTPException, Request, Depends, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +import re logger = structlog.get_logger(__name__) # ====================== КОНФИГ ====================== AUTH_DB = Path("data/auth.db") AUTH_DB.parent.mkdir(exist_ok=True) - SECRET_KEY = Path("data/.secret_key") +RATE_LIMIT_DB = Path("data/rate_limit.db") -ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа -REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней +# Токены +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа +REFRESH_TOKEN_EXPIRE_DAYS = 30 + +# Лимиты +MAX_LOGIN_ATTEMPTS = 5 +LOGIN_BLOCK_MINUTES = 15 # ====================== СЕКРЕТНЫЙ КЛЮЧ ====================== def _get_secret() -> bytes: + """Безопасное получение/создание секретного ключа""" if SECRET_KEY.exists(): return SECRET_KEY.read_bytes() key = secrets.token_bytes(64) @@ -36,31 +44,46 @@ def _get_secret() -> bytes: _SECRET = _get_secret() -def create_jwt(payload: dict) -> str: +# ====================== JWT ФУНКЦИИ ====================== +def create_jwt(payload: dict, expires_in: int = None) -> str: + """Создание JWT токена""" + if expires_in is None: + expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60 + + payload = payload.copy() + payload["exp"] = time.time() + expires_in + payload["iat"] = time.time() + payload["jti"] = secrets.token_hex(16) + 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]: + """Верификация JWT токена""" 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() - # Исправлено: правильный паддинг для base64url + header, body, sig = parts + sig_padded = sig + '=' * (4 - len(sig) % 4) + expected_sig = base64.urlsafe_b64decode(sig_padded) + + msg = f"{header}.{body}".encode() if not hmac.compare_digest( - base64.urlsafe_b64decode(sig_padded), - expected + hmac.new(_SECRET, msg, hashlib.sha256).digest(), + expected_sig ): return None @@ -69,75 +92,154 @@ def verify_jwt(token: str) -> Optional[dict]: if payload.get("exp", 0) < time.time(): return None + return payload except Exception: return None # ====================== БАЗА ДАННЫХ ====================== +@contextmanager def get_db(): - conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False) + """Контекстный менеджер для БД""" + conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False, timeout=10) conn.row_factory = sqlite3.Row - return conn + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() -def init_db(): - conn = get_db() +def init_rate_limit_db(): + """Инициализация БД для rate limiting""" + conn = sqlite3.connect(str(RATE_LIMIT_DB)) conn.executescript(""" - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - 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, - owner TEXT, - is_active BOOLEAN DEFAULT 1, - activated_by INTEGER REFERENCES users(id), - activated_at REAL, - expires_at REAL, - max_uses INTEGER DEFAULT 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) - ); - - CREATE TABLE IF NOT EXISTS refresh_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL, - expires_at REAL NOT NULL + CREATE TABLE IF NOT EXISTS login_attempts ( + ip TEXT PRIMARY KEY, + attempts INTEGER DEFAULT 1, + first_attempt REAL NOT NULL, + last_attempt REAL NOT NULL, + blocked_until REAL ); """) conn.commit() conn.close() + +def init_db(): + """Инициализация основной БД""" + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + uuid TEXT UNIQUE NOT NULL, + role INTEGER DEFAULT 0, + created_at REAL NOT NULL, + last_login REAL, + is_active BOOLEAN DEFAULT 1, + banned_until REAL + ); + + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at REAL NOT NULL, + revoked BOOLEAN DEFAULT 0, + created_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + created_at REAL NOT NULL, + expires_at REAL NOT NULL, + is_active BOOLEAN DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS passes ( + code TEXT PRIMARY KEY, + owner TEXT, + is_active BOOLEAN DEFAULT 1, + activated_by INTEGER REFERENCES users(id), + activated_at REAL, + expires_at REAL, + max_uses INTEGER DEFAULT 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) + ); + + CREATE TABLE IF NOT EXISTS pass_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL REFERENCES users(id), + target_username TEXT NOT NULL, + reason TEXT, + status TEXT DEFAULT 'pending', + decision_reason TEXT, + created_at REAL NOT NULL, + reviewed_by INTEGER REFERENCES users(id), + reviewed_at REAL + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + timestamp REAL NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); + CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id); + """) + + # Добавляем колонку role если её нет + cursor = conn.execute("PRAGMA table_info(users)") + columns = [col[1] for col in cursor.fetchall()] + + if "role" not in columns: + conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0") + logger.info("Added role column to users table") + + init_rate_limit_db() logger.info("Auth database initialized") # ====================== ХЕЛПЕРЫ ====================== def hash_password(password: str) -> str: - salt = secrets.token_hex(16) + """Хэширование пароля""" + salt = secrets.token_hex(32) hash_obj = hashlib.pbkdf2_hmac( 'sha256', - password.encode(), - salt.encode(), + password.encode('utf-8'), + salt.encode('utf-8'), 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(), + password.encode('utf-8'), + salt.encode('utf-8'), 300000 ) return hmac.compare_digest(hash_obj.hex(), stored_hash) @@ -145,283 +247,374 @@ def verify_password(password: str, stored: str) -> bool: return False def generate_uuid() -> str: + """Генерация UUID""" return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}" +def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]: + """Проверка rate limiting""" + conn = sqlite3.connect(str(RATE_LIMIT_DB)) + now = time.time() + + try: + row = conn.execute( + "SELECT attempts, blocked_until FROM login_attempts WHERE ip = ?", + (ip,) + ).fetchone() + + if row: + blocked_until = row[1] + if blocked_until and blocked_until > now: + return False, int(blocked_until - now) + + if row[0] >= MAX_LOGIN_ATTEMPTS: + blocked_until = now + (LOGIN_BLOCK_MINUTES * 60) + conn.execute( + "UPDATE login_attempts SET blocked_until = ? WHERE ip = ?", + (blocked_until, ip) + ) + conn.commit() + return False, LOGIN_BLOCK_MINUTES * 60 + return True, None + finally: + conn.close() + +def record_login_attempt(ip: str, success: bool): + """Запись попытки входа""" + conn = sqlite3.connect(str(RATE_LIMIT_DB)) + now = time.time() + + try: + if success: + conn.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,)) + else: + conn.execute(""" + INSERT INTO login_attempts (ip, attempts, first_attempt, last_attempt) + VALUES (?, 1, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + attempts = attempts + 1, + last_attempt = ? + """, (ip, now, now, now)) + conn.commit() + finally: + conn.close() + +def log_audit(user_id: int, action: str, details: str, ip_address: str): + """Логирование действий""" + with get_db() as conn: + conn.execute( + "INSERT INTO audit_log (user_id, action, details, ip_address, timestamp) VALUES (?, ?, ?, ?, ?)", + (user_id, action, details, ip_address, time.time()) + ) + # ====================== МОДЕЛИ ====================== class LoginRequest(BaseModel): - username: str - password: str + username: str = Field(..., min_length=3, max_length=32) + password: str = Field(..., min_length=6, max_length=128) + + @field_validator('username') + def validate_username(cls, v): + if not re.match(r'^[a-zA-Z0-9_]+$', v): + raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') + return v.lower() class RegisterRequest(BaseModel): - username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$") + username: str = Field(..., min_length=3, max_length=32) password: str = Field(..., min_length=6, max_length=128) + + @field_validator('username') + def validate_username(cls, v): + if not re.match(r'^[a-zA-Z0-9_]+$', v): + raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания') + return v.lower() class TokenResponse(BaseModel): access_token: str refresh_token: str expires_in: int + token_type: str = "bearer" username: str uuid: str + role: int + role_name: str -# ====================== ROUTER ====================== +# ====================== DEPENDENCIES ====================== router = APIRouter(prefix="/auth", tags=["auth"]) bearer = HTTPBearer(auto_error=False) -def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse: - now = time.time() +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer), + request: Request = None +) -> dict: + """Получение текущего пользователя""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Не авторизован", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = verify_jwt(credentials.credentials) + if not payload or payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен" + ) + + with get_db() as conn: + user = conn.execute( + "SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?", + (payload["sub"],) + ).fetchone() + + if not user: + raise HTTPException(401, "Пользователь не найден") + + if not user["is_active"]: + raise HTTPException(403, "Аккаунт деактивирован") + + if user["banned_until"] and user["banned_until"] > time.time(): + raise HTTPException(403, "Аккаунт забанен") + + return { + "id": user["id"], + "username": user["username"], + "uuid": user["uuid"], + "role": user["role"] + } - 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() - - 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 - ) +def require_role(min_role: int): + """Декоратор для проверки роли""" + async def dependency(current_user: dict = Depends(get_current_user)): + if current_user["role"] < min_role: + from roles import ROLE_NAMES + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Требуется роль {ROLE_NAMES.get(min_role, 'неизвестная')}" + ) + return current_user + return dependency +# ====================== ЭНДПОИНТЫ ====================== @router.post("/register", response_model=TokenResponse) async def register(body: RegisterRequest, request: Request): - conn = get_db() - try: + """Регистрация нового пользователя""" + ip = request.client.host if request.client else "unknown" + + allowed, wait = check_rate_limit(ip) + if not allowed: + raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") + + with get_db() as conn: existing = conn.execute( - "SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", + "SELECT username FROM users WHERE username = ?", (body.username,) ).fetchone() if existing: - raise HTTPException(status_code=409, detail="Имя пользователя уже занято") - + raise HTTPException(409, "Пользователь с таким именем уже существует") + uuid = generate_uuid() pw_hash = hash_password(body.password) now = time.time() - + cursor = conn.execute( - "INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)", - (body.username, pw_hash, uuid, now) + """INSERT INTO users (username, password_hash, uuid, created_at, role) + VALUES (?, ?, ?, ?, ?)""", + (body.username, pw_hash, uuid, now, 0) # role 0 = обычный пользователь ) - conn.commit() user_id = cursor.lastrowid - tokens = _issue_tokens(conn, user_id, body.username, uuid) - logger.info("User registered", username=body.username, user_id=user_id) - return tokens - - except HTTPException: - raise - except Exception as e: - logger.error("Register error", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") - finally: - conn.close() + # Создаем сессию + session_token = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) + ) + + # Токены + access_token = create_jwt({ + "sub": user_id, + "username": body.username, + "uuid": uuid, + "role": 0, + "type": "access", + "jti": session_token + }) + + refresh_token = create_jwt({ + "sub": user_id, + "type": "refresh", + "jti": secrets.token_hex(16) + }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) + + refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + conn.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) + ) + + log_audit(user_id, "register", f"User registered from {ip}", ip) + logger.info("User registered", username=body.username, user_id=user_id, ip=ip) + + from roles import ROLE_NAMES + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + username=body.username, + uuid=uuid, + role=0, + role_name=ROLE_NAMES[0] + ) @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", + """Вход в систему""" + ip = request.client.host if request.client else "unknown" + + allowed, wait = check_rate_limit(ip) + if not allowed: + raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") + + with get_db() as conn: + user = conn.execute( + "SELECT id, username, uuid, password_hash, role, is_active, banned_until FROM users WHERE username = ?", (body.username,) ).fetchone() - - if not row or not verify_password(body.password, row["password_hash"]): + + if not user or not verify_password(body.password, user["password_hash"]): + record_login_attempt(ip, False) + log_audit(0, "login_failed", f"Failed login for {body.username} from {ip}", ip) raise HTTPException(401, "Неверное имя пользователя или пароль") - + + if not user["is_active"]: + raise HTTPException(403, "Аккаунт деактивирован") + + if user["banned_until"] and user["banned_until"] > time.time(): + raise HTTPException(403, "Аккаунт забанен") + + record_login_attempt(ip, True) + + now = time.time() conn.execute( "UPDATE users SET last_login = ? WHERE id = ?", - (time.time(), row["id"]) + (now, user["id"]) + ) + + # Создаем сессию + session_token = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) + ) + + access_token = create_jwt({ + "sub": user["id"], + "username": user["username"], + "uuid": user["uuid"], + "role": user["role"], + "type": "access", + "jti": session_token + }) + + refresh_token = create_jwt({ + "sub": user["id"], + "type": "refresh", + "jti": secrets.token_hex(16) + }, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400) + + refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + conn.execute( + "INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now) + ) + + log_audit(user["id"], "login", f"User logged in from {ip}", ip) + logger.info("User logged in", username=user["username"], user_id=user["id"], ip=ip) + + from roles import ROLE_NAMES + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + username=user["username"], + uuid=user["uuid"], + role=user["role"], + role_name=ROLE_NAMES.get(user["role"], "Неизвестно") ) - conn.commit() - logger.info("User logged in", username=body.username, user_id=row["id"]) - return _issue_tokens(conn, row["id"], row["username"], row["uuid"]) - finally: - conn.close() +@router.post("/logout") +async def logout(current_user: dict = Depends(get_current_user), request: Request = None): + """Выход из системы""" + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: + conn.execute( + "UPDATE user_sessions SET is_active = 0 WHERE user_id = ?", + (current_user["id"],) + ) + conn.execute( + "UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", + (current_user["id"],) + ) + + log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip) + + logger.info("User logged out", user_id=current_user["id"], ip=ip) + return {"success": True} @router.post("/refresh") -async def refresh(body: dict): +async def refresh(body: dict, request: Request): + """Обновление access токена""" 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: + + ip = request.client.host if request.client else "unknown" + + with get_db() as conn: token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - row = conn.execute( - "SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?", + token_row = conn.execute( + "SELECT user_id, revoked FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?", (token_hash, time.time()) ).fetchone() - - if not row: + + if not token_row or token_row["revoked"]: raise HTTPException(401, "Refresh token истёк или недействителен") - - user_row = conn.execute( - "SELECT id, username, uuid FROM users WHERE id = ?", - (row["user_id"],) + + user = conn.execute( + "SELECT id, username, uuid, role FROM users WHERE id = ? AND is_active = 1", + (token_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() - -@router.post("/logout") -async def logout(body: dict): - refresh_token = body.get("refresh_token") - if refresh_token: - conn = get_db() - try: - token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - conn.execute( - "DELETE FROM refresh_tokens WHERE token_hash = ?", - (token_hash,) - ) - conn.commit() - finally: - conn.close() - return {"success": True} - -# ====================== ПРОХОДКИ ====================== - -class ActivatePassRequest(BaseModel): - pass_code: str = Field(..., min_length=8, max_length=20) - -@router.post("/pass/activate") -async def activate_pass_endpoint( - body: ActivatePassRequest, - credentials: HTTPAuthorizationCredentials = Depends(bearer) -): - if not credentials: - raise HTTPException(401, "Требуется авторизация") - - payload = verify_jwt(credentials.credentials) - if not payload or payload.get("type") != "access": - raise HTTPException(401, "Недействительный токен") - - user_id = payload["sub"] - username = payload["username"] - pass_code = body.pass_code.upper().strip() - - conn = get_db() - try: - pass_row = conn.execute( - "SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?", - (pass_code,) - ).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, "Проходка уже использована") - - # Проверка владельца - if pass_row["owner"] is not None: - if pass_row["owner"] != username: - raise HTTPException(409, "Проходка уже активирована другим пользователем") - - # Уже активирована этим пользователем - return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"} - + + if not user: + raise HTTPException(401, "Пользователь не найден или заблокирован") + now = time.time() - - # Активация + session_token = secrets.token_urlsafe(32) conn.execute( - "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)", - (user_id, pass_code, now) + "INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", + (user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60)) ) - - conn.execute( - """UPDATE passes - SET uses = uses + 1, - owner = ?, - activated_by = ?, - activated_at = ? - WHERE code = ?""", - (username, user_id, now, pass_code) - ) - - conn.commit() - - logger.info("Pass activated", user_id=user_id, username=username, pass_code=pass_code) - return {"success": True, "message": "Проходка успешно активирована!"} - - except HTTPException: - raise - except Exception as e: - logger.error("Pass activation error", exc_info=True) - raise HTTPException(500, f"Ошибка сервера: {str(e)}") - finally: - conn.close() - -@router.get("/pass/my") -async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)): - if not credentials: - raise HTTPException(401, "Требуется авторизация") - - payload = verify_jwt(credentials.credentials) - if not payload: - raise HTTPException(401, "Недействительный токен") - - user_id = payload["sub"] - - conn = get_db() - try: - rows = conn.execute(""" - SELECT p.code, p.expires_at, p.is_active, 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 = row["is_active"] and (expires is None or expires > now) - passes.append({ - "code": row["code"], - "activated_at": row["activated_at"], - "expires_at": expires, - "is_active": is_active - }) - + + new_access_token = create_jwt({ + "sub": user["id"], + "username": user["username"], + "uuid": user["uuid"], + "role": user["role"], + "type": "access", + "jti": session_token + }) + + log_audit(user["id"], "refresh_token", f"Token refreshed from {ip}", ip) + return { - "passes": passes, - "has_active": any(p["is_active"] for p in passes) - } - finally: - conn.close() \ No newline at end of file + "access_token": new_access_token, + "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60, + "token_type": "bearer" + } \ No newline at end of file diff --git a/server/main.py b/server/main.py index 0e4b013..1c98253 100644 --- a/server/main.py +++ b/server/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Request, Response +from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, JSONResponse from contextlib import asynccontextmanager from pathlib import Path @@ -9,17 +9,23 @@ import logging from datetime import datetime from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol +from cachetools import TTLCache +from urllib.parse import urlparse + from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR from models import PackMeta from middleware import LoggingMiddleware from cli import parse_args, run_test_mode, run_production_mode, run_development_mode from log_manager import init_logging +import re + import httpx import base64 from fastapi.responses import StreamingResponse -from auth import router as auth_router, init_db, verify_jwt +from auth import get_current_user, router as auth_router, init_db, verify_jwt +from server.roles import Permissions, has_permission logger = structlog.get_logger(__name__) @@ -52,6 +58,8 @@ async def lifespan(app: FastAPI): PACKS_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True) + BLOCKED_HOSTS = [] + init_db() app.include_router(auth_router) @@ -147,9 +155,16 @@ async def health(): # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== @app.get("/packs") -async def list_packs(): - """List all available packs""" - logger = logging.getLogger(__name__) +async def list_packs(current_user: dict = Depends(get_current_user)): + """List all available packs - требует проходку для просмотра""" + + # Проверяем, есть ли право на просмотр сборок + if not has_permission(current_user["role"], Permissions.VIEW_PACKS): + raise HTTPException( + status_code=403, + detail="Для просмотра сборок требуется активная проходка" + ) + packs = [] for pack_dir in PACKS_DIR.iterdir(): @@ -159,7 +174,6 @@ async def list_packs(): try: with open(meta_path, 'r', encoding='utf-8') as f: meta = json.load(f) - # Исправлено: конвертируем updated_at в строку если это datetime updated_at = meta.get("updated_at") if updated_at and isinstance(updated_at, datetime): updated_at = updated_at.isoformat() @@ -189,11 +203,22 @@ async def list_packs(): @app.post("/pack/{pack_name}/diff") -async def get_pack_diff(pack_name: str, request: Request): - """ - Client sends: { "mods/jei.jar": "sha256_hash", ... } +async def get_pack_diff( + pack_name: str, + request: Request, + current_user: dict = Depends(get_current_user) # Добавляем зависимость +): + """Client sends: { "mods/jei.jar": "sha256_hash", ... } Server returns diff information - """ + ТРЕБУЕТ ПРОХОДКУ ДЛЯ СКАЧИВАНИЯ""" + + # Проверяем наличие проходки + if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK): + raise HTTPException( + status_code=403, + detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору." + ) + client_ip = request.client.host if request.client else "unknown" # Читаем тело запроса @@ -495,13 +520,8 @@ async def get_launcher_full_info(): proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) # Кэш для часто запрашиваемых данных (5 минут) -from cachetools import TTLCache proxy_cache = TTLCache(maxsize=50, ttl=300) -# Список заблокированных/проблемных хостов (можно обновлять) -BLOCKED_HOSTS = [] - - @app.get("/proxy/fabric/versions/loader") async def proxy_fabric_versions(request: Request): """Прокси для Fabric Meta API - список версий загрузчика""" @@ -548,7 +568,6 @@ async def proxy_fabric_installer_latest(request: Request): xml = response.text # Парсим последнюю версию из XML - import re match = re.search(r'([^<]+)', xml) if match: version = match.group(1) @@ -746,7 +765,6 @@ async def proxy_download(request: Request): ] # Проверяем, что URL ведет на разрешенный домен - from urllib.parse import urlparse parsed = urlparse(url) domain = parsed.netloc.lower() diff --git a/server/pass_manager.py b/server/pass_manager.py deleted file mode 100644 index 574fcb6..0000000 --- a/server/pass_manager.py +++ /dev/null @@ -1,77 +0,0 @@ -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 diff --git a/server/roles.py b/server/roles.py new file mode 100644 index 0000000..f71b834 --- /dev/null +++ b/server/roles.py @@ -0,0 +1,101 @@ +# roles.py +from enum import IntEnum +from typing import Dict, Set + +class UserRole(IntEnum): + USER = 0 # Обычный пользователь + PASS_HOLDER = 1 # Пользователь с проходкой + MODERATOR = 2 # Модератор + ELDER = 3 # Elder Moderator + CREATOR = 4 # Создатель + +ROLE_NAMES: Dict[int, str] = { + UserRole.USER: "Игрок", + UserRole.PASS_HOLDER: "Игрок [Проходка]", + UserRole.MODERATOR: "Модератор", + UserRole.ELDER: "Elder Moderator", + UserRole.CREATOR: "Создатель" +} + +# Права доступа +class Permissions: + # Базовые права + DOWNLOAD_PACK = "download_pack" # Скачивание сборок + VIEW_PACKS = "view_packs" # Просмотр списка сборок + + # Права модератора + REQUEST_PASS = "request_pass" # Запрос проходки для игрока + VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей + + # Права Elder Moderator + APPROVE_PASS = "approve_pass" # Одобрение проходок + REJECT_PASS = "reject_pass" # Отклонение проходок + VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок + MANAGE_MODERATORS = "manage_moderators" # Управление модераторами + + # Права создателя + DIRECT_PASS = "direct_pass" # Прямая выдача проходки + MANAGE_ELDER = "manage_elder" # Управление Elder + MANAGE_SERVER = "manage_server" # Управление сервером + VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов + +# Маппинг ролей на права +ROLE_PERMISSIONS: Dict[int, Set[str]] = { + UserRole.USER: { + # Обычный игрок НЕ может даже смотреть сборки! + # Только авторизоваться и смотреть свой профиль + }, + UserRole.PASS_HOLDER: { + Permissions.VIEW_PACKS, # Может видеть список сборок + Permissions.DOWNLOAD_PACK, # Может скачивать сборки + }, + UserRole.MODERATOR: { + Permissions.VIEW_PACKS, + Permissions.DOWNLOAD_PACK, + Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков + Permissions.VIEW_USER_LIST, # Может видеть список пользователей + }, + UserRole.ELDER: { + Permissions.VIEW_PACKS, + Permissions.DOWNLOAD_PACK, + Permissions.REQUEST_PASS, + Permissions.VIEW_USER_LIST, + Permissions.APPROVE_PASS, # Может одобрять проходки + Permissions.REJECT_PASS, # Может отклонять проходки + Permissions.VIEW_PASS_REQUESTS, + Permissions.MANAGE_MODERATORS, # Может управлять модераторами + }, + UserRole.CREATOR: { + Permissions.VIEW_PACKS, + Permissions.DOWNLOAD_PACK, + Permissions.REQUEST_PASS, + Permissions.VIEW_USER_LIST, + Permissions.APPROVE_PASS, + Permissions.REJECT_PASS, + Permissions.VIEW_PASS_REQUESTS, + Permissions.MANAGE_MODERATORS, + Permissions.DIRECT_PASS, # Прямая выдача проходки + Permissions.MANAGE_ELDER, # Управление Elder + Permissions.MANAGE_SERVER, # Управление сервером + Permissions.VIEW_AUDIT_LOG, # Просмотр логов + } +} + +def has_permission(role: int, permission: str) -> bool: + """Проверка наличия права у роли""" + return permission in ROLE_PERMISSIONS.get(role, set()) + +def require_permission(permission: str): + """Декоратор для проверки права""" + from functools import wraps + from fastapi import HTTPException, Depends + from auth import get_current_user + + def decorator(func): + @wraps(func) + async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs): + if not has_permission(current_user["role"], permission): + raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}") + return await func(*args, current_user=current_user, **kwargs) + return wrapper + return decorator \ No newline at end of file