feat: Integrate CIE authentication flow
This commit is contained in:
+333
-10
@@ -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>> ¶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();
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user