first release of template
13
.env.sample
Normal 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
@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
.idea
|
||||||
|
|
||||||
|
temp/*
|
||||||
|
tmp/*
|
||||||
|
*.exe
|
||||||
|
mock_*
|
||||||
|
content-generator
|
||||||
|
*.tfvars
|
||||||
|
.terraform
|
||||||
|
.terraform.lock.hcl
|
||||||
|
templates
|
||||||
|
out
|
||||||
|
debugger
|
||||||
121
README.md
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||||
|
}
|
||||||
2747
config/keycloak/default_realm.json
Normal file
32
docker/Dockerfile
Normal 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
@ -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
@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
And then go to the `Users` tab and create a new user:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
BIN
docs/images/add_roles/keycloak_assign_role_100_pages.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/images/add_roles/keycloak_service_account_assign_role.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/images/add_roles/keycloak_service_account_list.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
docs/images/client_secret/docker_compose_client_id.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/images/client_secret/keycloak_admin_cli.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
docs/images/client_secret/keycloak_change_realm.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
docs/images/client_secret/keycloak_get_client_secret.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/images/client_secret/keycloak_set_authentication.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/images/keycloak_realms.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/images/keycloak_users.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/images/redirect_uri/keycloak_add_new_redirect_uri.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
docs/images/redirect_uri/keycloak_add_redirect_uri.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
76
docs/keycloak-setup.md
Normal 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:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
- Scroll down to the **Capability Config** section and enable the two switches as shown below:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This client secret is required in the Docker Compose file to configure the backend service. Add it to the appropriate section:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
- Select all roles by clicking the checkbox in the table header:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. After selecting all roles, click **Assign**. You should now see all roles listed as assigned:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. Go to the **Access Settings** section.
|
||||||
|
4. Under **Valid Redirect URIs**, add your desired redirect URI.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. Click **Save** to apply your changes.
|
||||||
|
|
||||||
|
This ensures your application can successfully handle authentication responses from Keycloak.
|
||||||
41
go.mod
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||||
|
}
|
||||||
16
internal/docs/swagger.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/docs/swagger.yaml
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
30
internal/repository/fhir/client.go
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
115
internal/repository/fhir/models.go
Normal 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"`
|
||||||
|
}
|
||||||
297
internal/repository/keycloak/client.go
Normal 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
|
||||||
|
}
|
||||||
57
internal/repository/keycloak/models.go
Normal 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"`
|
||||||
|
}
|
||||||
22
internal/repository/vhl/client.go
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/repository/vhl/models.go
Normal 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
@ -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
|
||||||
|
|
||||||