feat: Integrate CIE authentication flow

This commit is contained in:
gabdevele
2026-06-17 12:53:16 +02:00 Unverified
parent bb7ed18539
commit 6cbdf6462d
20 changed files with 946 additions and 15 deletions
+333 -10
View File
@@ -1,21 +1,33 @@
#include "services/AuthService.h"
#include "common/Email.h"
#include "common/Crypto.h"
#include "common/Validation.h"
#include "common/Crypto.h"
#include <drogon/drogon.h>
#include <algorithm>
#include <cctype>
#include <json/writer.h>
#include <optional>
#include <string_view>
#include <vector>
namespace visione::services
{
namespace
{
Json::Value failure(const std::string &code)
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;
}
@@ -42,6 +54,74 @@ int tokenTtlHours()
.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>> &params)
{
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();
@@ -58,6 +138,107 @@ Json::Value issueToken(long long userId)
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)
@@ -183,6 +364,143 @@ Json::Value AuthService::verifyOtp(const Json::Value &body)
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();
@@ -214,27 +532,32 @@ Json::Value AuthService::currentUser(const auth::UserContext &user)
return response;
}
Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) {
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()) {
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
);
user.id);
hasUpdates = true;
}
}
if (!hasUpdates) {
if (!hasUpdates)
{
return failure("missing_fields");
}
+5
View File
@@ -12,6 +12,11 @@ class AuthService
Json::Value registerCitizen(const Json::Value &body);
Json::Value requestOtp(const Json::Value &body);
Json::Value verifyOtp(const Json::Value &body);
Json::Value startCieLogin(const Json::Value &body);
Json::Value completeCieLogin(const std::string &rawBody,
const Json::Value &body,
const std::string &signatureHeader);
Json::Value cieSessionStatus(const std::string &state);
Json::Value currentUser(const auth::UserContext &user);
Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body);
};