From 6cbdf6462d16e29bc71cf6086efdeed72378610c Mon Sep 17 00:00:00 2001 From: gabdevele Date: Wed, 17 Jun 2026 12:53:16 +0200 Subject: [PATCH] feat: Integrate CIE authentication flow --- CMakeLists.txt | 2 + common/Crypto.cc | 26 +++ common/Crypto.h | 2 + common/Http.cc | 32 ++- common/Http.h | 4 +- config.json | 9 +- controllers/AuthController.cc | 54 +++++ controllers/AuthController.h | 19 ++ docs/API_SPEC.md | 4 + docs/ARCHITECTURE.md | 8 +- docs/CIE_AUTH_INTEGRATION.md | 182 +++++++++++++++ docs/DATABASE_SCHEMA.md | 6 + docs/LLM_CONTEXT.md | 10 + docs/openapi/visione.openapi.yaml | 198 ++++++++++++++++- migrations/011_cie_auth_bridge.sql | 36 +++ services/AuthService.cc | 343 ++++++++++++++++++++++++++++- services/AuthService.h | 5 + spid-cie-oidc-django | 1 + test/CMakeLists.txt | 1 + test/test_main.cc | 19 ++ 20 files changed, 946 insertions(+), 15 deletions(-) create mode 100644 docs/CIE_AUTH_INTEGRATION.md create mode 100644 migrations/011_cie_auth_bridge.sql create mode 160000 spid-cie-oidc-django diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c11926..8a15036 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/common/Crypto.cc b/common/Crypto.cc index 3cf0e44..5e2aa15 100644 --- a/common/Crypto.cc +++ b/common/Crypto.cc @@ -1,6 +1,9 @@ #include "common/Crypto.h" #include +#include +#include + #include #include #include @@ -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(key.size()), + reinterpret_cast(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; diff --git a/common/Crypto.h b/common/Crypto.h index 3382009..c9e8975 100644 --- a/common/Crypto.h +++ b/common/Crypto.h @@ -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); diff --git a/common/Http.cc b/common/Http.cc index dfd56cd..1f55870 100644 --- a/common/Http.cc +++ b/common/Http.cc @@ -1,5 +1,9 @@ #include "common/Http.h" +#include + +#include + 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 diff --git a/common/Http.h b/common/Http.h index 678c240..8f7d341 100644 --- a/common/Http.h +++ b/common/Http.h @@ -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 - diff --git a/config.json b/config.json index 7a79e78..a328ef9 100644 --- a/config.json +++ b/config.json @@ -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, @@ -72,4 +79,4 @@ "allowed_origin": "http://localhost:3000" } } -} \ No newline at end of file +} diff --git a/controllers/AuthController.cc b/controllers/AuthController.cc index 762548c..fbfa70a 100644 --- a/controllers/AuthController.cc +++ b/controllers/AuthController.cc @@ -66,6 +66,60 @@ void AuthController::verifyOtp( serviceResponse(service.verifyOtp(body), std::move(callback)); } +void AuthController::startCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&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 &&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 &&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 &&callback) diff --git a/controllers/AuthController.h b/controllers/AuthController.h index e4d2ca8..e58e0dc 100644 --- a/controllers/AuthController.h +++ b/controllers/AuthController.h @@ -15,6 +15,15 @@ class AuthController : public drogon::HttpController 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 void verifyOtp( const drogon::HttpRequestPtr &req, std::function &&callback); + void startCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void completeCieLogin( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void cieSessionStatus( + const drogon::HttpRequestPtr &req, + std::function &&callback, + std::string state); void me(const drogon::HttpRequestPtr &req, std::function &&callback); void editUser(const drogon::HttpRequestPtr &req, diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index aec34c5..d19c355 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -35,6 +35,10 @@ Authorization: Bearer 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` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4620b4d..7ea67c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`. @@ -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. diff --git a/docs/CIE_AUTH_INTEGRATION.md b/docs/CIE_AUTH_INTEGRATION.md new file mode 100644 index 0000000..57266a1 --- /dev/null +++ b/docs/CIE_AUTH_INTEGRATION.md @@ -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=`. +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": "mario.rossi@example.com" +} +``` + +Headers: +- `Content-Type: application/json` +- `X-Visione-Signature: ` + +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 diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index c252896..e832129 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -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` diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 57fbb84..b31f2a7 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -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` diff --git a/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml index 4cfd28a..533f487 100644 --- a/docs/openapi/visione.openapi.yaml +++ b/docs/openapi/visione.openapi.yaml @@ -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.", diff --git a/migrations/011_cie_auth_bridge.sql b/migrations/011_cie_auth_bridge.sql new file mode 100644 index 0000000..ff5d6b6 --- /dev/null +++ b/migrations/011_cie_auth_bridge.sql @@ -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) +); diff --git a/services/AuthService.cc b/services/AuthService.cc index f01262b..b5ec2e7 100644 --- a/services/AuthService.cc +++ b/services/AuthService.cc @@ -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 +#include +#include +#include +#include +#include +#include + 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> ¶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 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) @@ -183,6 +364,143 @@ Json::Value AuthService::verifyOtp(const Json::Value &body) 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(); @@ -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"); } diff --git a/services/AuthService.h b/services/AuthService.h index 695f3c2..03c3e97 100644 --- a/services/AuthService.h +++ b/services/AuthService.h @@ -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); }; diff --git a/spid-cie-oidc-django b/spid-cie-oidc-django new file mode 160000 index 0000000..931c953 --- /dev/null +++ b/spid-cie-oidc-django @@ -0,0 +1 @@ +Subproject commit 931c95394b53c9f8b31a03525759ef99ba29753d diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4912b43..82a7611 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) diff --git a/test/test_main.cc b/test/test_main.cc index 8b56bfb..90b6486 100644 --- a/test/test_main.cc +++ b/test/test_main.cc @@ -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;