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
+2
View File
@@ -8,6 +8,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
find_package(OpenSSL REQUIRED)
option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF)
@@ -71,6 +72,7 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PRIVATE
Drogon::Drogon
OpenSSL::Crypto
)
file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS
+26
View File
@@ -1,6 +1,9 @@
#include "common/Crypto.h"
#include <drogon/utils/Utilities.h>
#include <openssl/crypto.h>
#include <openssl/hmac.h>
#include <iomanip>
#include <random>
#include <sstream>
@@ -27,6 +30,29 @@ std::string sha256(const std::string &value)
return drogon::utils::getSha256(value);
}
std::string hmacSha256Hex(const std::string &key, const std::string &value)
{
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int digestSize = 0;
HMAC(EVP_sha256(),
key.data(),
static_cast<int>(key.size()),
reinterpret_cast<const unsigned char *>(value.data()),
value.size(),
digest,
&digestSize);
return toHex(digest, digestSize);
}
bool constantTimeEquals(const std::string &left, const std::string &right)
{
if (left.size() != right.size())
{
return false;
}
return CRYPTO_memcmp(left.data(), right.data(), left.size()) == 0;
}
std::string randomNumericCode(int digits)
{
std::random_device device;
+2
View File
@@ -5,6 +5,8 @@
namespace visione::crypto
{
std::string sha256(const std::string &value);
std::string hmacSha256Hex(const std::string &key, const std::string &value);
bool constantTimeEquals(const std::string &left, const std::string &right);
std::string randomNumericCode(int digits);
std::string randomToken();
std::string hashSecret(const std::string &secret);
+31 -1
View File
@@ -1,5 +1,9 @@
#include "common/Http.h"
#include <drogon/HttpRequest.h>
#include <sstream>
namespace visione::http
{
drogon::HttpResponsePtr json(Json::Value body, drogon::HttpStatusCode status)
@@ -22,6 +26,14 @@ drogon::HttpResponsePtr error(const std::string &code,
return json(std::move(body), status);
}
drogon::HttpResponsePtr redirect(const std::string &url,
drogon::HttpStatusCode status)
{
auto response = drogon::HttpResponse::newRedirectionResponse(url);
response->setStatusCode(status);
return response;
}
Json::Value requireJson(const drogon::HttpRequestPtr &request)
{
const auto &body = request->getJsonObject();
@@ -31,5 +43,23 @@ Json::Value requireJson(const drogon::HttpRequestPtr &request)
}
return *body;
}
} // namespace visione::http
Json::Value parseJsonBody(const std::string &body)
{
if (body.empty())
{
return Json::Value(Json::nullValue);
}
Json::CharReaderBuilder builder;
builder["collectComments"] = false;
std::string errors;
Json::Value parsed;
std::istringstream stream(body);
if (!Json::parseFromStream(builder, stream, &parsed, &errors))
{
return Json::Value(Json::nullValue);
}
return parsed;
}
} // namespace visione::http
+3 -1
View File
@@ -12,6 +12,8 @@ drogon::HttpResponsePtr json(Json::Value body,
drogon::HttpResponsePtr error(const std::string &code,
drogon::HttpStatusCode status,
const std::string &message = {});
drogon::HttpResponsePtr redirect(const std::string &url,
drogon::HttpStatusCode status = drogon::k302Found);
Json::Value requireJson(const drogon::HttpRequestPtr &request);
Json::Value parseJsonBody(const std::string &body);
} // namespace visione::http
+7
View File
@@ -56,6 +56,13 @@
"token_ttl_hours": 24,
"otp_ttl_minutes": 10
},
"cie_auth": {
"service_base_url": "http://127.0.0.1:8001",
"start_path": "/visione/start",
"shared_secret": "change-me-for-local-dev",
"state_ttl_minutes": 10,
"default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback"
},
"mail": {
"smtp_host": "127.0.0.1",
"smtp_port": 1025,
+54
View File
@@ -66,6 +66,60 @@ void AuthController::verifyOtp(
serviceResponse(service.verifyOtp(body), std::move(callback));
}
void AuthController::startCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
const auto body = visione::http::requireJson(req);
if (body.isNull())
{
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
return;
}
visione::services::AuthService service;
serviceResponse(service.startCieLogin(body), std::move(callback));
}
void AuthController::completeCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
const std::string rawBody(req->body());
const auto body = visione::http::parseJsonBody(rawBody);
if (body.isNull())
{
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
return;
}
visione::services::AuthService service;
auto responseBody =
service.completeCieLogin(rawBody, body, req->getHeader("X-Visione-Signature"));
if (responseBody.get("ok", false).asBool() &&
responseBody.isMember("redirect_url"))
{
callback(visione::http::redirect(responseBody["redirect_url"].asString()));
return;
}
serviceResponse(std::move(responseBody), std::move(callback));
}
void AuthController::cieSessionStatus(
const drogon::HttpRequestPtr & /*req*/,
std::function<void(const HttpResponsePtr &)> &&callback,
std::string state)
{
visione::services::AuthService service;
auto body = service.cieSessionStatus(state);
if (!body.get("ok", false).asBool())
{
callback(visione::http::json(std::move(body), drogon::k404NotFound));
return;
}
callback(visione::http::json(std::move(body)));
}
void AuthController::me(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
+19
View File
@@ -15,6 +15,15 @@ class AuthController : public drogon::HttpController<AuthController>
ADD_METHOD_TO(AuthController::verifyOtp,
"/api/v1/auth/otp/verify",
drogon::Post);
ADD_METHOD_TO(AuthController::startCieLogin,
"/api/v1/auth/cie/start",
drogon::Post);
ADD_METHOD_TO(AuthController::completeCieLogin,
"/api/v1/auth/cie/exchange",
drogon::Post);
ADD_METHOD_TO(AuthController::cieSessionStatus,
"/api/v1/auth/cie/session/{1}",
drogon::Get);
ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch);
METHOD_LIST_END
@@ -28,6 +37,16 @@ class AuthController : public drogon::HttpController<AuthController>
void verifyOtp(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void startCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void completeCieLogin(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void cieSessionStatus(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
std::string state);
void me(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void editUser(const drogon::HttpRequestPtr &req,
+4
View File
@@ -35,6 +35,10 @@ Authorization: Bearer <token>
sends OTP through Mailpit
- `POST /auth/otp/verify`
consumes OTP, returns bearer token
- `POST /auth/cie/start`
creates local CIE login state and returns Django RP authorization URL
- `GET /auth/cie/session/{state}`
frontend polling endpoint; returns final Visione bearer token on success
- `GET /me`
current user profile, roles, permissions, points
- `PATCH /me`
+7 -1
View File
@@ -25,6 +25,10 @@ generate public read-only intervention links.
## Runtime Model
- Auth uses bearer tokens from `api_tokens`.
- OTP login uses `otp_challenges` and Mailpit in local dev.
- CIE login uses external Django RP service:
- Visione creates local `state`
- Django RP completes CIE/OIDC and sends signed identity payload to Visione
- Visione creates or links local user, issues `api_tokens`, frontend polls session status
- DB access uses raw parameterized SQL through `drogon::orm::DbClient`.
- No Drogon generated ORM models. No `Mapper<T>`.
@@ -61,7 +65,7 @@ Effective permission helpers:
## Main Persistence Objects
- `users`, `roles`, `permissions`, `user_roles`, `role_permissions`
- `api_tokens`, `otp_challenges`
- `api_tokens`, `otp_challenges`, `cie_auth_sessions`, `user_external_identities`
- `report_categories`, `areas`, `team_categories`, `teams`
- `reports`, `report_status_history`, `assignments`, `report_photos`
- `messages`, `feedback`, `reward_events`, `notifications`
@@ -97,3 +101,5 @@ Output path:
still exposes citizen phone by design.
- Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard.
- No frontend doc set here beyond API/OpenAPI docs.
- CIE bridge trusts HMAC-signed callback from Django RP service; keep shared
secret rotated and never expose it to frontend.
+182
View File
@@ -0,0 +1,182 @@
# Visione CIE Authentication Integration
## Goal
Keep Visione backend simple.
Visione does not implement OIDC Federation or CIE protocol details directly.
`spid-cie-oidc-django` handles CIE login. Visione only manages:
- local login `state`
- user provisioning
- external identity linking
- local bearer token issuance
## Components
- Visione backend: `POST /api/v1/auth/cie/start`, `POST /api/v1/auth/cie/exchange`,
`GET /api/v1/auth/cie/session/{state}`
- Django RP service: `examples/relying_party`
- Frontend: browser redirect + polling
## End-to-End Flow
1. Frontend calls `POST /api/v1/auth/cie/start` with `frontend_redirect_uri`.
2. Visione stores row in `cie_auth_sessions` and returns `authorization_url`.
3. Frontend redirects browser to returned Django URL.
4. Django RP performs CIE login with provider.
5. On success Django extracts user attributes and sends signed JSON payload to
Visione `POST /api/v1/auth/cie/exchange`.
6. Visione verifies HMAC signature, validates `state`, finds or creates user,
stores `user_external_identities`, issues `api_tokens`, marks session authenticated.
7. Visione responds to Django with HTTP redirect to
`frontend_redirect_uri?state=<state>`.
8. Frontend reads `state` from query string and polls
`GET /api/v1/auth/cie/session/{state}` until authenticated.
9. Frontend stores returned Visione bearer token and calls `GET /api/v1/me`.
## Why This Architecture
- No OIDC/Federation crypto in C++ backend.
- No CIE protocol tables spread across Visione domain code.
- Django demo repo stays reusable and isolated.
- Visione keeps final auth authority for application tokens and user model.
## Visione Backend Config
`config.json`:
```json
"cie_auth": {
"service_base_url": "http://127.0.0.1:8001",
"start_path": "/visione/start",
"shared_secret": "change-me-for-local-dev",
"state_ttl_minutes": 10,
"default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback"
}
```
Meaning:
- `service_base_url`: Django RP base URL
- `start_path`: Django entrypoint used by frontend redirect
- `shared_secret`: HMAC secret shared only between Visione and Django
- `state_ttl_minutes`: local login state lifetime
- `default_frontend_redirect_uri`: fallback if frontend omits value
## Visione DB Additions
- `cie_auth_sessions`
stores local state, frontend redirect, final user id, final bearer token
- `user_external_identities`
stores `provider`, `issuer`, `subject`, `fiscal_code`, raw claims
User matching strategy:
1. exact external identity on `provider + issuer + subject`
2. fallback reunification on `fiscal_code`
3. otherwise create new `citizen`
## Signed Exchange Contract
Django sends `POST /api/v1/auth/cie/exchange` with JSON:
```json
{
"state": "visione-login-state",
"issuer": "http://127.0.0.1:8002/oidc/op",
"subject": "provider-subject",
"fiscal_code": "RSSMRA80A01H501U",
"first_name": "Mario",
"last_name": "Rossi",
"email": "[email protected]"
}
```
Headers:
- `Content-Type: application/json`
- `X-Visione-Signature: <UPPERCASE_HEX_HMAC_SHA256>`
Signature algorithm:
- payload bytes exactly as sent
- key = `cie_auth.shared_secret`
- HMAC-SHA256
- uppercase hex output
## Django Example Customization
Modified files in `spid-cie-oidc-django/examples/relying_party`:
- `relying_party/settingslocal.py.example`
- `relying_party/urls.py`
- `relying_party/visione_views.py`
Added endpoints:
- `GET /visione/start`
stores Visione `state` in Django session, then redirects to `/oidc/rp/authorization`
- `GET /visione/complete`
reads `oidc_rp_user_attrs`, signs payload, calls Visione exchange endpoint,
then redirects browser to frontend callback URL returned by Visione
Important:
- default OIDC callback remains managed by `spid_cie_oidc.relying_party`
- `LOGIN_REDIRECT_URL` now points to `/visione/complete`
- no need to modify RP metadata dump callback URI for this integration
## Frontend Responsibilities
1. Call:
```http
POST /api/v1/auth/cie/start
Content-Type: application/json
```
```json
{
"frontend_redirect_uri": "http://localhost:3000/auth/cie/callback"
}
```
2. Redirect browser to `authorization_url`.
3. On frontend callback page, read `state` from query string.
4. Poll:
```http
GET /api/v1/auth/cie/session/{state}
```
5. Handle states:
- `pending`: keep polling
- `authenticated`: save `auth.token`
- `failed`: show error and restart flow
6. After token, call `GET /api/v1/me`.
Frontend does not need:
- CIE secrets
- Django shared secret
- OIDC token parsing
## Local Dev Setup
1. Start Visione dependencies:
```bash
docker compose up -d
```
2. Run Visione:
```bash
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
./build/visione-backend
```
3. In separate repo checkout for `spid-cie-oidc-django`, configure:
- federation authority example
- provider example
- relying party example with `VISIONE_BACKEND_EXCHANGE_URL`
- same `VISIONE_SHARED_SECRET` as Visione `cie_auth.shared_secret`
4. Start Django RP on `http://127.0.0.1:8001`.
## Security Notes
- Shared secret must never reach frontend.
- `cie_auth.shared_secret` must change outside local dev.
- Keep `state_ttl_minutes` short.
- Visione bearer token stays out of query string; only `state` goes to frontend.
- Django service is trusted bridge. Protect it as internal auth component.
## Future Improvements
- Mark failed sessions explicitly when Django login fails
- Rotate HMAC secret through environment variables
- Add replay protection with timestamp/nonce in signed payload
- Add integration tests between Django RP and Visione
+6
View File
@@ -11,6 +11,8 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB
notifications, moderation flags, reward uniqueness
- `007_reference_data_and_intervention_links.sql`
category-to-team-category mapping, public intervention links
- `011_cie_auth_bridge.sql`
lightweight CIE bridge sessions and external identity links
## Core Auth Tables
- `users`
@@ -23,6 +25,10 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB
OTP flow, hashed codes, expiry, consumption
- `api_tokens`
bearer token hashes, expiry, revocation
- `cie_auth_sessions`
frontend-visible CIE login state, final issued token, expiry
- `user_external_identities`
links local users to external providers using `provider + issuer + subject`
## Reference Data
- `report_categories`
+10
View File
@@ -31,9 +31,18 @@ public revocable link generated by operator/admin.
- Registration creates `citizen`.
- Login path:
- OTP email via Mailpit
- CIE via external `spid-cie-oidc-django` relying party service
- Auth token: bearer token from `api_tokens`
- Banned users cannot authenticate.
### CIE Bridge Flow
1. Frontend calls `POST /api/v1/auth/cie/start`.
2. Visione creates `cie_auth_sessions.state` and returns Django start URL.
3. Browser completes CIE login on Django RP service.
4. Django sends signed payload to `POST /api/v1/auth/cie/exchange`.
5. Visione finds or creates user, stores `user_external_identities`, issues token.
6. Frontend polls `GET /api/v1/auth/cie/session/{state}` until `authenticated`.
## Current Report Workflow
### Citizen flow
1. Register/login.
@@ -116,6 +125,7 @@ public revocable link generated by operator/admin.
## Files To Read First
- `docs/API_SPEC.md`
- `docs/ARCHITECTURE.md`
- `docs/CIE_AUTH_INTEGRATION.md`
- `docs/openapi/visione.openapi.yaml`
- `controllers/*.h`
- `services/*.cc`
+197 -1
View File
@@ -1496,6 +1496,99 @@
],
"type": "object"
},
"CieStartRequest": {
"additionalProperties": false,
"properties": {
"frontend_redirect_uri": {
"type": "string"
}
},
"required": [
"frontend_redirect_uri"
],
"type": "object"
},
"CieStartResponse": {
"additionalProperties": false,
"properties": {
"authorization_url": {
"type": "string"
},
"ok": {
"type": "boolean"
},
"state": {
"type": "string"
},
"status": {
"type": "string"
}
},
"required": [
"ok",
"authorization_url",
"state",
"status"
],
"type": "object"
},
"CieExchangeRequest": {
"additionalProperties": false,
"properties": {
"email": {
"type": "string"
},
"fiscal_code": {
"type": "string"
},
"first_name": {
"type": "string"
},
"issuer": {
"type": "string"
},
"last_name": {
"type": "string"
},
"state": {
"type": "string"
},
"subject": {
"type": "string"
}
},
"required": [
"state",
"issuer",
"subject",
"fiscal_code"
],
"type": "object"
},
"CieSessionResponse": {
"additionalProperties": false,
"properties": {
"auth": {
"additionalProperties": true,
"type": "object"
},
"ok": {
"type": "boolean"
},
"status": {
"type": "string"
},
"user_id": {
"format": "int64",
"type": "integer"
}
},
"required": [
"ok",
"status"
],
"type": "object"
},
"UpdateModerationRequest": {
"additionalProperties": false,
"properties": {
@@ -1610,7 +1703,110 @@
"version": "0.1.0"
},
"openapi": "3.0.3",
"paths": {
"paths": {
"/api/v1/auth/cie/exchange": {
"post": {
"description": "Completes CIE bridge exchange after Django RP verifies the identity and posts signed user claims.",
"operationId": "AuthController.completeCieLogin",
"parameters": [ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieExchangeRequest"
}
}
},
"required": true
},
"responses": {
"302": {
"description": "Redirect to frontend callback URL with state query parameter."
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
},
"summary": "Complete CIE login",
"tags": [
"Auth"
]
}
},
"/api/v1/auth/cie/session/{id}": {
"get": {
"description": "Returns status for CIE login session started by Visione.",
"operationId": "AuthController.cieSessionStatus",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieSessionResponse"
}
}
},
"description": "Current login session status."
},
"404": {
"$ref": "#/components/responses/NotFound"
}
},
"summary": "CIE session status",
"tags": [
"Auth"
]
}
},
"/api/v1/auth/cie/start": {
"post": {
"description": "Creates a local CIE login session and returns Django RP authorization URL.",
"operationId": "AuthController.startCieLogin",
"parameters": [ ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieStartRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CieStartResponse"
}
}
},
"description": "Authorization URL and local state."
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"summary": "Start CIE login",
"tags": [
"Auth"
]
}
},
"/api/v1/admin/areas": {
"post": {
"description": "Creates a municipal area with GeoJSON boundary.",
+36
View File
@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS cie_auth_sessions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
state VARCHAR(128) NOT NULL,
frontend_redirect_uri VARCHAR(500) NOT NULL,
status ENUM('PENDING', 'AUTHENTICATED', 'FAILED') NOT NULL DEFAULT 'PENDING',
issuer VARCHAR(255) NULL,
subject VARCHAR(255) NULL,
fiscal_code VARCHAR(16) NULL,
authenticated_user_id BIGINT UNSIGNED NULL,
result_token VARCHAR(255) NULL,
raw_claims JSON NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY cie_auth_sessions_state_unique (state),
KEY cie_auth_sessions_status_idx (status),
KEY cie_auth_sessions_expires_idx (expires_at),
CONSTRAINT cie_auth_sessions_user_fk FOREIGN KEY (authenticated_user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_external_identities (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
provider VARCHAR(50) NOT NULL,
issuer VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
fiscal_code VARCHAR(16) NOT NULL,
raw_claims JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY user_external_identities_provider_subject_unique (provider, issuer, subject),
KEY user_external_identities_user_idx (user_id),
KEY user_external_identities_fiscal_code_idx (fiscal_code),
CONSTRAINT user_external_identities_user_fk FOREIGN KEY (user_id) REFERENCES users(id)
);
+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);
};
Submodule spid-cie-oidc-django added at 931c95394b
+1
View File
@@ -11,6 +11,7 @@ target_include_directories(${PROJECT_NAME}_test
target_link_libraries(${PROJECT_NAME}_test
PRIVATE
Drogon::Drogon
OpenSSL::Crypto
)
ParseAndAddDrogonTests(${PROJECT_NAME}_test)
+19
View File
@@ -5,6 +5,7 @@
#include "common/GeoJson.h"
#include "common/Crypto.h"
#include "common/Http.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
@@ -76,6 +77,24 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
CHECK(json["phone"].isString());
}
DROGON_TEST(CryptoHmacIsDeterministic)
{
const auto digest = visione::crypto::hmacSha256Hex("secret", "{\"a\":1}");
CHECK(digest == "AA9E2E3575F5D7098B6CACCDB4486EA647EFDC3E2CA6A5CEE762FCAFD14E3F14");
CHECK(visione::crypto::constantTimeEquals(digest, digest));
CHECK(!visione::crypto::constantTimeEquals(digest, "different"));
}
DROGON_TEST(ParseJsonBodyRejectsInvalidPayload)
{
const auto parsed = visione::http::parseJsonBody("{\"ok\":true}");
CHECK(parsed.isObject());
CHECK(parsed["ok"].asBool());
const auto invalid = visione::http::parseJsonBody("{oops");
CHECK(invalid.isNull());
}
DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
{
Json::Value polygon;