first release of template

This commit is contained in:
Sergio Peñafiel 2025-07-17 10:22:20 -04:00
commit 18f3526362
49 changed files with 5803 additions and 0 deletions

5
.aiignore Normal file
View File

@ -0,0 +1,5 @@
.env
*.key
*.pem
*secret*
credentials.*

13
.env.sample Normal file
View File

@ -0,0 +1,13 @@
KEYCLOAK_URL=http://localhost:9083
KEYCLOAK_REALM=lacpass
KEYCLOAK_CLIENT_ID=app
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=admin
KEYCLOAK_DEFAULT_USER=test
KEYCLOAK_DEFAULT_USER_PASSWORD=test
API_SWAGGER=false
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
KEYCLOAK_HOSTNAME=http://keycloak.lacpass.create.cl
FHIR_BASE_URL=http://lacpass.create.cl:8080
VHL_BASE_URL=http://lacpass.create.cl:8182

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
.env
.idea
temp/*
tmp/*
*.exe
mock_*
content-generator
*.tfvars
.terraform
.terraform.lock.hcl
templates
out
debugger

2
Makefile Normal file
View File

@ -0,0 +1,2 @@
run:
go run ./cmd/api

121
README.md Normal file
View File

@ -0,0 +1,121 @@
# PH4H PH4H Backend
Template for backend for the IPS PH4H App. A unified health app for Connectathon users to view, merge, and share IPS data
securely cross-border. This project provides a backend system composed of the IPS PH4H API for handling business logic and
a Keycloak server for authentication and authorization. The entire stack is containerized using Docker and can be easily
managed with Docker Compose.
## Table of Contents
- [Project Overview](#project-overview)
- [Components](#components)
- [Prerequisites](#prerequisites)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Running the Application](#running-the-application)
- [Accessing the Services](#accessing-the-services)
- [Keycloak Admin Console](#keycloak-admin-console)
- [Golang API](#golang-api)
- [Stopping the Application](#stopping-the-application)
- [Swagger Documentation](#swagger-documentation)
## Project Overview
The architecture of this backend system is designed to separate concerns between the application's business logic and user authentication.
## Components
- **IPS PH4H API**: A lightweight, high-performance API that implements the core features of your application.
It is protected and requires a valid JWT from an Authorization server to be accessed.
- **Authorization**: Identity and Access Management solution. It handles user registration, login, and token issuance.
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/)
- [Go 1.24](https://go.dev/dl/) `Only if you plan to ran the API without docker`
## Getting Started
### Configuration
- [Authentication](/docs/authentication.md)
- IPS PH4H 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:
```bash
docker compose --file=./docker/compose.yaml up
```
This command will:
- Build the Docker image for the IPS PH4H API.
- Pull the official Docker images for Keycloak and Postgres.
- Create and start the containers for all three services.
- Attach your terminal to the logs of all running containers.
### Setup Keycloak
After all services are running, you need to setup keycloak to have the correct configurations for the backend to authenticate and create users. Before contuining please follow the instructions [here](/docs/keycloak-setup.md).
## Accessing the Services
### Keycloak Admin Console
Once the services are running, you can access the Keycloak Admin Console to configure realms, clients, and users.
1. Open your web browser and navigate to `http://localhost:9083`.
2. You will be redirected to the Keycloak landing page. Click on the **Administration Console** link.
3. Log in with the admin credentials provided in your [configuration](/docs/authentication.md)
### IPS PH4H API
IPS PH4H API will be accessible at `http://localhost:9081`. You can use a tool like `curl` or Postman to interact
with your API endpoints. Remember that your API endpoints will be protected by Keycloak, so you will need to obtain a
valid JWT from Keycloak to make successful requests. There is a [helper script](./scripts/auth.sh), where you can request
a token using:
```bash
sh scripts/auth.sh access-token
```
If the token expires you can refresh it with:
```bash
sh scripts/auth.sh refresh-token
```
And to logout you can do:
```bash
sh scripts/auth.sh logout
```
## Stopping the Application
To stop and remove the containers, network, and volumes, press `Ctrl+C` in the terminal where `docker-compose` is running, and then run the following command:
```bash
docker-compose down
```
# Swagger Documentation
To activate Swagger make sure to set `API_SWAGGER=true` in your `.env` file and the `docker/compose.yaml`.
Then, the API docs can be seen in `http://localhost:9081/swagger/index.html`.

215
api/openapi/api.yaml Normal file
View File

@ -0,0 +1,215 @@
openapi: 3.1.0
info:
title: IPS Lacpass
description: IPS Lacpass
version: 1.0.0
servers:
- url: 'http://localhost:8081'
paths:
/users:
post:
summary: "Register a new user"
description: "Register a new user in the realm. It will send a confirmation email to the user."
tags:
- User
requestBody:
description: "User registration details"
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
format: email
description: The email address for the new account.
example: john.doe@example.com
password:
type: string
format: password
description: The password for the new account.
example: MySuperSecurePassword123!
password-confirm:
type: string
format: password
description: Confirmation of the password. Must match 'password'.
example: MySuperSecurePassword123!
firstName:
type: string
description: (Optional) The first name of the user.
example: John
lastName:
type: string
description: (Optional) The last name of the user.
example: Doe
locale:
type: string
description: "Preferred language."
enum:
- en
- es
- pt-br
example: en
document_type:
type: string
description: "Identity document type"
enum:
- passport
- id
example: passport
identifier:
type: string
description: "Document identifier"
example: F12345678
required:
- username
- email
- password
- password-confirm
- locale
- document_type
- identifier
responses:
"200":
description: "Successful registration. The user will get a confirmation e-mail."
content:
application/json:
schema:
type: object
properties:
sub:
type: string
description: "User's id on the realm."
name:
type: string
description: "Full name."
given_name:
type: string
description: "Given name or first name."
family_name:
type: string
description: "Surname or last name."
preferred_username:
type: string
description: "Preferred username."
email:
type: string
format: email
description: "E-mail address."
email_verified:
type: boolean
description: "Indicates if the user's email has been verified."
locale:
type: string
description: "Preferred language."
document_type:
type: string
description: "Identity document type"
enum:
- passport
- id
identifier:
type: string
description: "Document identifier"
example:
sub: "a8c6d49a-72c7-4402-a1b1-7a5e9f8b4d6c"
name: "John Doe"
given_name: "John"
family_name: "Doe"
preferred_username: "jdoe"
email: "johndoe@example.com"
email_verified: false
locale: "en"
document_type: "passport"
identifier: "F12345678"
"400":
description: "The request is malformed or missing required fields."
content:
application/json:
schema:
type: array
items:
type: object
properties:
error:
type: string
error_description:
type: string
required:
- error
- error_description
examples:
MissingUsername:
value:
- error: "missing_username"
error_description: "Missing required field: username"
MissingEmail:
value:
- error: "missing_email"
error_description: "Missing required field: email"
MissingPassword:
value:
- error: "missing_password"
error_description: "Missing required field: password"
MissingPasswordConfirmation:
value:
- error: "missing_password_confirmation"
error_description: "Missing required field: password-confirm"
MissingLocale:
value:
- error: "missing_locale"
error_description: "Missing required field: locale"
MissingDocumentType:
value:
- error: "missing_document_type"
error_description: "Missing required field: document_type"
MissingIdentifier:
value:
- error: "missing_identifier"
error_description: "Missing required field: identifier"
InvalidDocumentType:
value:
- error: "invalid_document_type"
error_description: "Invalid document type. Must be either 'passport' or 'id'."
InvalidIdentifierFormat:
value:
- error: "invalid_identifier_format"
error_description: "Invalid identifier format."
InvalidEmailFormat:
value:
- error: "invalid_email_format"
error_description: "Invalid email format"
PasswordMismatch:
value:
- error: "password_mismatch"
error_description: "Password and password confirmation do not match"
InvalidPasswordMinLength:
value:
- error: "invalid_password_min_length_message"
error_description: "Invalid password: minimum length <number>"
InvalidPasswordMaxLength:
value:
- error: "invalid_password_max_length_message"
error_description: "Invalid password: maximum length <number>"
"409":
description: "The request is malformed or missing required fields."
content:
application/json:
schema:
type: array
items:
type: object
properties:
error:
type: string
error_description:
type: string
required:
- error
- error_description
examples:
DuplicateUser:
value:
- error: "user_already_exists"
error_description: "User already exists"

354
api/openapi/auth.yaml Normal file
View File

@ -0,0 +1,354 @@
openapi: 3.1.0
info:
title: Lacpass Authentication Server
description: Authentication endpoints
version: 1.0.0
servers:
- url: 'http://localhost:8082'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: "Access token obtained via the Token endpoint."
schemas:
Account:
type: object
properties:
username:
type: string
firstName:
type: string
lastName:
type: string
email:
type: string
emailVerified:
type: boolean
attributes:
type: object
additionalProperties:
type: array
items:
type: string
UserRepresentation:
type: object
description: "Represents the current user's profile."
properties:
id:
type: string
readOnly: true
description: "User's ID."
username:
type: string
description: "User's username."
firstName:
type: string
description: "User's first name."
lastName:
type: string
description: "User's last name."
email:
type: string
format: email
description: "User's email address."
emailVerified:
type: boolean
readOnly: true
description: "Indicates if the user's email has been verified."
attributes:
type: object
additionalProperties:
type: string
description: "A map of custom user attributes."
SessionRepresentation:
type: object
description: "Represents an active user session."
properties:
id:
type: string
description: "Session ID."
ipAddress:
type: string
description: "IP address from which the session was initiated."
started:
type: integer
format: int64
description: "Timestamp of when the session started."
lastAccess:
type: integer
format: int64
description: "Timestamp of the last access in this session."
current:
type: boolean
description: "Indicates if this is the current session"
browser:
type: string
description: "Browser or user agent."
os:
type: string
description: "Operating system."
Error:
type: object
description: "Error"
properties:
error:
type: string
description: "Error type"
examples: ["invalid_request"]
error_description:
type: string
description: "Error description"
examples: ["Missing form parameter: grant_type"]
examples:
InvalidRequestError:
value:
error: "invalid_request"
error_description: "Missing form parameter: grant_type"
summary: "Invalid request"
InvalidUserCredentialsError:
value:
error: "invalid_grant"
error_description: "Invalid user credentials"
summary: "Invalid user credentials"
MaximumRefreshTokenUsesExceeded:
value:
error: "invalid_grant"
error_description: "Maximum allowed refresh token reuse exceeded"
summary: "Maximum allowed refresh token reuse exceeded"
# Group the endpoints into logical tabs.
tags:
- name: Authentication
description: "Endpoints for user authentication (OpenID Connect)."
- name: User
description: "Endpoints for user account"
paths:
/realms/{realm}/protocol/openid-connect/token:
post:
tags:
- Authentication
summary: "Get Token"
description: "Exchanges user credentials for an access and refresh tokens"
parameters:
- name: realm
in: path
required: true
schema:
type: string
examples: ["lacpass"]
description: "Name of the realm."
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
grant_type:
description: "Can be either `password` or `refresh_token`."
type: string
enum:
- password
- refresh_token
examples: ["password", "refresh_token"]
client_id:
description: "The ID of the client application."
type: string
enum:
- app
examples: ["app"]
refresh_token:
description: "Refresh token when `grant_type` refresh_token is used."
type: string
example: "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI1ZjA5MWRkYy1kNDQ3LTQ0OGYtODBmOS01NDUyYmRiMjM0ZjQifQ.eyJleHAiOjE3NTAxNzc3OTYsImlhdCI6MTc1MDA5MTM5NiwianRpIjoiNWJiMDNkMWQtYmY2Yy00MDZjLWI5NTktYmMzMjY4NjY3MjJlIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgyL3JlYWxtcy9sYWNwYXNzIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgyL3JlYWxtcy9sYWNwYXNzIiwic3ViIjoiNjYwMWIzMjYtOTU5Yi00ZWNkLTliOGMtYTkzNThkNjAxYzRhIiwidHlwIjoiUmVmcmVzaCIsImF6cCI6ImFwcCIsInNpZCI6IjRiODM5OWU3LTNjYzUtNDJjZi1hNTJhLWY3MmZkOTE1MGM0MyIsInNjb3BlIjoid2ViLW9yaWdpbnMgYmFzaWMgcHJvZmlsZSByb2xlcyBlbWFpbCBhY3IiLCJyZXVzZV9pZCI6IjZmNDRkMTkzLWQwODQtNGU5Yi1iMWY1LWNkNTI1OWQyZWY5NiJ9.8yv3KVAyiBNQ2q9rcO0_oHdEBfQ5eJfRLVoCsYRCQsLMwfdV9Jc4NOmIXc337artKwY8c6k0_1ILhud-STPuJg"
required: false
username:
type: string
description: "User's email. Required when `grant_type` is password."
examples: ["jdoe@example.com"]
required: false
password:
type: string
description: "User's password. Required when `grant_type` is password."
examples: ["secret-password"]
required: false
scope:
type: string
description: "The scopes being requested. `openid` scope should always be present"
examples: ["openid profile email"]
required: false
responses:
'200':
description: "Authentication successful."
content:
application/json:
schema:
type: object
properties:
access_token:
type: string
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
refresh_token:
type: string
example: "pvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
not-before-policy:
type: integer
example: 0
session_state:
type: string
example: "9c238c37-1021-43b3-a122-67301500a61d"
scope:
type: string
example: "profile email"
expires_in:
type: integer
example: 123058381
refresh_expires_in:
type: integer
example: 181283903
token_type:
type: string
example: "Bearer"
'401':
description: "Bad Request - a required parameter is missing or invalid."
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidRequest:
$ref: '#/components/examples/InvalidRequestError'
invalidUserCredentials:
$ref: '#/components/examples/InvalidUserCredentialsError'
MaximumRefreshTokenUsesExceeded:
$ref: '#/components/examples/MaximumRefreshTokenUsesExceeded'
/realms/{realm}/protocol/openid-connect/logout:
post:
tags:
- Authentication
summary: "Log Out"
description: "Logs the user out of their session. Requires the refresh token to fully invalidate the session."
parameters:
- name: realm
in: path
required: true
schema:
type: string
description: "The name of the realm."
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
client_id:
type: string
description: "The ID of the client application."
examples: ["my-app-client"]
refresh_token:
type: string
description: "The refresh token obtained during login."
responses:
'204':
description: "Logout successful."
/realms/{realm}/protocol/openid-connect/userinfo:
get:
summary: "Get User Information"
description: "This endpoint returns claims about the authenticated end-user."
tags:
- User
parameters:
- name: realm
in: path
required: true
description: "The name of the realm."
schema:
type: string
security:
- bearerAuth: [ ]
responses:
"200":
description: "Successful response with user resource."
content:
application/json:
schema:
type: object
properties:
sub:
type: string
description: "User's id on the realm."
name:
type: string
description: "Full name."
given_name:
type: string
description: "Given name or first name."
family_name:
type: string
description: "Surname or last name."
preferred_username:
type: string
description: "Preferred username."
email:
type: string
format: email
description: "E-mail address."
email_verified:
type: boolean
description: "Indicates if the user's email has been verified."
locale:
type: string
description: "Preferred language."
document_type:
type: string
description: "Identity document type"
enum:
- passport
- id
identifier:
type: string
description: "Document identifier"
example:
sub: "a8c6d49a-72c7-4402-a1b1-7a5e9f8b4d6c"
name: "John Doe"
given_name: "John"
family_name: "Doe"
preferred_username: "jdoe"
email: "johndoe@example.com"
email_verified: true
locale: "en"
document_type: "passport"
identifier: "F12345678"
"401":
description: "Unauthorized. The request is missing a valid bearer token."
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "unauthorized"
error_description:
type: string
example: "Bearer token not provided or is invalid."
"403":
description: "Forbidden. The provided token does not have the required permissions (e.g., missing 'openid' scope)."
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "insufficient_scope"
error_description:
type: string
example: "The 'openid' scope is required."

52
cmd/api/api.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"net/http"
"time"
)
type App struct {
router http.Handler
config Config
}
func New(config Config) *App {
app := &App{
config: config,
}
app.loadRoutes()
return app
}
func (a *App) Start(ctx context.Context) error {
server := &http.Server{
Addr: fmt.Sprintf(":%d", a.config.ServerPort),
Handler: a.router,
}
fmt.Println("Starting server on port", a.config.ServerPort)
ch := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if err != nil {
ch <- fmt.Errorf("failed to start server: %w", err)
}
close(ch)
}()
select {
case err := <-ch:
return err
case <-ctx.Done():
timeout, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
return server.Shutdown(timeout)
}
}

89
cmd/api/config.go Normal file
View File

@ -0,0 +1,89 @@
package main
import (
"os"
"strconv"
)
type Config struct {
ServerPort uint16
AuthHostName string
AuthInternalUrl string
AuthRealm string
AuthAdminClientID string
AuthClientSecret string
AuthEmailRedirectURI string
AuthEmailLifespan int
AuthEmailClientID string
FhirBaseUrl string
VhlBaseUrl string
APISwagger bool
LogLevel string
}
func LoadConfig() Config {
cfg := Config{
ServerPort: 3000,
AuthHostName: "http://localhost:9083",
AuthInternalUrl: "http://localhost:9083",
AuthRealm: "lacpass",
AuthAdminClientID: "admin-cli",
AuthClientSecret: "bbU4vnqhqe2AJ32XpdQVRVqfRMA82Hnu",
AuthEmailRedirectURI: "ph4happ://open/validated-email",
AuthEmailLifespan: 3600,
AuthEmailClientID: "app",
FhirBaseUrl: "http://lacpass.create.cl:8080",
VhlBaseUrl: "http://lacpass.create.cl:8182",
APISwagger: false,
LogLevel: "info",
}
if serverPort, exists := os.LookupEnv("API_PORT"); exists {
if port, err := strconv.ParseUint(serverPort, 10, 16); err == nil {
cfg.ServerPort = uint16(port)
}
}
if authUrl, exists := os.LookupEnv("AUTH_INTERNAL_URL"); exists {
cfg.AuthInternalUrl = authUrl
}
if authHostname, exists := os.LookupEnv("AUTH_HOSTNAME"); exists {
cfg.AuthHostName = authHostname
}
if authRealm, exists := os.LookupEnv("AUTH_REALM"); exists {
cfg.AuthRealm = authRealm
}
if authEmailClientID, exists := os.LookupEnv("AUTH_EMAIL_CLIENT_ID"); exists {
cfg.AuthEmailClientID = authEmailClientID
}
if authClientSecret, exists := os.LookupEnv("AUTH_CLIENT_SECRET"); exists {
cfg.AuthClientSecret = authClientSecret
}
if authEmailRedirectURI, exists := os.LookupEnv("AUTH_EMAIL_REDIRECT_URI"); exists {
cfg.AuthEmailRedirectURI = authEmailRedirectURI
}
if authEmailLifespan, exists := os.LookupEnv("AUTH_EMAIL_LIFESPAN"); exists {
if lifespan, err := strconv.Atoi(authEmailLifespan); err == nil {
cfg.AuthEmailLifespan = lifespan
}
}
if authEmailClientID, exists := os.LookupEnv("AUTH_EMAIL_CLIENT_ID"); exists {
cfg.AuthEmailClientID = authEmailClientID
}
if authEmailClientID, exists := os.LookupEnv("AUTH_EMAIL_CLIENT_ID"); exists {
cfg.AuthEmailClientID = authEmailClientID
}
if fhirBaseUrl, exists := os.LookupEnv("FHIR_BASE_URL"); exists {
cfg.FhirBaseUrl = fhirBaseUrl
}
if vhlBaseUrl, exists := os.LookupEnv("VHL_BASE_URL"); exists {
cfg.VhlBaseUrl = vhlBaseUrl
}
if apiSwagger, exists := os.LookupEnv("API_SWAGGER"); exists {
cfg.APISwagger = apiSwagger == "true"
}
if logLevel, exists := os.LookupEnv("LOG_LEVEL"); exists {
cfg.LogLevel = logLevel
}
return cfg
}

30
cmd/api/main.go Normal file
View File

@ -0,0 +1,30 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
)
// @title Lacpass App API
// @version 0.1
// @description This is the official API for Lacpass mobile app.
// @host localhost:9081
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
func main() {
app := New(LoadConfig())
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
err := app.Start(ctx)
if err != nil {
fmt.Println("failed to start app:", err)
}
}

140
cmd/api/routes.go Normal file
View File

@ -0,0 +1,140 @@
package main
import (
"ips-lacpass-backend/internal/core"
"ips-lacpass-backend/internal/repository/fhir"
"ips-lacpass-backend/internal/repository/keycloak"
"ips-lacpass-backend/internal/repository/vhl"
"log/slog"
"net/http"
"os"
"strings"
"time"
_ "ips-lacpass-backend/internal/docs"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httplog/v3"
httpSwagger "github.com/swaggo/http-swagger"
"ips-lacpass-backend/internal/handler"
customMiddleware "ips-lacpass-backend/internal/middleware"
)
func (a *App) loadRoutes() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RedirectSlashes)
r.Use(middleware.RealIP)
r.Use(middleware.Timeout(60 * time.Second))
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// TODO: Add configuration for CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, User-Agent")
next.ServeHTTP(w, r)
})
})
if strings.ToLower(a.config.LogLevel) == "debug" {
logFormat := httplog.SchemaECS.Concise(true)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: logFormat.ReplaceAttr,
})).With(
slog.String("api", "lacpass"),
slog.String("version", "0.1.0"),
slog.String("env", "development"),
)
r.Use(httplog.RequestLogger(logger, &httplog.Options{
Level: slog.LevelDebug,
Schema: httplog.SchemaECS,
RecoverPanics: true,
Skip: nil,
LogRequestHeaders: []string{"Authorization", "Content-Type", "User-Agent"},
}))
}
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r.Route("/users", a.loadUserRoutesNoAuth)
r.Group(func(r chi.Router) {
authMiddleware := customMiddleware.NewAuthMiddleware(
a.config.AuthInternalUrl,
a.config.AuthRealm,
a.config.AuthHostName,
)
authMiddleware.RefreshKeySet(24 * time.Hour)
r.Use(authMiddleware.Authenticator)
r.Route("/ips", a.loadIpsRoute)
r.Route("/users/auth", a.loadUserRoutesAuth)
r.Route("/qr", a.loadVhlRoute)
})
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
if a.config.APISwagger {
r.Get("/swagger/*", httpSwagger.Handler())
}
a.router = r
}
func (a *App) loadUserRoutesNoAuth(router chi.Router) {
r := keycloak.NewKeycloakClient(
a.config.AuthInternalUrl,
a.config.AuthRealm,
a.config.AuthAdminClientID,
a.config.AuthClientSecret,
a.config.AuthEmailRedirectURI,
a.config.AuthEmailClientID,
a.config.AuthEmailLifespan,
)
s := core.NewUserService(r)
h := handler.NewUserHandler(s)
router.Post("/", h.Create)
}
func (a *App) loadUserRoutesAuth(router chi.Router) {
r := keycloak.NewKeycloakClient(
a.config.AuthInternalUrl,
a.config.AuthRealm,
a.config.AuthAdminClientID,
a.config.AuthClientSecret,
a.config.AuthEmailRedirectURI,
a.config.AuthEmailClientID,
a.config.AuthEmailLifespan,
)
s := core.NewUserService(r)
h := handler.NewUserHandler(s)
router.Put("/update", h.Update)
}
func (a *App) loadIpsRoute(router chi.Router) {
r := fhir.FhirRepository{
Client: &http.Client{},
BaseURL: a.config.FhirBaseUrl,
}
s := core.NewFhirService(r)
h := handler.NewIpsHandler(s)
router.Get("/", h.Get)
}
func (a *App) loadVhlRoute(router chi.Router) {
r := vhl.VhlRepository{
Client: &http.Client{},
BaseURL: a.config.VhlBaseUrl,
}
s := core.NewVhlService(r)
h := handler.NewVhlHandler(s)
router.Post("/", h.Create)
}

File diff suppressed because it is too large Load Diff

32
docker/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# Swag stage to generate swagger docs
FROM golang:1.24-alpine AS docs
RUN apk add --no-cache git
WORKDIR /build
RUN go install github.com/swaggo/swag/cmd/swag@latest
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
COPY ./cmd/api/ ./
COPY ./internal ./internal
RUN swag fmt
RUN swag init -o internal/docs
# Go stage to build the API
FROM golang:1.24 as builder
WORKDIR /build
COPY --from=docs /build .
RUN CGO_ENABLED=0 GOOS=linux go build -o api .
EXPOSE 8080
EXPOSE 3000
FROM scratch
COPY --from=builder /build/api .
ENTRYPOINT ["./api"]

107
docker/compose.yaml Normal file
View File

@ -0,0 +1,107 @@
services:
lacpass-backend:
build:
context: ..
dockerfile: ./docker/Dockerfile
container_name: lacpass-backend
image: ips-backend
networks:
- backend
env_file:
- ../.env
environment:
API_PORT: ${API_PORT:-3000}
AUTH_INTERNAL_URL: ${AUTH_INTERNAL_URL:-http://auth:8080}
AUTH_HOSTNAME: ${AUTH_URL:-http://localhost:9083}
AUTH_REALM: ${AUTH_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
AUTH_CLIENT_SECRET: ${AUTH_CLIENT_SECRET:-bbU4vnqhqe2AJ32XpdQVRVqfRMA82Hnu}
AUTH_EMAIL_REDIRECT_URI: ${AUTH_EMAIL_REDIRECT_URI:-ph4happ://open/validated-email}
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}
API_SWAGGER: ${API_SWAGGER:-true}
ports:
- "9081:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
depends_on:
auth:
condition: service_healthy
auth:
image: bitnami/keycloak:26.2.5
container_name: auth
env_file:
- ../.env
volumes:
- ../config/keycloak:/opt/bitnami/keycloak/data/import
environment:
KEYCLOAK_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:9083}
KC_HTTP_PORT: 8080
KC_CACHE: local
KEYCLOAK_ADMIN_USER: ${KC_BOOTSTRAP_ADMIN_USERNAME:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:-admin}
KEYCLOAK_DATABASE_HOST: auth-db
KEYCLOAK_DATABASE_PORT: 5432
KEYCLOAK_DATABASE_NAME: ${POSTGRES_DB:-keycloak}
KEYCLOAK_DATABASE_USER: ${POSTGRES_USER:-keycloak}
KEYCLOAK_DATABASE_PASSWORD: ${POSTGRES_PASS:-p@ssw0rd}
KEYCLOAK_ENABLE_HEALTH_ENDPOINTS: true
KEYCLOAK_EXTRA_ARGS: --import-realm
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
interval: 15s
timeout: 5s
retries: 5
ports:
- "9083:8080"
networks:
- backend
- auth
depends_on:
auth-db:
condition: service_healthy
auth-db:
image: postgres:17.5-alpine
container_name: auth-db
volumes:
- auth-data:/var/lib/postgresql/data
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-keycloak}
POSTGRES_USER: ${POSTGRES_USER:-keycloak}
POSTGRES_PASSWORD: ${POSTGRES_PASS:-p@ssw0rd}
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-keycloak} -d ${POSTGRES_DB:-keycloak} -h localhost",
]
interval: 5s
timeout: 3s
retries: 5
networks:
- auth
mailcatcher:
image: haravich/fake-smtp-server:20250615
container_name: mailcatcher
platform: "linux/amd64"
ports:
- "25:1025"
- "9082:1080"
networks:
- auth
volumes:
auth-data:
networks:
auth:
backend:

53
docs/authentication.md Normal file
View File

@ -0,0 +1,53 @@
# Authentication
Lacpass backend uses [Keycloak](https://www.keycloak.org/) as its authentication service. An open source access management service. First lets make sure to have the `.env` configured. The sample environment file can be used and then edited accordingly:
```bash
cp .env.sample .env
```
Then, to start keycloak we can run it from the root directory with docker compose as:
```bash
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
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:
```json
{
"issuer": "http://localhost:8082/realms/lacpass",
"authorization_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/auth",
"token_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/token",
"introspection_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/token/introspect",
"userinfo_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/userinfo",
"end_session_endpoint": "http://localhost:8082/realms/lacpass/protocol/openid-connect/logout",
...
}
```
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")
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.
Once the user is created, we can use the helper script to get an access token from Keycloak:
```bash
sh scripts/auth.sh
```
For this to work we need to define both `KEYCLOAK_DEFAULT_USER_EMAIL` and `KEYCLOAK_DEFAULT_USER_PASSWORD` in our `.env`
file.

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

76
docs/keycloak-setup.md Normal file
View File

@ -0,0 +1,76 @@
# Keycloak Setup
This guide explains how to configure the backend service to work with Keycloak. Throughout these instructions, we assume you are already logged in with the `admin` account.
## Activate Authentication
Before using the API service, you must enable authentication and set the client ID so the backend can perform operations on Keycloak, such as registering users.
1. Open the Keycloak service at [http://localhost:9083/](http://localhost:9083/).
2. Once the page loads, ensure you are in the correct realm. The realm name is specified in the `.env` file:
![Change Realm](./images/client_secret/keycloak_change_realm.png)
3. To enable authentication:
- Go to the `admin-cli` configuration:
![Admin CLI Access](./images/client_secret/keycloak_admin_cli.png)
- Scroll down to the **Capability Config** section and enable the two switches as shown below:
![Set Authentication](./images/client_secret/keycloak_set_authentication.png)
- 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).
![Get Client Secret](./images/client_secret/keycloak_get_client_secret.png)
This client secret is required in the Docker Compose file to configure the backend service. Add it to the appropriate section:
![Client ID in Docker Compose](./images/client_secret/docker_compose_client_id.png)
## Set Roles for Backend Interaction
To allow the backend service to perform all necessary operations, the `admin` role must have all service account roles assigned.
1. Go to the **Service Account Roles** tab.
2. Click **Apply Roles** to assign roles.
![Service Account Roles](./images/add_roles/keycloak_service_account_assign_role.png)
3. To simplify selection:
- Change the page size to show 100 roles per page:
![Show 100 Roles](./images/add_roles/keycloak_assign_role_100_pages.png)
- Select all roles by clicking the checkbox in the table header:
![Select All Roles](./images/add_roles/keycloak_service_accont_role_select_all.png)
4. After selecting all roles, click **Assign**. You should now see all roles listed as assigned:
![Role Account List](./images/add_roles/keycloak_service_account_list.png)
## 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.
To do this:
1. Navigate to the **Clients** tab in Keycloak.
2. Select the `app` client ID.
![Navigate to app client ID](./images/redirect_uri/keycloak_add_redirect_uri.png)
3. Go to the **Access Settings** section.
4. Under **Valid Redirect URIs**, add your desired redirect URI.
![Add redirect URI](./images/redirect_uri/keycloak_add_new_redirect_uri.png)
5. Click **Save** to apply your changes.
This ensures your application can successfully handle authentication responses from Keycloak.

41
go.mod Normal file
View File

@ -0,0 +1,41 @@
module ips-lacpass-backend
go 1.24
require (
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
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/swaggo/files v1.0.1 // 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
golang.org/x/text v0.26.0 // indirect
golang.org/x/tools v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

117
go.sum Normal file
View File

@ -0,0 +1,117 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/httplog/v3 v3.2.2 h1:G0oYv3YYcikNjijArHFUlqfR78cQNh9fGT43i6StqVc=
github.com/go-chi/httplog/v3 v3.2.2/go.mod h1:N/J1l5l1fozUrqIVuT8Z/HzNeSy8TF2EFyokPLe6y2w=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
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/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=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

40
internal/core/models.go Normal file
View File

@ -0,0 +1,40 @@
package core
type DocumentType string
const (
Passport DocumentType = "passport"
Identifier DocumentType = "identifier"
)
var AllowedDocumenTypes = map[string]DocumentType{
"identifier": Identifier,
"passport": Passport,
}
type User struct {
ID string
Username string
Email string
FirstName string
LastName string
Locale string
DocumentType DocumentType
Identifier string
}
type UserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
PasswordConfirm string `json:"password_confirm" binding:"required"`
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Locale string `json:"locale" binding:"required"`
DocumentType DocumentType `json:"document_type" binding:"required"`
Identifier string `json:"identifier" binding:"required"`
}
type UserUpdateRequest struct {
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
}

10
internal/core/outbound.go Normal file
View File

@ -0,0 +1,10 @@
package core
import (
"context"
)
type UserRepository interface {
CreateUser(ctx context.Context, user User, password string) (*User, error)
UpdateUser(ctx context.Context, userUUID string, ur UserUpdateRequest) (*User, error)
}

150
internal/core/service.go Normal file
View File

@ -0,0 +1,150 @@
package core
import (
"context"
"errors"
"fmt"
customErrors "ips-lacpass-backend/internal/errors"
"ips-lacpass-backend/internal/repository/fhir"
"ips-lacpass-backend/internal/repository/vhl"
"sort"
authMiddleware "ips-lacpass-backend/internal/middleware"
)
type UserService struct {
Repository UserRepository
}
type FhirService struct {
Repository fhir.FhirRepository
}
type VhlService struct {
Repository vhl.VhlRepository
}
func NewUserService(r UserRepository) UserService {
return UserService{
Repository: r,
}
}
func NewFhirService(r fhir.FhirRepository) FhirService {
return FhirService{
Repository: r,
}
}
func NewVhlService(r vhl.VhlRepository) VhlService {
return VhlService{
Repository: r,
}
}
func (us *UserService) CreateUser(ctx context.Context, ur UserRequest) (*User, error) {
user := &User{
Username: ur.Identifier,
Email: ur.Email,
FirstName: ur.FirstName,
LastName: ur.LastName,
Locale: ur.Locale,
DocumentType: ur.DocumentType,
Identifier: ur.Identifier,
}
resp, err := us.Repository.CreateUser(ctx, *user, ur.Password)
if err != nil {
var uErr *customErrors.HttpError
if errors.As(err, &uErr) {
return nil, uErr
}
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err,
}
}
return resp, nil
}
func (us *UserService) UpdateUser(ctx context.Context, ur UserUpdateRequest) (*User, error) {
userUUID, err := authMiddleware.GetUserUUIDFromContext(ctx)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 401,
Body: []map[string]interface{}{{"error": "user_uuid_not_found", "message": "User UUID not found in request context"}},
Err: err,
}
}
resp, err := us.Repository.UpdateUser(ctx, userUUID, ur)
if err != nil {
var uErr *customErrors.HttpError
if errors.As(err, &uErr) {
return nil, uErr
}
return nil, &customErrors.HttpError{
StatusCode: 502,
Body: []map[string]interface{}{{"error": "auth_service_error", "message": "Failed to connect to authentication service"}}, Err: err,
}
}
return resp, nil
}
func (fs *FhirService) GetIps(ctx context.Context) (map[string]interface{}, error) {
userId, err := authMiddleware.GetUserDocIDFromContext(ctx)
if err != nil {
return nil, &customErrors.HttpError{
StatusCode: 401,
Body: []map[string]interface{}{{"error": "user_identifier_not_found", "message": "User identifier not found in request context"}},
Err: err,
}
}
bundle, err := fs.Repository.GetDocumentReference(userId)
if err != nil {
fmt.Printf("Error fetching document reference: %v\n", err)
return nil, err
}
entries := bundle.Entry
if len(entries) == 0 {
return nil, &customErrors.HttpError{
StatusCode: 404,
Body: []map[string]interface{}{{"error": "not_found", "message": "No IPS found for the user"}},
Err: fmt.Errorf("no IPS found for the user"),
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Resource.Meta.LastUpdated > entries[j].Resource.Meta.LastUpdated
})
ipsBundle, err := fs.Repository.GetIpsBundle(entries[0].Resource.Content[0].Attachment.URL)
if err != nil {
return nil, err
}
return ipsBundle, nil
}
func (vs *VhlService) CreateQrCode(ctx context.Context, expiresOn *string, content *string, passCode *string) (*vhl.QrData, error) {
if content == nil || *content == "" {
return nil, &customErrors.HttpError{
StatusCode: 400,
Body: []map[string]interface{}{{"error": "invalid_request", "message": "Content cannot be empty"}},
Err: errors.New("content cannot be empty"),
}
}
qrData, err := vs.Repository.CreateQr(ctx, vhl.CreateQrRequest{
JsonContent: *content,
ExpiresOn: *expiresOn,
PassCode: *passCode,
})
if err != nil {
return nil, err
}
return qrData, nil
}

41
internal/docs/docs.go Normal file
View File

@ -0,0 +1,41 @@
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {},
"securityDefinitions": {
"BasicAuth": {
"type": "basic"
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "0.1",
Host: "localhost:8091",
BasePath: "",
Schemes: []string{},
Title: "Lacpass App API",
Description: "This is the official API for Lacpass mobile app.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

View File

@ -0,0 +1,16 @@
{
"swagger": "2.0",
"info": {
"description": "This is the official API for Lacpass mobile app.",
"title": "Lacpass App API",
"contact": {},
"version": "0.1"
},
"host": "localhost:8091",
"paths": {},
"securityDefinitions": {
"BasicAuth": {
"type": "basic"
}
}
}

View File

@ -0,0 +1,11 @@
host: localhost:8091
info:
contact: {}
description: This is the official API for Lacpass mobile app.
title: Lacpass App API
version: "0.1"
paths: {}
securityDefinitions:
BasicAuth:
type: basic
swagger: "2.0"

17
internal/errors/errors.go Normal file
View File

@ -0,0 +1,17 @@
package errors
import "fmt"
type HttpError struct {
StatusCode int
Body []map[string]interface{}
Err error
}
func (e *HttpError) Error() string {
return fmt.Sprintf("Http error: %d - %s", e.StatusCode, e.Err)
}
func (e *HttpError) Unwrap() error {
return e.Err
}

70
internal/handler/ips.go Normal file
View File

@ -0,0 +1,70 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/core"
errors2 "ips-lacpass-backend/internal/errors"
"net/http"
)
type IpsHandler struct {
FhirService core.FhirService
}
func NewIpsHandler(s core.FhirService) *IpsHandler {
return &IpsHandler{
FhirService: s,
}
}
// GetIPS godoc
//
// @Summary Fetch IPS from national node.
// @Description Fetch IPS from national node using session access token user identifier.
// @Tags IPS FHIR
// @Produce json
//
// @Security ApiKeyAuth
//
// @Success 200 {object} fhir.Bundle
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /ips [get]
func (ih *IpsHandler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ips, err := ih.FhirService.GetIps(ctx)
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
}
res, err := json.Marshal(ips)
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
}
}

277
internal/handler/user.go Normal file
View File

@ -0,0 +1,277 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"ips-lacpass-backend/internal/core"
errors2 "ips-lacpass-backend/internal/errors"
"net/http"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
type UserHandler struct {
UserService core.UserService
}
func NewUserHandler(s core.UserService) *UserHandler {
return &UserHandler{UserService: s}
}
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
func toSnakeCase(str string) string {
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
return strings.ToLower(snake)
}
func translateError(body []map[string]interface{}) []map[string]interface{} {
for _, m := range body {
if val, ok := m["error"]; ok {
m["error"] = toSnakeCase(val.(string))
}
}
return body
}
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Locale string `json:"locale"`
DocumentType string `json:"document_type"`
Identifier string `json:"identifier"`
}
type userCreationRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"required"`
FirstName string `json:"first_name,omitempty" validate:"required"`
LastName string `json:"last_name,omitempty" validate:"required"`
Locale string `json:"locale" validate:"required,oneof=es en pt-br"`
DocumentType string `json:"document_type" validate:"required,oneof=passport identifier"`
Identifier string `json:"identifier" validate:"required"`
}
type userUpdateRequest struct {
FirstName string `json:"first_name,omitempty" validate:"required"`
LastName string `json:"last_name,omitempty" validate:"required"`
}
// Create User godoc
//
// @Summary Register a new Keycloak user
// @Description Register a new Keycloak user
// @Tags Users
// @Accept json
// @Produce json
//
// @Param user body core.UserRequest true "New user parameters"
//
// @Success 201 {object} UserResponse
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /users [post]
func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var body userCreationRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
validate := validator.New()
err := validate.Struct(body)
if err != nil {
var verr []map[string]string
for _, err := range err.(validator.ValidationErrors) {
name := toSnakeCase(err.Field())
switch err.Tag() {
case "required":
verr = append(verr, map[string]string{
"error": fmt.Sprintf("missing_%s", name),
"error_description": fmt.Sprintf("Missing required field: %s", err.Field()),
})
case "email":
verr = append(verr, map[string]string{
"error": fmt.Sprintf("invalid_%s", name),
"error_description": fmt.Sprintf("Invalid %s type", strings.ReplaceAll(name, "_", " ")),
})
case "oneof":
verr = append(verr, map[string]string{
"error": fmt.Sprintf("invalid_%s", name),
"error_description": fmt.Sprintf("Invalid %s. Must be either %s", strings.ReplaceAll(name, "_", " "), strings.ReplaceAll(err.Param(), " ", " or ")),
})
}
}
res, err := json.Marshal(verr)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusBadRequest)
w.Write(res)
return
}
if body.Password != body.PasswordConfirm {
w.WriteHeader(http.StatusBadRequest)
res, err := json.Marshal([]map[string]string{
{
"error": "invalid_password_confirm",
"error_description": "Password and password confirmation do not match",
},
})
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Write(res)
}
user, err := u.UserService.CreateUser(r.Context(), core.UserRequest{
Email: body.Email,
Password: body.Password,
PasswordConfirm: body.PasswordConfirm,
FirstName: body.FirstName,
LastName: body.LastName,
Locale: body.Locale,
DocumentType: core.AllowedDocumenTypes[body.DocumentType],
Identifier: body.Identifier,
})
if err != nil {
var cuErr *errors2.HttpError
if errors.As(err, &cuErr) {
res, err := json.Marshal(translateError(cuErr.Body))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if cuErr.StatusCode == 409 {
// TODO Conflict error, user already exists. Cannot give this details to the user.
res = []byte(`[{"error":"user_already_exists","error_description":"User already exists"}]`)
}
w.WriteHeader(cuErr.StatusCode)
w.Write(res)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
res, err := json.Marshal(
&UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Locale: user.Locale,
DocumentType: string(user.DocumentType),
Identifier: user.Identifier,
})
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
w.Write(res)
}
// UpdateUser godoc
//
// @Summary Update user profile
// @Description Update user profile. Only firs name, last name for now
// @Tags Users
// @Accept json
// @Produce json
//
// @Param user body core.UserUpdateRequest true "New user details"
//
// @Security ApiKeyAuth
//
// @Success 200 {object} UserResponse
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /users/auth/update [put]
func (u *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body userUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
validate := validator.New()
err := validate.Struct(body)
if err != nil {
var verr []map[string]string
for _, err := range err.(validator.ValidationErrors) {
name := toSnakeCase(err.Field())
switch err.Tag() {
case "required":
verr = append(verr, map[string]string{
"error": fmt.Sprintf("missing_%s", name),
"error_description": fmt.Sprintf("Missing required field: %s", err.Field()),
})
}
}
res, err := json.Marshal(verr)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusBadRequest)
w.Write(res)
return
}
user, err := u.UserService.UpdateUser(r.Context(), core.UserUpdateRequest{
FirstName: body.FirstName,
LastName: body.LastName,
})
if err != nil {
var cuErr *errors2.HttpError
if errors.As(err, &cuErr) {
res, err := json.Marshal(translateError(cuErr.Body))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(cuErr.StatusCode)
w.Write(res)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
res, err := json.Marshal(
&UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Locale: user.Locale,
DocumentType: string(user.DocumentType),
Identifier: user.Identifier,
})
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
w.Write(res)
}

94
internal/handler/vhl.go Normal file
View File

@ -0,0 +1,94 @@
package handler
import (
"encoding/json"
"errors"
"ips-lacpass-backend/internal/core"
customErrors "ips-lacpass-backend/internal/errors"
"net/http"
)
type VhlHandler struct {
VhlService core.VhlService
}
func NewVhlHandler(s core.VhlService) *VhlHandler {
return &VhlHandler{
VhlService: s,
}
}
type VhlRequest struct {
ExpiresOn string `json:"expires_on,omitempty"`
Content string `json:"content,required"`
PassCode string `json:"pass_code,omitempty"`
}
type VhlResponse struct {
Data string `json:"data"`
}
// Create QR data godoc
//
// @Summary Create QR data.
// @Description Create QR data from VHL issuance.
// @Tags IPS FHIR
// @Accept json
// @Produce json
//
// @Param data body VhlRequest true "Data parameters"
//
// @Security ApiKeyAuth
//
// @Success 200 {object} VhlResponse
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /qr [post]
func (vh *VhlHandler) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// TODO check if user is authenticated and has the permission to create a QR code
// TODO throw correct error body
var body VhlRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
qr, err := vh.VhlService.CreateQrCode(ctx, &body.ExpiresOn, &body.Content, &body.PassCode)
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(&VhlResponse{
Data: qr.Value,
})
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
}
}

224
internal/middleware/auth.go Normal file
View File

@ -0,0 +1,224 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
type AuthMiddleware struct {
BaseURL string
WebKeySetsUrl string
Realm string
KeySet jwk.Set
Issuer string
}
func GetUserDocIDFromContext(ctx context.Context) (string, error) {
userId, ok := ctx.Value("userDocId").(string)
if !ok {
return "", fmt.Errorf("user identifier not found in request context")
}
return userId, nil
}
func GetUserUUIDFromContext(ctx context.Context) (string, error) {
userUUID, ok := ctx.Value("userUUID").(string)
if !ok {
return "", fmt.Errorf("user UUID not found in request context")
}
return userUUID, nil
}
func NewAuthMiddleware(baseURL string, realm string, hostName string) *AuthMiddleware {
WebKeySetsUrl := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/certs", baseURL, realm)
keySet, err := jwk.Fetch(context.Background(), WebKeySetsUrl)
if err != nil {
fmt.Println(err)
fmt.Println("All authenticated requests will be rejected until the JWKS is available")
}
return &AuthMiddleware{
BaseURL: baseURL,
WebKeySetsUrl: WebKeySetsUrl,
Realm: realm,
KeySet: keySet,
Issuer: fmt.Sprintf("%s/realms/%s", hostName, realm),
}
}
func (kam *AuthMiddleware) RefreshKeySet(interval time.Duration) {
var mu sync.Mutex
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
keySet, err := jwk.Fetch(context.Background(), kam.WebKeySetsUrl)
if err != nil {
fmt.Printf("Error fetching JWKS from %s: \n", kam.WebKeySetsUrl, err)
continue
}
mu.Lock()
kam.KeySet = keySet
mu.Unlock()
fmt.Printf("Refreshed JWKS from %s\n", kam.WebKeySetsUrl)
}
}()
}
func WriteError(w http.ResponseWriter, httpErr []map[string]string) {
w.WriteHeader(http.StatusUnauthorized)
res, err := json.Marshal(httpErr)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
_, err = w.Write(res)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
func (kam *AuthMiddleware) Authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if kam.KeySet == nil {
httpError := []map[string]string{
{
"error": "key_set_not_available",
"error_description": "Key set is not available, please try again later",
},
}
WriteError(w, httpError)
return
}
ah := r.Header.Get("Authorization")
if ah == "" {
httpError := []map[string]string{
{
"error": "missing_authorization_header",
"error_description": "Missing Authorization header in request",
},
}
WriteError(w, httpError)
return
}
ts := strings.TrimPrefix(ah, "Bearer ")
if ts == ah {
httpError := []map[string]string{
{
"error": "bad_formatted_authorization_header",
"error_description": "Missing bearer prefix in authorization header",
},
}
WriteError(w, httpError)
return
}
token, err := jwt.Parse([]byte(ts), jwt.WithKeySet(kam.KeySet), jwt.WithValidate(true))
if err != nil {
httpError := []map[string]string{
{
"error": "invalid_token",
"error_description": "Invalid token or signature",
},
}
WriteError(w, httpError)
return
}
if token.Issuer() != kam.Issuer {
httpError := []map[string]string{
{
"error": "invalid_token_issuer",
"error_description": "Invalid issuer in token",
},
}
WriteError(w, httpError)
return
}
userId, _ := token.PrivateClaims()["identifier"].(string)
if userId == "" {
httpError := []map[string]string{
{
"error": "token_user_id_not_found",
"error_description": "Token does not contain user identifier",
},
}
WriteError(w, httpError)
return
}
realmAccess, ok := token.PrivateClaims()["realm_access"].(map[string]interface{})
if !ok {
httpError := []map[string]string{
{
"error": "token_realm_access_not_found",
"error_description": "Token does not contain realm access information",
},
}
WriteError(w, httpError)
return
}
ri, ok := realmAccess["roles"].([]interface{})
if !ok {
httpError := []map[string]string{
{
"error": "token_roles_not_found",
"error_description": "Token does not contain roles information",
},
}
WriteError(w, httpError)
return
}
var roles []string
for _, role := range ri {
strRole, ok := role.(string)
if !ok {
httpError := []map[string]string{
{
"error": "token_invalid_role",
"error_description": "Token contains an invalid role format",
},
}
WriteError(w, httpError)
return
}
roles = append(roles, strRole)
}
userUUID := token.Subject()
if userUUID == "" {
httpError := []map[string]string{
{
"error": "token_user_uuid_not_found",
"error_description": "Token does not contain user UUID",
},
}
WriteError(w, httpError)
return
}
ctx := context.WithValue(r.Context(), "userDocId", userId)
ctx = context.WithValue(ctx, "roles", roles)
ctx = context.WithValue(ctx, "userUUID", userUUID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@ -0,0 +1,30 @@
package fhir
import (
"fmt"
"ips-lacpass-backend/internal/errors"
"net/http"
)
type FhirRepository struct {
Client *http.Client
BaseURL string
}
func (c *FhirRepository) GetDocumentReference(identifier string) (*Bundle, 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"),
}
}
func (c *FhirRepository) GetIpsBundle(url string) (map[string]interface{}, 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 Ips Bundle"),
}
}

View File

@ -0,0 +1,115 @@
package fhir
type Bundle struct {
ResourceType string `json:"resourceType"`
ID string `json:"id,omitempty"`
Meta *Meta `json:"meta,omitempty"`
Identifier *Identifier `json:"identifier,omitempty"`
Type string `json:"type"`
Timestamp string `json:"timestamp,omitempty"`
Total int `json:"total,omitempty"`
Link []BundleLink `json:"link,omitempty"`
Entry []BundleEntry `json:"entry,omitempty"`
Signature *Signature `json:"signature,omitempty"`
}
type BundleLink struct {
Relation string `json:"relation"`
URL string `json:"url"`
}
type BundleEntry struct {
FullURL string `json:"fullUrl"`
Resource *EntryResource `json:"resource,omitempty"`
Search *BundleSearch `json:"search,omitempty"`
}
type BundleSearch struct {
Mode string `json:"mode"`
}
type EntryResource struct {
ResourceType string `json:"resourceType"`
ID string `json:"id,omitempty"`
Meta *Meta `json:"meta,omitempty"`
Text *ResourceText `json:"text,omitempty"`
MasterIdentifier *Identifier `json:"masterIdentifier,omitempty"`
Identifier []Identifier `json:"identifier,omitempty"`
Status string `json:"status"`
Type interface{} `json:"type,omitempty"` // This is an any type, can be a string or an object
Subject *Reference `json:"subject"`
Author []Reference `json:"author,omitempty"`
Title string `json:"title,omitempty"`
Date string `json:"date,omitempty"`
Confidentiality string `json:"confidentiality,omitempty"`
Custodian *Reference `json:"custodian,omitempty"`
Content []DocumentContent `json:"content,omitempty"`
Section []EntrySection `json:"section,omitempty"`
}
type ResourceText struct {
Status string `json:"status"`
Div string `json:"div,omitempty"`
}
type EntryType struct {
Coding []Coding `json:"coding"`
}
type Coding struct {
System string `json:"system"`
Code string `json:"code"`
Display string `json:"display,omitempty"`
}
type EntrySection struct {
Title string `json:"title,omitempty"`
Code *EntryType `json:"code,omitempty"`
Text *ResourceText `json:"text,omitempty"`
Entry []Reference `json:"entry,omitempty"`
}
type Meta struct {
VersionID string `json:"versionId,omitempty"`
LastUpdated string `json:"lastUpdated,omitempty"`
Source string `json:"source,omitempty"`
Profile []string `json:"profile,omitempty"`
}
type Identifier struct {
System string `json:"system,omitempty"`
Value string `json:"value,omitempty"`
Use string `json:"use,omitempty"`
Type *EntryType `json:"type,omitempty"`
}
type Reference struct {
Reference string `json:"reference"`
Type string `json:"type,omitempty"`
Display string `json:"display,omitempty"`
}
type DocumentContent struct {
Attachment Attachment `json:"attachment"`
}
type Attachment struct {
ContentType string `json:"contentType"`
URL string `json:"url"`
}
type Signature struct {
Type []SignatureType `json:"type"`
When string `json:"when"`
Who *SignatureWho `json:"who"`
Data string `json:"data"`
}
type SignatureType struct {
System string `json:"system"`
Code string `json:"code"`
}
type SignatureWho struct {
Identifier Identifier `json:"identifier"`
}

View File

@ -0,0 +1,297 @@
package keycloak
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"ips-lacpass-backend/internal/core"
"ips-lacpass-backend/internal/errors"
"net/http"
"net/url"
"strconv"
"strings"
)
func NewKeycloakClient(baseURL, realm, clientId, clientSecret, emailRedirectUri, emailClientId string, emailLifeSpan int) core.UserRepository {
return &Client{
Client: &http.Client{},
BaseURL: baseURL,
Realm: realm,
AdminClientID: clientId,
ClientSecret: clientSecret,
EmailRedirectURI: emailRedirectUri,
EmailClientID: emailClientId,
EmailLifespan: emailLifeSpan,
}
}
func (kc *Client) getAccessToken() (*TokenResponse, error) {
tokenEndpoint := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", kc.BaseURL, kc.Realm)
data := url.Values{}
data.Set("client_id", kc.AdminClientID)
data.Set("client_secret", kc.ClientSecret)
data.Set("grant_type", "client_credentials")
resp, err := http.PostForm(tokenEndpoint, data)
if err != nil {
return nil, fmt.Errorf("failed to connect to Keycloak: %w", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Errorf("failed to close response body: %s", err.Error())
}
}(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to obtain token: %s", string(body))
}
var token TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
return &token, nil
}
func (kc *Client) sendValidationEmail(ctx context.Context, userID string) error {
actions := []string{"VERIFY_EMAIL"}
body, err := json.Marshal(actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %w", err)
}
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s/execute-actions-email", kc.BaseURL, kc.Realm, userID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, ku, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
q := req.URL.Query()
q.Add("client_id", kc.EmailClientID)
q.Add("lifespan", strconv.Itoa(kc.EmailLifespan))
q.Add("redirect_uri", kc.EmailRedirectURI)
req.URL.RawQuery = q.Encode()
t, err := kc.getAccessToken()
if err != nil {
return fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
resp, err := kc.Client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return fmt.Errorf("failed to decode error response: %w", err)
}
return &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
return nil
}
func (kc *Client) CreateUser(ctx context.Context, user core.User, password string) (*core.User, error) {
r := UserRegistrationRequest{
Username: user.Identifier,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Enabled: true,
Attributes: map[string][]string{
"locale": {user.Locale},
"document_type": {string(user.DocumentType)},
"identifier": {user.Identifier},
},
Credentials: []UserCredential{
{
Type: "password",
Value: password,
Temporary: false,
},
},
}
body, err := json.Marshal(r)
if err != nil {
return nil, fmt.Errorf("failed to marshal user payload: %w", err)
}
ku := fmt.Sprintf("%s/admin/realms/%s/users", kc.BaseURL, kc.Realm)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ku, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
t, err := kc.getAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
resp, err := kc.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %v", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusCreated {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return nil, fmt.Errorf("failed to decode error response: %w", err)
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
location := resp.Header.Get("Location")
if location == "" {
return nil, fmt.Errorf("user created but Location header is missing")
}
parts := strings.Split(location, "/")
userID := parts[len(parts)-1]
fmt.Println("New user ID:", userID)
go func() {
newCtx := context.Background()
if err := kc.sendValidationEmail(newCtx, userID); err != nil {
// Log the error but don't return it since this is running asynchronously
fmt.Println("Error sending validation email: %v", err)
}
}()
// Return the first user since we're querying by username/email
return &core.User{
ID: userID,
Username: user.Identifier,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Locale: user.Locale,
DocumentType: user.DocumentType,
Identifier: user.Identifier,
}, nil
}
func (kc *Client) UpdateUser(ctx context.Context, userUUID string, ur core.UserUpdateRequest) (*core.User, error) {
// Get UserRepresentation from keycloack
ku := fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ku, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
t, err := kc.getAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
resp, err := kc.Client.Do(req)
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusOK {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return nil, fmt.Errorf("failed to decode error response: %w", err)
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
var userRep UserRegistration
if err := json.NewDecoder(resp.Body).Decode(&userRep); err != nil {
return nil, fmt.Errorf("failed to decode user response: %w", err)
}
// Edit User representation
// For now, only firstName and lastName can be edited
changed := false
if ur.FirstName != userRep.FirstName {
userRep.FirstName = ur.FirstName
changed = true
}
if ur.LastName != userRep.LastName {
userRep.LastName = ur.LastName
changed = true
}
if !changed {
return nil, &errors.HttpError{
StatusCode: http.StatusBadRequest,
Body: []map[string]interface{}{{"error": "update_user_no_change", "message": "update request does not change user attributes"}},
}
}
//save updated user representation
body, err := json.Marshal(userRep)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated user payload: %w", err)
}
ku = fmt.Sprintf("%s/admin/realms/%s/users/%s", kc.BaseURL, kc.Realm, userUUID)
req, err = http.NewRequestWithContext(ctx, http.MethodPut, ku, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.AccessToken))
resp, err = kc.Client.Do(req)
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
fmt.Println("failed to close response body: %w", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusNoContent {
var errorResponse map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
return nil, fmt.Errorf("failed to decode error response: %w", err)
}
return nil, &errors.HttpError{
StatusCode: resp.StatusCode,
Body: []map[string]interface{}{errorResponse},
Err: err,
}
}
return &core.User{
ID: userRep.ID,
Username: userRep.Username,
Email: userRep.Email,
FirstName: ur.FirstName,
LastName: ur.LastName,
Locale: userRep.Attributes["locale"][0],
DocumentType: core.AllowedDocumenTypes[userRep.Attributes["document_type"][0]],
Identifier: userRep.Attributes["identifier"][0],
}, nil
}

View File

@ -0,0 +1,57 @@
package keycloak
import (
"net/http"
)
type Client struct {
Client *http.Client
BaseURL string
Realm string
AdminClientID string
ClientSecret string
EmailRedirectURI string
EmailLifespan int
EmailClientID string
}
type UserRegistration struct {
ID string `json:"id,omitempty"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Attributes map[string][]string `json:"attributes,omitempty"`
Credentials []Credential `json:"credentials,omitempty"`
}
type Credential struct {
Type string `json:"type"`
Value string `json:"value,omitempty"`
Temporary bool `json:"temporary"`
CreatedDate int64 `json:"createdDate,omitempty"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
RefreshExpiresIn int `json:"refresh_expires_in"`
TokenType string `json:"token_type"`
}
type UserRegistrationRequest struct {
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Enabled bool `json:"enabled"`
Attributes map[string][]string `json:"attributes"`
Credentials []UserCredential `json:"credentials"`
}
type UserCredential struct {
Type string `json:"type"`
Value string `json:"value"`
Temporary bool `json:"temporary"`
}

View File

@ -0,0 +1,22 @@
package vhl
import (
"context"
"fmt"
"ips-lacpass-backend/internal/errors"
"net/http"
)
type VhlRepository struct {
Client *http.Client
BaseURL string
}
func (c *VhlRepository) CreateQr(ctx context.Context, body CreateQrRequest) (*QrData, 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 create VHL QR"),
}
}

View File

@ -0,0 +1,11 @@
package vhl
type CreateQrRequest struct {
ExpiresOn string `json:"expiresOn,omitempty"`
JsonContent string `json:"jsonContent,required"`
PassCode string `json:"passCode,omitempty"`
}
type QrData struct {
Value string
}

113
scripts/auth.sh Normal file
View File

@ -0,0 +1,113 @@
#!/bin/bash
source ./.env
if ! command -v jq >/dev/null 2>&1
then
echo "JQ could not be found"
exit 1
fi
TOKEN_ENDPOINT="$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token"
get_access_token() {
RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$KEYCLOAK_CLIENT_ID" \
-d "username=$KEYCLOAK_DEFAULT_USER" \
-d "password=$KEYCLOAK_DEFAULT_USER_PASSWORD" \
-d "scope=openid" \
-d "grant_type=password")
if [ $? -ne 0 ]; then
echo "Error: Failed to connect to Keycloak."
exit 1
fi
# Extract the access token from the JSON response using jq
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
REFRESH_TOKEN=$(echo "$RESPONSE" | jq -r .refresh_token)
mkdir -p ./tmp
echo -n "$REFRESH_TOKEN" > ./tmp/refresh_token
# Check if an access token was returned
if [[ -z "${ACCESS_TOKEN}" || "$ACCESS_TOKEN" == null ]]; then
echo "Error: Failed to obtain access token. Check your credentials and client configuration."
echo "Response from Keycloak: $RESPONSE"
exit 1
fi
echo "Successfully logged in!"
echo "Access Token: $ACCESS_TOKEN"
exit 0
}
get_refresh_token() {
REFRESH_TOKEN=$(cat ./tmp/refresh_token)
if [[ -z "${REFRESH_TOKEN}" || "$REFRESH_TOKEN" == null ]]; then
echo "Could not find refresh token, getting a new access token"
get_access_token
fi
RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$KEYCLOAK_CLIENT_ID" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "grant_type=refresh_token")
if [ $? -ne 0 ]; then
echo "Error: Failed to connect to Keycloak."
exit 1
fi
# Extract the access token from the JSON response using jq
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
# Check if an access token was returned
if [[ -z "${ACCESS_TOKEN}" || "$ACCESS_TOKEN" == null ]]; then
echo "Error: Failed to refresh token. Check your credentials and client configuration."
echo "Response from Keycloak: $RESPONSE"
exit 1
fi
echo "Successfully refreshed token!"
echo "Access Token: $ACCESS_TOKEN"
exit 0
}
logout() {
LOGOUT_ENDPOINT="$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/logout"
REFRESH_TOKEN=$(cat ./tmp/refresh_token)
if [[ -z "${REFRESH_TOKEN}" || "$REFRESH_TOKEN" == null ]]; then
echo "Could not find refresh token, cannot logout"
exit 1
fi
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$KEYCLOAK_CLIENT" \
-d "refresh_token=$REFRESH_TOKEN" \
"$LOGOUT_ENDPOINT")
if [ "$RESPONSE" -eq 204 ]; then
echo "Success: Logout successful. The refresh token has been invalidated."
exit 0
elif [ "$RESPONSE" -eq 400 ]; then
echo "Error: Bad Request (400). The refresh token was likely invalid or already revoked."
elif [ "$RESPONSE" -eq 401 ]; then
echo "Error: Unauthorized (401). Check if your KEYCLOAK_CLIENT_SECRET is correct."
else
echo "Error: An unexpected error occurred. Keycloak responded with HTTP status $RESPONSE."
fi
exit 1
}
case $1 in
access-token)
get_access_token
;;
refresh-token)
get_refresh_token
;;
logout)
logout
;;
esac