release version 4

This commit is contained in:
Sergio Peñafiel 2025-10-17 17:32:44 -03:00
parent a1651c914e
commit 33e99c167d
13 changed files with 444 additions and 21 deletions

View File

@ -18,3 +18,4 @@ WALLET_ENABLED=false
WALLET_URL=https://conectathon-balancer.izer.tech
WALLET_IDENTIFIER=test
WALLET_API_KEY=""
ICVP_VALIDATOR_URL=http://lacpass.create.cl:7089

View File

@ -24,6 +24,7 @@ type Config struct {
WalletUrl string
WalletIdentifier string
WalletAPIKey string
ICVPValidatorUrl string
}
func LoadConfig() Config {
@ -39,13 +40,14 @@ func LoadConfig() Config {
AuthEmailClientID: "app",
FhirBaseUrl: "http://lacpass.create.cl:8080",
VhlBaseUrl: "http://lacpass.create.cl:8182",
FhirMediatorBaseUrl: "http://lacpass.create.cl:3000/",
FhirMediatorBaseUrl: "http://lacpass.create.cl:3000",
APISwagger: false,
LogLevel: "info",
WalletEnabled: false,
WalletUrl: "https://conectathon-balancer.izer.tech/",
WalletIdentifier: "test",
WalletAPIKey: "",
ICVPValidatorUrl: "http://lacpass.create.cl:7089",
}
if serverPort, exists := os.LookupEnv("API_PORT"); exists {
@ -112,5 +114,9 @@ func LoadConfig() Config {
cfg.WalletAPIKey = walletAPIKey
}
if icvpValidatorUrl, exists := os.LookupEnv("ICVP_VALIDATOR_URL"); exists {
cfg.ICVPValidatorUrl = icvpValidatorUrl
}
return cfg
}

View File

@ -140,11 +140,12 @@ func (a *App) loadIpsRoute(router chi.Router) {
}
func (a *App) loadVhlRoute(router chi.Router) {
r := vhlClient.NewClient(a.config.VhlBaseUrl)
r := vhlClient.NewClient(a.config.VhlBaseUrl, a.config.ICVPValidatorUrl)
s := vhlCore.NewService(&r)
h := vhlHandler.NewHandler(&s)
router.Post("/", h.Create)
router.Post("/fetch", h.Get)
router.Post("/validate", h.Validate)
}
func (a *App) loadWalletRoutes(router chi.Router) {

View File

@ -12,7 +12,7 @@ services:
environment:
API_PORT: ${API_PORT:-3000}
AUTH_INTERNAL_URL: ${AUTH_INTERNAL_URL:-http://auth:8080}
AUTH_HOSTNAME: ${KEYCLOAK_URL:-http://localhost:9083}
AUTH_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:9083}
AUTH_REALM: ${KEYCLOAK_REALM:-lacpass}
AUTH_CLIENT_ID: ${AUTH_CLIENT_ID:-admin-cli}
# Need to set this after creating a client for Keycloak Admin API access, using service account
@ -27,6 +27,7 @@ services:
WALLET_URL: ${WALLET_URL:-https://conectathon-balancer.izer.tech/}
WALLET_IDENTIFIER: ${WALLET_IDENTIFIER:-test}
WALLET_API_KEY: ${WALLET_API_KEY:-}
ICVP_VALIDATOR_URL: ${ICVP_VALIDATOR_URL:-http://lacpass.create.cl:7089}
ports:
- "9081:3000"
healthcheck:

View File

@ -14,7 +14,7 @@ Then, to start keycloak we can run it from the root directory with docker compos
docker compose --file=./docker/compose.yaml up auth
```
When the service starts we can visit http://localhost:8082 and check that is running correctly. The admin user will have
When the service starts we can visit http://localhost:8082 and check that is running correctly. The admin user will have
the same credentials specified in the `.env` file. A default realm `lacpass` will be created. The [openid configuration](http://localhost:8082/realms/lacpass/.well-known/openid-configuration)
should be as follows:
@ -30,7 +30,7 @@ should be as follows:
}
```
To create a test user we can enter our [local instance](http://localhost:8082) and then in the `Manage realms` tab,
To create a test user we can enter our [local instance](http://localhost:8082) and then in the `Manage realms` tab,
select `lacpass` realm.
![](./images/keycloak_realms.png "Keycloak realms")
@ -39,17 +39,22 @@ And then go to the `Users` tab and create a new user:
![](./images/keycloak_users.png "Keycloak users")
> In the compose we have a mail-catcher container running on port 25 that will show you any email sent by keycloak to
the users registered. This emails will not be sent out is just for development.
> In the compose we have a mail-catcher container running on port 25 that will show you any email sent by keycloak to
> the users registered. This emails will not be sent out is just for development.
Once the user is created, we can use the helper script to get an access token from Keycloak:
```bash
sh scripts/auth.sh
sh scripts/auth.sh access-token
```
The access token will show after the command, like this:
```bash
Successfully logged in!
Access Token:
XXXXX.VVVV.BBBB
```
For this to work we need to define both `KEYCLOAK_DEFAULT_USER_EMAIL` and `KEYCLOAK_DEFAULT_USER_PASSWORD` in our `.env`
file.

View File

@ -54,3 +54,6 @@ Fhir server endpoint for FHIR IPS managment. Default: `http://lacpass.create.cl:
`VHL_BASE_URL`
VHL server endpoint for VHL QR generation, validation and retrieve. Default: `http://lacpass.create.cl:8182`
`ICVP_VALIDATOR_URL`
Endpoint to validate the QR content of ICVPs not linked to an IPS. Default: `http://lacpass.create.cl:7089`

View File

@ -100,3 +100,49 @@ To do this:
4. On the bottom set your STMP credentials in the **Connection & Authentication** section and save.
![Configure your SMTP](./images/email_config/configure_smtp.png)
## Enable HTTP access
> [!WARNING]
> Do not use HTTP in Production, since this will expose users data. We recomended always use a secure HTTPS endpoint.
By default Keycloak supports to be hosted only in a HTTPS endpoint. If you host it in a HTTP endpoint, a error message will be displayed when trying to login
into the webclient, and when trying yo authenticate an user.
To disable the required HTTPS, please add to `docker/compose.yaml` this environment variables for `auth`
service, under the `environments` section:
```yaml
environment:
#...
KC_HOSTNAME_STRICT: false
KC_HOSTNAME_STRICT_HTTPS: false
```
This will tell keycloak to not require a HTTPS connection.
But if you already build and started your keycloak service, this parameter is already saved
into keycloak's database. To override it we need to go into the `auth-db` container.
To get inside the `auth-db` container, please run the next command in the project root folder:
```bash
docker compose exec -it auth-db sh
```
This will start initialize a shell console inside the container. Then to connect to the
database run the following command, where `<POSTGRES_USER>` is the enviroment variable in your `.env` file:
```bash
psql -U <POSTGRES_USER> keycloak
```
This will connect you into a Postgres console inside the keycloak database. Finally run
```sql
UPDATE realm SET ssl_required='NONE' WHERE name='master';
UPDATE realm SET ssl_required='NONE' WHERE name='lacpass';
```
This will update the keycloak parameters to not required HTTPS.
To exit just run `exit` twice, one to get out of Postgres and a second time to get out of the container.

View File

@ -12,17 +12,20 @@ import (
type ClientInterface interface {
GetDocumentReference(identifier string) (*Bundle, error)
GetIpsBundle(url string) (map[string]interface{}, error)
GetIpsICVP(idBundle string, immunizationId *string) (string, error)
}
type IpsClient struct {
Client *http.Client
BaseURL string
Client *http.Client
BaseURL string
MediatorBaseURL string
}
func NewClient(baseURL string) IpsClient {
func NewClient(baseURL string, mediatorBaseURL string) IpsClient {
return IpsClient{
Client: &http.Client{},
BaseURL: baseURL,
Client: &http.Client{},
BaseURL: baseURL,
MediatorBaseURL: mediatorBaseURL,
}
}
@ -62,3 +65,12 @@ func (c *IpsClient) GetIpsBundle(url string) (map[string]interface{}, error) {
Err: fmt.Errorf("failed to get document reference"),
}
}
func (c *IpsClient) GetIpsICVP(idBundle string, immunizationId *string) (string, error) {
// TODO: To be implemented by the participant
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "Not implemented error", "message": "this method is not implemented yet"}},
Err: fmt.Errorf("failed to get document reference"),
}
}

View File

@ -7,6 +7,7 @@ import (
"ips-lacpass-backend/internal/ips/client"
customErrors "ips-lacpass-backend/pkg/errors"
authMiddleware "ips-lacpass-backend/pkg/middleware"
"log/slog"
"slices"
"sort"
"strings"
@ -143,6 +144,113 @@ func removeDuplicates(entries []Entry) []Entry {
return result
}
func findCountryInBundle(bundle Bundle) string {
for _, entry := range bundle.Entry {
if entry.Resource == nil {
slog.Warn("No resource found in bundle: ", entry.FullURL)
continue
}
rtype, ok := entry.Resource["resourceType"]
if !ok || rtype == nil || rtype != "Organization" {
slog.Warn("Resource type is not an Organization: ", entry.FullURL)
continue
}
addressEntry, ok := entry.Resource["address"]
if !ok || addressEntry == nil {
slog.Warn("Resource has has noo Address: ", entry.FullURL)
continue
}
addresses, ok := entry.Resource["address"].([]interface{})
if !ok || len(addresses) == 0 {
slog.Warn("Resource has no address: ", entry.FullURL)
continue
}
address, ok := addresses[0].(map[string]interface{})
if !ok || address == nil {
slog.Warn("Resource address is not a map: ", entry.FullURL)
continue
}
country, ok := address["country"]
if !ok || country == nil {
slog.Warn("Resource address has no country: ", entry.FullURL)
continue
}
countryCode, ok := country.(string)
if !ok {
slog.Warn("Resource country is not a string: ", entry.FullURL)
continue
}
return countryCode
}
return ""
}
func findOrganizationEntries(bundle Bundle) []Entry {
var entries []Entry
for _, entry := range bundle.Entry {
if entry.Resource == nil {
slog.Warn("No resource found in bundle: ", entry.FullURL)
continue
}
rtype, ok := entry.Resource["resourceType"]
if !ok || rtype == nil || rtype != "Organization" {
continue
}
entries = append(entries, entry)
}
return entries
}
func addOriginExtension(resource map[string]interface{}, bundleID string, country string) {
if resource == nil {
return
}
originExtension := map[string]interface{}{
"url": "http://lacpass.org/fhir/StructureDefinition/resource-origin",
"extension": []map[string]interface{}{
{
"url": "bundleId",
"valueString": bundleID,
},
{
"url": "country",
"valueString": country,
},
},
}
extensions, ok := resource["extension"].([]interface{})
if !ok {
extensions = []interface{}{}
}
// Avoid adding duplicate extensions
for _, ext := range extensions {
extMap, ok := ext.(map[string]interface{})
if !ok {
continue
}
url, ok := extMap["url"].(string)
if !ok || url != "http://lacpass.org/fhir/StructureDefinition/resource-origin" {
continue
}
return
}
extensions = append(extensions, originExtension)
resource["extension"] = extensions
}
func (is *IpsService) MergeIPS(ctx context.Context, currentIpsBundle map[string]interface{}, newIpsBundle map[string]interface{}) (map[string]interface{}, error) {
var currIPS, newIPS Bundle
if err := mapstructure.Decode(currentIpsBundle, &currIPS); err != nil {
@ -161,6 +269,17 @@ func (is *IpsService) MergeIPS(ctx context.Context, currentIpsBundle map[string]
}
}
currentCountry := findCountryInBundle(currIPS)
newCountry := findCountryInBundle(newIPS)
for i := range currIPS.Entry {
addOriginExtension(currIPS.Entry[i].Resource, currIPS.ID, currentCountry)
}
for i := range newIPS.Entry {
addOriginExtension(newIPS.Entry[i].Resource, newIPS.ID, newCountry)
}
curComp, err := getIPSComposition(currIPS.Entry)
if err != nil {
return nil, &customErrors.HttpError{
@ -275,6 +394,16 @@ func (is *IpsService) MergeIPS(ctx context.Context, currentIpsBundle map[string]
}
}
}
// Add original Organization resources from both IPSs
currIPSOrganizations := findOrganizationEntries(currIPS)
newIPSOrganizations := findOrganizationEntries(newIPS)
for _, org := range newIPSOrganizations {
mergedIPS.Entry = append(mergedIPS.Entry, org)
}
for _, org := range currIPSOrganizations {
mergedIPS.Entry = append(mergedIPS.Entry, org)
}
mergedIPS.Entry = removeDuplicates(mergedIPS.Entry)
jsonData, err = json.Marshal(mergedIPS)

View File

@ -12,14 +12,16 @@ import (
)
type VhlClient struct {
Client *http.Client
BaseURL string
Client *http.Client
BaseURL string
ICVPValidatorUrl string
}
func NewClient(baseURL string) VhlClient {
func NewClient(baseURL string, icvpValidatorUrl string) VhlClient {
return VhlClient{
Client: &http.Client{},
BaseURL: baseURL,
Client: &http.Client{},
BaseURL: baseURL,
ICVPValidatorUrl: icvpValidatorUrl,
}
}
@ -49,3 +51,71 @@ func (c *VhlClient) GetIpsUrl(ctx context.Context, shLink string, passCode strin
Err: fmt.Errorf("failed to get document reference"),
}
}
func (c *VhlClient) ICVPValidate(ctx context.Context, qrData string) (*ICVPQRValidationResponse, error) {
r := ICVPQrValidationRequest{
IncludeRaw: true,
QRData: qrData,
}
body, err := json.Marshal(r)
if err != nil {
return nil, fmt.Errorf("failed to marshal user payload: %w", err)
}
vu := fmt.Sprintf("%s/decode/hcert", c.ICVPValidatorUrl)
req, err := http.NewRequest("POST", vu, bytes.NewBuffer(body))
if err != nil {
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to create request"}},
Err: err,
}
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.Client.Do(req)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to VHL service"}},
Err: err,
}
}
defer utils.CloseBody(resp.Body)
if resp.StatusCode != http.StatusOK {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to read response body"}},
Err: err,
}
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{{"error": "service_error", "message": string(bodyBytes)}},
Err: fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(bodyBytes)),
}
}
bb, err := io.ReadAll(resp.Body)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to read response body"}},
Err: err,
}
}
var valResp ICVPQRValidationResponse
err = json.Unmarshal(bb, &valResp)
if err != nil {
return nil, &errors.HttpError{
StatusCode: 500,
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to read parse response body"}},
Err: err,
}
}
return &valResp, nil
}

