Files
backend/services/AuthService.cc
T
2026-06-15 11:23:56 +02:00

302 lines
8.9 KiB
C++

#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 Mailpit";
return failure("otp_email_failed");
}
auto response = success();
response["delivery"] = "mailpit";
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::changePassword(const auth::UserContext &user,
const Json::Value &body)
{
const auto missing =
validation::missingStrings(body, {"current_password", "new_password"});
if (!missing.empty())
{
return failure("missing_fields");
}
if (body["new_password"].asString().size() < 10)
{
return failure("weak_password");
}
auto db = drogon::app().getDbClient();
const auto current = db->execSqlSync(
"SELECT password_hash FROM users WHERE id = ? AND banned = FALSE",
user.id);
if (current.empty() ||
!crypto::verifyPassword(
body["current_password"].asString(),
current[0]["password_hash"].as<std::string>()))
{
return failure("invalid_credentials");
}
db->execSqlSync(
"UPDATE users SET password_hash = ?, must_change_password = FALSE "
"WHERE id = ?",
crypto::hashPassword(body["new_password"].asString()),
user.id);
return success();
}
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;
for (const auto &role : user.roles)
{
response["user"]["roles"].append(role);
}
for (const auto &permission : user.permissions)
{
response["user"]["permissions"].append(permission);
}
}
return response;
}
Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) {
const auto allowedFields = {"first_name", "last_name", "phone"};
auto db = drogon::app().getDbClient();
bool hasUpdates = false;
for (const auto& field : allowedFields) {
if (body.isMember(field)) {
if (!body[field].isString()) {
return failure("invalid_field_type");
}
db->execSqlSync(
"UPDATE users SET " + std::string(field) + " = ? WHERE id = ?",
body[field].asString(),
user.id
);
hasUpdates = true;
}
}
if (!hasUpdates) {
return failure("missing_fields");
}
return success();
}
} // namespace visione::services