diff --git a/.env.sample b/.env.sample index 9b8628e..84836e7 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/cmd/api/config.go b/cmd/api/config.go index 1561e86..81e510a 100644 --- a/cmd/api/config.go +++ b/cmd/api/config.go @@ -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 } diff --git a/cmd/api/routes.go b/cmd/api/routes.go index 698a354..0bd6817 100644 --- a/cmd/api/routes.go +++ b/cmd/api/routes.go @@ -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) { diff --git a/docker/compose.yaml b/docker/compose.yaml index 77be7e4..47f6b39 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -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: diff --git a/docs/authentication.md b/docs/authentication.md index 3aa7181..c111e0c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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. - - - diff --git a/docs/environment.md b/docs/environment.md index 1a37384..7d42606 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -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` diff --git a/docs/keycloak-setup.md b/docs/keycloak-setup.md index 177b55a..d9d350a 100644 --- a/docs/keycloak-setup.md +++ b/docs/keycloak-setup.md @@ -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 `` is the enviroment variable in your `.env` file: + +```bash +psql -U 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. diff --git a/internal/ips/client/client.go b/internal/ips/client/client.go index 697fbb2..fb6d927 100644 --- a/internal/ips/client/client.go +++ b/internal/ips/client/client.go @@ -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"), + } +} \ No newline at end of file diff --git a/internal/ips/core/service.go b/internal/ips/core/service.go index fa4a4cc..8535ee6 100644 --- a/internal/ips/core/service.go +++ b/internal/ips/core/service.go @@ -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) diff --git a/internal/vhl/client/client.go b/internal/vhl/client/client.go index a5ee602..e33e8cd 100644 --- a/internal/vhl/client/client.go +++ b/internal/vhl/client/client.go @@ -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 +} diff --git a/internal/vhl/client/models.go b/internal/vhl/client/models.go index 177bd54..2f96326 100644 --- a/internal/vhl/client/models.go +++ b/internal/vhl/client/models.go @@ -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"` +} + +//-------------------- diff --git a/internal/vhl/core/service.go b/internal/vhl/core/service.go index aa3abc0..02b9fe8 100644 --- a/internal/vhl/core/service.go +++ b/internal/vhl/core/service.go @@ -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 +} diff --git a/internal/vhl/handler/vhl.go b/internal/vhl/handler/vhl.go index f1e5a3f..188a583 100644 --- a/internal/vhl/handler/vhl.go +++ b/internal/vhl/handler/vhl.go @@ -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 + } +}