feat: Integrate CIE authentication flow
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
- `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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user