567 lines
17 KiB
C++
567 lines
17 KiB
C++
#include "services/AuthService.h"
|
|
|
|
#include "common/Email.h"
|
|
#include "common/Crypto.h"
|
|
#include "common/Validation.h"
|
|
|
|
#include <drogon/drogon.h>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <json/writer.h>
|
|
#include <optional>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
namespace visione::services
|
|
{
|
|
namespace
|
|
{
|
|
constexpr std::string_view kCieProvider = "cie";
|
|
|
|
Json::Value failure(const std::string &code, const std::string &message = {})
|
|
{
|
|
Json::Value body;
|
|
body["ok"] = false;
|
|
body["error"] = code;
|
|
if (!message.empty())
|
|
{
|
|
body["message"] = message;
|
|
}
|
|
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();
|
|
}
|
|
|
|
const Json::Value &cieConfig()
|
|
{
|
|
return drogon::app().getCustomConfig()["cie_auth"];
|
|
}
|
|
|
|
std::string cieAuthBaseUrl()
|
|
{
|
|
return cieConfig().get("service_base_url", "").asString();
|
|
}
|
|
|
|
std::string cieStartPath()
|
|
{
|
|
return cieConfig().get("start_path", "/visione/start").asString();
|
|
}
|
|
|
|
std::string cieSharedSecret()
|
|
{
|
|
return cieConfig().get("shared_secret", "").asString();
|
|
}
|
|
|
|
int cieStateTtlMinutes()
|
|
{
|
|
return cieConfig().get("state_ttl_minutes", 10).asInt();
|
|
}
|
|
|
|
std::string cieDefaultFrontendRedirect()
|
|
{
|
|
return cieConfig().get("default_frontend_redirect_uri", "").asString();
|
|
}
|
|
|
|
std::string ensureCitizenEmail(const std::string &email,
|
|
const std::string &fiscalCode)
|
|
{
|
|
if (!email.empty())
|
|
{
|
|
return email;
|
|
}
|
|
return fiscalCode + "@cie.local";
|
|
}
|
|
|
|
std::string buildUrlWithQuery(const std::string &base,
|
|
const std::vector<std::pair<std::string, std::string>> ¶ms)
|
|
{
|
|
std::string url = base;
|
|
url += (base.find('?') == std::string::npos) ? "?" : "&";
|
|
bool first = true;
|
|
for (const auto &[key, value] : params)
|
|
{
|
|
if (!first)
|
|
{
|
|
url += "&";
|
|
}
|
|
first = false;
|
|
url += drogon::utils::urlEncode(key) + "=" + drogon::utils::urlEncode(value);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
std::string frontendRedirectWithState(const std::string &baseUrl,
|
|
const std::string &state)
|
|
{
|
|
if (baseUrl.empty())
|
|
{
|
|
return {};
|
|
}
|
|
return buildUrlWithQuery(baseUrl, {{"state", state}});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
std::optional<std::string> readString(const Json::Value &body, const char *key)
|
|
{
|
|
if (!body.isMember(key) || !body[key].isString())
|
|
{
|
|
return std::nullopt;
|
|
}
|
|
return body[key].asString();
|
|
}
|
|
|
|
std::string normalizeFiscalCode(std::string fiscalCode)
|
|
{
|
|
std::ranges::transform(fiscalCode, fiscalCode.begin(), [](unsigned char ch) {
|
|
return static_cast<char>(std::toupper(ch));
|
|
});
|
|
return fiscalCode;
|
|
}
|
|
|
|
long long findOrCreateCieCitizen(const Json::Value &claims)
|
|
{
|
|
auto db = drogon::app().getDbClient();
|
|
|
|
const auto issuer = claims["issuer"].asString();
|
|
const auto subject = claims["subject"].asString();
|
|
const auto fiscalCode = normalizeFiscalCode(claims["fiscal_code"].asString());
|
|
const auto firstName = claims.get("first_name", "").asString();
|
|
const auto lastName = claims.get("last_name", "").asString();
|
|
const auto email = claims.get("email", "").asString();
|
|
|
|
const auto existingIdentity = db->execSqlSync(
|
|
"SELECT user_id FROM user_external_identities "
|
|
"WHERE provider = ? AND issuer = ? AND subject = ? LIMIT 1",
|
|
std::string(kCieProvider),
|
|
issuer,
|
|
subject);
|
|
if (!existingIdentity.empty())
|
|
{
|
|
return existingIdentity[0]["user_id"].as<long long>();
|
|
}
|
|
|
|
long long userId = 0;
|
|
const auto byFiscal = db->execSqlSync(
|
|
"SELECT id FROM users WHERE fiscal_code = ? LIMIT 1", fiscalCode);
|
|
if (!byFiscal.empty())
|
|
{
|
|
userId = byFiscal[0]["id"].as<long long>();
|
|
db->execSqlSync(
|
|
"UPDATE users SET first_name = COALESCE(NULLIF(?, ''), first_name), "
|
|
"last_name = COALESCE(NULLIF(?, ''), last_name), "
|
|
"email = COALESCE(NULLIF(?, ''), email) WHERE id = ?",
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
userId);
|
|
}
|
|
else
|
|
{
|
|
db->execSqlSync(
|
|
"INSERT INTO users "
|
|
"(first_name, last_name, email, fiscal_code, birth_date, birth_place, phone) "
|
|
"VALUES (?, ?, ?, ?, NULL, NULL, NULL)",
|
|
firstName.empty() ? fiscalCode : firstName,
|
|
lastName.empty() ? fiscalCode : lastName,
|
|
ensureCitizenEmail(email, fiscalCode),
|
|
fiscalCode);
|
|
const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id");
|
|
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);
|
|
}
|
|
|
|
Json::StreamWriterBuilder writer;
|
|
writer["indentation"] = "";
|
|
db->execSqlSync(
|
|
"INSERT INTO user_external_identities "
|
|
"(user_id, provider, issuer, subject, fiscal_code, raw_claims) "
|
|
"VALUES (?, ?, ?, ?, ?, CAST(? AS JSON)) "
|
|
"ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), "
|
|
"fiscal_code = VALUES(fiscal_code), raw_claims = VALUES(raw_claims)",
|
|
userId,
|
|
std::string(kCieProvider),
|
|
issuer,
|
|
subject,
|
|
fiscalCode,
|
|
Json::writeString(writer, claims));
|
|
return userId;
|
|
}
|
|
|
|
bool validateCieExchangeSignature(const std::string &rawBody,
|
|
const std::string &signatureHeader)
|
|
{
|
|
const auto secret = cieSharedSecret();
|
|
if (secret.empty() || signatureHeader.empty())
|
|
{
|
|
return false;
|
|
}
|
|
const auto expected = crypto::hmacSha256Hex(secret, rawBody);
|
|
return crypto::constantTimeEquals(expected, signatureHeader);
|
|
}
|
|
} // namespace
|
|
|
|
Json::Value AuthService::registerCitizen(const Json::Value &body)
|
|
{
|
|
const auto missing = validation::missingStrings(
|
|
body, {"first_name", "last_name", "email", "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, fiscal_code, birth_date, "
|
|
"birth_place, phone) VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?)",
|
|
body["first_name"].asString(),
|
|
body["last_name"].asString(),
|
|
email,
|
|
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::startCieLogin(const Json::Value &body)
|
|
{
|
|
const auto frontendRedirect =
|
|
body.get("frontend_redirect_uri", cieDefaultFrontendRedirect()).asString();
|
|
if (frontendRedirect.empty())
|
|
{
|
|
return failure("missing_frontend_redirect_uri");
|
|
}
|
|
|
|
const auto serviceBaseUrl = cieAuthBaseUrl();
|
|
if (serviceBaseUrl.empty())
|
|
{
|
|
return failure("cie_auth_not_configured");
|
|
}
|
|
|
|
const auto state = crypto::randomToken();
|
|
auto db = drogon::app().getDbClient();
|
|
db->execSqlSync(
|
|
"INSERT INTO cie_auth_sessions "
|
|
"(state, frontend_redirect_uri, status, expires_at) "
|
|
"VALUES (?, ?, 'PENDING', DATE_ADD(NOW(), INTERVAL ? MINUTE))",
|
|
state,
|
|
frontendRedirect,
|
|
cieStateTtlMinutes());
|
|
|
|
auto response = success();
|
|
response["state"] = state;
|
|
response["authorization_url"] = buildUrlWithQuery(
|
|
serviceBaseUrl + cieStartPath(),
|
|
{{"state", state}, {"frontend_redirect_uri", frontendRedirect}});
|
|
response["status"] = "pending";
|
|
return response;
|
|
}
|
|
|
|
Json::Value AuthService::completeCieLogin(const std::string &rawBody,
|
|
const Json::Value &body,
|
|
const std::string &signatureHeader)
|
|
{
|
|
if (!validateCieExchangeSignature(rawBody, signatureHeader))
|
|
{
|
|
return failure("invalid_signature");
|
|
}
|
|
|
|
const auto state = readString(body, "state");
|
|
const auto issuer = readString(body, "issuer");
|
|
const auto subject = readString(body, "subject");
|
|
const auto fiscalCode = readString(body, "fiscal_code");
|
|
if (!state || !issuer || !subject || !fiscalCode)
|
|
{
|
|
return failure("missing_fields");
|
|
}
|
|
if (!validation::isValidFiscalCode(*fiscalCode))
|
|
{
|
|
return failure("invalid_fiscal_code");
|
|
}
|
|
|
|
auto db = drogon::app().getDbClient();
|
|
const auto session = db->execSqlSync(
|
|
"SELECT id, frontend_redirect_uri, status FROM cie_auth_sessions "
|
|
"WHERE state = ? AND consumed_at IS NULL AND expires_at > NOW() LIMIT 1",
|
|
*state);
|
|
if (session.empty())
|
|
{
|
|
return failure("invalid_or_expired_state");
|
|
}
|
|
|
|
const auto sessionId = session[0]["id"].as<long long>();
|
|
const auto status = session[0]["status"].as<std::string>();
|
|
if (status == "AUTHENTICATED")
|
|
{
|
|
auto response = success();
|
|
response["redirect_url"] = frontendRedirectWithState(
|
|
session[0]["frontend_redirect_uri"].as<std::string>(), *state);
|
|
return response;
|
|
}
|
|
|
|
Json::Value claims(Json::objectValue);
|
|
claims["issuer"] = *issuer;
|
|
claims["subject"] = *subject;
|
|
claims["fiscal_code"] = normalizeFiscalCode(*fiscalCode);
|
|
claims["first_name"] = body.get("first_name", "").asString();
|
|
claims["last_name"] = body.get("last_name", "").asString();
|
|
claims["email"] = body.get("email", "").asString();
|
|
|
|
const auto userId = findOrCreateCieCitizen(claims);
|
|
const auto token = issueToken(userId);
|
|
|
|
Json::StreamWriterBuilder writer;
|
|
writer["indentation"] = "";
|
|
db->execSqlSync(
|
|
"UPDATE cie_auth_sessions "
|
|
"SET status = 'AUTHENTICATED', issuer = ?, subject = ?, fiscal_code = ?, "
|
|
"authenticated_user_id = ?, result_token = ?, raw_claims = CAST(? AS JSON), "
|
|
"consumed_at = NOW() WHERE id = ?",
|
|
*issuer,
|
|
*subject,
|
|
normalizeFiscalCode(*fiscalCode),
|
|
userId,
|
|
token["token"].asString(),
|
|
Json::writeString(writer, claims),
|
|
sessionId);
|
|
|
|
auto response = success();
|
|
response["redirect_url"] = frontendRedirectWithState(
|
|
session[0]["frontend_redirect_uri"].as<std::string>(), *state);
|
|
return response;
|
|
}
|
|
|
|
Json::Value AuthService::cieSessionStatus(const std::string &state)
|
|
{
|
|
auto db = drogon::app().getDbClient();
|
|
const auto session = db->execSqlSync(
|
|
"SELECT status, authenticated_user_id, result_token FROM cie_auth_sessions "
|
|
"WHERE state = ? LIMIT 1",
|
|
state);
|
|
if (session.empty())
|
|
{
|
|
return failure("session_not_found");
|
|
}
|
|
|
|
auto response = success();
|
|
const auto status = session[0]["status"].as<std::string>();
|
|
if (status == "AUTHENTICATED")
|
|
{
|
|
response["status"] = "authenticated";
|
|
response["auth"]["token"] = session[0]["result_token"].as<std::string>();
|
|
response["auth"]["token_type"] = "Bearer";
|
|
response["auth"]["expires_in_hours"] = tokenTtlHours();
|
|
response["user_id"] =
|
|
Json::Int64(session[0]["authenticated_user_id"].as<long long>());
|
|
return response;
|
|
}
|
|
|
|
response["status"] = status == "FAILED" ? "failed" : "pending";
|
|
return response;
|
|
}
|
|
|
|
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 "
|
|
"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"]["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
|