#include "services/AuthService.h" #include "common/Email.h" #include "common/Crypto.h" #include "common/Validation.h" #include #include #include #include #include #include #include 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> ¶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 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(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 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(); 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(); 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(); 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(); 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(), 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()); return issueToken(challenge[0]["user_id"].as()); } 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(); const auto status = session[0]["status"].as(); if (status == "AUTHENTICATED") { auto response = success(); response["redirect_url"] = frontendRedirectWithState( session[0]["frontend_redirect_uri"].as(), *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(), *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(); if (status == "AUTHENTICATED") { response["status"] = "authenticated"; response["auth"]["token"] = session[0]["result_token"].as(); response["auth"]["token_type"] = "Bearer"; response["auth"]["expires_in_hours"] = tokenTtlHours(); response["user_id"] = Json::Int64(session[0]["authenticated_user_id"].as()); 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()); response["user"]["first_name"] = result[0]["first_name"].as(); response["user"]["last_name"] = result[0]["last_name"].as(); response["user"]["email"] = result[0]["email"].as(); response["user"]["fiscal_code"] = result[0]["fiscal_code"].as(); response["user"]["phone"] = result[0]["phone"].as(); response["user"]["points"] = result[0]["points"].as(); 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