275 lines
8.2 KiB
C++
275 lines
8.2 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;
|
|
}
|
|
} // namespace visione::services
|