почти рабочая авторизация. оказывается сейчас нет payload у запроса, поэтому невозможно распарсить из него json.

This commit is contained in:
Vladislav Ostapov 2024-11-04 17:57:47 +03:00
parent 0b794fac40
commit b561dedb2b
13 changed files with 362 additions and 138 deletions

View File

@ -40,6 +40,8 @@ add_executable(terminal-web-server
src/auth/resources.h src/auth/resources.h
src/auth/jwt.cpp src/auth/jwt.cpp
src/auth/jwt.h src/auth/jwt.h
src/auth/utils.cpp
src/auth/utils.h
) )
find_package(Boost 1.53.0 COMPONENTS system thread filesystem log log_setup REQUIRED) find_package(Boost 1.53.0 COMPONENTS system thread filesystem log log_setup REQUIRED)

View File

@ -1,5 +1,80 @@
//
// Created by vlad on 04.11.2024.
//
#include "jwt.h" #include "jwt.h"
#include <random>
#include "utils.h"
std::string http::auth::jwt::secretKey;
void http::auth::jwt::generateSecretKey() {
secretKey.clear();
std::random_device rd;
std::mt19937 generator(rd());
std::uniform_int_distribution<> distribution('!', '~');
for (size_t i = 0; i < 32; ++i) {
secretKey += static_cast<char>(distribution(generator));
}
}
// payload, signature
static std::pair<std::string, std::string> parseJwtFromCookie(const std::string& input) {
std::string val1, val2;
size_t dotPos = input.find('.');
// Если точка найдена
if (dotPos != std::string::npos) {
val1 = input.substr(0, dotPos);
// Если в val1 есть еще точки, нужно найти последнюю точку
size_t lastDotPos = val1.find_last_of('.');
if (lastDotPos != std::string::npos) {
val1 = val1.substr(0, lastDotPos + 1);
}
val2 = input.substr(dotPos + 1);
} else {
// Точка не найдена, val1 - вся строка
val1 = input;
}
return std::make_pair(http::utils::b64Decode(val1), val2);
}
http::auth::jwt::Jwt http::auth::jwt::Jwt::fromCookies(const std::string &cookie) {
auto pc = utils::parseCookies(cookie);
Jwt t;
if (pc.find("auth") != pc.end()) {
auto tmp = parseJwtFromCookie(pc.at("auth"));
t.payload = tmp.first;
t.signature = tmp.second;
}
return t;
}
http::auth::jwt::Jwt http::auth::jwt::Jwt::fromUser(const std::string &user) {
Jwt t;
t.payload = user;
return t;
}
bool http::auth::jwt::Jwt::isValid() {
if (payload.empty() || signature.empty()) {
return false;
}
auto realSignature = utils::sha256(this->payload + secretKey);
return signature == realSignature;
}
std::string http::auth::jwt::Jwt::getUsername() {
return payload;
}
std::string http::auth::jwt::Jwt::asCookie() {
signature = utils::sha256(this->payload + secretKey);
auto val = utils::b64Encode(payload) + "." + signature;
return val + ";Path=/; Max-Age=86400; HttpOnly; SameSite=Lax";
}
http::auth::jwt::Jwt::~Jwt() = default;

View File