View File

@ -14,6 +14,11 @@ type QrValidationRequest struct {
QRCodeContent string `json:"qrCodeContent,required"`
}
type ICVPQrValidationRequest struct {
IncludeRaw bool `json:"include_raw,required"`
QRData string `json:"qr_data,required"`
}
type ValidationResponseStep struct {
Step string `json:"step,omitempty"`
Status string `json:"status,omitempty"`
@ -48,3 +53,73 @@ type VhlManifestResponseFile struct {
ContentType string `json:"contentType,omitempty"`
Location string `json:"location,required"`
}
// ----------------
// ICVP Validation response strucs
type ICVPQRValidationResponse struct {
COSE COSEData `json:"cose"`
Diagnostics DiagnosticsData `json:"diagnostics"`
HCERT interface{} `json:"hcert"` // Can be null, use interface{}
Payload PayloadData `json:"payload"`
}
type COSEData struct {
Raw RawData `json:"_raw"`
KidB64 string `json:"kid_b64"`
KidHex string `json:"kid_hex"`
Protected ProtectedData `json:"protected"`
Signature string `json:"signature"`
Unprotected map[string]interface{} `json:"unprotected"` // Empty object, use map[string]interface{}
}
type RawData struct {
PayloadBstr string `json:"payload_bstr"`
ProtectedBstr string `json:"protected_bstr"`
Signature string `json:"signature"`
}
type ProtectedData struct {
Key1 int `json:"1"` // The key is the number '1'
Key4 Key4Data `json:"4"` // The key is the number '4'
}
type Key4Data struct {
B64 string `json:"_b64"`
}
type DiagnosticsData struct {
Base45DecodedLen int `json:"base45_decoded_len"`
ZlibDecompressedLen int `json:"zlib_decompressed_len"`
}
type PayloadData struct {
Key260 Payload260Data `json:"-260"` // The key is the number '-260'
Key1 string `json:"1"` // The key is the number '1'
Key6 int `json:"6"` // The key is the number '6'
}
type Payload260Data struct {
Key6 InnerPayloadData `json:"-6"` // The key is the number '-6'
}
type InnerPayloadData struct {
DOB string `json:"dob"`
N string `json:"n"`
NDT string `json:"ndt"`
NID string `json:"nid"`
NTL string `json:"ntl"`
S string `json:"s"`
V VaccinationData `json:"v"`
}
type VaccinationData struct {
BO string `json:"bo"`
CN string `json:"cn"`
DT string `json:"dt"`
IS string `json:"is"`
VLE string `json:"vle"`
VLS string `json:"vls"`
VP string `json:"vp"`
}
//--------------------

View File

@ -78,3 +78,11 @@ func (vs *VhlService) GetQrIps(ctx context.Context, qrData string, passCode stri
}
return ipsBundle, nil
}
func (vs *VhlService) GetICVPValidation(ctx context.Context, qrData string) (*client.ICVPQRValidationResponse, error) {
validationData, err := vs.Client.ICVPValidate(ctx, qrData)
if err != nil {
return nil, err
}
return validationData, nil
}

