new cie implementation #2
@@ -8,6 +8,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
|||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||||
|
find_package(OpenSSL REQUIRED)
|
||||||
|
|
||||||
option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF)
|
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}
|
target_link_libraries(${PROJECT_NAME}
|
||||||
PRIVATE
|
PRIVATE
|
||||||
Drogon::Drogon
|
Drogon::Drogon
|
||||||
|
OpenSSL::Crypto
|
||||||
)
|
)
|
||||||
|
|
||||||
file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS
|
file(GLOB CONTROLLER_HEADERS CONFIGURE_DEPENDS
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
#include "common/Crypto.h"
|
#include "common/Crypto.h"
|
||||||
|
|
||||||
#include <drogon/utils/Utilities.h>
|
#include <drogon/utils/Utilities.h>
|
||||||
|
#include <openssl/crypto.h>
|
||||||
|
#include <openssl/hmac.h>
|
||||||
|
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -27,6 +30,29 @@ std::string sha256(const std::string &value)
|
|||||||
return drogon::utils::getSha256(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::string randomNumericCode(int digits)
|
||||||
{
|
{
|
||||||
std::random_device device;
|
std::random_device device;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
namespace visione::crypto
|
namespace visione::crypto
|
||||||
{
|
{
|
||||||
std::string sha256(const std::string &value);
|
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 randomNumericCode(int digits);
|
||||||
std::string randomToken();
|
std::string randomToken();
|
||||||
std::string hashSecret(const std::string &secret);
|
std::string hashSecret(const std::string &secret);
|
||||||
|
|||||||
+31
-1
@@ -1,5 +1,9 @@
|
|||||||
#include "common/Http.h"
|
#include "common/Http.h"
|
||||||
|
|
||||||
|
#include <drogon/HttpRequest.h>
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
namespace visione::http
|
namespace visione::http
|
||||||
{
|
{
|
||||||
drogon::HttpResponsePtr json(Json::Value body, drogon::HttpStatusCode status)
|
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);
|
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)
|
Json::Value requireJson(const drogon::HttpRequestPtr &request)
|
||||||
{
|
{
|
||||||
const auto &body = request->getJsonObject();
|
const auto &body = request->getJsonObject();
|
||||||
@@ -31,5 +43,23 @@ Json::Value requireJson(const drogon::HttpRequestPtr &request)
|
|||||||
}
|
}
|
||||||
return *body;
|
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
@@ -12,6 +12,8 @@ drogon::HttpResponsePtr json(Json::Value body,
|
|||||||
drogon::HttpResponsePtr error(const std::string &code,
|
drogon::HttpResponsePtr error(const std::string &code,
|
||||||
drogon::HttpStatusCode status,
|
drogon::HttpStatusCode status,
|
||||||
const std::string &message = {});
|
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 requireJson(const drogon::HttpRequestPtr &request);
|
||||||
|
Json::Value parseJsonBody(const std::string &body);
|
||||||
} // namespace visione::http
|
} // namespace visione::http
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -56,6 +56,13 @@
|
|||||||
"token_ttl_hours": 24,
|
"token_ttl_hours": 24,
|
||||||
"otp_ttl_minutes": 10
|
"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": {
|
"mail": {
|
||||||
"smtp_host": "127.0.0.1",
|
"smtp_host": "127.0.0.1",
|
||||||
"smtp_port": 1025,
|
"smtp_port": 1025,
|
||||||
@@ -72,4 +79,4 @@
|
|||||||
"allowed_origin": "http://localhost:3000"
|
"allowed_origin": "http://localhost:3000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,60 @@ void AuthController::verifyOtp(
|
|||||||
serviceResponse(service.verifyOtp(body), std::move(callback));
|
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(
|
void AuthController::me(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback)
|
std::function<void(const HttpResponsePtr &)> &&callback)
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ class AuthController : public drogon::HttpController<AuthController>
|
|||||||
ADD_METHOD_TO(AuthController::verifyOtp,
|
ADD_METHOD_TO(AuthController::verifyOtp,
|
||||||
"/api/v1/auth/otp/verify",
|
"/api/v1/auth/otp/verify",
|
||||||
drogon::Post);
|
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::me, "/api/v1/me", drogon::Get);
|
||||||
ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch);
|
ADD_METHOD_TO(AuthController::editUser, "/api/v1/me", drogon::Patch);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
@@ -28,6 +37,16 @@ class AuthController : public drogon::HttpController<AuthController>
|
|||||||
void verifyOtp(
|
void verifyOtp(
|
||||||
const drogon::HttpRequestPtr &req,
|
const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
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,
|
void me(const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
|
||||||
void editUser(const drogon::HttpRequestPtr &req,
|
void editUser(const drogon::HttpRequestPtr &req,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ Authorization: Bearer <token>
|
|||||||
sends OTP through Mailpit
|
sends OTP through Mailpit
|
||||||
- `POST /auth/otp/verify`
|
- `POST /auth/otp/verify`
|
||||||
consumes OTP, returns bearer token
|
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`
|
- `GET /me`
|
||||||
current user profile, roles, permissions, points
|
current user profile, roles, permissions, points
|
||||||
- `PATCH /me`
|
- `PATCH /me`
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ generate public read-only intervention links.
|
|||||||
## Runtime Model
|
## Runtime Model
|
||||||
- Auth uses bearer tokens from `api_tokens`.
|
- Auth uses bearer tokens from `api_tokens`.
|
||||||
- OTP login uses `otp_challenges` and Mailpit in local dev.
|
- 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`.
|
- DB access uses raw parameterized SQL through `drogon::orm::DbClient`.
|
||||||
- No Drogon generated ORM models. No `Mapper<T>`.
|
- No Drogon generated ORM models. No `Mapper<T>`.
|
||||||
|
|
||||||
@@ -61,7 +65,7 @@ Effective permission helpers:
|
|||||||
|
|
||||||
## Main Persistence Objects
|
## Main Persistence Objects
|
||||||
- `users`, `roles`, `permissions`, `user_roles`, `role_permissions`
|
- `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`
|
- `report_categories`, `areas`, `team_categories`, `teams`
|
||||||
- `reports`, `report_status_history`, `assignments`, `report_photos`
|
- `reports`, `report_status_history`, `assignments`, `report_photos`
|
||||||
- `messages`, `feedback`, `reward_events`, `notifications`
|
- `messages`, `feedback`, `reward_events`, `notifications`
|
||||||
@@ -97,3 +101,5 @@ Output path:
|
|||||||
still exposes citizen phone by design.
|
still exposes citizen phone by design.
|
||||||
- Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard.
|
- Allowed-area containment for admin areas is vertex-based, simple not strict GIS-hard.
|
||||||
- No frontend doc set here beyond API/OpenAPI docs.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,6 +11,8 @@ Schema truth is `migrations/*.sql`. This file is compact human map of current DB
|
|||||||
notifications, moderation flags, reward uniqueness
|
notifications, moderation flags, reward uniqueness
|
||||||
- `007_reference_data_and_intervention_links.sql`
|
- `007_reference_data_and_intervention_links.sql`
|
||||||
category-to-team-category mapping, public intervention links
|
category-to-team-category mapping, public intervention links
|
||||||
|
- `011_cie_auth_bridge.sql`
|
||||||
|
lightweight CIE bridge sessions and external identity links
|
||||||
|
|
||||||
## Core Auth Tables
|
## Core Auth Tables
|
||||||
- `users`
|
- `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
|
OTP flow, hashed codes, expiry, consumption
|
||||||
- `api_tokens`
|
- `api_tokens`
|
||||||
bearer token hashes, expiry, revocation
|
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
|
## Reference Data
|
||||||
- `report_categories`
|
- `report_categories`
|
||||||
|
|||||||
@@ -31,9 +31,18 @@ public revocable link generated by operator/admin.
|
|||||||
- Registration creates `citizen`.
|
- Registration creates `citizen`.
|
||||||
- Login path:
|
- Login path:
|
||||||
- OTP email via Mailpit
|
- OTP email via Mailpit
|
||||||
|
- CIE via external `spid-cie-oidc-django` relying party service
|
||||||
- Auth token: bearer token from `api_tokens`
|
- Auth token: bearer token from `api_tokens`
|
||||||
- Banned users cannot authenticate.
|
- 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
|
## Current Report Workflow
|
||||||
### Citizen flow
|
### Citizen flow
|
||||||
1. Register/login.
|
1. Register/login.
|
||||||
@@ -116,6 +125,7 @@ public revocable link generated by operator/admin.
|
|||||||
## Files To Read First
|
## Files To Read First
|
||||||
- `docs/API_SPEC.md`
|
- `docs/API_SPEC.md`
|
||||||
- `docs/ARCHITECTURE.md`
|
- `docs/ARCHITECTURE.md`
|
||||||
|
- `docs/CIE_AUTH_INTEGRATION.md`
|
||||||
- `docs/openapi/visione.openapi.yaml`
|
- `docs/openapi/visione.openapi.yaml`
|
||||||
- `controllers/*.h`
|
- `controllers/*.h`
|
||||||
- `services/*.cc`
|
- `services/*.cc`
|
||||||
|
|||||||
@@ -1496,6 +1496,99 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"UpdateModerationRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1610,7 +1703,110 @@
|
|||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
"openapi": "3.0.3",
|
"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": {
|
"/api/v1/admin/areas": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Creates a municipal area with GeoJSON boundary.",
|
"description": "Creates a municipal area with GeoJSON boundary.",
|
||||||
|
|||||||
@@ -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
@@ -1,21 +1,33 @@
|
|||||||
#include "services/AuthService.h"
|
#include "services/AuthService.h"
|
||||||
|
|
||||||
#include "common/Email.h"
|
#include "common/Email.h"
|
||||||
|
#include "common/Crypto.h"
|
||||||
#include "common/Validation.h"
|
#include "common/Validation.h"
|
||||||
|
|
||||||
#include "common/Crypto.h"
|
|
||||||
|
|
||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <json/writer.h>
|
||||||
|
#include <optional>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace visione::services
|
namespace visione::services
|
||||||
{
|
{
|
||||||
namespace
|
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;
|
Json::Value body;
|
||||||
body["ok"] = false;
|
body["ok"] = false;
|
||||||
body["error"] = code;
|
body["error"] = code;
|
||||||
|
if (!message.empty())
|
||||||
|
{
|
||||||
|
body["message"] = message;
|
||||||
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +54,74 @@ int tokenTtlHours()
|
|||||||
.asInt();
|
.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)
|
Json::Value issueToken(long long userId)
|
||||||
{
|
{
|
||||||
auto token = crypto::randomToken();
|
auto token = crypto::randomToken();
|
||||||
@@ -58,6 +138,107 @@ Json::Value issueToken(long long userId)
|
|||||||
body["expires_in_hours"] = tokenTtlHours();
|
body["expires_in_hours"] = tokenTtlHours();
|
||||||
return body;
|
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
|
} // namespace
|
||||||
|
|
||||||
Json::Value AuthService::registerCitizen(const Json::Value &body)
|
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>());
|
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)
|
Json::Value AuthService::currentUser(const auth::UserContext &user)
|
||||||
{
|
{
|
||||||
auto db = drogon::app().getDbClient();
|
auto db = drogon::app().getDbClient();
|
||||||
@@ -214,27 +532,32 @@ Json::Value AuthService::currentUser(const auth::UserContext &user)
|
|||||||
return response;
|
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"};
|
const auto allowedFields = {"first_name", "last_name", "phone"};
|
||||||
auto db = drogon::app().getDbClient();
|
auto db = drogon::app().getDbClient();
|
||||||
bool hasUpdates = false;
|
bool hasUpdates = false;
|
||||||
|
|
||||||
for (const auto& field : allowedFields) {
|
for (const auto &field : allowedFields)
|
||||||
if (body.isMember(field)) {
|
{
|
||||||
if (!body[field].isString()) {
|
if (body.isMember(field))
|
||||||
|
{
|
||||||
|
if (!body[field].isString())
|
||||||
|
{
|
||||||
return failure("invalid_field_type");
|
return failure("invalid_field_type");
|
||||||
}
|
}
|
||||||
|
|
||||||
db->execSqlSync(
|
db->execSqlSync(
|
||||||
"UPDATE users SET " + std::string(field) + " = ? WHERE id = ?",
|
"UPDATE users SET " + std::string(field) + " = ? WHERE id = ?",
|
||||||
body[field].asString(),
|
body[field].asString(),
|
||||||
user.id
|
user.id);
|
||||||
);
|
|
||||||
hasUpdates = true;
|
hasUpdates = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasUpdates) {
|
if (!hasUpdates)
|
||||||
|
{
|
||||||
return failure("missing_fields");
|
return failure("missing_fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ class AuthService
|
|||||||
Json::Value registerCitizen(const Json::Value &body);
|
Json::Value registerCitizen(const Json::Value &body);
|
||||||
Json::Value requestOtp(const Json::Value &body);
|
Json::Value requestOtp(const Json::Value &body);
|
||||||
Json::Value verifyOtp(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 currentUser(const auth::UserContext &user);
|
||||||
Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body);
|
Json::Value editUser(const auth::UserContext & user, Json::Value::const_iterator::reference body);
|
||||||
};
|
};
|
||||||
|
|||||||
Submodule
+1
Submodule spid-cie-oidc-django added at 931c95394b
@@ -11,6 +11,7 @@ target_include_directories(${PROJECT_NAME}_test
|
|||||||
target_link_libraries(${PROJECT_NAME}_test
|
target_link_libraries(${PROJECT_NAME}_test
|
||||||
PRIVATE
|
PRIVATE
|
||||||
Drogon::Drogon
|
Drogon::Drogon
|
||||||
|
OpenSSL::Crypto
|
||||||
)
|
)
|
||||||
|
|
||||||
ParseAndAddDrogonTests(${PROJECT_NAME}_test)
|
ParseAndAddDrogonTests(${PROJECT_NAME}_test)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include "common/GeoJson.h"
|
#include "common/GeoJson.h"
|
||||||
#include "common/Crypto.h"
|
#include "common/Crypto.h"
|
||||||
|
#include "common/Http.h"
|
||||||
#include "common/Validation.h"
|
#include "common/Validation.h"
|
||||||
#include "repositories/Sql.h"
|
#include "repositories/Sql.h"
|
||||||
|
|
||||||
@@ -76,6 +77,24 @@ DROGON_TEST(SqlScalarSerializationKeepsTypes)
|
|||||||
CHECK(json["phone"].isString());
|
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)
|
DROGON_TEST(GeoJsonMatchesPointInsidePolygon)
|
||||||
{
|
{
|
||||||
Json::Value polygon;
|
Json::Value polygon;
|
||||||
|
|||||||
Reference in New Issue
Block a user