@ -4,10 +4,20 @@
#include "resources.h" #include "resources.h"
namespace http::auth::jwt { namespace http::auth::jwt {
extern std::string secretKey;
void generateSecretKey();
/**
* Упрощенная реализация JWT (Json Web Token). Токен имеет вид: `{ username | base64 }.{ signature | base64 }`.
* Сигнатура вычисляется следующим образом: `SHA256(username + secret)`.
* Имя cookie: `auth`.
*/
class Jwt { class Jwt {
std::string payload;
std::string signature;
public: public:
static Jwt fromCookies(const std::string& cookie); static Jwt fromCookies(const std::string& cookie);
static Jwt fromString(const std::string& cookie);
static Jwt fromUser(const std::string& User); static Jwt fromUser(const std::string& User);
bool isValid(); bool isValid();

View File

@ -1,15 +1,79 @@
//
// Created by vlad on 31.10.2024.
//
#include "resources.h" #include "resources.h"
#include <boost/log/trivial.hpp>
#include <boost/algorithm/string.hpp>
#include "jwt.h"
#include "utils.h"
#include <utility>
// http::auth::AuentificationRequiredResource::AuentificationRequiredResource(const std::string &path, AuthProvider& provider, resource::respGenerator generator): BasicResource(path), generator_(std::move(generator)) { http::auth::User::User(const std::string &username, const std::string &passwordHash): username(username), passwordHash(passwordHash) {}
// }
// bool http::auth::User::checkPassword(const std::string &pass) const {
// void http::auth::AuentificationRequiredResource::handle(const server::Request &req, server::Reply &rep) { return utils::sha256(pass) == passwordHash;
// } }
//
// http::auth::AuentificationRequiredResource::~AuentificationRequiredResource() = default; void http::auth::User::setPassword(const std::string &pass) {
this->passwordHash = utils::sha256(pass);
}
bool http::auth::User::checkPremisions(uint32_t p) const {
if (this->perms & SUPERUSER) {
return true;
} else {
return (this->perms & p) == p;
}
}
void http::auth::User::setPremisions(uint32_t p) {
if (p & SUPERUSER) {
this->perms = SUPERUSER;
} else {
this->perms |= p;
}
}
void http::auth::User::resetPremisions(uint32_t p) {
this->perms &= p;
}
http::auth::User::~User() = default;
http::auth::AuthProvider::AuthProvider() = default;
std::shared_ptr<http::auth::User> http::auth::AuthProvider::doAuth(const std::string &username, const std::string &password, server::Reply &rep) {
for (const auto& u: users) {
if (u->username == username) {
if (u->checkPassword(password)) {
auto t = jwt::Jwt::fromUser(u->username);
rep.headers.push_back({.name = "Set-Cookie", .value = t.asCookie()});
return u;
}
BOOST_LOG_TRIVIAL(warning) << "http::auth::AuthProvider::doAuth(): Failed to login " << username << ", password: " << password << " (incorrect password)";
return nullptr;
}
}
BOOST_LOG_TRIVIAL(warning) << "http::auth::AuthProvider::doAuth(): Failed to login " << username << ", password: " << password << " (user not found)";
return nullptr;
}
std::shared_ptr<http::auth::User> http::auth::AuthProvider::getSession(const server::Request &req) {
for (const auto& header: req.headers) {
if (boost::iequals(header.name, "cookie")) {
auto t = jwt::Jwt::fromCookies(header.value);
if (t.isValid()) {
const auto name = t.getUsername();
// токен валидный, ищем юзера
for (auto& u: users) {
if (u->username == name) {
return u;
}
}
BOOST_LOG_TRIVIAL(warning) << "http::auth::AuthProvider::getSession(): Found valid session for a non-existent user " << name;
}
}
}
return nullptr;
}
http::auth::AuthProvider::~AuthProvider() = default;

View File

@ -9,20 +9,25 @@ namespace http::auth {
*/ */
class User { class User {
private: private:
uint32_t perms; uint32_t perms{};
public: public:
const std::string username; const std::string username;
std::string passwordHash; std::string passwordHash;
User(const std::string& username, const std::string& passwordHash); /**
* Конструктор пользователя.
* @param username Имя пользователя, он же - логин.
* @param passwordHash Хеш sha256 пароля пользователя. Если передать пустой, то пароль будет сгенерирован такой же, как и имя пользователя.
*/
explicit User(const std::string& username, const std::string& passwordHash = "");
/** /**
* Проверить пароль на соответствие хешу * Проверить пароль на соответствие хешу
* @param pass * @param pass
* @return * @return
*/ */
bool checkPassword(const std::string& pass); bool checkPassword(const std::string& pass) const;
/** /**
* Установка пароля * Установка пароля
@ -43,7 +48,7 @@ namespace http::auth {
* @param p набор прав, из констант данного класса. * @param p набор прав, из констант данного класса.
* @return * @return
*/ */
bool checkPremisions(uint32_t p); bool checkPremisions(uint32_t p) const;
void setPremisions(uint32_t p); void setPremisions(uint32_t p);
void resetPremisions(uint32_t p); void resetPremisions(uint32_t p);
@ -65,9 +70,11 @@ namespace http::auth {
* @param rep * @param rep
* @return true, в случае успешной авторизации * @return true, в случае успешной авторизации
*/ */
bool doAuth(const std::string& username, const std::string& password, server::Reply& rep); std::shared_ptr<http::auth::User> doAuth(const std::string &username, const std::string &password, server::Reply &rep);
std::shared_ptr<User> getSession(server::Request& req); std::vector<std::shared_ptr<User>> users;
std::shared_ptr<User> getSession(const server::Request &req);
~AuthProvider(); ~AuthProvider();
}; };

78
src/auth/utils.cpp Normal file
View File

@ -0,0 +1,78 @@
#include "utils.h"
#include <openssl/sha.h>
#include <openssl/evp.h>
#include <openssl/bio.h>
#include <openssl/buffer.h>
#include <string>
#include <iomanip>
std::string http::utils::sha256(const std::string &payload) {
// Вычисляем SHA256 хеш
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast<const unsigned char *>(payload.c_str()), payload.length(), hash);
// Преобразуем хеш в шестнадцатеричную строку
std::stringstream ss;
for (unsigned char i : hash) {
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(i);
}
return ss.str();
}
std::string http::utils::b64Encode(const std::string &payload) {
BIO *bio_mem = BIO_new(BIO_s_mem());
BIO *bio_b64 = BIO_new(BIO_f_base64());
bio_b64 = BIO_push(bio_b64, bio_mem);
BIO_write(bio_b64, payload.c_str(), payload.length());
BIO_flush(bio_b64);
BUF_MEM *bptr = nullptr;
BIO_get_mem_data(bio_mem, &bptr);
std::string result(bptr->data, bptr->length);
BIO_free_all(bio_b64);
return result;
}
std::string http::utils::b64Decode(const std::string &payload) {
BIO *bio_mem = BIO_new(BIO_s_mem());
BIO *bio_b64 = BIO_new(BIO_f_base64());
bio_b64 = BIO_push(bio_b64, bio_mem);
BIO_write(bio_b64, payload.c_str(), static_cast<int>(payload.size()));
BIO_flush(bio_b64);
const int len = BIO_get_mem_data(bio_mem, NULL);
char buffer[static_cast<size_t>(len)];
BIO_read(bio_mem, buffer, len);
std::string result(buffer, static_cast<unsigned int>(len));
BIO_free_all(bio_b64);
return result;
}
std::map<std::string, std::string> http::utils::parseCookies(const std::string& cookieString) {
std::map<std::string, std::string> cookies;
std::istringstream cookieStream(cookieString);
std::string cookie;
while (std::getline(cookieStream, cookie, ';')) {
// Разделяем имя и значение Cookie
size_t equalPos = cookie.find('=');
if (equalPos == std::string::npos) {
continue; // Неверный формат Cookie
}
std::string name = cookie.substr(0, equalPos);
std::string value = cookie.substr(equalPos + 1);
// Удаляем пробелы с начала и конца значения Cookie
value.erase(0, value.find_first_not_of(' '));
value.erase(value.find_last_not_of(' ') + 1);
// Добавляем Cookie в map
cookies[name] = value;
}
return cookies;
}

17
src/auth/utils.h Normal file
View File

@ -0,0 +1,17 @@
#ifndef UTILS_H
#define UTILS_H
#include <map>
#include <string>
namespace http::utils {
std::string sha256(const std::string& payload);
std::string sha256AsB64(const std::string& payload);
std::string b64Encode(const std::string& payload);
std::string b64Decode(const std::string& payload);
std::map<std::string, std::string> parseCookies(const std::string& cookieStrin);
}
#endif //UTILS_H

View File

@ -11,12 +11,15 @@
#include <boost/log/utility/setup/formatter_parser.hpp> #include <boost/log/utility/setup/formatter_parser.hpp>
#include <boost/asio/buffer.hpp> #include <boost/asio/buffer.hpp>
#include <boost/asio/ssl/context.hpp> #include <boost/asio/ssl/context.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include <cstddef> #include <cstddef>
#include <memory> #include <memory>
#include <fstream> #include <fstream>
#include "terminal_api_driver.h" #include "terminal_api_driver.h"
#include "auth/resources.h" #include "auth/resources.h"
#include "auth/jwt.h"
namespace ssl = boost::asio::ssl; // from <boost/asio/ssl.hpp> namespace ssl = boost::asio::ssl; // from <boost/asio/ssl.hpp>
@ -76,11 +79,11 @@ void init_logging() {
class ServerResources { class ServerResources {
std::unique_ptr<http::resource::StaticFileFactory> sf; std::unique_ptr<http::resource::StaticFileFactory> sf;
std::unique_ptr<api_driver::ApiDriver> api; std::unique_ptr<api_driver::ApiDriver> api;
http::auth::AuthProvider auth{};
public: public:
static constexpr const char* INDEX_HTML = "static/main.html"; static constexpr const char* INDEX_HTML = "static/main.html";
static constexpr const char* LOGIN_HTML = "static/login.html"; static constexpr const char* LOGIN_HTML = "static/login.html";
static constexpr const char* LOGIN_FAILED_HTML = "static/login-failed.html";
// картинки, их даже можно кешировать // картинки, их даже можно кешировать
static constexpr const char* FAVICON_ICO = "static/favicon.png"; static constexpr const char* FAVICON_ICO = "static/favicon.png";
@ -94,9 +97,10 @@ public:
ServerResources(const ServerResources&) = delete; ServerResources(const ServerResources&) = delete;
ServerResources(): sf(std::make_unique<http::resource::StaticFileFactory>()), api(std::make_unique<api_driver::ApiDriver>()) { ServerResources(): sf(std::make_unique<http::resource::StaticFileFactory>()), api(std::make_unique<api_driver::ApiDriver>()) {
auth.users.emplace_back(std::make_shared<http::auth::User>("admin"));
sf->registerFile(INDEX_HTML, mime_types::text_html, false); sf->registerFile(INDEX_HTML, mime_types::text_html, false);
sf->registerFile(LOGIN_HTML, mime_types::text_html, false); sf->registerFile(LOGIN_HTML, mime_types::text_html, false);
sf->registerFile(LOGIN_FAILED_HTML, mime_types::text_html, false);
sf->registerFile(FAVICON_ICO, mime_types::image_png, true); sf->registerFile(FAVICON_ICO, mime_types::image_png, true);
sf->registerFile(KROKODIL_GIF, mime_types::image_gif, true); sf->registerFile(KROKODIL_GIF, mime_types::image_gif, true);
@ -114,15 +118,36 @@ public:
void registerResources(http::server::Server& s) { void registerResources(http::server::Server& s) {
s.resources.emplace_back(std::make_unique<http::resource::GenericResource>("/", [this](const auto& req, auto& rep) { s.resources.emplace_back(std::make_unique<http::resource::GenericResource>("/", [this](const auto& req, auto& rep) {
boost::ignore_unused(req); auto user = auth.getSession(req);
sf->serve(INDEX_HTML, rep); if (user == nullptr) {
http::server::httpRedirect(rep, "/login");
} else {
sf->serve(INDEX_HTML, rep);
}
})); }));
s.resources.emplace_back(std::make_unique<http::resource::GenericResource>("/login", [this](const auto& req, auto& rep) { s.resources.emplace_back(std::make_unique<http::resource::GenericResource>("/login", [this](const auto& req, auto& rep) {
if (req.method == "GET") { if (req.method == "GET") {
sf->serve(LOGIN_HTML, rep); sf->serve(LOGIN_HTML, rep);
} else if (req.method == "POST") { } else if (req.method == "POST") {
sf->serve(LOGIN_FAILED_HTML, rep); rep.status = http::server::ok;
rep.headers.clear();
rep.headers.push_back({.name = "Content-Type", .value = toString(mime_types::json)});
try {
std::istringstream is(req.body);
boost::property_tree::ptree pt;
boost::property_tree::read_json(is, pt);
auto u = auth.doAuth(req);
if (u == nullptr) {
throw std::runtime_error("invalid session");
}
std::string result = R"({"redirect":"/"})";
rep.content.insert(rep.content.end(), result.c_str(), result.c_str() + result.size());
} catch (std::exception &e) {
std::string result = R"({"error":"Неверный логин или пароль"})";
rep.content.insert(rep.content.end(), result.c_str(), result.c_str() + result.size());
}
} else { } else {
http::server::stockReply(http::server::bad_request, rep); http::server::stockReply(http::server::bad_request, rep);
} }
@ -214,6 +239,9 @@ int main(int argc, char *argv[]) {
BOOST_LOG_TRIVIAL(info) << "Starting RELEASE build" << argv[0]; BOOST_LOG_TRIVIAL(info) << "Starting RELEASE build" << argv[0];
#endif #endif
http::auth::jwt::generateSecretKey();
BOOST_LOG_TRIVIAL(info) << "Generated new secret key " << http::auth::jwt::secretKey;
ServerResources resources; ServerResources resources;
// Initialise the server. // Initialise the server.

View File

@ -126,9 +126,7 @@ namespace http::server {
void SslConnection::doWrite() { void SslConnection::doWrite() {
reply_.headers.push_back({.name = "Server", .value = SERVER_HEADER_VALUE}); reply_.headers.push_back({.name = "Server", .value = SERVER_HEADER_VALUE});
if (!reply_.content.empty()) { reply_.headers.push_back({.name = "Content-Length", .value = std::to_string(reply_.content.size())});
reply_.headers.push_back({.name = "Content-Length", .value = std::to_string(reply_.content.size())});
}
if (request_.http_version_major == 1) { if (request_.http_version_major == 1) {
reply_.headers.push_back({.name = "Connection", .value = "keep-alive"}); reply_.headers.push_back({.name = "Connection", .value = "keep-alive"});
} }

View File

@ -13,6 +13,7 @@ namespace http::server {
const std::string multiple_choices = "HTTP/1.1 300 Multiple Choices\r\n"; const std::string multiple_choices = "HTTP/1.1 300 Multiple Choices\r\n";
const std::string moved_permanently = "HTTP/1.1 301 Moved Permanently\r\n"; const std::string moved_permanently = "HTTP/1.1 301 Moved Permanently\r\n";
const std::string moved_temporarily = "HTTP/1.1 302 Moved Temporarily\r\n"; const std::string moved_temporarily = "HTTP/1.1 302 Moved Temporarily\r\n";
const std::string see_other_redirect = "HTTP/1.1 303 See Other\r\n";
const std::string not_modified = "HTTP/1.1 304 Not Modified\r\n"; const std::string not_modified = "HTTP/1.1 304 Not Modified\r\n";
const std::string bad_request = "HTTP/1.1 400 Bad Request\r\n"; const std::string bad_request = "HTTP/1.1 400 Bad Request\r\n";
const std::string unauthorized = "HTTP/1.1 401 Unauthorized\r\n"; const std::string unauthorized = "HTTP/1.1 401 Unauthorized\r\n";
@ -39,6 +40,8 @@ namespace http::server {
return boost::asio::buffer(moved_permanently); return boost::asio::buffer(moved_permanently);
case status_type::moved_temporarily: case status_type::moved_temporarily:
return boost::asio::buffer(moved_temporarily); return boost::asio::buffer(moved_temporarily);
case status_type::see_other_redirect:
return boost::asio::buffer(see_other_redirect);
case status_type::not_modified: case status_type::not_modified:
return boost::asio::buffer(not_modified); return boost::asio::buffer(not_modified);
case status_type::bad_request: case status_type::bad_request:
@ -78,7 +81,9 @@ namespace http::server {
buffers.push_back(boost::asio::buffer(misc_strings::crlf)); buffers.push_back(boost::asio::buffer(misc_strings::crlf));
} }
buffers.emplace_back(boost::asio::buffer(misc_strings::crlf)); buffers.emplace_back(boost::asio::buffer(misc_strings::crlf));
buffers.emplace_back(boost::asio::buffer(content)); if (!content.empty()) {
buffers.emplace_back(boost::asio::buffer(content));
}
return buffers; return buffers;
} }
@ -212,4 +217,11 @@ namespace http::server {
rep.headers.push_back({.name = "Content-Type", .value = toString(mime_types::text_html)}); rep.headers.push_back({.name = "Content-Type", .value = toString(mime_types::text_html)});
stock_replies::as_content(status, rep.content); stock_replies::as_content(status, rep.content);
} }
void httpRedirect(Reply &rep, const std::string &location) {
rep.status = see_other_redirect;
rep.content.clear();
rep.headers.push_back({.name = "Content-Type", .value = toString(mime_types::text_html)});
rep.headers.push_back({.name = "Location", .value = location});
}
} // namespace http::server } // namespace http::server

View File

@ -16,6 +16,7 @@ namespace http::server {
multiple_choices = 300, multiple_choices = 300,
moved_permanently = 301, moved_permanently = 301,
moved_temporarily = 302, moved_temporarily = 302,
see_other_redirect = 303,
not_modified = 304, not_modified = 304,
bad_request = 400, bad_request = 400,
unauthorized = 401, unauthorized = 401,
@ -46,6 +47,7 @@ namespace http::server {
/// Get a stock reply. /// Get a stock reply.
void stockReply(status_type status, Reply& rep); void stockReply(status_type status, Reply& rep);
void httpRedirect(Reply& rep, const std::string& location);
} // namespace http::Server } // namespace http::Server

View File

@ -1,99 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSCM-101 | Вход</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<style>
#form-wrapper {
overflow: hidden;
max-width: 27em;
margin: 5em auto;
height: auto;
text-align: center;
}
.form-row {
padding: 4px 0;
margin: 1.5em;
}
.form-row * {
font-size: 1em;
text-align: left;
display: block;
}
.form-row label {
line-height: 2em;
font-weight: bolder;
}
.form-row input {
padding: 8px;
width: 100%;
box-sizing: border-box;
border: none;
border-bottom: var(--brand-bg) 2px solid;
background-color: var(--bg-color);
text-overflow: ellipsis;
min-height: 2em;
}
.form-row input:focus {
outline: none;
border: none;
border-bottom: var(--brand-text) 2px solid;
background-color: var(--bg-selected);
}
#submit {
border: none;
font-weight: bolder;
background: var(--bg-action);
text-align: center;
}
</style>
</head>
<body>
<div id="form-wrapper">
<h1> Вход </h1>
<form method="POST" id="login-form">
{% csrf_token %}
<div class="form-row value-bad">
Неверный логин или пароль
</div>
<div class="form-row">
<label for="username">Имя пользователя</label>
<input type="text" name="username" id="username" required/>
</div>
<div class="form-row">
<label for="password">Пароль</label>
<input type="password" name="password" id="password" required/>
</div>
<div class="form-row">
<input id="submit" type="submit" value="Войти">
</div>
</form>
</div>
<script>
document.getElementById("username").onkeydown = (e) => {
if (e.key === 'Enter') {
document.getElementById("password").focus()
}
}
document.getElementById("password").onkeydown = (e) => {
if (e.key === 'Enter') {
document.getElementById("login-form").submit()
}
}
</script>
</body>
</html>

View File

@ -61,13 +61,8 @@
<div id="form-wrapper"> <div id="form-wrapper">
<h1> Вход </h1> <h1> Вход </h1>
<form method="POST" id="login-form"> <form id="login-form" onsubmit="submitLoginForm(); return false">
<!-- {% csrf_token %}--> <div class="form-row value-bad" hidden id="form-error-message"></div>
<!-- {% if message %}-->
<!-- <div class="form-row value-bad">-->
<!-- {{ message }}-->
<!-- </div>-->
<!-- {% endif %}-->
<div class="form-row"> <div class="form-row">
<label for="username">Имя пользователя</label> <label for="username">Имя пользователя</label>
@ -97,6 +92,41 @@
document.getElementById("login-form").submit() document.getElementById("login-form").submit()
} }
} }
const loginForm = document.getElementById('login-form');
const formErrorMessage = document.getElementById('form-error-message')
function submitLoginForm() {
const username = document.getElementById('username').value
const password = document.getElementById('password').value
const requestData = {
"username": username,
"password": password
};
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
}).then(response => {
// Обработка ответа сервера
response.json().then((value) => {
if (value["error"]) {
formErrorMessage.innerText = value["error"]
formErrorMessage.removeAttribute("hidden")
} else {
window.location = "/"
}
})
}).catch(error => {
formErrorMessage.innerText = error
formErrorMessage.removeAttribute("hidden")
console.error('Ошибка отправки запроса:', error) // Обработка ошибки отправки запроса
})
}
</script> </script>
</body> </body>
</html> </html>