View File

@ -31,6 +31,10 @@ type VhlGetRequest struct {
PassCode string `json:"pass_code,omitempty"`
}
type ICVPValidateRequest struct {
Data string `json:"data,require"`
}
type VhlResponse struct {
Data string `json:"data"`
Payload map[string]interface{} `json:"payload"`
@ -168,3 +172,65 @@ func (vh *Handler) Get(w http.ResponseWriter, r *http.Request) {
return
}
}
// Validate ICVP data for ICVP that dont come from a IPS
//
// @Summary Validate ICVP.
// @Description Validate ICVP data. Usefull for ICVPs not linked to a IPS.
// @Tags IPS FHIR
// @Accept json
// @Produce json
//
// @Security ApiKeyAuth
//
// @Param data body ICVPValidateRequest true "Data parameters"
//
// @Success 200 {object} any
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /qr/validate [post]
func (vh *Handler) Validate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// TODO throw correct error body
var body ICVPValidateRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
icvpValidationResponseData, err := vh.Service.GetICVPValidation(ctx, body.Data)
if err != nil {
var httpErr *customErrors.HttpError
if errors.As(err, &httpErr) {
res, err := json.Marshal(httpErr.Body)
if err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
return
}
w.WriteHeader(httpErr.StatusCode)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
res, err := json.Marshal(icvpValidationResponseData)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(res)
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
}