Compare commits

...

48 Commits

Author SHA1 Message Date
Elias Schneider
712ff396f4 release: 0.25.0 2025-01-19 20:18:07 +01:00
Elias Schneider
090eca202d fix: improve spacing of checkboxes on application configuration page 2025-01-19 19:15:11 +01:00
Elias Schneider
d4055af3f4 tests: adapt OIDC tests 2025-01-19 15:56:54 +01:00
Elias Schneider
692ff70c91 refactor: run formatter 2025-01-19 15:41:16 +01:00
Elias Schneider
d5dd118a3f feat: automatically authorize client if signed in 2025-01-19 15:39:55 +01:00
Elias Schneider
06b90eddd6 feat: allow sign in with email (#100) 2025-01-19 15:30:31 +01:00
Elias Schneider
e284e352e2 fix: don't panic if LDAP sync fails on startup 2025-01-19 13:09:16 +01:00
Kyle Mendell
5101b14eec feat: add LDAP sync (#106)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-19 13:02:07 +01:00
Elias Schneider
bc8f454ea1 fix: session duration ignored in cookie expiration 2025-01-18 23:27:55 +01:00
Chris Danis
fda08ac1cd fix: always set secure on cookie (#130) 2025-01-18 22:33:41 +01:00
Elias Schneider
05a98ebe87 fix: search input not displayed if response hasn't any items 2025-01-18 22:29:20 +01:00
Elias Schneider
6e3728ddc8 docs: add guide to setup Pocket ID with Caddy 2025-01-15 14:05:44 +01:00
Elias Schneider
5c57beb4d7 release: 0.24.1 2025-01-13 15:14:10 +01:00
Elias Schneider
2a984eeaf1 docs: add account recovery to README 2025-01-13 15:13:56 +01:00
Elias Schneider
be6e25a167 fix: remove restrictive validation for group names 2025-01-13 12:38:02 +01:00
Elias Schneider
888557171d fix: optional arguments not working with create-one-time-access-token.sh 2025-01-13 12:32:22 +01:00
Elias Schneider
4d337a20c5 fix: audit log table overflow if row data is long 2025-01-12 01:21:47 +01:00
Elias Schneider
69afd9ad9f release: 0.24.0 2025-01-11 23:46:39 +01:00
Elias Schneider
fd69830c26 feat: add sorting for tables 2025-01-11 20:32:22 +01:00
Elias Schneider
61d18a9d1b fix: pkce state not correctly reflected in oidc client info 2025-01-10 09:32:51 +01:00
Elias Schneider
a649c4b4a5 fix: send test email to the user that has requested it 2025-01-10 09:25:26 +01:00
Elias Schneider
82e475a923 release: 0.23.0 2025-01-03 16:34:23 +01:00
Elias Schneider
2d31fc2cc9 feat: use same table component for OIDC client list as all other lists 2025-01-03 16:19:15 +01:00
Elias Schneider
adcf3ddc66 feat: add PKCE for non public clients 2025-01-03 16:15:10 +01:00
Elias Schneider
785200de61 chore: include static assets in binary 2025-01-03 15:12:07 +01:00
Elias Schneider
ee885fbff5 release: 0.22.0 2025-01-01 23:13:53 +01:00
Elias Schneider
333a1a18d5 fix: make user validation consistent between pages 2025-01-01 23:13:16 +01:00
Elias Schneider
1ff20caa3c fix: allow first and last name of user to be between 1 and 50 characters 2025-01-01 22:48:51 +01:00
Elias Schneider
f6f2736bba fix: hash in callback url is incorrectly appended 2025-01-01 22:46:59 +01:00
Elias Schneider
993330d932 Merge remote-tracking branch 'origin/main' 2025-01-01 22:46:29 +01:00
Jan-Philipp Fischer
204313aacf docs: add "groups" scope to the oauth2-proxy sample configuration (#85) 2024-12-31 11:31:39 +01:00
Elias Schneider
0729ce9e1a fix: passkey can't be added if PUBLIC_APP_URL includes a port 2024-12-31 10:42:54 +01:00
Elias Schneider
2d0bd8dcbf feat: add warning if passkeys missing 2024-12-23 09:59:12 +01:00
Elias Schneider
ff75322e7d docs: improve text in README 2024-12-20 08:20:40 +01:00
Elias Schneider
daced661c4 release: 0.21.0 2024-12-17 19:58:55 +01:00
Elias Schneider
0716c38fb8 feat: improve error state design for login page 2024-12-17 19:36:47 +01:00
Elias Schneider
789d9394a5 fix: OIDC client logo gets removed if other properties get updated 2024-12-17 19:00:33 +01:00
Elias Schneider
aeda512cb7 release: 0.20.1 2024-12-13 09:12:37 +01:00
Elias Schneider
5480ab0f18 tests: add e2e test for one time access tokens 2024-12-13 09:03:52 +01:00
Elias Schneider
bad901ea2b fix: wrong date time datatype used for read operations with Postgres 2024-12-13 08:43:46 +01:00
Elias Schneider
34e35193f9 fix: create-one-time-access-token.sh script not compatible with postgres 2024-12-12 23:03:07 +01:00
Elias Schneider
232c13b5ca release: 0.20.0 2024-12-12 17:21:58 +01:00
Elias Schneider
9d20a98dbb feat: add support for Postgres database provider (#79) 2024-12-12 17:21:28 +01:00
Elias Schneider
e9d83dd6c3 docs: add ghcr.io Docker image to docker-compose.yml 2024-12-12 17:18:25 +01:00
Elias Schneider
3006bc9ef7 docs: add callback url to proxy-services.md 2024-12-03 20:35:47 +01:00
Elias Schneider
ae1e2f5e77 release: 0.19.0 2024-11-29 23:17:26 +01:00
soup
edce3d3371 feat(geolite): add Tailscale IP detection with CGNAT range check (#77)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-11-29 23:17:08 +01:00
Elias Schneider
9a8ec15678 docs: add demo link 2024-11-29 20:24:26 +01:00
269 changed files with 3495 additions and 1413 deletions

View File

@@ -1,2 +0,0 @@
APP_ENV=test
PUBLIC_APP_URL=http://localhost

View File

@@ -5,22 +5,43 @@ on:
pull_request:
branches: [main]
jobs:
build-and-test:
build:
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
tags: stonith404/pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
test-sqlite:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Build Docker Image
run: docker build -t stonith404/pocket-id .
- name: Run Docker Container
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker Image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
@@ -30,6 +51,13 @@ jobs:
working-directory: ./frontend
run: npx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB
run: |
docker run -d --name pocket-id-sqlite \
-p 80:80 \
-e APP_ENV=test \
stonith404/pocket-id:test
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
@@ -37,7 +65,80 @@ jobs:
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-sqlite
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15
test-postgres:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker Image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Install Playwright Browsers
working-directory: ./frontend
run: npx playwright install --with-deps chromium
- name: Create Docker network
run: docker network create pocket-id-network
- name: Start Postgres DB
run: |
docker run -d --name pocket-id-db \
--network pocket-id-network \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=pocket-id \
-p 5432:5432 \
postgres:17
- name: Wait for Postgres to start
run: |
for i in {1..10}; do
if docker exec pocket-id-db pg_isready -U postgres; then
echo "Postgres is ready"
break
fi
echo "Waiting for Postgres..."
sleep 2
done
- name: Run Docker Container with Postgres DB
run: |
docker run -d --name pocket-id-postgres \
--network pocket-id-network \
-p 80:80 \
-e APP_ENV=test \
-e DB_PROVIDER=postgres \
-e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
stonith404/pocket-id:test
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-postgres
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15

View File

@@ -1 +1 @@
0.18.0
0.25.0

View File

@@ -1,3 +1,100 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.24.1...v) (2025-01-19)
### Features
* add LDAP sync ([#106](https://github.com/stonith404/pocket-id/issues/106)) ([5101b14](https://github.com/stonith404/pocket-id/commit/5101b14eec68a9507e1730994178d0ebe8185876))
* allow sign in with email ([#100](https://github.com/stonith404/pocket-id/issues/100)) ([06b90ed](https://github.com/stonith404/pocket-id/commit/06b90eddd645cce57813f2536e4a6a8836548f2b))
* automatically authorize client if signed in ([d5dd118](https://github.com/stonith404/pocket-id/commit/d5dd118a3f4ad6eed9ca496c458201bb10f148a0))
### Bug Fixes
* always set secure on cookie ([#130](https://github.com/stonith404/pocket-id/issues/130)) ([fda08ac](https://github.com/stonith404/pocket-id/commit/fda08ac1cd88842e25dc47395ed1288a5cfac4f8))
* don't panic if LDAP sync fails on startup ([e284e35](https://github.com/stonith404/pocket-id/commit/e284e352e2b95fac1d098de3d404e8531de4b869))
* improve spacing of checkboxes on application configuration page ([090eca2](https://github.com/stonith404/pocket-id/commit/090eca202d198852e6fbf4e6bebaf3b5ada13944))
* search input not displayed if response hasn't any items ([05a98eb](https://github.com/stonith404/pocket-id/commit/05a98ebe87d7a88e8b96b144c53250a40d724ec3))
* session duration ignored in cookie expiration ([bc8f454](https://github.com/stonith404/pocket-id/commit/bc8f454ea173ecc60e06450a1d22e24207f76714))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.0...v) (2025-01-13)
### Bug Fixes
* audit log table overflow if row data is long ([4d337a2](https://github.com/stonith404/pocket-id/commit/4d337a20c5cb92ef80bb7402f9b99b08e3ad0b6b))
* optional arguments not working with `create-one-time-access-token.sh` ([8885571](https://github.com/stonith404/pocket-id/commit/888557171d61589211b10f70dce405126216ad61))
* remove restrictive validation for group names ([be6e25a](https://github.com/stonith404/pocket-id/commit/be6e25a167de8bf07075b46f09d9fc1fa6c74426))
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)
### Features
* add sorting for tables ([fd69830](https://github.com/stonith404/pocket-id/commit/fd69830c2681985e4fd3c5336a2b75c9fb7bc5d4))
### Bug Fixes
* pkce state not correctly reflected in oidc client info ([61d18a9](https://github.com/stonith404/pocket-id/commit/61d18a9d1b167ff59a59523ff00d00ca8f23258d))
* send test email to the user that has requested it ([a649c4b](https://github.com/stonith404/pocket-id/commit/a649c4b4a543286123f4d1f3c411fe1a7e2c6d71))
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)
### Features
* add PKCE for non public clients ([adcf3dd](https://github.com/stonith404/pocket-id/commit/adcf3ddc6682794e136a454ef9e69ddd130626a8))
* use same table component for OIDC client list as all other lists ([2d31fc2](https://github.com/stonith404/pocket-id/commit/2d31fc2cc9201bb93d296faae622f52c6dcdfebc))
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
### Features
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
### Bug Fixes
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
### Features
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
### Bug Fixes
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
### Bug Fixes
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
### Features
* add support for Postgres database provider ([#79](https://github.com/stonith404/pocket-id/issues/79)) ([9d20a98](https://github.com/stonith404/pocket-id/commit/9d20a98dbbc322fa6f0644e8b31e6b97769887ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.18.0...v) (2024-11-29)
### Features
* **geolite:** add Tailscale IP detection with CGNAT range check ([#77](https://github.com/stonith404/pocket-id/issues/77)) ([edce3d3](https://github.com/stonith404/pocket-id/commit/edce3d337129c9c6e8a60df2122745984ba0f3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.17.0...v) (2024-11-28)

View File

@@ -33,9 +33,6 @@ COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh

View File

@@ -2,20 +2,40 @@
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
→ Try out the [Demo](https://pocket-id.eliasschneider.com)
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
## Table of Contents
- [ Pocket ID](#-pocket-id)
- [Table of Contents](#table-of-contents)
- [Setup](#setup)
- [Before you start](#before-you-start)
- [Installation with Docker (recommended)](#installation-with-docker-recommended)
- [Unraid](#unraid)
- [Stand-alone Installation](#stand-alone-installation)
- [Nginx Reverse Proxy](#nginx-reverse-proxy)
- [Proxy Services with Pocket ID](#proxy-services-with-pocket-id)
- [Update](#update)
- [Docker](#docker)
- [Stand-alone](#stand-alone)
- [Environment variables](#environment-variables)
- [Account recovery](#account-recovery)
- [Contribute](#contribute)
## Setup
> [!WARNING]
> [!WARNING]
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
### Before you start
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
### Installation with Docker (recommended)
@@ -76,14 +96,14 @@ Required tools:
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
```
You can now sign in with the admin account on `http://localhost/login/setup`.
### Nginx Reverse Proxy
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
```nginx
proxy_busy_buffers_size 512k;
@@ -93,7 +113,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
The goal of Pocket ID is to function exclusively as an OIDC provider. As such, we don't have a built-in proxy provider. However, you can use other tools that act as a middleware to protect your services and support OIDC as an authentication provider.
See the [guide](docs/proxy-services.md) for more information.
@@ -134,24 +154,37 @@ docker compose up -d
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
```
## Environment variables
| Variable | Default Value | Recommended to change | Description |
| ---------------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `MAXMIND_LICENSE_KEY` | `-` | yes | License Key for the GeoLite2 Database. The license key is required to retrieve the geographical location of IP addresses in the audit log. If the key is not provided, IP locations will be marked as "unknown." You can obtain a license key for free [here](https://www.maxmind.com/en/geolite2/signup). |
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
| `GEOLITE_DB_PATH` | `data/GeoLite2-City.mmdb` | no | The path where the GeoLite2 database should be stored. |
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
| `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
| Variable | Default Value | Recommended to change | Description |
| ---------------------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `MAXMIND_LICENSE_KEY` | `-` | yes | License Key for the GeoLite2 Database. The license key is required to retrieve the geographical location of IP addresses in the audit log. If the key is not provided, IP locations will be marked as "unknown." You can obtain a license key for free [here](https://www.maxmind.com/en/geolite2/signup). |
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
| `DB_PROVIDER` | `sqlite` | no | The database provider you want to use. Currently `sqlite` and `postgres` are supported. |
| `SQLITE_DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. This gets ignored if you didn't set `DB_PROVIDER` to `sqlite`. |
| `POSTGRES_CONNECTION_STRING` | `-` | no | The connection string to your Postgres database. This gets ignored if you didn't set `DB_PROVIDER` to `postgres`. A connection string can look like this: `postgresql://user:password@host:5432/pocket-id`. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
| `GEOLITE_DB_PATH` | `data/GeoLite2-City.mmdb` | no | The path where the GeoLite2 database should be stored. |
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
| `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
## Account recovery
There are two ways to create a one-time access link for a user:
1. **UI**: An admin can create a one-time access link for the user in the admin panel under the "Users" tab by clicking on the three dots next to the user's name and selecting "One-time link".
2. **Terminal**: You can create a one-time access link for a user by running the `scripts/create-one-time-access-token.sh` script. To execute this script with Docker you have to run the following command:
```bash
docker compose exec pocket-id sh "sh scripts/create-one-time-access-token.sh <username or email>"
```
## Contribute

View File

@@ -1,6 +1,8 @@
APP_ENV=production
PUBLIC_APP_URL=http://localhost
DB_PATH=data/pocket-id.db
DB_PROVIDER=sqlite
SQLITE_DB_PATH=data/pocket-id.db
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
UPLOAD_PATH=data/uploads
PORT=8080
HOST=localhost
HOST=localhost

3
backend/.gitignore vendored
View File

@@ -13,4 +13,5 @@
# Dependency directories (remove the comment below to include it)
# vendor/
./data
./data
.env

View File

@@ -5,7 +5,6 @@ go 1.23.1
require (
github.com/caarlos0/env/v11 v11.2.2
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.12.1
github.com/go-playground/validator/v10 v10.22.1
@@ -16,19 +15,23 @@ require (
github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
golang.org/x/crypto v0.27.0
golang.org/x/crypto v0.31.0
golang.org/x/time v0.6.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-ldap/ldap/v3 v3.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.14 // indirect
@@ -36,6 +39,10 @@ require (
github.com/google/go-tpm v0.9.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
@@ -43,6 +50,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -56,9 +64,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.10.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,3 +1,10 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -13,18 +20,36 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
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/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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=
@@ -39,6 +64,8 @@ github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
@@ -50,11 +77,29 @@ github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -85,16 +130,28 @@ github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNG
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -106,6 +163,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -118,34 +176,118 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=

View File

@@ -3,8 +3,10 @@ package bootstrap
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path"
"strings"
)
@@ -12,7 +14,7 @@ import (
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := os.ReadDir("./images")
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
@@ -27,10 +29,10 @@ func initApplicationImages() {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := "./images/" + sourceFile.Name()
destFilePath := dirPath + "/" + sourceFile.Name()
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyFile(srcFilePath, destFilePath)
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}

View File

@@ -2,7 +2,6 @@ package bootstrap
import (
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/service"
)
@@ -11,6 +10,5 @@ func Bootstrap() {
appConfigService := service.NewAppConfigService(db)
initApplicationImages()
job.RegisterJobs(db)
initRouter(db, appConfigService)
}

View File

@@ -2,9 +2,15 @@ package bootstrap
import (
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/resources"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -19,37 +25,67 @@ func newDatabase() (db *gorm.DB) {
log.Fatalf("failed to connect to database: %v", err)
}
sqlDb, err := db.DB()
sqlDb.SetMaxOpenConns(1)
if err != nil {
log.Fatalf("failed to get sql.DB: %v", err)
}
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres", driver)
// Choose the correct driver for the database provider
var driver database.Driver
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
log.Fatalf("failed to create migration instance: %v", err)
log.Fatalf("failed to create migration driver: %v", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
log.Fatalf("failed to apply migrations: %v", err)
// Run migrations
if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
return db
}
func connectDatabase() (db *gorm.DB, err error) {
dbPath := common.EnvConfig.DBPath
func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil {
return fmt.Errorf("failed to create embedded migration source: %v", err)
}
// Use in-memory database for testing
if common.EnvConfig.AppEnv == "test" {
dbPath = "file::memory:?cache=shared"
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %v", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %v", err)
}
return nil
}
func connectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector
// Choose the correct database provider
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
case common.DbProviderPostgres:
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
for i := 1; i <= 3; i++ {
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true,
Logger: getLogger(),
})

View File

@@ -2,12 +2,12 @@ package bootstrap
import (
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/controller"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
@@ -29,8 +29,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(gin.Logger())
// Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
emailService, err := service.NewEmailService(appConfigService, db, templateDir)
emailService, err := service.NewEmailService(appConfigService, db)
if err != nil {
log.Fatalf("Unable to create email service: %s", err)
}
@@ -39,27 +38,34 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService)
userService := service.NewUserService(db, jwtService, auditLogService, emailService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
// Initialize middleware
job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes
apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -1,37 +1,59 @@
package common
import (
"log"
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
"log"
)
type DbProvider string
const (
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DBPath string `env:"DB_PATH"`
UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
SqliteDBPath string `env:"SQLITE_DB_PATH"`
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DBPath: "data/pocket-id.db",
UploadPath: "data/uploads",
AppURL: "http://localhost",
Port: "8080",
Host: "localhost",
EmailTemplatesPath: "./email-templates",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
AppEnv: "production",
DbProvider: "sqlite",
SqliteDBPath: "data/pocket-id.db",
PostgresConnectionString: "",
UploadPath: "data/uploads",
AppURL: "http://localhost",
Port: "8080",
Host: "localhost",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
}
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
}
// Validate the environment variables
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
}
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
}
}

View File

@@ -58,7 +58,9 @@ func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" }
func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{}
@@ -95,7 +97,7 @@ func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbid
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests. Please wait a while before trying again."
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
@@ -160,3 +162,17 @@ func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }

View File

@@ -16,11 +16,13 @@ func NewAppConfigController(
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
acc := &AppConfigController{
appConfigService: appConfigService,
emailService: emailService,
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -34,11 +36,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
}
type AppConfigController struct {
appConfigService *service.AppConfigService
emailService *service.EmailService
ldapService *service.LdapService
}
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -182,8 +186,19 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail()
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID)
if err != nil {
c.Error(err)
return

View File

@@ -3,8 +3,8 @@ package controller
import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
@@ -23,12 +23,16 @@ type AuditLogController struct {
}
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
userID := c.GetString("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
if err != nil {
c.Error(err)
return

View File

@@ -5,8 +5,8 @@ import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strconv"
"strings"
)
@@ -153,11 +153,14 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
}
func (oc *OidcController) listClientsHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return

View File

@@ -1,21 +1,22 @@
package controller
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
"net/http"
"strconv"
"time"
)
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{
UserService: userService,
AppConfigService: appConfigService,
userService: userService,
appConfigService: appConfigService,
}
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
@@ -29,19 +30,23 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
}
type UserController struct {
UserService *service.UserService
AppConfigService *service.AppConfigService
userService *service.UserService
appConfigService *service.AppConfigService
}
func (uc *UserController) listUsersHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
@@ -60,7 +65,7 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
}
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id"))
user, err := uc.userService.GetUser(c.Param("id"))
if err != nil {
c.Error(err)
return
@@ -76,7 +81,7 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
}
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID"))
user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil {
c.Error(err)
return
@@ -92,7 +97,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
}
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
return
}
@@ -107,7 +112,7 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
return
}
user, err := uc.UserService.CreateUser(input)
user, err := uc.userService.CreateUser(input)
if err != nil {
c.Error(err)
return
@@ -127,7 +132,7 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
}
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
return
}
@@ -141,7 +146,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
return
}
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
c.Error(err)
return
@@ -150,8 +155,24 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"token": token})
}
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
@@ -163,12 +184,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin()
user, token, err := uc.userService.SetupInitialAdmin()
if err != nil {
c.Error(err)
return
@@ -180,7 +201,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
@@ -198,7 +219,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id")
}
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
if err != nil {
c.Error(err)
return

View File

@@ -1,13 +1,12 @@
package controller
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
@@ -28,16 +27,20 @@ type UserGroupController struct {
}
func (ugc *UserGroupController) list(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
}
// Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
@@ -104,7 +107,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
if err != nil {
c.Error(err)
return

View File

@@ -5,6 +5,7 @@ import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"time"
@@ -13,8 +14,8 @@ import (
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
wc := &WebauthnController{webAuthnService: webauthnService}
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
@@ -29,7 +30,8 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
}
type WebauthnController struct {
webAuthnService *service.WebAuthnService
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
@@ -40,7 +42,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
return
}
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response)
}
@@ -74,7 +76,7 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
return
}
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response)
}
@@ -91,9 +93,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return
}
userID := c.GetString("userID")
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
@@ -105,7 +105,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
utils.AddAccessTokenCookie(c, wc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
@@ -165,6 +165,6 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
c.SetCookie("access_token", "", 0, "/", "", false, true)
utils.AddAccessTokenCookie(c, "0", "")
c.Status(http.StatusNoContent)
}

View File

@@ -12,16 +12,31 @@ type AppConfigVariableDto struct {
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"`
LdapBindDn string `json:"ldapBindDn"`
LdapBindPassword string `json:"ldapBindPassword"`
LdapBase string `json:"ldapBase"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
}

View File

@@ -10,6 +10,7 @@ type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CreatedBy UserDto `json:"createdBy"`
}
@@ -17,6 +18,7 @@ type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
}
type AuthorizeOidcClientRequestDto struct {

View File

@@ -10,17 +10,24 @@ type UserDto struct {
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=3,max=20"`
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
LastName string `json:"lastName" binding:"required,min=3,max=30"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
LdapID string `json:"-"`
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type OneTimeAccessEmailDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}

View File

@@ -10,6 +10,7 @@ type UserGroupDtoWithUsers struct {
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
@@ -19,12 +20,14 @@ type UserGroupDtoWithUserCount struct {
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"`
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=2,max=255"`
LdapID string `json:"-"`
}
type UserGroupUpdateUsersDto struct {

View File

@@ -28,13 +28,6 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched
}
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain lowercase letters, numbers, and underscores
regex := "^[a-z0-9_]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
@@ -53,13 +46,6 @@ func init() {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("userGroupName", validateUserGroupName); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)

View File

@@ -4,12 +4,13 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"log"
"time"
)
func RegisterJobs(db *gorm.DB) {
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
@@ -29,22 +30,22 @@ type Jobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

@@ -0,0 +1,39 @@
package job
import (
"log"
"github.com/go-co-op/gocron/v2"
"github.com/stonith404/pocket-id/backend/internal/service"
)
type LdapJobs struct {
ldapService *service.LdapService
appConfigService *service.AppConfigService
}
func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *service.AppConfigService) {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
// Register the job to run every hour
registerJob(scheduler, "SyncLdap", "0 * * * *", jobs.syncLdap)
// Run the job immediately on startup
if err := jobs.syncLdap(); err != nil {
log.Printf("Failed to sync LDAP: %s", err)
}
scheduler.Start()
}
func (j *LdapJobs) syncLdap() error {
if j.appConfigService.DbConfig.LdapEnabled.Value == "true" {
return j.ldapService.SyncAll()
}
return nil
}

View File

@@ -16,8 +16,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine
go cleanupClients()
go cleanupClients(&mu, clients)
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -29,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
return
}
limiter := getLimiter(ip, limit, burst)
limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{})
c.Abort()
@@ -45,12 +49,8 @@ type client struct {
lastSeen time.Time
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() {
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for {
time.Sleep(time.Minute)
mu.Lock()
@@ -64,7 +64,7 @@ func cleanupClients() {
}
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock()
defer mu.Unlock()

View File

@@ -10,16 +10,16 @@ type AppConfigVariable struct {
}
type AppConfig struct {
// General
AppName AppConfigVariable
SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable
// Internal
BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
EmailEnabled AppConfigVariable
// Email
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
@@ -27,4 +27,21 @@ type AppConfig struct {
SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable
LdapBindDn AppConfigVariable
LdapBindPassword AppConfigVariable
LdapBase AppConfigVariable
LdapSkipCertVerify AppConfigVariable
LdapAttributeUserUniqueIdentifier AppConfigVariable
LdapAttributeUserUsername AppConfigVariable
LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable
}

View File

@@ -9,11 +9,11 @@ import (
type AuditLog struct {
Base
Event AuditLogEvent
IpAddress string
Country string
City string
UserAgent string
Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
UserID string
Data AuditLogData
}

View File

@@ -9,8 +9,8 @@ import (
// Base contains common columns for all tables.
type Base struct {
ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime
ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime `sortable:"true"`
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {

View File

@@ -36,12 +36,13 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string
Name string `sortable:"true"`
Secret string
CallbackURLs CallbackURLs
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
CreatedByID string
CreatedBy User

View File

@@ -2,10 +2,11 @@ package datatype
import (
"database/sql/driver"
"github.com/stonith404/pocket-id/backend/internal/common"
"time"
)
// DateTime custom type for time.Time to store date as unix timestamp in the database
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time
func (date *DateTime) Scan(value interface{}) (err error) {
@@ -14,7 +15,11 @@ func (date *DateTime) Scan(value interface{}) (err error) {
}
func (date DateTime) Value() (driver.Value, error) {
return time.Time(date).Unix(), nil
if common.EnvConfig.DbProvider == common.DbProviderSqlite {
return time.Time(date).Unix(), nil
} else {
return time.Time(date), nil
}
}
func (date DateTime) UTC() time.Time {

View File

@@ -9,11 +9,12 @@ import (
type User struct {
Base
Username string
Email string
FirstName string
LastName string
IsAdmin bool
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
LdapID *string
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -33,7 +34,7 @@ func (u User) WebAuthnCredentials() []webauthn.Credential {
for i, credential := range u.Credentials {
credentials[i] = webauthn.Credential{
ID: []byte(credential.CredentialID),
ID: credential.CredentialID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
Transport: credential.Transport,

View File

@@ -2,8 +2,9 @@ package model
type UserGroup struct {
Base
FriendlyName string
Name string `gorm:"unique"`
FriendlyName string `sortable:"true"`
Name string `sortable:"true"`
LdapID *string
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"github.com/go-webauthn/webauthn/protocol"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"time"
)
@@ -12,7 +13,7 @@ type WebauthnSession struct {
Base
Challenge string
ExpiresAt time.Time
ExpiresAt datatype.DateTime
UserVerification string
}
@@ -20,7 +21,7 @@ type WebauthnCredential struct {
Base
Name string
CredentialID string
CredentialID []byte
PublicKey []byte
AttestationType string
Transport AuthenticatorTransportList

View File

@@ -2,15 +2,16 @@ package service
import (
"fmt"
"log"
"mime/multipart"
"os"
"reflect"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"log"
"mime/multipart"
"os"
"reflect"
)
type AppConfigService struct {
@@ -30,6 +31,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
}
var defaultDbConfig = model.AppConfig{
// General
AppName: model.AppConfigVariable{
Key: "appName",
Type: "string",
@@ -52,6 +54,7 @@ var defaultDbConfig = model.AppConfig{
IsPublic: true,
DefaultValue: "true",
},
// Internal
BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType",
Type: "string",
@@ -70,11 +73,7 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true,
DefaultValue: "svg",
},
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
DefaultValue: "false",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
@@ -105,6 +104,76 @@ var defaultDbConfig = model.AppConfig{
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
}
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
@@ -119,6 +188,13 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()

View File

@@ -58,8 +58,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
return createdAuditLog
}
// If the user hasn't logged in from the same device before, send an email
if count <= 1 {
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)
@@ -84,11 +84,11 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID)
pagination, err := utils.Paginate(page, pageSize, query, &logs)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
return logs, pagination, err
}

View File

@@ -3,22 +3,25 @@ package service
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template"
"io/fs"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/smtp"
"net/textproto"
ttemplate "text/template"
"time"
)
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct {
appConfigService *AppConfigService
db *gorm.DB
@@ -26,13 +29,13 @@ type EmailService struct {
textTemplates map[string]*ttemplate.Template
}
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths)
textTemplates, err := email.PrepareTextTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
@@ -45,9 +48,9 @@ func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDi
}, nil
}
func (srv *EmailService) SendTestEmail() error {
func (srv *EmailService) SendTestEmail(recipientUserId string) error {
var user model.User
if err := srv.db.First(&user).Error; err != nil {
if err := srv.db.First(&user, "id = ?", recipientUserId).Error; err != nil {
return err
}
@@ -59,11 +62,6 @@ func (srv *EmailService) SendTestEmail() error {
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
@@ -113,11 +111,13 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer client.Close()
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
@@ -142,7 +142,11 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
tlsDialer := &tls.Dialer{
NetDialer: netDialer,
Config: tlsConfig,
}
conn, err := tlsDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
@@ -157,7 +161,7 @@ func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string,
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}

View File

@@ -9,7 +9,7 @@ import (
/**
How to add new template:
- pick unique and descriptive template ${name} (for example "login-with-new-device")
- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
- Path *must* be ${name}
- add xxxTemplate.Path to "emailTemplatePaths" at the end
@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
},
}
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
Path: "one-time-access",
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
return "One time access"
},
}
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
@@ -42,5 +49,9 @@ type NewLoginTemplateData struct {
DateTime time.Time
}
type OneTimeAccessTemplateData = struct {
Link string
}
// this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}

View File

@@ -5,15 +5,18 @@ import (
"compress/gzip"
"errors"
"fmt"
"github.com/oschwald/maxminddb-golang/v2"
"github.com/stonith404/pocket-id/backend/internal/common"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"time"
"github.com/oschwald/maxminddb-golang/v2"
"github.com/stonith404/pocket-id/backend/internal/common"
)
type GeoLiteService struct{}
@@ -33,6 +36,13 @@ func NewGeoLiteService() *GeoLiteService {
// GetLocationByIP returns the country and city of the given IP address.
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
// Check if IP is in Tailscale's CGNAT range (100.64.0.0/10)
if ip := net.ParseIP(ipAddress); ip != nil {
if ip.To4() != nil && ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127 {
return "Internal Network", "Tailscale", nil
}
}
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
if err != nil {
return "", "", err

View File

@@ -12,7 +12,6 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"log"
"math/big"
"os"
@@ -96,7 +95,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
},
IsAdmin: user.IsAdmin,
}
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, utils.GetHostFromURL(common.EnvConfig.AppURL)) {
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
return nil, errors.New("audience doesn't match")
}
return claims, nil

View File

@@ -0,0 +1,261 @@
package service
import (
"crypto/tls"
"fmt"
"log"
"strings"
"github.com/go-ldap/ldap/v3"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type LdapService struct {
db *gorm.DB
appConfigService *AppConfigService
userService *UserService
groupService *UserGroupService
}
func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService) *LdapService {
return &LdapService{db: db, appConfigService: appConfigService, userService: userService, groupService: groupService}
}
func (s *LdapService) createClient() (*ldap.Conn, error) {
if s.appConfigService.DbConfig.LdapEnabled.Value != "true" {
return nil, fmt.Errorf("LDAP is not enabled")
}
// Setup LDAP connection
ldapURL := s.appConfigService.DbConfig.LdapUrl.Value
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true"
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify}))
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
}
// Bind as service account
bindDn := s.appConfigService.DbConfig.LdapBindDn.Value
bindPassword := s.appConfigService.DbConfig.LdapBindPassword.Value
err = client.Bind(bindDn, bindPassword)
if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
}
return client, nil
}
func (s *LdapService) SyncAll() error {
err := s.SyncUsers()
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
err = s.SyncGroups()
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
return nil
}
func (s *LdapService) SyncGroups() error {
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
filter := "(objectClass=groupOfUniqueNames)"
searchAttrs := []string{
nameAttribute,
uniqueIdentifierAttribute,
"member",
}
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
result, err := client.Search(searchReq)
if err != nil {
return fmt.Errorf("failed to query LDAP: %w", err)
}
// Create a mapping for groups that exist
ldapGroupIDs := make(map[string]bool)
for _, value := range result.Entries {
var usersToAddDto dto.UserGroupUpdateUsersDto
var membersUserId []string
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
ldapGroupIDs[ldapId] = true
// Try to find the group in the database
var databaseGroup model.UserGroup
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues("member")
for _, member := range groupMembers {
// Normal output of this would be CN=username,ou=people,dc=example,dc=com
// Splitting at the "=" and "," then just grabbing the username for that string
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
var databaseUser model.User
s.db.Where("username = ?", singleMember).First(&databaseUser)
membersUserId = append(membersUserId, databaseUser.ID)
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(nameAttribute),
FriendlyName: value.GetAttributeValue(nameAttribute),
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
}
usersToAddDto = dto.UserGroupUpdateUsersDto{
UserIDs: membersUserId,
}
if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
} else {
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
}
}
} else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err
}
}
}
// Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup
if err := s.db.Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch groups from database: %v", err))
}
// Delete groups that no longer exist in LDAP
for _, group := range ldapGroupsInDb {
if _, exists := ldapGroupIDs[*group.LdapID]; !exists {
if err := s.db.Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).Error; err != nil {
log.Printf("Failed to delete group %s with: %v", group.Name, err)
} else {
log.Printf("Deleted group %s", group.Name)
}
}
}
return nil
}
func (s *LdapService) SyncUsers() error {
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeUserUniqueIdentifier.Value
usernameAttribute := s.appConfigService.DbConfig.LdapAttributeUserUsername.Value
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
filter := "(objectClass=person)"
searchAttrs := []string{
"memberOf",
"sn",
"cn",
uniqueIdentifierAttribute,
usernameAttribute,
emailAttribute,
firstNameAttribute,
lastNameAttribute,
}
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
result, err := client.Search(searchReq)
if err != nil {
fmt.Println(fmt.Errorf("failed to query LDAP: %w", err))
}
// Create a mapping for users that exist
ldapUserIDs := make(map[string]bool)
for _, value := range result.Entries {
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
ldapUserIDs[ldapId] = true
// Get the user from the database
var databaseUser model.User
s.db.Where("ldap_id = ?", ldapId).First(&databaseUser)
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if strings.Contains(group, adminGroupAttribute) {
isAdmin = true
}
}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(usernameAttribute),
Email: value.GetAttributeValue(emailAttribute),
FirstName: value.GetAttributeValue(firstNameAttribute),
LastName: value.GetAttributeValue(lastNameAttribute),
IsAdmin: isAdmin,
LdapID: ldapId,
}
if databaseUser.ID == "" {
_, err = s.userService.CreateUser(newUser)
if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err)
}
} else {
_, err = s.userService.UpdateUser(databaseUser.ID, newUser, false, true)
if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err)
}
}
}
// Get all LDAP users from the database
var ldapUsersInDb []model.User
if err := s.db.Find(&ldapUsersInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch users from database: %v", err))
}
// Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists {
if err := s.db.Delete(&model.User{}, "ldap_id = ?", user.LdapID).Error; err != nil {
log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else {
log.Printf("Deleted user %s", user.Username)
}
}
}
return nil
}

View File

@@ -131,8 +131,8 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, code
return "", "", &common.OidcInvalidAuthorizationCodeError{}
}
// If the client is public, the code verifier must match the code challenge
if client.IsPublic {
// If the client is public or PKCE is enabled, the code verifier must match the code challenge
if client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", &common.OidcInvalidCodeVerifierError{}
}
@@ -167,7 +167,7 @@ func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
return client, nil
}
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
@@ -176,7 +176,7 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
query = query.Where("name LIKE ?", searchPattern)
}
pagination, err := utils.Paginate(page, pageSize, query, &clients)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
@@ -189,6 +189,8 @@ func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string)
Name: input.Name,
CallbackURLs: input.CallbackURLs,
CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
}
if err := s.db.Create(&client).Error; err != nil {
@@ -207,6 +209,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err
@@ -406,6 +409,10 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
}
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
}
if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge
}

View File

@@ -7,8 +7,10 @@ import (
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path/filepath"
"time"
"github.com/go-webauthn/webauthn/protocol"
@@ -57,10 +59,33 @@ func (s *TestService) SeedDatabase() error {
}
}
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e",
ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
},
Name: "developers",
FriendlyName: "Developers",
@@ -146,7 +171,7 @@ func (s *TestService) SeedDatabase() error {
webauthnCredentials := []model.WebauthnCredential{
{
Name: "Passkey 1",
CredentialID: "test-credential-1",
CredentialID: []byte("test-credential-1"),
PublicKey: publicKey1,
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
@@ -154,7 +179,7 @@ func (s *TestService) SeedDatabase() error {
},
{
Name: "Passkey 2",
CredentialID: "test-credential-2",
CredentialID: []byte("test-credential-2"),
PublicKey: publicKey2,
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
@@ -169,7 +194,7 @@ func (s *TestService) SeedDatabase() error {
webauthnSession := model.WebauthnSession{
Challenge: "challenge",
ExpiresAt: time.Now().Add(1 * time.Hour),
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserVerification: "preferred",
}
if err := tx.Create(&webauthnSession).Error; err != nil {
@@ -183,13 +208,29 @@ func (s *TestService) SeedDatabase() error {
func (s *TestService) ResetDatabase() error {
err := s.db.Transaction(func(tx *gorm.DB) error {
var tables []string
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
return err
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Query to get all tables for SQLite
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
return err
}
case common.DbProviderPostgres:
// Query to get all tables for PostgreSQL
if err := tx.Raw(`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND tablename != 'schema_migrations';
`).Scan(&tables).Error; err != nil {
return err
}
default:
return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
// Delete all rows from all tables
for _, table := range tables {
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil {
return err
}
}
@@ -206,11 +247,21 @@ func (s *TestService) ResetApplicationImages() error {
return err
}
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil {
log.Printf("Error copying directory: %v", err)
files, err := resources.FS.ReadDir("images")
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join("images", file.Name())
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}

View File

@@ -17,14 +17,26 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
return &UserGroupService{db: db}
}
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
response, err = utils.Paginate(page, pageSize, query, &groups)
// As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
return groups, response, err
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
return groups, response, err
}
@@ -39,6 +51,10 @@ func (s *UserGroupService) Delete(id string) error {
return err
}
if group.LdapID != nil {
return &common.LdapUserGroupUpdateError{}
}
return s.db.Delete(&group).Error
}
@@ -46,6 +62,7 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
group = model.UserGroup{
FriendlyName: input.FriendlyName,
Name: input.Name,
LdapID: &input.LdapID,
}
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
@@ -57,14 +74,19 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
return group, nil
}
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allowLdapUpdate bool) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
if group.LdapID != nil && !allowLdapUpdate {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
}
group.Name = input.Name
group.FriendlyName = input.FriendlyName
group.LdapID = &input.LdapID
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {

View File

@@ -2,12 +2,17 @@ package service
import (
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"log"
"net/url"
"strings"
"time"
)
@@ -15,13 +20,14 @@ type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
}
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.Model(&model.User{})
@@ -30,7 +36,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.Paginate(page, pageSize, query, &users)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err
}
@@ -46,6 +52,10 @@ func (s *UserService) DeleteUser(userID string) error {
return err
}
if user.LdapID != nil {
return &common.LdapUserUpdateError{}
}
return s.db.Delete(&user).Error
}
@@ -56,6 +66,7 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
LdapID: &input.LdapID,
}
if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
@@ -66,11 +77,16 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
return user, nil
}
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err
}
if user.LdapID != nil && !allowLdapUpdate {
return model.User{}, &common.LdapUserUpdateError{}
}
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
@@ -89,7 +105,46 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if err != nil {
return err
}
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
go func() {
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link,
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
return nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", err
@@ -105,14 +160,12 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err
}
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
@@ -127,6 +180,10 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
return model.User{}, "", err
}
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
}
return oneTimeAccessToken.User, accessToken, nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"net/http"
@@ -22,7 +23,7 @@ type WebAuthnService struct {
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
@@ -55,7 +56,7 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
}
sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires,
ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
@@ -79,7 +80,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt,
Expires: storedSession.ExpiresAt.ToTime(),
UserID: []byte(userID),
}
@@ -95,7 +96,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
CredentialID: string(credential.ID),
CredentialID: credential.ID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
Transport: credential.Transport,
@@ -117,7 +118,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}
sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires,
ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
@@ -133,7 +134,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil
}
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.User{}, "", err
@@ -141,7 +142,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt,
Expires: storedSession.ExpiresAt.ToTime(),
}
var user *model.User
@@ -156,10 +157,6 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
return model.User{}, "", err
}
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err

View File

@@ -0,0 +1,12 @@
package utils
import (
"github.com/gin-gonic/gin"
"strconv"
)
func AddAccessTokenCookie(c *gin.Context, sessionDurationInMinutes string, token string) {
sessionDurationInMinutesParsed, _ := strconv.Atoi(sessionDurationInMinutes)
maxAge := sessionDurationInMinutesParsed * 60
c.SetCookie("access_token", token, maxAge, "/", "", true, true)
}

View File

@@ -2,14 +2,13 @@ package email
import (
"fmt"
"github.com/stonith404/pocket-id/backend/resources"
htemplate "html/template"
"io/fs"
"path"
ttemplate "text/template"
)
const templateComponentsDir = "components"
type Template[V any] struct {
Path string
Title func(data *TemplateData[V]) string
@@ -35,36 +34,37 @@ type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) {
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root html template: %w", err)
return *new(V), fmt.Errorf("clone root template: %w", err)
}
filename := fmt.Sprintf("%s%s", template, suffix)
_, err = tmpl.ParseFS(templateDir, filename)
templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil {
return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err)
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
}
return tmpl, nil
}
func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join(templateComponentsDir, "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(templateDir, components)
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
var textTemplates = make(map[string]*ttemplate.Template, len(templates))
textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
}
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl")
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
@@ -73,21 +73,21 @@ func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*tt
return textTemplates, nil
}
func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) {
components := path.Join(templateComponentsDir, "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(templateDir, components)
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
var htmlTemplates = make(map[string]*htemplate.Template, len(templates))
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
}
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl")
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}

View File

@@ -1,6 +1,7 @@
package utils
import (
"github.com/stonith404/pocket-id/backend/resources"
"io"
"mime/multipart"
"os"
@@ -28,27 +29,8 @@ func GetImageMimeType(ext string) string {
}
}
func CopyDirectory(srcDir, destDir string) error {
files, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join(srcDir, file.Name())
destFilePath := filepath.Join(destDir, file.Name())
err := CopyFile(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}
func CopyFile(srcFilePath, destFilePath string) error {
srcFile, err := os.Open(srcFilePath)
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return err
}

View File

@@ -2,6 +2,7 @@ package utils
import (
"gorm.io/gorm"
"reflect"
)
type PaginationResponse struct {
@@ -11,7 +12,36 @@ type PaginationResponse struct {
ItemsPerPage int `json:"itemsPerPage"`
}
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
type SortedPaginationRequest struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
}
func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gorm.DB, result interface{}) (PaginationResponse, error) {
pagination := sortedPaginationRequest.Pagination
sort := sortedPaginationRequest.Sort
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable := sortField.Tag.Get("sortable") == "true"
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder {
query = query.Order(CamelCaseToSnakeCase(sort.Column) + " " + sort.Direction)
}
return Paginate(pagination.Page, pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 {
page = 1
}
@@ -25,16 +55,21 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati
offset := (page - 1) * pageSize
var totalItems int64
if err := db.Count(&totalItems).Error; err != nil {
if err := query.Count(&totalItems).Error; err != nil {
return PaginationResponse{}, err
}
if err := db.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
return PaginationResponse{}, err
}
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
return PaginationResponse{
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"math/big"
"net/url"
"unicode"
)
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
@@ -29,15 +30,35 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
return string(result), nil
}
func GetHostFromURL(rawURL string) string {
func GetHostnameFromURL(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsedURL.Host
return parsedURL.Hostname()
}
// StringPointer creates a string pointer from a string value
func StringPointer(s string) *string {
return &s
}
func CapitalizeFirstLetter(s string) string {
if s == "" {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func CamelCaseToSnakeCase(s string) string {
var result []rune
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
result = append(result, '_')
}
result = append(result, unicode.ToLower(r))
}
return string(result)
}

View File

@@ -76,5 +76,20 @@
font-size: 1rem;
line-height: 1.5;
}
.button {
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
outline: none;
border: none;
text-decoration: none;
}
.button-container {
text-align: center;
margin-top: 24px;
}
</style>
{{ end }}

View File

@@ -1,7 +1,7 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>

View File

@@ -0,0 +1,17 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>One-Time Access</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -0,0 +1,8 @@
{{ define "base" -}}
One-Time Access
====================
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
{{ .Data.Link }}
{{ end -}}

View File

@@ -1,7 +1,7 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>

View File

@@ -0,0 +1,8 @@
package resources
import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations
var FS embed.FS

View File

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 539 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -0,0 +1,126 @@
CREATE TABLE app_config_variables
(
key VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
type VARCHAR(20) NOT NULL,
is_public BOOLEAN DEFAULT FALSE NOT NULL,
is_internal BOOLEAN DEFAULT FALSE NOT NULL,
default_value TEXT
);
CREATE TABLE user_groups
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
friendly_name VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE users
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(100),
last_name VARCHAR(100),
is_admin BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE audit_logs
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
event VARCHAR(100) NOT NULL,
ip_address INET NOT NULL,
data JSONB NOT NULL,
user_id UUID REFERENCES users ON DELETE SET NULL,
user_agent TEXT,
country VARCHAR(100),
city VARCHAR(100)
);
CREATE TABLE custom_claims
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
user_id UUID REFERENCES users ON DELETE CASCADE,
user_group_id UUID REFERENCES user_groups ON DELETE CASCADE,
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
);
CREATE TABLE oidc_authorization_codes
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
code VARCHAR(255) NOT NULL UNIQUE,
scope TEXT NOT NULL,
nonce VARCHAR(255),
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
client_id UUID NOT NULL,
code_challenge VARCHAR(255),
code_challenge_method_sha256 BOOLEAN
);
CREATE TABLE oidc_clients
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
name VARCHAR(255),
secret TEXT,
callback_urls JSONB,
image_type VARCHAR(10),
created_by_id UUID REFERENCES users ON DELETE SET NULL,
is_public BOOLEAN DEFAULT FALSE
);
CREATE TABLE one_time_access_tokens
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE TABLE user_authorized_oidc_clients
(
scope VARCHAR(255),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
PRIMARY KEY (user_id, client_id)
);
CREATE TABLE user_groups_users
(
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
user_group_id UUID NOT NULL REFERENCES user_groups ON DELETE CASCADE,
PRIMARY KEY (user_id, user_group_id)
);
CREATE TABLE webauthn_credentials
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
name VARCHAR(255) NOT NULL,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
attestation_type VARCHAR(20) NOT NULL,
transport JSONB NOT NULL,
user_id UUID REFERENCES users ON DELETE CASCADE,
backup_eligible BOOLEAN DEFAULT FALSE NOT NULL,
backup_state BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE webauthn_sessions
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
challenge VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_verification VARCHAR(255) NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users
DROP COLUMN ldap_id;
ALTER TABLE user_groups
DROP COLUMN ldap_id;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN ldap_id TEXT;
ALTER TABLE user_groups ADD COLUMN ldap_id TEXT;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id);

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;

Some files were not shown because too many files have changed in this diff Show More