Add Visione backend foundation
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
#include "services/AuthService.h"
|
||||
|
||||
#include "common/Crypto.h"
|
||||
#include "common/Email.h"
|
||||
#include "common/Validation.h"
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
namespace visione::services
|
||||
{
|
||||
namespace
|
||||
{
|
||||
Json::Value failure(const std::string &code)
|
||||
{
|
||||
Json::Value body;
|
||||
body["ok"] = false;
|
||||
body["error"] = code;
|
||||
return body;
|
||||
}
|
||||
|
||||
Json::Value success()
|
||||
{
|
||||
Json::Value body;
|
||||
body["ok"] = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
int otpTtlMinutes()
|
||||
{
|
||||
return drogon::app()
|
||||
.getCustomConfig()["auth"]
|
||||
.get("otp_ttl_minutes", 10)
|
||||
.asInt();
|
||||
}
|
||||
|
||||
int tokenTtlHours()
|
||||
{
|
||||
return drogon::app()
|
||||
.getCustomConfig()["auth"]
|
||||
.get("token_ttl_hours", 24)
|
||||
.asInt();
|
||||
}
|
||||
|
||||
Json::Value issueToken(long long userId)
|
||||
{
|
||||
auto token = crypto::randomToken();
|
||||
drogon::app().getDbClient()->execSqlSync(
|
||||
"INSERT INTO api_tokens (user_id, token_hash, expires_at) "
|
||||
"VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? HOUR))",
|
||||
userId,
|
||||
crypto::hashSecret(token),
|
||||
tokenTtlHours());
|
||||
|
||||
auto body = success();
|
||||
body["token"] = token;
|
||||
body["token_type"] = "Bearer";
|
||||
body["expires_in_hours"] = tokenTtlHours();
|
||||
return body;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json::Value AuthService::registerCitizen(const Json::Value &body)
|
||||
{
|
||||
const auto missing = validation::missingStrings(
|
||||
body, {"first_name", "last_name", "email", "password", "fiscal_code"});
|
||||
if (!missing.empty())
|
||||
{
|
||||
auto response = failure("missing_fields");
|
||||
for (const auto &field : missing)
|
||||
{
|
||||
response["fields"].append(field);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const auto email = body["email"].asString();
|
||||
const auto fiscalCode = body["fiscal_code"].asString();
|
||||
if (!validation::isValidEmail(email))
|
||||
{
|
||||
return failure("invalid_email");
|
||||
}
|
||||
if (!validation::isValidFiscalCode(fiscalCode))
|
||||
{
|
||||
return failure("invalid_fiscal_code");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto duplicate = db->execSqlSync(
|
||||
"SELECT id FROM users WHERE email = ? OR fiscal_code = ? LIMIT 1",
|
||||
email,
|
||||
fiscalCode);
|
||||
if (!duplicate.empty())
|
||||
{
|
||||
return failure("user_already_exists");
|
||||
}
|
||||
|
||||
db->execSqlSync(
|
||||
"INSERT INTO users "
|
||||
"(first_name, last_name, email, password_hash, fiscal_code, birth_date, "
|
||||
"birth_place, phone) VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
|
||||
body["first_name"].asString(),
|
||||
body["last_name"].asString(),
|
||||
email,
|
||||
crypto::hashPassword(body["password"].asString()),
|
||||
fiscalCode,
|
||||
body.get("birth_date", "").asString(),
|
||||
body.get("birth_place", "").asString(),
|
||||
body.get("phone", "").asString());
|
||||
|
||||
const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id");
|
||||
const auto userId = inserted[0]["id"].as<long long>();
|
||||
db->execSqlSync(
|
||||
"INSERT INTO user_roles (user_id, role_id) "
|
||||
"SELECT ?, id FROM roles WHERE name = 'citizen'",
|
||||
userId);
|
||||
|
||||
auto response = success();
|
||||
response["user_id"] = Json::Int64(userId);
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AuthService::requestOtp(const Json::Value &body)
|
||||
{
|
||||
if (!validation::hasString(body, "email"))
|
||||
{
|
||||
return failure("missing_email");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto user = db->execSqlSync(
|
||||
"SELECT id, email FROM users WHERE email = ? AND banned = FALSE LIMIT 1",
|
||||
body["email"].asString());
|
||||
if (user.empty())
|
||||
{
|
||||
return failure("user_not_found");
|
||||
}
|
||||
|
||||
const auto code = crypto::randomNumericCode(6);
|
||||
const auto userId = user[0]["id"].as<long long>();
|
||||
db->execSqlSync(
|
||||
"INSERT INTO otp_challenges (user_id, code_hash, expires_at) "
|
||||
"VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))",
|
||||
userId,
|
||||
crypto::hashSecret(code),
|
||||
otpTtlMinutes());
|
||||
|
||||
email::EmailService mail;
|
||||
if (!mail.sendOtp(user[0]["email"].as<std::string>(), code))
|
||||
{
|
||||
LOG_WARN << "Could not send OTP email through MailHog";
|
||||
return failure("otp_email_failed");
|
||||
}
|
||||
|
||||
auto response = success();
|
||||
response["delivery"] = "mailhog";
|
||||
response["expires_in_minutes"] = otpTtlMinutes();
|
||||
return response;
|
||||
}
|
||||
|
||||
Json::Value AuthService::verifyOtp(const Json::Value &body)
|
||||
{
|
||||
const auto missing = validation::missingStrings(body, {"email", "code"});
|
||||
if (!missing.empty())
|
||||
{
|
||||
return failure("missing_fields");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto challenge = db->execSqlSync(
|
||||
"SELECT c.id, c.user_id FROM otp_challenges c "
|
||||
"JOIN users u ON u.id = c.user_id "
|
||||
"WHERE u.email = ? AND c.code_hash = ? AND c.consumed_at IS NULL "
|
||||
"AND c.expires_at > NOW() ORDER BY c.id DESC LIMIT 1",
|
||||
body["email"].asString(),
|
||||
crypto::hashSecret(body["code"].asString()));
|
||||
if (challenge.empty())
|
||||
{
|
||||
return failure("invalid_otp");
|
||||
}
|
||||
|
||||
db->execSqlSync("UPDATE otp_challenges SET consumed_at = NOW() WHERE id = ?",
|
||||
challenge[0]["id"].as<long long>());
|
||||
return issueToken(challenge[0]["user_id"].as<long long>());
|
||||
}
|
||||
|
||||
Json::Value AuthService::loginWithPassword(const Json::Value &body)
|
||||
{
|
||||
const auto missing = validation::missingStrings(body, {"email", "password"});
|
||||
if (!missing.empty())
|
||||
{
|
||||
return failure("missing_fields");
|
||||
}
|
||||
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto user = db->execSqlSync(
|
||||
"SELECT id, password_hash FROM users WHERE email = ? AND banned = FALSE "
|
||||
"LIMIT 1",
|
||||
body["email"].asString());
|
||||
if (user.empty() ||
|
||||
!crypto::verifyPassword(body["password"].asString(),
|
||||
user[0]["password_hash"].as<std::string>()))
|
||||
{
|
||||
return failure("invalid_credentials");
|
||||
}
|
||||
return issueToken(user[0]["id"].as<long long>());
|
||||
}
|
||||
|
||||
Json::Value AuthService::currentUser(const auth::UserContext &user)
|
||||
{
|
||||
auto db = drogon::app().getDbClient();
|
||||
const auto result = db->execSqlSync(
|
||||
"SELECT id, first_name, last_name, email, fiscal_code, phone, points, "
|
||||
"must_change_password FROM users WHERE id = ?",
|
||||
user.id);
|
||||
|
||||
auto response = success();
|
||||
if (!result.empty())
|
||||
{
|
||||
response["user"]["id"] = Json::Int64(result[0]["id"].as<long long>());
|
||||
response["user"]["first_name"] = result[0]["first_name"].as<std::string>();
|
||||
response["user"]["last_name"] = result[0]["last_name"].as<std::string>();
|
||||
response["user"]["email"] = result[0]["email"].as<std::string>();
|
||||
response["user"]["fiscal_code"] = result[0]["fiscal_code"].as<std::string>();
|
||||
response["user"]["phone"] = result[0]["phone"].as<std::string>();
|
||||
response["user"]["points"] = result[0]["points"].as<int>();
|
||||
response["user"]["must_change_password"] =
|
||||
result[0]["must_change_password"].as<bool>();
|
||||
response["user"]["role"] = user.role;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
} // namespace visione::services
|
||||
|
||||
Reference in New Issue
Block a user