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