release version 3
This commit is contained in:
parent
f3ee84b5ca
commit
a1651c914e
@ -13,3 +13,8 @@ POSTGRES_PASSWORD=keycloak
|
||||
KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl
|
||||
FHIR_BASE_URL=http://lacpass.create.cl:8080
|
||||
VHL_BASE_URL=http://lacpass.create.cl:8182
|
||||
FHIR_MEDIATOR_BASE_URL=http://lacpass.create.cl:3000
|
||||
WALLET_ENABLED=false
|
||||
WALLET_URL=https://conectathon-balancer.izer.tech
|
||||
WALLET_IDENTIFIER=test
|
||||
WALLET_API_KEY=""
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,4 +12,3 @@ content-generator
|
||||
templates
|
||||
out
|
||||
debugger
|
||||
.DS_Store
|
||||
|
||||
12
README.md
12
README.md
@ -42,18 +42,6 @@ The architecture of this backend system is designed to separate concerns between
|
||||
- [Authentication](/docs/authentication.md)
|
||||
- IPS Lacpass API (WIP)
|
||||
|
||||
### ⚠️ Complete the not implemented calls
|
||||
|
||||
This template backend application includes some functionalities that are intentionally left unimplemented. These are meant to be completed by the participant to assess their knowledge of the FHIR/VHL standards.
|
||||
|
||||
To complete these functionalities, search the code for the `TODO: To be implemented by the participant` message. You will find 3 missing functionaties for:
|
||||
|
||||
- Implementing an ITI-67 call
|
||||
- Implementing an ITI-68 call
|
||||
- Implementing a QR code generation using VHL
|
||||
|
||||
After completing these steps, you can proceed to the next section on running the application.
|
||||
|
||||
### Running the Application
|
||||
|
||||
Open a terminal in the root directory of the project and run the following command:
|
||||
|
||||
@ -17,8 +17,13 @@ type Config struct {
|
||||
AuthEmailClientID string
|
||||
FhirBaseUrl string
|
||||
VhlBaseUrl string
|
||||
FhirMediatorBaseUrl string
|
||||
APISwagger bool
|
||||
LogLevel string
|
||||
WalletEnabled bool
|
||||
WalletUrl string
|
||||
WalletIdentifier string
|
||||
WalletAPIKey string
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
@ -34,8 +39,13 @@ func LoadConfig() Config {
|
||||
AuthEmailClientID: "app",
|
||||
FhirBaseUrl: "http://lacpass.create.cl:8080",
|
||||
VhlBaseUrl: "http://lacpass.create.cl:8182",
|
||||
FhirMediatorBaseUrl: "http://lacpass.create.cl:3000/",
|
||||
APISwagger: false,
|
||||
LogLevel: "info",
|
||||
WalletEnabled: false,
|
||||
WalletUrl: "https://conectathon-balancer.izer.tech/",
|
||||
WalletIdentifier: "test",
|
||||
WalletAPIKey: "",
|
||||
}
|
||||
|
||||
if serverPort, exists := os.LookupEnv("API_PORT"); exists {
|
||||
@ -75,6 +85,9 @@ func LoadConfig() Config {
|
||||
if fhirBaseUrl, exists := os.LookupEnv("FHIR_BASE_URL"); exists {
|
||||
cfg.FhirBaseUrl = fhirBaseUrl
|
||||
}
|
||||
if fhirMediatorBaseUrl, exists := os.LookupEnv("FHIR_MEDIATOR_BASE_URL"); exists {
|
||||
cfg.FhirMediatorBaseUrl = fhirMediatorBaseUrl
|
||||
}
|
||||
if vhlBaseUrl, exists := os.LookupEnv("VHL_BASE_URL"); exists {
|
||||
cfg.VhlBaseUrl = vhlBaseUrl
|
||||
}
|
||||
@ -85,5 +98,19 @@ func LoadConfig() Config {
|
||||
cfg.LogLevel = logLevel
|
||||
}
|
||||
|
||||
if walletEnabled, exists := os.LookupEnv("WALLET_ENABLED"); exists {
|
||||
cfg.WalletEnabled = walletEnabled == "true"
|
||||
}
|
||||
if walletUrl, exists := os.LookupEnv("WALLET_URL"); exists {
|
||||
cfg.WalletUrl = walletUrl
|
||||
}
|
||||
if walletIdentifier, exists := os.LookupEnv("WALLET_IDENTIFIER"); exists {
|
||||
cfg.WalletIdentifier = walletIdentifier
|
||||
}
|
||||
|
||||
if walletAPIKey, exists := os.LookupEnv("WALLET_API_KEY"); exists {
|
||||
cfg.WalletAPIKey = walletAPIKey
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
@ -24,6 +24,10 @@ import (
|
||||
vhlClient "ips-lacpass-backend/internal/vhl/client"
|
||||
vhlCore "ips-lacpass-backend/internal/vhl/core"
|
||||
vhlHandler "ips-lacpass-backend/internal/vhl/handler"
|
||||
|
||||
walletClient "ips-lacpass-backend/internal/wallet/client"
|
||||
walletCore "ips-lacpass-backend/internal/wallet/core"
|
||||
walletHandler "ips-lacpass-backend/internal/wallet/handler"
|
||||
)
|
||||
|
||||
func (a *App) loadRoutes() {
|
||||
@ -80,6 +84,9 @@ func (a *App) loadRoutes() {
|
||||
r.Route("/ips", a.loadIpsRoute)
|
||||
r.Route("/users/auth", a.loadUserRoutesAuth)
|
||||
r.Route("/qr", a.loadVhlRoute)
|
||||
if a.config.WalletEnabled {
|
||||
r.Route("/wallet", a.loadWalletRoutes)
|
||||
}
|
||||
})
|
||||
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
@ -124,11 +131,12 @@ func (a *App) loadUserRoutesAuth(router chi.Router) {
|
||||
}
|
||||
|
||||
func (a *App) loadIpsRoute(router chi.Router) {
|
||||
r := ipsClient.NewClient(a.config.FhirBaseUrl)
|
||||
r := ipsClient.NewClient(a.config.FhirBaseUrl, a.config.FhirMediatorBaseUrl)
|
||||
s := ipsCore.NewService(&r)
|
||||
h := ipsHandler.NewHandler(&s)
|
||||
router.Get("/", h.Get)
|
||||
router.Post("/merge", h.Merge)
|
||||
router.Get("/icvp", h.GetICVP)
|
||||
}
|
||||
|
||||
func (a *App) loadVhlRoute(router chi.Router) {
|
||||
@ -138,3 +146,10 @@ func (a *App) loadVhlRoute(router chi.Router) {
|
||||
router.Post("/", h.Create)
|
||||
router.Post("/fetch", h.Get)
|
||||
}
|
||||
|
||||
func (a *App) loadWalletRoutes(router chi.Router) {
|
||||
r := walletClient.NewClient(a.config.WalletUrl, a.config.WalletIdentifier, a.config.WalletAPIKey)
|
||||
s := walletCore.NewService(&r)
|
||||
h := walletHandler.NewHandler(&s)
|
||||
router.Post("/generate-link", h.GenerateWalletLink)
|
||||
}
|
||||
|
||||
@ -21,7 +21,12 @@ services:
|
||||
AUTH_EMAIL_CLIENT_ID: ${AUTH_EMAIL_CLIENT_ID:-app}
|
||||
FHIR_BASE_URL: ${FHIR_BASE_URL:-http://lacpass.create.cl:8080}
|
||||
VHL_BASE_URL: ${VHL_BASE_URL:-http://lacpass.create.cl:8182}
|
||||
FHIR_MEDIATOR_BASE_URL: ${FHIR_MEDIATOR_BASE_URL:-http://lacpass.create.cl:3000}
|
||||
API_SWAGGER: ${API_SWAGGER:-true}
|
||||
WALLET_ENABLED: ${WALLET_ENABLED:-0}
|
||||
WALLET_URL: ${WALLET_URL:-https://conectathon-balancer.izer.tech/}
|
||||
WALLET_IDENTIFIER: ${WALLET_IDENTIFIER:-test}
|
||||
WALLET_API_KEY: ${WALLET_API_KEY:-}
|
||||
ports:
|
||||
- "9081:3000"
|
||||
healthcheck:
|
||||
|
||||
BIN
docs/.DS_Store
vendored
Normal file
BIN
docs/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
docs/images/.DS_Store
vendored
Normal file
BIN
docs/images/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
docs/images/change_frontend_url.png
Normal file
BIN
docs/images/change_frontend_url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/images/email_config/configure_smtp.png
Normal file
BIN
docs/images/email_config/configure_smtp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/images/email_config/go_to_email_tab.png
Normal file
BIN
docs/images/email_config/go_to_email_tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
@ -12,6 +12,7 @@ Before using the API service, you must enable authentication and set the client
|
||||

|
||||
|
||||
3. To enable authentication:
|
||||
|
||||
- Go to the `admin-cli` configuration:
|
||||
|
||||

|
||||
@ -23,6 +24,7 @@ Before using the API service, you must enable authentication and set the client
|
||||
- Click **Save** to apply the changes.
|
||||
|
||||
4. To retrieve the client credentials:
|
||||
|
||||
- Navigate to the **Credentials** tab.
|
||||
- Copy the client secret value (it may be hidden by default).
|
||||
|
||||
@ -42,6 +44,7 @@ To allow the backend service to perform all necessary operations, the `admin` ro
|
||||

|
||||
|
||||
3. To simplify selection:
|
||||
|
||||
- Change the page size to show 100 roles per page:
|
||||
|
||||

|
||||
@ -54,7 +57,6 @@ To allow the backend service to perform all necessary operations, the `admin` ro
|
||||
|
||||

|
||||
|
||||
|
||||
## Set Custom Redirect URI (Optional)
|
||||
|
||||
If you are not using the provided P4H4 application and plan to integrate with the Keycloak service directly, you must configure your own redirect URIs.
|
||||
@ -74,3 +76,27 @@ To do this:
|
||||
5. Click **Save** to apply your changes.
|
||||
|
||||
This ensures your application can successfully handle authentication responses from Keycloak.
|
||||
|
||||
## Set Frontend URL (for local development)
|
||||
|
||||
To test all Keycloak features in your local environment or when using IP addresses as domains, you need to configure the **Frontend URL** in your realm settings. You can do this by going to **Realm Settings → General**, as shown in the image below:
|
||||
|
||||

|
||||
|
||||
When working locally, do **not** use `localhost`. Instead, use `10.0.2.2`.
|
||||
This should point to the URL where Keycloak is running, so don’t forget to include the port.
|
||||
|
||||
## Set Authenticationl email
|
||||
|
||||
For recover password and similar services, a SMTP email account must be set to send emails to users.
|
||||
To do this:
|
||||
|
||||
1. Ensure you are in **lacpass** realm.
|
||||
2. Go to **Realm settings** tab on the botton left.
|
||||
3. Go to **Email** tab.
|
||||
|
||||

|
||||
|
||||
4. On the bottom set your STMP credentials in the **Connection & Authentication** section and save.
|
||||
|
||||

|
||||
|
||||
3
go.mod
3
go.mod
@ -3,6 +3,7 @@ module ips-lacpass-backend
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/httplog/v3 v3.2.2
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
@ -11,6 +12,7 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/veraison/go-cose v1.3.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -34,6 +36,7 @@ require (
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
@ -72,6 +74,10 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
|
||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/veraison/go-cose v1.3.0 h1:2/H5w8kdSpQJyVtIhx8gmwPJ2uSz1PkyWFx0idbd7rk=
|
||||
github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
||||
BIN
internal/.DS_Store
vendored
Normal file
BIN
internal/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
internal/ips/.DS_Store
vendored
Normal file
BIN
internal/ips/.DS_Store
vendored
Normal file
Binary file not shown.
@ -95,7 +95,8 @@ type DocumentContent struct {
|
||||
|
||||
type Attachment struct {
|
||||
ContentType string `json:"contentType"`
|
||||
URL string `json:"url"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Signature struct {
|
||||
|
||||
@ -62,6 +62,14 @@ func (is *IpsService) GetIps(ctx context.Context) (map[string]interface{}, error
|
||||
|
||||
}
|
||||
|
||||
func (is *IpsService) GetIpsICVP(ctx context.Context, idBundle string, immunizationId *string) (string, error) {
|
||||
result, err := is.Repository.GetIpsICVP(idBundle, immunizationId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Will return the IPS composition sections
|
||||
func getIPSComposition(entries []Entry) (*Composition, error) {
|
||||
i := slices.IndexFunc(entries, func(e Entry) bool {
|
||||
|
||||
@ -5,9 +5,15 @@ import (
|
||||
"errors"
|
||||
"ips-lacpass-backend/internal/ips/core"
|
||||
errors2 "ips-lacpass-backend/pkg/errors"
|
||||
"ips-lacpass-backend/pkg/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ICVPDataResponse struct {
|
||||
Data string `json:"data"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
type MergeIPSRequest struct {
|
||||
CurrentIPS map[string]interface{} `json:"current_ips"`
|
||||
NewIPS map[string]interface{} `json:"new_ips"`
|
||||
@ -116,3 +122,77 @@ func (ih *Handler) Merge(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetICVP godoc
|
||||
//
|
||||
// @Summary Generate ICVP certificate from an IPS
|
||||
// @Description Generate ICVP vaccination certificate using the id of an IPS and optionally the id of an immunization.
|
||||
// @Tags IPS FHIR
|
||||
// @Produce json
|
||||
//
|
||||
// @Security ApiKeyAuth
|
||||
//
|
||||
// @Param bundleId query string true "IPS bundle id"
|
||||
// @Param immunizationId query string false "Immunization id"
|
||||
//
|
||||
// @Success 200 {object} ICVPDataResponse
|
||||
// @Failure 400
|
||||
// @Failure 404
|
||||
// @Failure 500
|
||||
// @Router /ips/icvp [get]
|
||||
func (ih *Handler) GetICVP(w http.ResponseWriter, r *http.Request) {
|
||||
bundleId := r.URL.Query().Get("bundleId")
|
||||
if bundleId == "" {
|
||||
http.Error(w, "bundleId query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
immunizationId := r.URL.Query().Get("immunizationId")
|
||||
|
||||
ctx := r.Context()
|
||||
var immunizationIdPtr *string
|
||||
if immunizationId != "" {
|
||||
immunizationIdPtr = &immunizationId
|
||||
}
|
||||
|
||||
icvp, err := ih.IpsService.GetIpsICVP(ctx, bundleId, immunizationIdPtr)
|
||||
if err != nil {
|
||||
var httpErr *errors2.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
|
||||
}
|
||||
|
||||
decodedPayload, err := utils.DecodeHCert(icvp)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to decode hcert", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := ICVPDataResponse{Data: icvp, Payload: decodedPayload}
|
||||
res, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = w.Write(res)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
BIN
internal/users/.DS_Store
vendored
Normal file
BIN
internal/users/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
internal/vhl/.DS_Store
vendored
Normal file
BIN
internal/vhl/.DS_Store
vendored
Normal file
Binary file not shown.
@ -71,7 +71,7 @@ func (vs *VhlService) GetQrIps(ctx context.Context, qrData string, passCode stri
|
||||
Err: errors.New("content cannot be empty"),
|
||||
}
|
||||
}
|
||||
ipsClt := ipsClient.NewClient("")
|
||||
ipsClt := ipsClient.NewClient("", "")
|
||||
ipsBundle, err := ipsClt.GetIpsBundle(ipsFetchUrl.Files[0].Location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -3,8 +3,10 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ips-lacpass-backend/internal/vhl/core"
|
||||
customErrors "ips-lacpass-backend/pkg/errors"
|
||||
"ips-lacpass-backend/pkg/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@ -31,6 +33,7 @@ type VhlGetRequest struct {
|
||||
|
||||
type VhlResponse struct {
|
||||
Data string `json:"data"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// Create QR data godoc
|
||||
@ -83,8 +86,14 @@ func (vh *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
decodedPayload, err := utils.DecodeHCert(qr.Value)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to decode hcert: ", err)
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&VhlResponse{
|
||||
Data: qr.Value,
|
||||
Payload: decodedPayload,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
|
||||
BIN
internal/wallet/.DS_Store
vendored
Normal file
BIN
internal/wallet/.DS_Store
vendored
Normal file
Binary file not shown.
95
internal/wallet/client/client.go
Normal file
95
internal/wallet/client/client.go
Normal file
@ -0,0 +1,95 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"ips-lacpass-backend/pkg/errors"
|
||||
"ips-lacpass-backend/pkg/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ClientInterface interface {
|
||||
GenerateWalletLink(ctx context.Context, claims map[string]interface{}) (*GenerateWalletLinkResponse, error)
|
||||
}
|
||||
|
||||
type WalletClient struct {
|
||||
Client *http.Client
|
||||
BaseURL string
|
||||
Identifier string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, identifier string, apiKey string) WalletClient {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return WalletClient{
|
||||
Client: &http.Client{Transport: tr},
|
||||
BaseURL: baseURL,
|
||||
Identifier: identifier,
|
||||
APIKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WalletClient) GenerateWalletLink(ctx context.Context, claims map[string]interface{}, credentialType CredentialType) (*GenerateWalletLinkResponse, error) {
|
||||
url := fmt.Sprintf("%s/credentials/%s", c.BaseURL, c.Identifier)
|
||||
|
||||
reqBody := GenerateWalletLinkRequest{
|
||||
Claims: claims,
|
||||
CredentialType: credentialType,
|
||||
PinRequired: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, &errors.HttpError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to marshal request body"}},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, &errors.HttpError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to create request"}},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", c.APIKey)
|
||||
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, &errors.HttpError{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Body: []map[string]interface{}{{"error": "service_unavailable", "message": "Failed to connect to wallet service"}},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
defer utils.CloseBody(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &errors.HttpError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: []map[string]interface{}{{"error": "wallet_service_error", "message": "Wallet service returned an error"}},
|
||||
Err: fmt.Errorf("wallet service returned status code %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
var walletResponse GenerateWalletLinkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&walletResponse); err != nil {
|
||||
return nil, &errors.HttpError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: []map[string]interface{}{{"error": "internal_error", "message": "Failed to decode response"}},
|
||||
Err: fmt.Errorf("failed to decode response: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
return &walletResponse, nil
|
||||
}
|
||||
23
internal/wallet/client/models.go
Normal file
23
internal/wallet/client/models.go
Normal file
@ -0,0 +1,23 @@
|
||||
package client
|
||||
|
||||
type CredentialType string
|
||||
|
||||
const (
|
||||
VerifiableHealthLink CredentialType = "VerifiableHealthLink"
|
||||
ICVP CredentialType = "ICVP"
|
||||
)
|
||||
|
||||
// GenerateWalletLinkRequest represents the request body for generating a wallet link.
|
||||
type GenerateWalletLinkRequest struct {
|
||||
Claims map[string]interface{} `json:"claims"`
|
||||
CredentialType CredentialType `json:"credentialType"`
|
||||
PinRequired bool `json:"pinRequired"`
|
||||
}
|
||||
|
||||
// GenerateWalletLinkResponse represents the response from the wallet service.
|
||||
type GenerateWalletLinkResponse struct {
|
||||
PreAuthorizedCode string `json:"preAuthorizedCode"`
|
||||
QrURL string `json:"qrUrl"`
|
||||
CoURL string `json:"coUrl"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
3
internal/wallet/core/models.go
Normal file
3
internal/wallet/core/models.go
Normal file
@ -0,0 +1,3 @@
|
||||
package core
|
||||
|
||||
// Intentionally empty for now.
|
||||
24
internal/wallet/core/service.go
Normal file
24
internal/wallet/core/service.go
Normal file
@ -0,0 +1,24 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"ips-lacpass-backend/internal/wallet/client"
|
||||
)
|
||||
|
||||
type WalletService struct {
|
||||
Repository *client.WalletClient
|
||||
}
|
||||
|
||||
func NewService(r *client.WalletClient) WalletService {
|
||||
return WalletService{
|
||||
Repository: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WalletService) GenerateWalletLink(ctx context.Context, claims map[string]interface{}, credentialType client.CredentialType) (*client.GenerateWalletLinkResponse, error) {
|
||||
walletResponse, err := ws.Repository.GenerateWalletLink(ctx, claims, credentialType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return walletResponse, nil
|
||||
}
|
||||
89
internal/wallet/handler/wallet.go
Normal file
89
internal/wallet/handler/wallet.go
Normal file
@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"ips-lacpass-backend/internal/wallet/client"
|
||||
"ips-lacpass-backend/internal/wallet/core"
|
||||
customErrors "ips-lacpass-backend/pkg/errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
WalletService *core.WalletService
|
||||
}
|
||||
|
||||
func NewHandler(s *core.WalletService) *Handler {
|
||||
return &Handler{
|
||||
WalletService: s,
|
||||
}
|
||||
}
|
||||
|
||||
type GenerateWalletLinkRequest struct {
|
||||
Claims map[string]interface{} `json:"claims"`
|
||||
CredentialType client.CredentialType `json:"credentialType"`
|
||||
}
|
||||
|
||||
// GenerateWalletLink godoc
|
||||
//
|
||||
// @Summary Generate a wallet link.
|
||||
// @Description Generate a new wallet link with the given claims. Must enable wallet in config.
|
||||
// @Tags Wallet
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
//
|
||||
// @Security ApiKeyAuth
|
||||
//
|
||||
// @Param data body GenerateWalletLinkRequest true "Claims for the wallet link"
|
||||
//
|
||||
// @Success 200 {object} client.GenerateWalletLinkResponse
|
||||
// @Failure 400
|
||||
// @Failure 500
|
||||
// @Router /wallet/generate-link [post]
|
||||
func (h *Handler) GenerateWalletLink(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var reqBody GenerateWalletLinkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, `{"error": "invalid_request_body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if reqBody.CredentialType != client.VerifiableHealthLink && reqBody.CredentialType != client.ICVP {
|
||||
http.Error(w, `{"error": "invalid_credential_type", "message": "credentialType must be either VerifiableHealthLink or ICVP"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
walletResponse, err := h.WalletService.GenerateWalletLink(ctx, reqBody.Claims, reqBody.CredentialType)
|
||||
if err != nil {
|
||||
var httpErr *customErrors.HttpError
|
||||
if errors.As(err, &httpErr) {
|
||||
res, err := json.Marshal(httpErr.Body)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(httpErr.StatusCode)
|
||||
_, err = w.Write(res)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
res, err := json.Marshal(walletResponse)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = w.Write(res)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error": "internal_server_error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
BIN
pkg/.DS_Store
vendored
Normal file
BIN
pkg/.DS_Store
vendored
Normal file
Binary file not shown.
186
pkg/utils/hcert_utils.go
Normal file
186
pkg/utils/hcert_utils.go
Normal file
@ -0,0 +1,186 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/veraison/go-cose"
|
||||
)
|
||||
|
||||
const base45Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
|
||||
|
||||
var base45reverse map[byte]int
|
||||
|
||||
func init() {
|
||||
base45reverse = make(map[byte]int)
|
||||
for i, c := range []byte(base45Alphabet) {
|
||||
base45reverse[c] = i
|
||||
}
|
||||
}
|
||||
|
||||
func convertInterfaceMap(m map[interface{}]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range m {
|
||||
var keyStr string
|
||||
switch kType := k.(type) {
|
||||
case string:
|
||||
keyStr = kType
|
||||
case float64:
|
||||
keyStr = strconv.FormatFloat(kType, 'f', -1, 64)
|
||||
case int:
|
||||
keyStr = strconv.Itoa(kType)
|
||||
case uint64:
|
||||
keyStr = strconv.FormatUint(kType, 10)
|
||||
case int64:
|
||||
keyStr = strconv.FormatInt(kType, 10)
|
||||
default:
|
||||
fmt.Printf("Encountered unexpected key type: %T with value: %v\n", kType, k)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the value is a nested map
|
||||
if nestedMap, isMap := v.(map[interface{}]interface{}); isMap {
|
||||
result[keyStr] = convertInterfaceMap(nestedMap)
|
||||
} else if nestedSlice, isSlice := v.([]interface{}); isSlice {
|
||||
result[keyStr] = convertInterfaceSlice(nestedSlice)
|
||||
} else {
|
||||
result[keyStr] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertInterfaceSlice(s []interface{}) []interface{} {
|
||||
result := make([]interface{}, len(s))
|
||||
for i, v := range s {
|
||||
if nestedMap, isMap := v.(map[interface{}]interface{}); isMap {
|
||||
result[i] = convertInterfaceMap(nestedMap)
|
||||
} else if nestedSlice, isSlice := v.([]interface{}); isSlice {
|
||||
result[i] = convertInterfaceSlice(nestedSlice)
|
||||
} else {
|
||||
result[i] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeBase45(s string) ([]byte, error) {
|
||||
var out []byte
|
||||
for i := 0; i < len(s); {
|
||||
if len(s)-i < 2 {
|
||||
return nil, fmt.Errorf("invalid base45 string length")
|
||||
}
|
||||
if len(s)-i < 3 {
|
||||
// 2-character case
|
||||
c1, ok1 := base45reverse[s[i]]
|
||||
c2, ok2 := base45reverse[s[i+1]]
|
||||
if !ok1 || !ok2 {
|
||||
return nil, fmt.Errorf("invalid character in base45 string")
|
||||
}
|
||||
val := c1 + c2*45
|
||||
if val > 255 {
|
||||
return nil, fmt.Errorf("invalid 2-character encoding")
|
||||
}
|
||||
out = append(out, byte(val))
|
||||
i += 2
|
||||
} else {
|
||||
// 3-character case
|
||||
c1, ok1 := base45reverse[s[i]]
|
||||
c2, ok2 := base45reverse[s[i+1]]
|
||||
c3, ok3 := base45reverse[s[i+2]]
|
||||
if !ok1 || !ok2 || !ok3 {
|
||||
return nil, fmt.Errorf("invalid character in base45 string")
|
||||
}
|
||||
val := c1 + c2*45 + c3*45*45
|
||||
if val > 65535 {
|
||||
return nil, fmt.Errorf("invalid 3-character encoding")
|
||||
}
|
||||
out = append(out, byte(val>>8), byte(val&0xFF))
|
||||
i += 3
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecodeHCert decodes a base45 string encoded using Zlib/COSE/CBOR pipeline.
|
||||
// The string is an HCERT so it starts with "HC1:".
|
||||
func DecodeHCert(hcert string) (map[string]interface{}, error) {
|
||||
if !strings.HasPrefix(hcert, "HC1:") {
|
||||
return nil, fmt.Errorf("invalid HCERT prefix")
|
||||
}
|
||||
|
||||
// 1. Remove "HC1:" prefix
|
||||
base45Encoded := strings.TrimPrefix(hcert, "HC1:")
|
||||
|
||||
// 2. Base45 decode
|
||||
compressedCose, err := decodeBase45(base45Encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base45 decoding failed: %w", err)
|
||||
}
|
||||
|
||||
// 3. Zlib decompress
|
||||
var cosePayload []byte
|
||||
r, err := zlib.NewReader(bytes.NewReader(compressedCose))
|
||||
if err != nil {
|
||||
// Not zlib compressed, use as is
|
||||
cosePayload = compressedCose
|
||||
} else {
|
||||
decompressed, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zlib decompression failed: %w", err)
|
||||
}
|
||||
cosePayload = decompressed
|
||||
defer r.Close()
|
||||
}
|
||||
|
||||
// 4. COSE decode
|
||||
var payload []byte
|
||||
var msg cose.Sign1Message
|
||||
if err := msg.UnmarshalCBOR(cosePayload); err == nil {
|
||||
payload = msg.Payload
|
||||
} else {
|
||||
var signMessage cose.SignMessage
|
||||
if err2 := signMessage.UnmarshalCBOR(cosePayload); err2 == nil {
|
||||
payload = signMessage.Payload
|
||||
} else {
|
||||
// Both failed, try manual parsing
|
||||
var rawCoseMessage interface{}
|
||||
if err3 := cbor.Unmarshal(cosePayload, &rawCoseMessage); err3 != nil {
|
||||
return nil, fmt.Errorf("cose unmarshalling failed for both Sign1Message and SignMessage and raw: %v, %v, %v", err, err2, err3)
|
||||
}
|
||||
|
||||
coseArray, ok := rawCoseMessage.([]interface{})
|
||||
if !ok {
|
||||
if tagged, ok := rawCoseMessage.(cbor.Tag); ok {
|
||||
if content, ok := tagged.Content.([]interface{}); ok {
|
||||
coseArray = content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if coseArray == nil || len(coseArray) < 3 {
|
||||
return nil, fmt.Errorf("cose unmarshalling failed for both Sign1Message and SignMessage: %v, %v", err, err2)
|
||||
}
|
||||
|
||||
p, ok := coseArray[2].([]byte)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to extract payload from cose message: payload is not a byte string")
|
||||
}
|
||||
payload = p
|
||||
}
|
||||
}
|
||||
|
||||
// 5. CBOR decode the payload from COSE message
|
||||
var cborData map[interface{}]interface{}
|
||||
if err := cbor.Unmarshal(payload, &cborData); err != nil {
|
||||
return nil, fmt.Errorf("cbor unmarshalling of payload failed: %w", err)
|
||||
}
|
||||
|
||||
cborDataConverted := convertInterfaceMap(cborData)
|
||||
return cborDataConverted, nil
|
||||
}
|
||||
76
test/hcert_test.go
Normal file
76
test/hcert_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"ips-lacpass-backend/pkg/utils"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHCertICVPDecode(t *testing.T) {
|
||||
hcert := "HC1:6BFOXN%TSMAHN-HJM80DOO8W%TG34UE726*2OC9Y.TW1ANU9SCE7JM:UC*ELIQ5B264IM:/42JO2 7V35U:7+V4YC5/HQ6EOHCRBK81EPFJM5C9YCBJ%GBVCL+9-0G2PBUDBACARDAEI97KE*LHXQM.FDBIK4LD JM3.K/HLNOI3.KH+G7IKSH9NOIEJK5+K6IASD9YHI1KKK3MYII3IKEIAM0G6JK%86%X49/SQN4:U45ALD-4$XKHBTQ1LTA3$73HRJFRJ9STE-4/-KFU4-EF:57MUBMTF*MCXJL RGBFH*RK%4U7U*+RDQJHY23QPX4MQ2S1$U4ST236MDNW*PGNETTU4DK/$TJ7PS4JLDV%0K1GDMDP $A*EK/JP:T3%.4OYB"
|
||||
|
||||
expectedJSON := `{
|
||||
"-260": {
|
||||
"-6": {
|
||||
"dob": "1905-08-23",
|
||||
"n": "Aulo Agerio",
|
||||
"ndt": "NI",
|
||||
"nid": "16337361-9",
|
||||
"s": "male",
|
||||
"v": {
|
||||
"bo": "123123123",
|
||||
"dt": "2017-12-11",
|
||||
"vls": "2017-12-11",
|
||||
"vp": "YellowFeverProductd2c75a15ed309658b3968519ddb31690"
|
||||
}
|
||||
}
|
||||
},
|
||||
"1": "XCL",
|
||||
"6": 1757187943
|
||||
}`
|
||||
|
||||
decoded, err := utils.DecodeHCert(hcert)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode HCert: %v", err)
|
||||
}
|
||||
|
||||
decodedPretty, err := json.MarshalIndent(decoded, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if string(expectedJSON) != string(decodedPretty) {
|
||||
t.Errorf("Decoded value is not correct. Decoded: %s, Expected: %s", decodedPretty, expectedJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHCertVHLDecode(t *testing.T) {
|
||||
hcert := "HC1:6BFOXNMG2N9HZBPYHQ3D69SO5D6%9L60JO DJS4L:P:R8LCDO%08JJG.NSOEV 9OG6%6Q4TJ7AJENS:NK7VCECM:MQ0FE%JC5Y479D/*8G.CV3NV3OVLD86J:KE2HF86GX2BTLHA9A86GNY8XOIROBZQMQOB9MEBED:KE87B MH:8DZYK%KNU9O%UL75E2*KH42$T8CRJ.V89:GF-K8JV Y8GJNKY8%97JR8ZV0:JVIP46+8KD35T8/Z8ZIV-YKAUVH40DQL2I6AI8LZP9WHHK5.SMIY9TO6YN6MJE2I6DF5P.P%OE-M6U JGKETW7YP6GUMY.HBNMAP50TBIM5GUMSWPVYB5RH+PEGKE5SG7UT4L5%K82OO-L8+$RTNKCZUN.DSB1971PFU%0F$5MH6QTMUEO1HB5*%L4NH7KEK%56VEUS17%E2F14LETP5 9VZ*MTJR.*U6.CH8795KTD8B836B4X/9+JIQT24GA-+DVE9B2K9FDJ4N172IM2%-2SFL -UNNF0GJG0AG16%$V%*C9:A8+I2QOHUQDVJ7VF +AU61$8IE0U4NOKIS1RE0BBSEWUVKI9K4/TQQP5U974CI9JQI10DEG30QUKL1"
|
||||
|
||||
expectedJSON := `{
|
||||
"-260": {
|
||||
"5": [
|
||||
{
|
||||
"u": "shlink://eyJ1cmwiOiJodHRwOi8vbGFjcGFzcy5jcmVhdGUuY2w6ODE4Mi92Mi9tYW5pZmVzdHMvYjEzYzA0Y2QtMDc1Yy00YjY4LTgyOTQtMzJhZTMwN2YxYjA5IiwiZmxhZyI6IlAiLCJleHAiOjE3NjAxNDAyMjEwMDAsImtleSI6IkxTTnVaTXFHZEo1cmdQLUpJSEoySllLaWtuYzJXZDcwaG1VMFBSZFAwSHM9IiwibGFiZWwiOiJHREhDTiBWYWxpZGF0b3IifQ=="
|
||||
}
|
||||
]
|
||||
},
|
||||
"1": "XJ",
|
||||
"4": 1760140221,
|
||||
"6": 1757271643804
|
||||
}`
|
||||
|
||||
decoded, err := utils.DecodeHCert(hcert)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode HCert: %v", err)
|
||||
}
|
||||
|
||||
decodedPretty, err := json.MarshalIndent(decoded, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if string(expectedJSON) != string(decodedPretty) {
|
||||
t.Errorf("Decoded value is not correct. Decoded: %s, Expected: %s", decodedPretty, expectedJSON)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user