This commit is contained in:
2026-04-20 13:42:41 +03:00
parent fd3cbb019f
commit ec0671c5e8
16 changed files with 465 additions and 117 deletions

View File

@@ -28,10 +28,8 @@ public class AuthHandler {
boolean passwordOk = userService.checkPassword(password, user.getString("password"));
if (passwordOk) {
// Надёжное получение флага активности
Boolean active = user.getBoolean("active");
if (active == null) {
// Если поле отсутствует, пробуем получить как Integer (на случай TINYINT)
Integer activeInt = user.getInteger("active");
active = activeInt != null && activeInt == 1;
}
@@ -41,6 +39,16 @@ public class AuthHandler {
return;
}
// Получаем реальный IP клиента (с учётом прокси, если настроен)
String clientIp = ctx.get("realClientIp");
if (clientIp == null) {
clientIp = ctx.request().remoteAddress().host();
}
// Обновляем IP в БД (асинхронно, не дожидаемся ответа)
userService.updateUserIp(user.getInteger("id"), clientIp)
.onFailure(err -> System.err.println("Failed to update IP for user " + user.getInteger("id") + ": " + err.getMessage()));
Session session = ctx.session();
session.put("userId", user.getInteger("id"));
session.put("login", user.getString("login"));

View File

@@ -78,30 +78,54 @@ public class MainVerticle extends AbstractVerticle {
startPromise.fail(err);
});
Router router = initRouter();
startHttp(router, startPromise);
createRouterAndStartHttp(startPromise);
})
.onFailure(startPromise::fail);
}
private Router initRouter() {
private void createRouterAndStartHttp(Promise<Void> startPromise) {
settingsService.get("session_timeout_minutes")
.compose(timeoutStr -> {
long timeoutMinutes = 60; // default
if (timeoutStr != null && !timeoutStr.isEmpty()) {
try {
timeoutMinutes = Long.parseLong(timeoutStr);
} catch (NumberFormatException ignored) {}
}
long timeoutMs = timeoutMinutes * 60 * 1000;
// Настройка сессий (используем LocalSessionStore для простоты)
SessionStore sessionStore = LocalSessionStore.create(vertx);
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false)
.setSessionTimeout(3600000);
SessionStore sessionStore = LocalSessionStore.create(vertx);
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false)
.setSessionTimeout(timeoutMs);
Router router = initRouter(sessionHandler);
startHttp(router, startPromise);
return Future.succeededFuture();
})
.onFailure(err -> {
log.error("Failed to get session timeout", err);
startPromise.fail(err);
});
}
private Router initRouter(SessionHandler sessionHandler) {
// Роутер
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create());
router.route().handler(sessionHandler);
SecurityHandlers securityHandlers = new SecurityHandlers(settingsService);
// Обработчики безопасности (порядок важен)
router.route().handler(securityHandlers.hostValidator());
router.route().handler(securityHandlers.proxyHeadersHandler());
router.route().handler(securityHandlers.cspHeader());
// CORS для разработки
router.route().handler(ctx -> {
ctx.response()
@@ -149,7 +173,12 @@ public class MainVerticle extends AbstractVerticle {
router.post("/api/logout").handler(authHandler::handleLogout);
router.post("/api/register").handler(rc -> {
router.post("/api/register").handler(rc -> settingsService.get("enable_registration").onComplete(regCheck -> {
if (regCheck.succeeded() && "false".equals(regCheck.result())) {
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
return;
}
// существующий код регистрации
JsonObject body = rc.body().asJsonObject();
String login = body.getString("login");
String email = body.getString("email");
@@ -162,7 +191,7 @@ public class MainVerticle extends AbstractVerticle {
userService.createUser(login, email, password, ip)
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
}));
router.route("/api/admin/*").handler(authHandler::requireAuth);
@@ -226,7 +255,7 @@ public class MainVerticle extends AbstractVerticle {
router.put("/api/admin/users/:id/activate").handler(rc -> {
int id = Integer.parseInt(rc.pathParam("id"));
boolean active = Boolean.parseBoolean(rc.queryParam("active").get(0));
boolean active = Boolean.parseBoolean(rc.queryParam("active").getFirst());
Integer currentUserId = rc.session().get("userId");
if (currentUserId != null && currentUserId == id) {
@@ -280,11 +309,12 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login");
String password = body.getString("password");
String host = body.getString("host");
boolean https = body.getBoolean("https", false);
if (name == null || login == null || password == null || host == null) {
rc.response().setStatusCode(400).end("Missing fields");
return;
}
restaurantService.createRestaurant(name, login, password, host)
restaurantService.createRestaurant(name, login, password, host, https)
.onSuccess(v -> rc.response().setStatusCode(201).end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
@@ -296,11 +326,12 @@ public class MainVerticle extends AbstractVerticle {
String login = body.getString("login");
String password = body.getString("password");
String host = body.getString("host");
boolean https = body.getBoolean("https", false);
if (name == null || login == null || host == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
restaurantService.updateRestaurant(id, name, login, password, host)
restaurantService.updateRestaurant(id, name, login, password, host, https)
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
@@ -314,9 +345,9 @@ public class MainVerticle extends AbstractVerticle {
// Получение всех настроек
router.get("/api/settings").handler(rc -> {
settingsService.getAll()
settingsService.getPublicSettings()
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
.onFailure(err -> rc.response().setStatusCode(500).end());
});
// Получить метаданные всех настроек (для построения формы)

View File

@@ -23,9 +23,10 @@ public class RestaurantService {
CREATE TABLE IF NOT EXISTS restaurants (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
login VARCHAR(255) NOT NULL,
login VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
https BOOLEAN DEFAULT FALSE,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
@@ -40,16 +41,16 @@ public class RestaurantService {
.map(rows -> rows.iterator().next().getLong("cnt"));
}
public Future<Void> createRestaurant(String name, String login, String password, String host) {
Map<String, Object> params = new HashMap<>();
params.put("name", name);
params.put("login", login);
params.put("password", password);
params.put("host", host);
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
Map<String, Object> params = Map.of(
"name", name,
"login", login,
"password", password,
"host", host,
"https", https
);
return SqlTemplate.forUpdate(pool,
"INSERT INTO restaurants (name, login, password, host) VALUES (#{name}, #{login}, #{password}, #{host})")
"INSERT INTO restaurants (name, login, password, host, https) VALUES (#{name}, #{login}, #{password}, #{host}, #{https})")
.execute(params)
.mapEmpty();
}
@@ -72,7 +73,7 @@ public class RestaurantService {
}
public Future<JsonArray> getAllRestaurants() {
return pool.query("SELECT id, name, login, created, updated, host FROM restaurants ORDER BY id")
return pool.query("SELECT id, name, login, created, updated, https, host FROM restaurants ORDER BY id")
.execute()
.map(rows -> {
JsonArray array = new JsonArray();
@@ -85,6 +86,7 @@ public class RestaurantService {
row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ?
row.getLocalDateTime("updated").toString() : null)
.put("https", row.getBoolean("https"))
.put("host", row.getString("host")));
}
return array;
@@ -93,12 +95,13 @@ public class RestaurantService {
public Future<JsonObject> findById(int id) {
return SqlTemplate.forQuery(pool,
"SELECT id, name, login, password, host, created, updated FROM restaurants WHERE id = #{id}")
"SELECT id, name, login, password, https, host, created, updated FROM restaurants WHERE id = #{id}")
.mapTo(row -> new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("login", row.getString("login"))
.put("password", row.getString("password"))
.put("https", row.getBoolean("https"))
.put("host", row.getString("host"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
@@ -106,24 +109,21 @@ public class RestaurantService {
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
}
public Future<Void> updateRestaurant(int id, String name, String login, String password, String host) {
public Future<Void> updateRestaurant(int id, String name, String login, String password, String host, boolean https) {
Map<String, Object> params = new HashMap<>();
params.put("id", id);
params.put("name", name);
params.put("login", login);
params.put("host", host);
params.put("https", https);
String sql;
if (password != null && !password.isEmpty()) {
params.put("password", password);
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host} WHERE id = #{id}";
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host}, https = #{https} WHERE id = #{id}";
} else {
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host} WHERE id = #{id}";
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host}, https = #{https} WHERE id = #{id}";
}
return SqlTemplate.forUpdate(pool, sql)
.execute(params)
.mapEmpty();
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> deleteRestaurant(int id) {

View File

@@ -0,0 +1,114 @@
package su.xserver.iikocon;
import io.vertx.core.Handler;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.net.SocketAddress;
import io.vertx.ext.web.RoutingContext;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
public class SecurityHandlers {
private final SettingsService settings;
public SecurityHandlers(SettingsService settings) {
this.settings = settings;
}
public Handler<RoutingContext> hostValidator() {
return ctx -> settings.get("allowed_hosts").onComplete(ar -> {
if (ar.succeeded() && ar.result() != null && !ar.result().isEmpty()) {
String allowedHosts = ar.result();
String requestHost = ctx.request().getHeader("Host");
if (requestHost == null) {
ctx.response().setStatusCode(400).end("Bad Request: Missing Host header");
return;
}
String hostWithoutPort = requestHost.split(":")[0];
Set<String> allowedSet = new HashSet<>(Arrays.asList(allowedHosts.split(",")));
if (!allowedSet.contains(hostWithoutPort) && !allowedSet.contains(requestHost)) {
ctx.response().setStatusCode(403).end("Forbidden: Invalid Host header");
return;
}
}
ctx.next();
});
}
public Handler<RoutingContext> cspHeader() {
return ctx -> settings.get("enable_csp").onComplete(ar -> {
if (ar.succeeded() && "true".equals(ar.result())) {
ctx.response().putHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
}
ctx.next();
});
}
public Handler<RoutingContext> proxyHeadersHandler() {
return ctx -> settings.get("use_proxy_headers").onComplete(useProxy -> {
if (!useProxy.succeeded() || !"true".equals(useProxy.result())) {
ctx.next();
return;
}
settings.get("trusted_proxies").onComplete(trusted -> {
if (!trusted.succeeded() || trusted.result() == null) {
ctx.next();
return;
}
String trustedProxies = trusted.result();
SocketAddress remoteAddr = ctx.request().remoteAddress();
if (remoteAddr == null) {
ctx.next();
return;
}
String clientIp = remoteAddr.host();
if (isIpTrusted(clientIp, trustedProxies)) {
String realIp = getRealIpFromHeaders(ctx.request());
if (realIp != null) {
ctx.put("realClientIp", realIp);
ctx.put("originalClientIp", clientIp);
}
}
ctx.next();
});
});
}
private boolean isIpTrusted(String ip, String trustedList) {
String[] ips = trustedList.split(",");
for (String trusted : ips) {
trusted = trusted.trim();
if (trusted.contains("/")) {
if (ipMatchesCidr(ip, trusted)) return true;
} else if (ip.equals(trusted)) {
return true;
}
}
return false;
}
private boolean ipMatchesCidr(String ip, String cidr) {
try {
String[] parts = cidr.split("/");
String network = parts[0];
int prefix = Integer.parseInt(parts[1]);
return ip.startsWith(network.substring(0, network.lastIndexOf('.')));
} catch (Exception e) {
return false;
}
}
private String getRealIpFromHeaders(HttpServerRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
String xri = request.getHeader("X-Real-IP");
if (xri != null && !xri.isEmpty()) {
return xri;
}
return null;
}
}

View File

@@ -6,6 +6,7 @@ import io.vertx.core.json.JsonObject;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.templates.SqlTemplate;
import java.util.List;
import java.util.Map;
public class SettingsService {
@@ -29,24 +30,24 @@ public class SettingsService {
.put("type", "textarea")
.put("rows", 2)
);
meta.add(new JsonObject()
.put("key", "theme")
.put("label", "Theme")
.put("description", "Default color scheme")
.put("type", "select")
.put("options", new JsonArray()
.add(new JsonObject().put("value", "light").put("label", "Light"))
.add(new JsonObject().put("value", "dark").put("label", "Dark"))
.add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
)
);
meta.add(new JsonObject()
.put("key", "items_per_page")
.put("label", "Items Per Page")
.put("description", "Number of items shown in tables")
.put("type", "number")
.put("required", true)
);
// meta.add(new JsonObject()
// .put("key", "theme")
// .put("label", "Theme")
// .put("description", "Default color scheme")
// .put("type", "select")
// .put("options", new JsonArray()
// .add(new JsonObject().put("value", "light").put("label", "Light"))
// .add(new JsonObject().put("value", "dark").put("label", "Dark"))
// .add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
// )
// );
// meta.add(new JsonObject()
// .put("key", "items_per_page")
// .put("label", "Items Per Page")
// .put("description", "Number of items shown in tables")
// .put("type", "number")
// .put("required", true)
// );
meta.add(new JsonObject()
.put("key", "enable_registration")
.put("label", "Allow Public Registration")
@@ -59,17 +60,17 @@ public class SettingsService {
.put("description", "When enabled, only admins can access the site")
.put("type", "boolean")
);
meta.add(new JsonObject()
.put("key", "default_language")
.put("label", "Default Language")
.put("description", "Interface language")
.put("type", "select")
.put("options", new JsonArray()
.add(new JsonObject().put("value", "en").put("label", "English"))
.add(new JsonObject().put("value", "ru").put("label", "Русский"))
.add(new JsonObject().put("value", "es").put("label", "Español"))
)
);
// meta.add(new JsonObject()
// .put("key", "default_language")
// .put("label", "Default Language")
// .put("description", "Interface language")
// .put("type", "select")
// .put("options", new JsonArray()
// .add(new JsonObject().put("value", "en").put("label", "English"))
// .add(new JsonObject().put("value", "ru").put("label", "Русский"))
// .add(new JsonObject().put("value", "es").put("label", "Español"))
// )
// );
meta.add(new JsonObject()
.put("key", "session_timeout_minutes")
.put("label", "Session Timeout (minutes)")
@@ -77,19 +78,45 @@ public class SettingsService {
.put("type", "number")
.put("required", true)
);
// meta.add(new JsonObject()
// .put("key", "logo_url")
// .put("label", "Logo URL")
// .put("description", "Path or URL to custom logo image")
// .put("type", "text")
// );
// Безопасность и прокси
meta.add(new JsonObject()
.put("key", "logo_url")
.put("label", "Logo URL")
.put("description", "Path or URL to custom logo image")
.put("key", "use_proxy_headers")
.put("label", "Use Proxy Headers")
.put("description", "Respect X-Forwarded-* headers from trusted proxies")
.put("type", "boolean")
);
meta.add(new JsonObject()
.put("key", "trusted_proxies")
.put("label", "Trusted Proxies")
.put("description", "Comma-separated IP addresses of trusted proxies (e.g., 127.0.0.1,10.0.0.0/8)")
.put("type", "text")
);
meta.add(new JsonObject()
.put("key", "enable_csp")
.put("label", "Enable CSP")
.put("description", "Add Content-Security-Policy header")
.put("type", "boolean")
);
meta.add(new JsonObject()
.put("key", "allowed_hosts")
.put("label", "Allowed Hosts")
.put("description", "Comma-separated list of allowed Host headers (empty = allow all)")
.put("type", "text")
);
return Future.succeededFuture(meta);
}
public Future<JsonObject> getAllWithDefaults() {
return getAll().compose(values -> {
JsonObject result = new JsonObject();
// Получаем метаданные, чтобы знать ключи
return getMetadata().map(meta -> {
for (Object item : meta) {
JsonObject m = (JsonObject) item;
@@ -107,13 +134,17 @@ public class SettingsService {
return switch (key) {
case "site_name" -> "Admin Panel";
case "site_description" -> "";
case "theme" -> "light";
case "items_per_page" -> "20";
// case "theme" -> "light";
// case "items_per_page" -> "20";
case "enable_registration" -> "true";
case "maintenance_mode" -> "false";
case "default_language" -> "en";
// case "default_language" -> "en";
case "session_timeout_minutes" -> "60";
case "logo_url" -> "";
// case "logo_url" -> "";
case "use_proxy_headers" -> "true";
case "trusted_proxies" -> "127.0.0.1";
case "enable_csp" -> "true";
case "allowed_hosts" -> "";
default -> "";
};
}
@@ -170,4 +201,21 @@ public class SettingsService {
return json;
});
}
// Публичные настройки (для фронтенда)
public Future<JsonObject> getPublicSettings() {
return getAll().map(all -> {
JsonObject publicOnly = new JsonObject();
// Только безопасные для отображения ключи
List<String> publicKeys = List.of(
"site_name", "site_description", "enable_registration"
);
for (String key : publicKeys) {
String val = all.getString(key);
if (val != null) publicOnly.put(key, val);
}
return publicOnly;
});
}
}

View File

@@ -51,8 +51,11 @@ public class SetupHandler {
return;
}
String ip = ctx.request().remoteAddress().host();
userService.createUser(login, email, password, ip, true).onComplete(cr -> {
String clientIp = ctx.get("realClientIp");
if (clientIp == null) {
clientIp = ctx.request().remoteAddress().host();
}
userService.createUser(login, email, password, clientIp, true).onComplete(cr -> {
if (cr.succeeded()) {
ctx.response().setStatusCode(201)
.end(new JsonObject().put("success", true).encode());

View File

@@ -139,6 +139,12 @@ public class UserService {
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
}
public Future<Void> updateUserIp(int userId, String ip) {
return pool.preparedQuery("UPDATE users SET ip = ? WHERE id = ?")
.execute(Tuple.of(ip, userId))
.mapEmpty();
}
public Future<Void> deleteUser(int id) {
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
.execute(Collections.singletonMap("id", id))