mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-12 00:03:00 +03:00
Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7446f853fc | ||
|
|
652ee6ad5d | ||
|
|
dca9e7a11a | ||
|
|
816c198a42 | ||
|
|
339837bec4 | ||
|
|
39b46e99a9 | ||
|
|
dc9e64de3d | ||
|
|
6207e10279 | ||
|
|
7550333fe2 | ||
|
|
3de1301fa8 | ||
|
|
c3980d3d28 | ||
|
|
4d0fff821e | ||
|
|
2e66211b7f | ||
|
|
2071d002fc | ||
|
|
0d071694cd | ||
|
|
39e403d00f | ||
|
|
4e858420e9 | ||
|
|
2d78349b38 | ||
|
|
9ed2adb0f8 | ||
|
|
43790dc1be | ||
|
|
7fbc356d8d | ||
|
|
9b77e8b7c1 | ||
|
|
bea115866f | ||
|
|
626f87d592 | ||
|
|
0751540d7d | ||
|
|
7c04bda5b7 | ||
|
|
98add37390 | ||
|
|
3dda2e16e9 | ||
|
|
3a6fce5c4b | ||
|
|
07ee087c3d | ||
|
|
d66cf70d50 | ||
|
|
fb8cc0bb22 | ||
|
|
0bae7e4f53 | ||
|
|
974b7b3c34 | ||
|
|
15cde6ac66 | ||
|
|
e864d5dcbf | ||
|
|
c6ab2b252c | ||
|
|
7350e3486d | ||
|
|
96303ded2b | ||
|
|
d06257ec9b | ||
|
|
19ef4833e9 | ||
|
|
e2c38138be | ||
|
|
13b02a072f | ||
|
|
430421e98b | ||
|
|
61e71ad43b | ||
|
|
4db44e4818 | ||
|
|
9ab178712a | ||
|
|
ecd74b794f | ||
|
|
5afd651434 | ||
|
|
2d3cba6308 | ||
|
|
e607fe424a | ||
|
|
8ae446322a | ||
|
|
37a835b44e | ||
|
|
75f531fbc6 | ||
|
|
28346da731 | ||
|
|
a1b20f0e74 | ||
|
|
7497f4ad40 | ||
|
|
b530d646ac | ||
|
|
77985800ae | ||
|
|
ea21eba281 | ||
|
|
66edb18f2c | ||
|
|
dab37c5967 | ||
|
|
781ff7ae7b | ||
|
|
04c7f180de | ||
|
|
5c452ceef0 | ||
|
|
8cd834a503 | ||
|
|
a65ce56b42 | ||
|
|
4a97986f52 | ||
|
|
a879bfa418 | ||
|
|
164ce6a3d7 | ||
|
|
ef1aeb7152 | ||
|
|
47c39f6d38 | ||
|
|
2884021055 | ||
|
|
def39b8703 | ||
|
|
d071641890 | ||
|
|
397544c0f3 | ||
|
|
1fb99e5d52 | ||
|
|
7b403552ba | ||
|
|
440a9f1ba0 | ||
|
|
d02f4753f3 | ||
|
|
ede7d8fc15 | ||
|
|
e4e6c9b680 | ||
|
|
c12bf2955b | ||
|
|
c211d3fc67 | ||
|
|
d87eb416cd | ||
|
|
f7710f2988 | ||
|
|
72923bb86d | ||
|
|
6e44b5e367 | ||
|
|
8a1db0cb4a | ||
|
|
3f02d08109 | ||
|
|
715040ba04 | ||
|
|
a8b9d60a86 | ||
|
|
712ff396f4 | ||
|
|
090eca202d | ||
|
|
d4055af3f4 | ||
|
|
692ff70c91 | ||
|
|
d5dd118a3f | ||
|
|
06b90eddd6 | ||
|
|
e284e352e2 | ||
|
|
5101b14eec | ||
|
|
bc8f454ea1 | ||
|
|
fda08ac1cd | ||
|
|
05a98ebe87 | ||
|
|
6e3728ddc8 | ||
|
|
5c57beb4d7 | ||
|
|
2a984eeaf1 | ||
|
|
be6e25a167 | ||
|
|
888557171d | ||
|
|
4d337a20c5 | ||
|
|
69afd9ad9f | ||
|
|
fd69830c26 | ||
|
|
61d18a9d1b | ||
|
|
a649c4b4a5 | ||
|
|
82e475a923 | ||
|
|
2d31fc2cc9 | ||
|
|
adcf3ddc66 | ||
|
|
785200de61 | ||
|
|
ee885fbff5 | ||
|
|
333a1a18d5 | ||
|
|
1ff20caa3c | ||
|
|
f6f2736bba | ||
|
|
993330d932 | ||
|
|
204313aacf | ||
|
|
0729ce9e1a | ||
|
|
2d0bd8dcbf | ||
|
|
ff75322e7d | ||
|
|
daced661c4 | ||
|
|
0716c38fb8 | ||
|
|
789d9394a5 | ||
|
|
aeda512cb7 | ||
|
|
5480ab0f18 | ||
|
|
bad901ea2b | ||
|
|
34e35193f9 | ||
|
|
232c13b5ca | ||
|
|
9d20a98dbb | ||
|
|
e9d83dd6c3 | ||
|
|
3006bc9ef7 | ||
|
|
ae1e2f5e77 | ||
|
|
edce3d3371 | ||
|
|
9a8ec15678 | ||
|
|
62cdab2b59 | ||
|
|
f2bfc73158 | ||
|
|
a9f4dada32 | ||
|
|
f9fa2c6706 | ||
|
|
7d6b1d19e9 | ||
|
|
31a6b57ec1 | ||
|
|
f11ed44733 | ||
|
|
541481721f | ||
|
|
0e95e9c56f | ||
|
|
fcf08a4d89 | ||
|
|
0b4101ccce | ||
|
|
27ea1fc2d3 | ||
|
|
f637a89f57 | ||
|
|
058084ed64 | ||
|
|
9370292fe5 | ||
|
|
46eef1fcb7 | ||
|
|
e784093342 | ||
|
|
653d948f73 | ||
|
|
a1302ef7bf | ||
|
|
5f44fef85f | ||
|
|
3613ac261c | ||
|
|
760c8e83bb | ||
|
|
3f29325f45 | ||
|
|
aca2240a50 | ||
|
|
de45398903 | ||
|
|
3d3fb4d855 | ||
|
|
725388fcc7 | ||
|
|
ad1d3560f9 | ||
|
|
becfc0004a | ||
|
|
376d747616 | ||
|
|
5b9f4d7326 | ||
|
|
0de4b55dc4 | ||
|
|
78c88f5339 | ||
|
|
60e7dafa01 | ||
|
|
2ccabf835c | ||
|
|
590cb02f6c | ||
|
|
8c96ab9574 | ||
|
|
3484daf870 | ||
|
|
cfbc0d6d35 | ||
|
|
939601b6a4 | ||
|
|
b9daa5d757 | ||
|
|
8304065652 | ||
|
|
7bfc3f43a5 | ||
|
|
c056089c60 | ||
|
|
3350398abc | ||
|
|
0b0a6781ff | ||
|
|
735dc70d5f | ||
|
|
47e164b4b5 | ||
|
|
18c5103c20 | ||
|
|
5565f60d6d | ||
|
|
bd4f87b2d2 | ||
|
|
6560fd9279 | ||
|
|
29d632c151 | ||
|
|
2092007752 | ||
|
|
0aff6181c9 | ||
|
|
824c5cb4f3 | ||
|
|
3a300a2b51 | ||
|
|
a1985ce1b2 | ||
|
|
b39bc4f79a | ||
|
|
0a07344139 | ||
|
|
f3f0e1d56d | ||
|
|
70ad0b4f39 | ||
|
|
2587058ded | ||
|
|
ff06bf0b34 | ||
|
|
11ed661f86 | ||
|
|
29748cc6c7 | ||
|
|
edfb99d221 | ||
|
|
282ff82b0c | ||
|
|
9d5f83da78 | ||
|
|
896da812a3 | ||
|
|
d2b3b7647d | ||
|
|
025378d14e | ||
|
|
e033ba6d45 | ||
|
|
e09562824a | ||
|
|
08f7fd16a9 | ||
|
|
be45eed125 | ||
|
|
9e94a436cc | ||
|
|
f82020ccfb | ||
|
|
a4a90a16a9 | ||
|
|
365734ec5d | ||
|
|
d02d8931a0 | ||
|
|
24c948e6a6 | ||
|
|
7a54d3ae20 | ||
|
|
5e1d19e0a4 | ||
|
|
d6a9bb4c09 | ||
|
|
3c67765992 | ||
|
|
6bb613e0e7 | ||
|
|
7be115f7da | ||
|
|
924bb1468b | ||
|
|
4553458939 | ||
|
|
9c2848db1d | ||
|
|
64cf56276a | ||
|
|
1f0ec08290 | ||
|
|
9121239dd7 | ||
|
|
4010ee27d6 | ||
|
|
4e7574a297 | ||
|
|
8038a111dd | ||
|
|
c6f83a581a | ||
|
|
8ad632e6c1 | ||
|
|
903b0b3918 | ||
|
|
fd21ce5aac | ||
|
|
e7861df95a | ||
|
|
8e27320649 | ||
|
|
2b9413c757 | ||
|
|
fd5a881cfb | ||
|
|
28ed064668 | ||
|
|
5446b46b65 | ||
|
|
0ce6045657 | ||
|
|
3fe24a04de | ||
|
|
6769cc8c10 | ||
|
|
97f7fc4e28 | ||
|
|
fc47c2a2a4 | ||
|
|
f1a6c8db85 | ||
|
|
552d7ccfa5 | ||
|
|
e45b0b3ed0 | ||
|
|
8166e2ead7 | ||
|
|
ae7aeb0945 | ||
|
|
16f273ffce | ||
|
|
9f49e5577e | ||
|
|
d92b80b80f | ||
|
|
aaed71e1c8 | ||
|
|
4780548843 | ||
|
|
a5dfdd2178 | ||
|
|
9eec7a3e9e | ||
|
|
fdc1921f5d | ||
|
|
601f6c488a | ||
|
|
0595d73ea5 | ||
|
|
74f4c22800 | ||
|
|
b49063d692 |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
/frontend/.svelte-kit
|
||||||
|
/frontend/build
|
||||||
|
/backend/bin
|
||||||
|
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
data
|
||||||
|
/scripts/development
|
||||||
@@ -1 +1,6 @@
|
|||||||
|
# See the README for more information: https://github.com/pocket-id/pocket-id?tab=readme-ov-file#environment-variables
|
||||||
PUBLIC_APP_URL=http://localhost
|
PUBLIC_APP_URL=http://localhost
|
||||||
|
TRUST_PROXY=false
|
||||||
|
MAXMIND_LICENSE_KEY=
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|||||||
21
.github/ISSUE_TEMPLATE/bug.yml
vendored
21
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -34,4 +34,23 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Before submitting, please check if the issues hasn't been raised before.
|
### Additional Information
|
||||||
|
- type: textarea
|
||||||
|
id: extra-information
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Version and Environment"
|
||||||
|
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||||
|
placeholder: "e.g., v0.24.1"
|
||||||
|
- type: textarea
|
||||||
|
id: log-files
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
attributes:
|
||||||
|
label: "Log Output"
|
||||||
|
description: "Output of log files when the issue occured to help us diagnose the issue."
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Before submitting, please check if the issue hasn't been raised before.**
|
||||||
|
|||||||
@@ -6,29 +6,50 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},prefix=v
|
||||||
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker registry
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: 'Login to GitHub Container Registry'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{github.repository_owner}}
|
||||||
|
password: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
130
.github/workflows/e2e-tests.yml
vendored
130
.github/workflows/e2e-tests.yml
vendored
@@ -2,24 +2,55 @@ name: E2E Tests
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- ".github/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- ".github/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
runs-on: ubuntu-latest
|
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: pocket-id/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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Download Docker image artifact
|
||||||
run: docker build -t stonith404/pocket-id .
|
uses: actions/download-artifact@v4
|
||||||
- name: Run Docker Container
|
with:
|
||||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
name: docker-image
|
||||||
|
path: /tmp
|
||||||
|
- name: Load Docker Image
|
||||||
|
run: docker load -i /tmp/docker-image.tar
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@@ -29,17 +60,94 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx playwright install --with-deps chromium
|
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 \
|
||||||
|
pocket-id/pocket-id:test
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
|
|
||||||
- name: Get container logs
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
run: docker logs pocket-id
|
with:
|
||||||
|
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 \
|
||||||
|
pocket-id/pocket-id:test
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npx playwright test
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report-postgres
|
||||||
path: frontend/tests/.output
|
path: frontend/tests/.report
|
||||||
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -34,4 +34,17 @@ vite.config.ts.timestamp-*
|
|||||||
# Application specific
|
# Application specific
|
||||||
data
|
data
|
||||||
/frontend/tests/.auth
|
/frontend/tests/.auth
|
||||||
pocket-id-backend
|
/frontend/tests/.report
|
||||||
|
pocket-id-backend
|
||||||
|
/backend/GeoLite2-City.mmdb
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|||||||
515
CHANGELOG.md
515
CHANGELOG.md
@@ -1,3 +1,518 @@
|
|||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.34.0...v) (2025-02-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to upload a profile picture ([#244](https://github.com/pocket-id/pocket-id/issues/244)) ([652ee6a](https://github.com/pocket-id/pocket-id/commit/652ee6ad5d6c46f0d35c955ff7bb9bdac6240ca6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* app config strings starting with a number are parsed incorrectly ([816c198](https://github.com/pocket-id/pocket-id/commit/816c198a42c189cb1f2d94885d2e3623e47e2848))
|
||||||
|
* emails do not get rendered correctly in Gmail ([dca9e7a](https://github.com/pocket-id/pocket-id/commit/dca9e7a11a3ba5d3b43a937f11cb9d16abad2db5))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
|
||||||
|
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
|
||||||
|
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to override the UI configuration with environment variables ([4e85842](https://github.com/pocket-id/pocket-id/commit/4e858420e9d9713e19f3b35c45c882403717f72f))
|
||||||
|
* add warning for only having one passkey configured ([#220](https://github.com/pocket-id/pocket-id/issues/220)) ([39e403d](https://github.com/pocket-id/pocket-id/commit/39e403d00f3870f9e960427653a1d9697da27a6f))
|
||||||
|
* display source in user and group table ([#225](https://github.com/pocket-id/pocket-id/issues/225)) ([9ed2adb](https://github.com/pocket-id/pocket-id/commit/9ed2adb0f8da13725fd9a4ef6a7798c377d13513))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* user linking in ldap group sync ([#222](https://github.com/pocket-id/pocket-id/issues/222)) ([2d78349](https://github.com/pocket-id/pocket-id/commit/2d78349b381d7ca10f47d3c03cef685a576b1b49))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.29.0...v) (2025-02-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add custom ldap search filters ([#216](https://github.com/pocket-id/pocket-id/issues/216)) ([626f87d](https://github.com/pocket-id/pocket-id/commit/626f87d59211f4129098b91dc1d020edb4aca692))
|
||||||
|
* update host configuration to allow external access ([#218](https://github.com/pocket-id/pocket-id/issues/218)) ([bea1158](https://github.com/pocket-id/pocket-id/commit/bea115866fd8e4b15d3281c422d2fb72312758b1))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.28.1...v) (2025-02-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add JSON support in custom claims ([15cde6a](https://github.com/pocket-id/pocket-id/commit/15cde6ac66bc857ac28df545a37c1f4341977595))
|
||||||
|
* add option to disable Caddy in the Docker container ([e864d5d](https://github.com/pocket-id/pocket-id/commit/e864d5dcbff1ef28dc6bf120e4503093a308c5c8))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.28.0...v) (2025-02-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* don't return error page if version info fetching failed ([d06257e](https://github.com/stonith404/pocket-id/commit/d06257ec9b5e46e25e40c174b4bef02dca0a1ea3))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.27.2...v) (2025-02-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* allow LDAP users and groups to be deleted if LDAP gets disabled ([9ab1787](https://github.com/stonith404/pocket-id/commit/9ab178712aa3cc71546a89226e67b7ba91245251))
|
||||||
|
* map allowed groups to OIDC clients ([#202](https://github.com/stonith404/pocket-id/issues/202)) ([13b02a0](https://github.com/stonith404/pocket-id/commit/13b02a072f20ce10e12fd8b897cbf42a908f3291))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **caddy:** trusted_proxies for IPv6 enabled hosts ([#189](https://github.com/stonith404/pocket-id/issues/189)) ([37a835b](https://github.com/stonith404/pocket-id/commit/37a835b44e308622f6862de494738dd2bfb58ef0))
|
||||||
|
* missing user service dependency ([61e71ad](https://github.com/stonith404/pocket-id/commit/61e71ad43b8f0f498133d3eb2381382e7bc642b9))
|
||||||
|
* non LDAP user group can't be updated after update ([ecd74b7](https://github.com/stonith404/pocket-id/commit/ecd74b794f1ffb7da05bce0046fb8d096b039409))
|
||||||
|
* use cursor pointer on clickable elements ([7798580](https://github.com/stonith404/pocket-id/commit/77985800ae9628104e03e7f2e803b7ed9eaaf4e0))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.27.1...v) (2025-01-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* smtp hello for tls connections ([#180](https://github.com/stonith404/pocket-id/issues/180)) ([781ff7a](https://github.com/stonith404/pocket-id/commit/781ff7ae7b84b13892e7a565b7a78f20c52ee2c9))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.27.0...v) (2025-01-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add `__HOST` prefix to cookies ([#175](https://github.com/stonith404/pocket-id/issues/175)) ([164ce6a](https://github.com/stonith404/pocket-id/commit/164ce6a3d7fa8ae5275c94302952cf318e3b3113))
|
||||||
|
* send hostname derived from `PUBLIC_APP_URL` with SMTP EHLO command ([397544c](https://github.com/stonith404/pocket-id/commit/397544c0f3f2b49f1f34ae53e6b9daf194d1ae28))
|
||||||
|
* use OS hostname for SMTP EHLO message ([47c39f6](https://github.com/stonith404/pocket-id/commit/47c39f6d382c496cb964262adcf76cc8dbb96da3))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.26.0...v) (2025-01-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* display private IP ranges correctly in audit log ([#139](https://github.com/stonith404/pocket-id/issues/139)) ([72923bb](https://github.com/stonith404/pocket-id/commit/72923bb86dc5d07d56aea98cf03320667944b553))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add save changes dialog before sending test email ([#165](https://github.com/stonith404/pocket-id/issues/165)) ([d02f475](https://github.com/stonith404/pocket-id/commit/d02f4753f3fbda75cd415ebbfe0702765c38c144))
|
||||||
|
* ensure the downloaded GeoLite2 DB is not corrupted & prevent RW race condition ([#138](https://github.com/stonith404/pocket-id/issues/138)) ([f7710f2](https://github.com/stonith404/pocket-id/commit/f7710f298898d322885c1c83680e26faaa0bb800))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.25.1...v) (2025-01-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* support wildcard callback URLs ([8a1db0c](https://github.com/stonith404/pocket-id/commit/8a1db0cb4a5d4b32b4fdc19d41fff688a7c71a56))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* non LDAP users get created with a empty LDAP ID string ([3f02d08](https://github.com/stonith404/pocket-id/commit/3f02d081098ad2caaa60a56eea4705639f80d01f))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.25.0...v) (2025-01-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* disable account details inputs if user is imported from LDAP ([a8b9d60](https://github.com/stonith404/pocket-id/commit/a8b9d60a86e80c10d6fba07072b1d32cec400ecb))
|
||||||
|
|
||||||
|
## [](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)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to disable TLS for email sending ([f9fa2c6](https://github.com/stonith404/pocket-id/commit/f9fa2c6706a8bf949fe5efd6664dec8c80e18659))
|
||||||
|
* allow empty user and password in SMTP configuration ([a9f4dad](https://github.com/stonith404/pocket-id/commit/a9f4dada321841d3611b15775307228b34e7793f))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* email save toast shows two times ([f2bfc73](https://github.com/stonith404/pocket-id/commit/f2bfc731585ad7424eb8c4c41c18368fc0f75ffc))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* add option to specify the Max Mind license key for the Geolite2 db
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
|
||||||
|
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
|
||||||
|
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
|
||||||
|
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
|
||||||
|
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
|
||||||
|
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
|
||||||
|
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add script for creating one time access token ([a1985ce](https://github.com/stonith404/pocket-id/commit/a1985ce1b200550e91c5cb42a8d19899dcec831e))
|
||||||
|
* add version information to footer and update link if new update is available ([70ad0b4](https://github.com/stonith404/pocket-id/commit/70ad0b4f39699fd81ffdfd5c8d6839f49348be78))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* cache version information for 3 hours ([29d632c](https://github.com/stonith404/pocket-id/commit/29d632c1514d6edacdfebe6deae4c95fc5a0f621))
|
||||||
|
* improve text for initial admin account setup ([0a07344](https://github.com/stonith404/pocket-id/commit/0a0734413943b1fff27d8f4ccf07587e207e2189))
|
||||||
|
* increase callback url count ([f3f0e1d](https://github.com/stonith404/pocket-id/commit/f3f0e1d56d7656bdabbd745a4eaf967f63193b6c))
|
||||||
|
* no DTO was returned from exchange one time access token endpoint ([824c5cb](https://github.com/stonith404/pocket-id/commit/824c5cb4f3d6be7f940c1758112fbe9322df5768))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add environment variable to change the caddy port in Docker ([ff06bf0](https://github.com/stonith404/pocket-id/commit/ff06bf0b34496ce472ba6d3ebd4ea249f21c0ec3))
|
||||||
|
* use improve table for users and audit logs ([11ed661](https://github.com/stonith404/pocket-id/commit/11ed661f86a512f78f66d604a10c1d47d39f2c39))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow copy to clipboard for client secret ([29748cc](https://github.com/stonith404/pocket-id/commit/29748cc6c7b7e5a6b54bfe837e0b1a98fa1ad594))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add key id to JWK ([282ff82](https://github.com/stonith404/pocket-id/commit/282ff82b0c7e2414b3528c8ca325758245b8ae61))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.7.1...v) (2024-10-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add location based on ip to the audit log ([025378d](https://github.com/stonith404/pocket-id/commit/025378d14edd2d72da76e90799a0ccdd42cf672c))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.7.0...v) (2024-10-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* initials don't get displayed if Gravatar avatar doesn't exist ([e095628](https://github.com/stonith404/pocket-id/commit/e09562824a794bc7d240e9d229709d4b389db7d5))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.6.0...v) (2024-10-03)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* add ability to set light and dark mode logo
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to set light and dark mode logo ([be45eed](https://github.com/stonith404/pocket-id/commit/be45eed125e33e9930572660a034d5f12dc310ce))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.3...v) (2024-10-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add copy to clipboard option for OIDC client information ([f82020c](https://github.com/stonith404/pocket-id/commit/f82020ccfb0d4fbaa1dd98182188149d8085252a))
|
||||||
|
* add gravatar profile picture integration ([365734e](https://github.com/stonith404/pocket-id/commit/365734ec5d8966c2ab877c60cfb176b9cdc36880))
|
||||||
|
* add user groups ([24c948e](https://github.com/stonith404/pocket-id/commit/24c948e6a66f283866f6c8369c16fa6cbcfa626c))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* only return user groups if it is explicitly requested ([a4a90a1](https://github.com/stonith404/pocket-id/commit/a4a90a16a9726569a22e42560184319b25fd7ca6))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.2...v) (2024-09-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add space to "Firstname" and "Lastname" label ([#31](https://github.com/stonith404/pocket-id/issues/31)) ([d6a9bb4](https://github.com/stonith404/pocket-id/commit/d6a9bb4c09efb8102da172e49c36c070b341f0fc))
|
||||||
|
* port environment variables get ignored in caddyfile ([3c67765](https://github.com/stonith404/pocket-id/commit/3c67765992d7369a79812bc8cd216c9ba12fd96e))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.1...v) (2024-09-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* updated application name doesn't apply to webauthn credential ([924bb14](https://github.com/stonith404/pocket-id/commit/924bb1468bbd8e42fa6a530ef740be73ce3b3914))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.0...v) (2024-09-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **email:** improve email templating ([#27](https://github.com/stonith404/pocket-id/issues/27)) ([64cf562](https://github.com/stonith404/pocket-id/commit/64cf56276a07169bc601a11be905c1eea67c4750))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* debounce oidc client and user search ([9c2848d](https://github.com/stonith404/pocket-id/commit/9c2848db1d93c230afc6c5f64e498e9f6df8c8a7))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add audit log with email notification ([#26](https://github.com/stonith404/pocket-id/issues/26)) ([9121239](https://github.com/stonith404/pocket-id/commit/9121239dd7c14a2107a984f9f94f54227489a63a))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add name claim to userinfo endpoint and id token ([4e7574a](https://github.com/stonith404/pocket-id/commit/4e7574a297307395603267c7a3285d538d4111d8))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* limit width of content on large screens ([c6f83a5](https://github.com/stonith404/pocket-id/commit/c6f83a581ad385391d77fec7eeb385060742f097))
|
||||||
|
* show error message if error occurs while authorizing new client ([8038a11](https://github.com/stonith404/pocket-id/commit/8038a111dd7fa8f5d421b29c3bc0c11d865dc71b))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.3.1...v) (2024-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add setup details to oidc client details ([fd21ce5](https://github.com/stonith404/pocket-id/commit/fd21ce5aac1daeba04e4e7399a0720338ea710c2))
|
||||||
|
* add support for more username formats ([903b0b3](https://github.com/stonith404/pocket-id/commit/903b0b39181c208e9411ee61849d2671e7c56dc5))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* non pointer passed to create user ([e7861df](https://github.com/stonith404/pocket-id/commit/e7861df95a6beecab359d1c56f4383373f74bb73))
|
||||||
|
* oidc client logo not displayed on authorize page ([28ed064](https://github.com/stonith404/pocket-id/commit/28ed064668afeec8f80adda59ba94f1fc2fbce17))
|
||||||
|
* typo in hasLogo property of oidc dto ([2b9413c](https://github.com/stonith404/pocket-id/commit/2b9413c7575e1322f8547490a9b02a1836bad549))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.3.0...v) (2024-08-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* empty lists don't get returned correctly from the api ([97f7fc4](https://github.com/stonith404/pocket-id/commit/97f7fc4e288c2bb49210072a7a151b58ef44f5b5))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.2.1...v) (2024-08-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add support for multiple callback urls ([8166e2e](https://github.com/stonith404/pocket-id/commit/8166e2ead7fc71a0b7a45950b05c5c65a60833b6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* db migration for multiple callback urls ([552d7cc](https://github.com/stonith404/pocket-id/commit/552d7ccfa58d7922ecb94bdfe6a86651b4cf2745))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* session duration can't be updated ([4780548](https://github.com/stonith404/pocket-id/commit/478054884389ed8a08d707fd82da7b31177a67e5))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.1.3...v) (2024-08-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add `INTERNAL_BACKEND_URL` env variable ([0595d73](https://github.com/stonith404/pocket-id/commit/0595d73ea5afbd7937b8f292ffe624139f818f41))
|
||||||
|
* add user info endpoint to support more oidc clients ([fdc1921](https://github.com/stonith404/pocket-id/commit/fdc1921f5dcb5ac6beef8d1c9b1b7c53f514cce5))
|
||||||
|
* change default logo ([9eec7a3](https://github.com/stonith404/pocket-id/commit/9eec7a3e9eb7f690099f38a5d4cf7c2516ea9ef9))
|
||||||
|
|
||||||
## [](https://github.com/stonith404/pocket-id/compare/v0.1.2...v) (2024-08-13)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.1.2...v) (2024-08-13)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in Ty
|
|||||||
3. Install the dependencies with `npm install`
|
3. Install the dependencies with `npm install`
|
||||||
4. Start the frontend with `npm run dev`
|
4. Start the frontend with `npm run dev`
|
||||||
|
|
||||||
You're all set!
|
|
||||||
|
|
||||||
### Reverse Proxy
|
### Reverse Proxy
|
||||||
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
|
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
Run `caddy run --config Caddyfile` in the root folder.
|
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
||||||
|
|
||||||
|
You're all set!
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -69,5 +69,5 @@ We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
|||||||
|
|
||||||
The tests can be run like this:
|
The tests can be run like this:
|
||||||
1. Start the backend normally
|
1. Start the backend normally
|
||||||
2. Start the frontend in production mode with `npm run build && node build/index.js`
|
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js`
|
||||||
3. Run the tests with `npm run test`
|
3. Run the tests with `npm run test`
|
||||||
|
|||||||
10
Caddyfile
10
Caddyfile
@@ -1,10 +0,0 @@
|
|||||||
:80 {
|
|
||||||
reverse_proxy /api/* http://localhost:8080
|
|
||||||
reverse_proxy /.well-known/* http://localhost:8080
|
|
||||||
reverse_proxy /* http://localhost:3000
|
|
||||||
|
|
||||||
log {
|
|
||||||
output file /var/log/caddy/access.log
|
|
||||||
level WARN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
Dockerfile
22
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build Frontend
|
# Stage 1: Build Frontend
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY ./frontend/package*.json ./
|
COPY ./frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -8,7 +8,7 @@ RUN npm run build
|
|||||||
RUN npm prune --production
|
RUN npm prune --production
|
||||||
|
|
||||||
# Stage 2: Build Backend
|
# Stage 2: Build Backend
|
||||||
FROM golang:1.22-alpine AS backend-builder
|
FROM golang:1.23-alpine AS backend-builder
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY ./backend/go.mod ./backend/go.sum ./
|
COPY ./backend/go.mod ./backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -20,9 +20,12 @@ WORKDIR /app/backend/cmd
|
|||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
||||||
|
|
||||||
# Stage 3: Production Image
|
# Stage 3: Production Image
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
RUN apk add --no-cache caddy
|
# Delete default node user
|
||||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
RUN deluser --remove-home node
|
||||||
|
|
||||||
|
RUN apk add --no-cache caddy curl su-exec
|
||||||
|
COPY ./reverse-proxy /etc/caddy/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||||
@@ -30,13 +33,12 @@ COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
|
|||||||
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
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/pocket-id-backend ./backend/pocket-id-backend
|
||||||
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
|
||||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
|
||||||
|
|
||||||
COPY ./scripts ./scripts
|
COPY ./scripts ./scripts
|
||||||
|
RUN chmod +x ./scripts/*.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 80
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
# Use a shell form to run both the frontend and backend
|
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||||
CMD ["sh", "./scripts/docker-entrypoint.sh"]
|
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||||
136
README.md
136
README.md
@@ -1,138 +1,20 @@
|
|||||||
# <div align="center"><img src="https://github.com/user-attachments/assets/5b5e0d42-e2b4-4523-add5-ac87042a72f1" width="100"/> </br>Pocket ID</div>
|
# <div align="center"><img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"/> </br>Pocket ID</div>
|
||||||
|
|
||||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||||
|
|
||||||
<img src="https://github.com/user-attachments/assets/953c534c-b667-44e5-b976-a59142f1efb8" width="1200"/>
|
→ Try out the [Demo](https://demo.pocket-id.org)
|
||||||
|
|
||||||
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, Pocket ID only support passkey authentication which is a passwordless authentication method.
|
<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 don’t need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, you’ll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
> [!WARNING]
|
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.
|
||||||
> Pocket ID is in its early stages and may contain bugs.
|
|
||||||
|
|
||||||
### Installation with Docker (recommended)
|
Visit the [documentation](https://docs.pocket-id.org) for the setup guide and more information.
|
||||||
|
|
||||||
1. Download the `docker-compose.yml` and `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -O https://raw.githubusercontent.com/stonith404/pocket-id/main/docker-compose.yml
|
|
||||||
|
|
||||||
curl -o .env https://raw.githubusercontent.com/stonith404/pocket-id/main/.env.example
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Edit the `.env` file so that it fits your needs. See the [environment variables](#environment-variables) section for more information.
|
|
||||||
3. Run `docker compose up -d`
|
|
||||||
|
|
||||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
|
||||||
|
|
||||||
### Stand-alone Installation
|
|
||||||
|
|
||||||
Required tools:
|
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/download/) >= 20
|
|
||||||
- [Go](https://golang.org/doc/install) >= 1.22
|
|
||||||
- [Git](https://git-scm.com/downloads)
|
|
||||||
- [PM2](https://pm2.keymetrics.io/)
|
|
||||||
- [Caddy](https://caddyserver.com/docs/install) (optional)
|
|
||||||
|
|
||||||
1. Copy the `.env.example` file in the `frontend` and `backend` folder to `.env` and change it so that it fits your needs.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp frontend/.env.example frontend/.env
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
```
|
|
||||||
2. Run the following commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/stonith404/pocket-id
|
|
||||||
cd pocket-id
|
|
||||||
|
|
||||||
# Checkout the latest version
|
|
||||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
|
||||||
|
|
||||||
# Start the backend
|
|
||||||
cd backend/cmd
|
|
||||||
go build -o ../pocket-id-backend
|
|
||||||
cd ..
|
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
|
||||||
|
|
||||||
# Start the frontend
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
pm2 start --name pocket-id-frontend --node-args="--env-file .env" build/index.js
|
|
||||||
|
|
||||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
|
||||||
cd ..
|
|
||||||
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
|
||||||
|
|
||||||
### Add Pocket ID as an OIDC provider
|
|
||||||
|
|
||||||
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
|
|
||||||
|
|
||||||
After you have added the client, you can obtain the client ID and client secret.
|
|
||||||
|
|
||||||
You may need the following information:
|
|
||||||
|
|
||||||
- **Authorization URL**: `https://<your-domain>/authorize`
|
|
||||||
- **Token URL**: `https://<your-domain>/api/oidc/token`
|
|
||||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
|
||||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
|
||||||
- **PKCE**: `false` as this is not supported yet.
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
#### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Stand-alone
|
|
||||||
|
|
||||||
1. Stop the running services:
|
|
||||||
```bash
|
|
||||||
pm2 delete pocket-id-backend pocket-id-frontend pocket-id-caddy
|
|
||||||
```
|
|
||||||
2. Run the following commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pocket-id
|
|
||||||
|
|
||||||
# Checkout the latest version
|
|
||||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
|
||||||
|
|
||||||
# Start the backend
|
|
||||||
cd backend/cmd
|
|
||||||
go build -o ../pocket-id-backend
|
|
||||||
cd ..
|
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
|
||||||
|
|
||||||
# Start the frontend
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
pm2 start build/index.js --name pocket-id-frontend
|
|
||||||
|
|
||||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
|
||||||
cd ..
|
|
||||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Default Value | Recommended to change | Description |
|
|
||||||
| ---------------- | ------------------- | --------------------- | --------------------------------------------- |
|
|
||||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
|
||||||
| `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. |
|
|
||||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
|
||||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
|
||||||
|
|
||||||
## Contribute
|
## Contribute
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
PUBLIC_APP_URL=http://localhost
|
PUBLIC_APP_URL=http://localhost
|
||||||
DB_PATH=data/pocket-id.db
|
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
|
||||||
|
DB_PROVIDER=sqlite
|
||||||
|
# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log
|
||||||
|
SQLITE_DB_PATH=data/pocket-id.db
|
||||||
|
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
||||||
UPLOAD_PATH=data/uploads
|
UPLOAD_PATH=data/uploads
|
||||||
PORT=8080
|
PORT=8080
|
||||||
HOST=localhost
|
HOST=0.0.0.0
|
||||||
|
|||||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -13,4 +13,5 @@
|
|||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
# Dependency directories (remove the comment below to include it)
|
||||||
# vendor/
|
# vendor/
|
||||||
./data
|
./data
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang-rest-api-template/internal/bootstrap"
|
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -1,63 +1,74 @@
|
|||||||
module golang-rest-api-template
|
module github.com/pocket-id/pocket-id/backend
|
||||||
|
|
||||||
go 1.22
|
go 1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.2.0
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
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/gin-gonic/gin v1.10.0
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0
|
github.com/go-co-op/gocron/v2 v2.15.0
|
||||||
github.com/go-webauthn/webauthn v0.11.0
|
github.com/go-ldap/ldap/v3 v3.4.10
|
||||||
|
github.com/go-playground/validator/v10 v10.24.0
|
||||||
|
github.com/go-webauthn/webauthn v0.11.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/crypto v0.25.0
|
github.com/mileusna/useragent v1.3.5
|
||||||
golang.org/x/time v0.6.0
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||||
gorm.io/driver/sqlite v1.5.6
|
golang.org/x/crypto v0.32.0
|
||||||
gorm.io/gorm v1.25.11
|
golang.org/x/image v0.24.0
|
||||||
|
golang.org/x/time v0.9.0
|
||||||
|
gorm.io/driver/postgres v1.5.11
|
||||||
|
gorm.io/driver/sqlite v1.5.7
|
||||||
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.12.1 // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
github.com/bytedance/sonic v1.12.8 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
github.com/go-webauthn/x v0.1.16 // indirect
|
||||||
github.com/go-webauthn/x v0.1.12 // indirect
|
github.com/goccy/go-json v0.10.4 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/google/go-tpm v0.9.3 // indirect
|
||||||
github.com/google/go-tpm v0.9.1 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // 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-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // 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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.9.0 // indirect
|
golang.org/x/arch v0.13.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||||
golang.org/x/net v0.27.0 // indirect
|
golang.org/x/net v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.23.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.4 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
275
backend/go.sum
275
backend/go.sum
@@ -1,73 +1,127 @@
|
|||||||
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
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 h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
|
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
|
||||||
|
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/caarlos0/env/v11 v11.2.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs=
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo=
|
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||||
|
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
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 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
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.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||||
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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
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.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
||||||
|
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||||
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||||
github.com/go-webauthn/webauthn v0.11.0 h1:2U0jWuGeoiI+XSZkHPFRtwaYtqmMUsqABtlfSq1rODo=
|
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||||
github.com/go-webauthn/webauthn v0.11.0/go.mod h1:57ZrqsZzD/eboQDVtBkvTdfqFYAh/7IwzdPT+sPWqB0=
|
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||||
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
|
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
|
||||||
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
|
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.4/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 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
|
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
|
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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.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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
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 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
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 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -79,73 +133,172 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||||
|
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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
|
||||||
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
|
||||||
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
|
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
||||||
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||||
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
|
golang.org/x/sync v0.11.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
|
golang.org/x/time v0.9.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.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
||||||
|
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,17 +0,0 @@
|
|||||||
<svg id="a"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1015 1015">
|
|
||||||
<path id="a" d="M838.28,380.27c-13.38-135.36-124.37-245.08-263.77-257.28h-299.08c-26.25,0-47.57,21.21-47.7,47.46-1.2,233.2-2.39,466.4-3.59,699.61-.14,26.44,21.26,47.95,47.7,47.95h102.86c24.66,0,45.25-18.79,47.5-43.35,11.45-124.75,22.89-249.51,34.34-374.26-43.84-29.69-66.46-88.34-40.37-148.47,10.44-24.06,29.57-43.41,53.58-53.98,86.02-37.84,169.22,24.14,169.22,105.56,0,40.38-20.47,75.98-51.6,96.99,6.71,56.71,13.42,113.43,20.14,170.14,1.2,10.14,11.21,16.74,21.03,13.93,134.16-38.42,223.25-167.79,209.75-304.31Z"/>
|
|
||||||
<style>
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
#a path {
|
|
||||||
fill: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
#a path {
|
|
||||||
fill: #000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 871 B |
60
backend/internal/bootstrap/application_images_bootstrap.go
Normal file
60
backend/internal/bootstrap/application_images_bootstrap.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||||
|
func initApplicationImages() {
|
||||||
|
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||||
|
|
||||||
|
sourceFiles, err := resources.FS.ReadDir("images")
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Fatalf("Error reading directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationFiles, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Fatalf("Error reading directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||||
|
for _, sourceFile := range sourceFiles {
|
||||||
|
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srcFilePath := path.Join("images", sourceFile.Name())
|
||||||
|
destFilePath := path.Join(dirPath, sourceFile.Name())
|
||||||
|
|
||||||
|
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error copying file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
|
||||||
|
for _, destinationFile := range destinationFiles {
|
||||||
|
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
|
||||||
|
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
|
||||||
|
|
||||||
|
if sourceFileWithoutExtension == destinationFileWithoutExtension {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImageNameWithoutExtension(fileName string) string {
|
||||||
|
splitted := strings.Split(fileName, ".")
|
||||||
|
return strings.Join(splitted[:len(splitted)-1], ".")
|
||||||
|
}
|
||||||
@@ -1,78 +1,15 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
"golang-rest-api-template/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"golang-rest-api-template/internal/common/middleware"
|
|
||||||
"golang-rest-api-template/internal/handler"
|
|
||||||
"golang-rest-api-template/internal/job"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Bootstrap() {
|
func Bootstrap() {
|
||||||
common.InitDatabase()
|
|
||||||
common.InitDbConfig()
|
|
||||||
initApplicationImages()
|
initApplicationImages()
|
||||||
job.RegisterJobs()
|
|
||||||
initRouter()
|
db := newDatabase()
|
||||||
}
|
appConfigService := service.NewAppConfigService(db)
|
||||||
|
|
||||||
func initRouter() {
|
initRouter(db, appConfigService)
|
||||||
switch common.EnvConfig.AppEnv {
|
|
||||||
case "production":
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
case "development":
|
|
||||||
gin.SetMode(gin.DebugMode)
|
|
||||||
case "test":
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
r.Use(gin.Logger())
|
|
||||||
|
|
||||||
r.Use(middleware.Cors())
|
|
||||||
r.Use(middleware.RateLimiter(rate.Every(time.Second), 60))
|
|
||||||
|
|
||||||
apiGroup := r.Group("/api")
|
|
||||||
handler.RegisterRoutes(apiGroup)
|
|
||||||
handler.RegisterOIDCRoutes(apiGroup)
|
|
||||||
handler.RegisterUserRoutes(apiGroup)
|
|
||||||
handler.RegisterConfigurationRoutes(apiGroup)
|
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
|
||||||
handler.RegisterTestRoutes(apiGroup)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseGroup := r.Group("/")
|
|
||||||
handler.RegisterWellKnownRoutes(baseGroup)
|
|
||||||
|
|
||||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func initApplicationImages() {
|
|
||||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
|
||||||
|
|
||||||
files, err := os.ReadDir(dirPath)
|
|
||||||
if err != nil && !os.IsNotExist(err) {
|
|
||||||
log.Fatalf("Error reading directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if files already exist
|
|
||||||
if len(files) > 1 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy files from source to destination
|
|
||||||
err = utils.CopyDirectory("./images", dirPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error copying directory: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
124
backend/internal/bootstrap/db_bootstrap.go
Normal file
124
backend/internal/bootstrap/db_bootstrap.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"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/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDatabase() (db *gorm.DB) {
|
||||||
|
db, err := connectDatabase()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
sqlDb, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to get sql.DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 driver: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := migrateDatabase(driver); err != nil {
|
||||||
|
log.Fatalf("failed to run migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(dialector, &gorm.Config{
|
||||||
|
TranslateError: true,
|
||||||
|
Logger: getLogger(),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogger() logger.Interface {
|
||||||
|
isProduction := common.EnvConfig.AppEnv == "production"
|
||||||
|
|
||||||
|
var logLevel logger.LogLevel
|
||||||
|
if isProduction {
|
||||||
|
logLevel = logger.Error
|
||||||
|
} else {
|
||||||
|
logLevel = logger.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger.New(
|
||||||
|
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||||
|
logger.Config{
|
||||||
|
SlowThreshold: 200 * time.Millisecond,
|
||||||
|
LogLevel: logLevel,
|
||||||
|
IgnoreRecordNotFoundError: isProduction,
|
||||||
|
ParameterizedQueries: isProduction,
|
||||||
|
Colorful: !isProduction,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
86
backend/internal/bootstrap/router_bootstrap.go
Normal file
86
backend/internal/bootstrap/router_bootstrap.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||||
|
// Set the appropriate Gin mode based on the environment
|
||||||
|
switch common.EnvConfig.AppEnv {
|
||||||
|
case "production":
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
case "development":
|
||||||
|
gin.SetMode(gin.DebugMode)
|
||||||
|
case "test":
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
r.Use(gin.Logger())
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
emailService, err := service.NewEmailService(appConfigService, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to create email service: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
geoLiteService := service.NewGeoLiteService()
|
||||||
|
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||||
|
jwtService := service.NewJwtService(appConfigService)
|
||||||
|
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||||
|
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
||||||
|
customClaimService := service.NewCustomClaimService(db)
|
||||||
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
|
testService := service.NewTestService(db, appConfigService, jwtService)
|
||||||
|
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||||
|
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(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
|
||||||
|
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||||
|
|
||||||
|
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, appConfigService)
|
||||||
|
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||||
|
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||||
|
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
|
||||||
|
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||||
|
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||||
|
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||||
|
|
||||||
|
// Add test controller in non-production environments
|
||||||
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
|
controller.NewTestController(apiGroup, testService)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up base routes
|
||||||
|
baseGroup := r.Group("/")
|
||||||
|
controller.NewWellKnownController(baseGroup, jwtService)
|
||||||
|
|
||||||
|
// Run the server
|
||||||
|
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/caarlos0/env/v11"
|
|
||||||
_ "github.com/joho/godotenv/autoload"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"log"
|
|
||||||
"reflect"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
|
||||||
AppEnv: "production",
|
|
||||||
DBPath: "data/pocket-id.db",
|
|
||||||
UploadPath: "data/uploads",
|
|
||||||
AppURL: "http://localhost",
|
|
||||||
Port: "8080",
|
|
||||||
Host: "localhost",
|
|
||||||
}
|
|
||||||
|
|
||||||
var DbConfig = NewDefaultDbConfig()
|
|
||||||
|
|
||||||
func NewDefaultDbConfig() model.ApplicationConfiguration {
|
|
||||||
return model.ApplicationConfiguration{
|
|
||||||
AppName: model.ApplicationConfigurationVariable{
|
|
||||||
Key: "appName",
|
|
||||||
Type: "string",
|
|
||||||
IsPublic: true,
|
|
||||||
Value: "Pocket ID",
|
|
||||||
},
|
|
||||||
SessionDuration: model.ApplicationConfigurationVariable{
|
|
||||||
Key: "sessionDuration",
|
|
||||||
Type: "number",
|
|
||||||
Value: "60",
|
|
||||||
},
|
|
||||||
BackgroundImageType: model.ApplicationConfigurationVariable{
|
|
||||||
Key: "backgroundImageType",
|
|
||||||
Type: "string",
|
|
||||||
IsInternal: true,
|
|
||||||
Value: "jpg",
|
|
||||||
},
|
|
||||||
LogoImageType: model.ApplicationConfigurationVariable{
|
|
||||||
Key: "logoImageType",
|
|
||||||
Type: "string",
|
|
||||||
IsInternal: true,
|
|
||||||
Value: "svg",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadDbConfigFromDb refreshes the database configuration by loading the current values
|
|
||||||
// from the database and updating the DbConfig struct.
|
|
||||||
func LoadDbConfigFromDb() error {
|
|
||||||
dbConfigReflectValue := reflect.ValueOf(&DbConfig).Elem()
|
|
||||||
|
|
||||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
|
||||||
dbConfigField := dbConfigReflectValue.Field(i)
|
|
||||||
currentConfigVar := dbConfigField.Interface().(model.ApplicationConfigurationVariable)
|
|
||||||
var storedConfigVar model.ApplicationConfigurationVariable
|
|
||||||
if err := DB.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitDbConfig creates the default configuration values in the database if they do not exist,
|
|
||||||
// updates existing configurations if they differ from the default, and deletes any configurations
|
|
||||||
// that are not in the default configuration.
|
|
||||||
func InitDbConfig() {
|
|
||||||
// Reflect to get the underlying value of DbConfig and its default configuration
|
|
||||||
dbConfigReflectValue := reflect.ValueOf(&DbConfig).Elem()
|
|
||||||
defaultDbConfig := NewDefaultDbConfig()
|
|
||||||
defaultConfigReflectValue := reflect.ValueOf(&defaultDbConfig).Elem()
|
|
||||||
defaultKeys := make(map[string]struct{})
|
|
||||||
|
|
||||||
// Iterate over the fields of DbConfig
|
|
||||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
|
||||||
dbConfigField := dbConfigReflectValue.Field(i)
|
|
||||||
currentConfigVar := dbConfigField.Interface().(model.ApplicationConfigurationVariable)
|
|
||||||
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.ApplicationConfigurationVariable)
|
|
||||||
defaultKeys[currentConfigVar.Key] = struct{}{}
|
|
||||||
|
|
||||||
var storedConfigVar model.ApplicationConfigurationVariable
|
|
||||||
if err := DB.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
|
|
||||||
// If the configuration does not exist, create it
|
|
||||||
if err := DB.Create(&defaultConfigVar).Error; err != nil {
|
|
||||||
log.Fatalf("Failed to create default configuration: %v", err)
|
|
||||||
}
|
|
||||||
dbConfigField.Set(reflect.ValueOf(defaultConfigVar))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing configuration if it differs from the default
|
|
||||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
|
|
||||||
storedConfigVar.Type = defaultConfigVar.Type
|
|
||||||
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
|
||||||
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
|
||||||
if err := DB.Save(&storedConfigVar).Error; err != nil {
|
|
||||||
log.Fatalf("Failed to update configuration: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the value in DbConfig
|
|
||||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete any configurations not in the default keys
|
|
||||||
var allConfigVars []model.ApplicationConfigurationVariable
|
|
||||||
if err := DB.Find(&allConfigVars).Error; err != nil {
|
|
||||||
log.Fatalf("Failed to retrieve existing configurations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, config := range allConfigVars {
|
|
||||||
if _, exists := defaultKeys[config.Key]; !exists {
|
|
||||||
if err := DB.Delete(&config).Error; err != nil {
|
|
||||||
log.Fatalf("Failed to delete outdated configuration: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var DB *gorm.DB
|
|
||||||
|
|
||||||
func InitDatabase() {
|
|
||||||
connectDatabase()
|
|
||||||
sqlDb, err := DB.DB()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("failed to get sql db", err)
|
|
||||||
}
|
|
||||||
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
|
|
||||||
m, err := migrate.NewWithDatabaseInstance(
|
|
||||||
"file://migrations",
|
|
||||||
"postgres", driver)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("failed to create migration instance", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.Up()
|
|
||||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
|
||||||
log.Fatal("failed to run migrations", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectDatabase() {
|
|
||||||
var database *gorm.DB
|
|
||||||
var err error
|
|
||||||
|
|
||||||
dbPath := EnvConfig.DBPath
|
|
||||||
if EnvConfig.AppEnv == "test" {
|
|
||||||
dbPath = "file::memory:?cache=shared"
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 1; i <= 3; i++ {
|
|
||||||
database, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
|
||||||
TranslateError: true,
|
|
||||||
Logger: getLogger(),
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DB = database
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLogger() logger.Interface {
|
|
||||||
isProduction := EnvConfig.AppEnv == "production"
|
|
||||||
|
|
||||||
var logLevel logger.LogLevel
|
|
||||||
if isProduction {
|
|
||||||
logLevel = logger.Error
|
|
||||||
} else {
|
|
||||||
logLevel = logger.Info
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the GORM logger
|
|
||||||
return logger.New(
|
|
||||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
|
||||||
logger.Config{
|
|
||||||
SlowThreshold: 200 * time.Millisecond,
|
|
||||||
LogLevel: logLevel,
|
|
||||||
IgnoreRecordNotFoundError: isProduction,
|
|
||||||
ParameterizedQueries: isProduction,
|
|
||||||
Colorful: !isProduction,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
64
backend/internal/common/env_config.go
Normal file
64
backend/internal/common/env_config.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/caarlos0/env/v11"
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DbProvider string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DbProviderSqlite DbProvider = "sqlite"
|
||||||
|
DbProviderPostgres DbProvider = "postgres"
|
||||||
|
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvConfigSchema struct {
|
||||||
|
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"`
|
||||||
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var EnvConfig = &EnvConfigSchema{
|
||||||
|
AppEnv: "production",
|
||||||
|
DbProvider: "sqlite",
|
||||||
|
SqliteDBPath: "data/pocket-id.db",
|
||||||
|
PostgresConnectionString: "",
|
||||||
|
UploadPath: "data/uploads",
|
||||||
|
AppURL: "http://localhost",
|
||||||
|
Port: "8080",
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
MaxMindLicenseKey: "",
|
||||||
|
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||||
|
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||||
|
UiConfigDisabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
221
backend/internal/common/errors.go
Normal file
221
backend/internal/common/errors.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppError interface {
|
||||||
|
error
|
||||||
|
HttpStatusCode() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom error types for various conditions
|
||||||
|
|
||||||
|
type AlreadyInUseError struct {
|
||||||
|
Property string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AlreadyInUseError) Error() string {
|
||||||
|
return fmt.Sprintf("%s is already in use", e.Property)
|
||||||
|
}
|
||||||
|
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type SetupAlreadyCompletedError struct{}
|
||||||
|
|
||||||
|
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||||
|
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type TokenInvalidOrExpiredError struct{}
|
||||||
|
|
||||||
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type TokenInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *TokenInvalidError) Error() string {
|
||||||
|
return "Token is invalid"
|
||||||
|
}
|
||||||
|
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingAuthorizationError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||||
|
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcGrantTypeNotSupportedError struct{}
|
||||||
|
|
||||||
|
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||||
|
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingClientCredentialsError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||||
|
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcClientSecretInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||||
|
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcInvalidAuthorizationCodeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||||
|
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) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type FileTypeNotSupportedError struct{}
|
||||||
|
|
||||||
|
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||||
|
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type InvalidCredentialsError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
|
||||||
|
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type FileTooLargeError struct {
|
||||||
|
MaxSize string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileTooLargeError) Error() string {
|
||||||
|
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
|
||||||
|
}
|
||||||
|
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||||
|
|
||||||
|
type NotSignedInError struct{}
|
||||||
|
|
||||||
|
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
||||||
|
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
|
type MissingPermissionError struct{}
|
||||||
|
|
||||||
|
func (e *MissingPermissionError) Error() string {
|
||||||
|
return "You don't have permission to perform this action"
|
||||||
|
}
|
||||||
|
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type TooManyRequestsError struct{}
|
||||||
|
|
||||||
|
func (e *TooManyRequestsError) Error() string {
|
||||||
|
return "Too many requests"
|
||||||
|
}
|
||||||
|
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||||
|
|
||||||
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
|
return "Client id or secret not provided"
|
||||||
|
}
|
||||||
|
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type WrongFileTypeError struct {
|
||||||
|
ExpectedFileType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *WrongFileTypeError) Error() string {
|
||||||
|
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
|
||||||
|
}
|
||||||
|
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type MissingSessionIdError struct{}
|
||||||
|
|
||||||
|
func (e *MissingSessionIdError) Error() string {
|
||||||
|
return "Missing session id"
|
||||||
|
}
|
||||||
|
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type ReservedClaimError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ReservedClaimError) Error() string {
|
||||||
|
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||||
|
}
|
||||||
|
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type DuplicateClaimError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DuplicateClaimError) Error() string {
|
||||||
|
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||||
|
}
|
||||||
|
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type AccountEditNotAllowedError struct{}
|
||||||
|
|
||||||
|
func (e *AccountEditNotAllowedError) Error() string {
|
||||||
|
return "You are not allowed to edit your account"
|
||||||
|
}
|
||||||
|
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||||
|
return "Invalid code verifier"
|
||||||
|
}
|
||||||
|
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcMissingCodeChallengeError struct{}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
|
type OidcAccessDeniedError struct{}
|
||||||
|
|
||||||
|
func (e *OidcAccessDeniedError) Error() string {
|
||||||
|
return "You're not allowed to access this service"
|
||||||
|
}
|
||||||
|
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcClientIdNotMatchingError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientIdNotMatchingError) Error() string {
|
||||||
|
return "Client id in request doesn't match client id in token"
|
||||||
|
}
|
||||||
|
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcNoCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcNoCallbackURLError) Error() string {
|
||||||
|
return "No callback URL provided"
|
||||||
|
}
|
||||||
|
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type UiConfigDisabledError struct{}
|
||||||
|
|
||||||
|
func (e *UiConfigDisabledError) Error() string {
|
||||||
|
return "The configuration can't be changed since the UI configuration is disabled"
|
||||||
|
}
|
||||||
|
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type InvalidUUIDError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidUUIDError) Error() string {
|
||||||
|
return "Invalid UUID"
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvalidEmailError struct{}
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"log"
|
|
||||||
"math/big"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
PrivateKey *rsa.PrivateKey
|
|
||||||
PublicKey *rsa.PublicKey
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
privateKeyPath = "data/keys/jwt_private_key.pem"
|
|
||||||
publicKeyPath = "data/keys/jwt_public_key.pem"
|
|
||||||
)
|
|
||||||
|
|
||||||
type accessTokenJWTClaims struct {
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
IsAdmin bool `json:"isAdmin,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateIDToken generates an ID token for the given user, clientID, scope and nonce.
|
|
||||||
func GenerateIDToken(user model.User, clientID string, scope string, nonce string) (tokenString string, err error) {
|
|
||||||
profileClaims := map[string]interface{}{
|
|
||||||
"given_name": user.FirstName,
|
|
||||||
"family_name": user.LastName,
|
|
||||||
"email": user.Email,
|
|
||||||
"preferred_username": user.Username,
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": user.ID,
|
|
||||||
"aud": clientID,
|
|
||||||
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
|
||||||
"iat": jwt.NewNumericDate(time.Now()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if nonce != "" {
|
|
||||||
claims["nonce"] = nonce
|
|
||||||
}
|
|
||||||
if strings.Contains(scope, "profile") {
|
|
||||||
for k, v := range profileClaims {
|
|
||||||
claims[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(scope, "email") {
|
|
||||||
claims["email"] = user.Email
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
||||||
signedToken, err := token.SignedString(PrivateKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return signedToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAccessToken generates an access token for the given user.
|
|
||||||
func GenerateAccessToken(user model.User) (tokenString string, err error) {
|
|
||||||
sessionDurationInMinutes, _ := strconv.Atoi(DbConfig.SessionDuration.Value)
|
|
||||||
claim := accessTokenJWTClaims{
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Subject: user.ID,
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
Audience: jwt.ClaimStrings{utils.GetHostFromURL(EnvConfig.AppURL)},
|
|
||||||
},
|
|
||||||
IsAdmin: user.IsAdmin,
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
|
||||||
tokenString, err = token.SignedString(PrivateKey)
|
|
||||||
return tokenString, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyAccessToken verifies the given access token and returns the claims if the token is valid.
|
|
||||||
func VerifyAccessToken(tokenString string) (*accessTokenJWTClaims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &accessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
return PublicKey, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, errors.New("couldn't handle this token")
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, isValid := token.Claims.(*accessTokenJWTClaims)
|
|
||||||
if !isValid {
|
|
||||||
return nil, errors.New("can't parse claims")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Contains(claims.Audience, utils.GetHostFromURL(EnvConfig.AppURL)) {
|
|
||||||
return nil, errors.New("audience doesn't match")
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type JWK struct {
|
|
||||||
Kty string `json:"kty"`
|
|
||||||
Use string `json:"use"`
|
|
||||||
Kid string `json:"kid"`
|
|
||||||
Alg string `json:"alg"`
|
|
||||||
N string `json:"n"`
|
|
||||||
E string `json:"e"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
|
||||||
func GetJWK() (JWK, error) {
|
|
||||||
if PublicKey == nil {
|
|
||||||
return JWK{}, errors.New("public key is not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create JWK from RSA public key
|
|
||||||
jwk := JWK{
|
|
||||||
Kty: "RSA",
|
|
||||||
Use: "sig",
|
|
||||||
Kid: "1", // Key ID can be set to any identifier. Here it's statically set to "1"
|
|
||||||
Alg: "RS256",
|
|
||||||
N: base64.RawURLEncoding.EncodeToString(PublicKey.N.Bytes()),
|
|
||||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(PublicKey.E)).Bytes()),
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwk, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateKeys generates a new RSA key pair and saves the private and public keys to the data folder.
|
|
||||||
func generateKeys() {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
|
||||||
log.Fatal("Failed to create directories for keys", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to generate private key", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyFile, err := os.Create(privateKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to create private key file", err)
|
|
||||||
}
|
|
||||||
defer privateKeyFile.Close()
|
|
||||||
|
|
||||||
privateKeyPEM := pem.EncodeToMemory(
|
|
||||||
&pem.Block{
|
|
||||||
Type: "RSA PRIVATE KEY",
|
|
||||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_, err = privateKeyFile.Write(privateKeyPEM)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to write private key file", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKey := &privateKey.PublicKey
|
|
||||||
publicKeyFile, err := os.Create(publicKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to create public key file", err)
|
|
||||||
}
|
|
||||||
defer publicKeyFile.Close()
|
|
||||||
|
|
||||||
publicKeyPEM := pem.EncodeToMemory(
|
|
||||||
&pem.Block{
|
|
||||||
Type: "RSA PUBLIC KEY",
|
|
||||||
Bytes: x509.MarshalPKCS1PublicKey(publicKey),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_, err = publicKeyFile.Write(publicKeyPEM)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to write public key file", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
|
||||||
generateKeys()
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Can't read jwt private key", err)
|
|
||||||
}
|
|
||||||
PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Can't parse jwt private key", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Can't read jwt public key", err)
|
|
||||||
}
|
|
||||||
PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Can't parse jwt public key", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Cors() gin.HandlerFunc {
|
|
||||||
return cors.New(cors.Config{
|
|
||||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
|
||||||
AllowMethods: []string{"*"},
|
|
||||||
AllowHeaders: []string{"*"},
|
|
||||||
MaxAge: 12 * time.Hour,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func JWTAuth(adminOnly bool) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
|
|
||||||
// Extract the token from the cookie or the Authorization header
|
|
||||||
token, err := c.Cookie("access_token")
|
|
||||||
if err != nil {
|
|
||||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
|
||||||
if len(authorizationHeaderSplitted) == 2 {
|
|
||||||
token = authorizationHeaderSplitted[1]
|
|
||||||
} else {
|
|
||||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the token
|
|
||||||
claims, err := common.VerifyAccessToken(token)
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the user is an admin
|
|
||||||
if adminOnly && !claims.IsAdmin {
|
|
||||||
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set("userID", claims.Subject)
|
|
||||||
c.Set("userIsAdmin", claims.IsAdmin)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
WebAuthn *webauthn.WebAuthn
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
config := &webauthn.Config{
|
|
||||||
RPDisplayName: DbConfig.AppName.Value,
|
|
||||||
RPID: utils.GetHostFromURL(EnvConfig.AppURL),
|
|
||||||
RPOrigins: []string{EnvConfig.AppURL},
|
|
||||||
Timeouts: webauthn.TimeoutsConfig{
|
|
||||||
Login: webauthn.TimeoutConfig{
|
|
||||||
Enforce: true,
|
|
||||||
Timeout: time.Second * 60,
|
|
||||||
TimeoutUVD: time.Second * 60,
|
|
||||||
},
|
|
||||||
Registration: webauthn.TimeoutConfig{
|
|
||||||
Enforce: true,
|
|
||||||
Timeout: time.Second * 60,
|
|
||||||
TimeoutUVD: time.Second * 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if WebAuthn, err = webauthn.New(config); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
209
backend/internal/controller/app_config_controller.go
Normal file
209
backend/internal/controller/app_config_controller.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAppConfigController(
|
||||||
|
group *gin.RouterGroup,
|
||||||
|
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)
|
||||||
|
group.PUT("/application-configuration", acc.updateAppConfigHandler)
|
||||||
|
|
||||||
|
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
||||||
|
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
||||||
|
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
||||||
|
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
||||||
|
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
||||||
|
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) {
|
||||||
|
configuration, err := acc.appConfigService.ListAppConfig(false)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||||
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, configVariablesDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||||
|
configuration, err := acc.appConfigService.ListAppConfig(true)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, configVariablesDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||||
|
var input dto.AppConfigUpdateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
|
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||||
|
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||||
|
|
||||||
|
var imageName string
|
||||||
|
var imageType string
|
||||||
|
|
||||||
|
if lightLogo {
|
||||||
|
imageName = "logoLight"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
|
||||||
|
} else {
|
||||||
|
imageName = "logoDark"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.getImage(c, imageName, imageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||||
|
acc.getImage(c, "favicon", "ico")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
||||||
|
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||||
|
acc.getImage(c, "background", imageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||||
|
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||||
|
|
||||||
|
var imageName string
|
||||||
|
var imageType string
|
||||||
|
|
||||||
|
if lightLogo {
|
||||||
|
imageName = "logoLight"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
|
||||||
|
} else {
|
||||||
|
imageName = "logoDark"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateImage(c, imageName, imageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
|
if fileType != "ico" {
|
||||||
|
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc.updateImage(c, "favicon", "ico")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
||||||
|
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||||
|
acc.updateImage(c, "background", imageType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
|
||||||
|
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
||||||
|
mimeType := utils.GetImageMimeType(imageType)
|
||||||
|
|
||||||
|
c.Header("Content-Type", mimeType)
|
||||||
|
c.File(imagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
60
backend/internal/controller/audit_log_controller.go
Normal file
60
backend/internal/controller/audit_log_controller.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
||||||
|
alc := AuditLogController{
|
||||||
|
auditLogService: auditLogService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogController struct {
|
||||||
|
auditLogService *service.AuditLogService
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Fetch audit logs for the user
|
||||||
|
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the audit logs to DTOs
|
||||||
|
var logsDtos []dto.AuditLogDto
|
||||||
|
err = dto.MapStructList(logs, &logsDtos)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add device information to the logs
|
||||||
|
for i, logsDto := range logsDtos {
|
||||||
|
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||||
|
logsDtos[i] = logsDto
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": logsDtos,
|
||||||
|
"pagination": pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
79
backend/internal/controller/custom_claim_controller.go
Normal file
79
backend/internal/controller/custom_claim_controller.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||||
|
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||||
|
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
||||||
|
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
||||||
|
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimController struct {
|
||||||
|
customClaimService *service.CustomClaimService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||||
|
claims, err := ccc.customClaimService.GetSuggestions()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||||
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.Param("userId")
|
||||||
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||||
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.Param("userGroupId")
|
||||||
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
|
}
|
||||||
344
backend/internal/controller/oidc_controller.go
Normal file
344
backend/internal/controller/oidc_controller.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
|
||||||
|
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
|
||||||
|
|
||||||
|
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
|
||||||
|
group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler)
|
||||||
|
|
||||||
|
group.POST("/oidc/token", oc.createTokensHandler)
|
||||||
|
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||||
|
group.POST("/oidc/end-session", oc.EndSessionHandler)
|
||||||
|
group.GET("/oidc/end-session", oc.EndSessionHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
||||||
|
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
|
||||||
|
group.GET("/oidc/clients/:id", oc.getClientHandler)
|
||||||
|
group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
|
||||||
|
group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
|
||||||
|
|
||||||
|
group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler)
|
||||||
|
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||||
|
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||||
|
group.POST("/oidc/clients/:id/logo", jwtAuthMiddleware.Add(true), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcController struct {
|
||||||
|
oidcService *service.OidcService
|
||||||
|
jwtService *service.JwtService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||||
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.AuthorizeOidcClientResponseDto{
|
||||||
|
Code: code,
|
||||||
|
CallbackURL: callbackURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
||||||
|
var input dto.AuthorizationRequiredDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
|
// Disable cors for this endpoint
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
var input dto.OidcCreateTokensDto
|
||||||
|
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := input.ClientID
|
||||||
|
clientSecret := input.ClientSecret
|
||||||
|
|
||||||
|
// Client id and secret can also be passed over the Authorization header
|
||||||
|
if clientID == "" && clientSecret == "" {
|
||||||
|
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||||
|
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
||||||
|
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := jwtClaims.Subject
|
||||||
|
clientId := jwtClaims.Audience[0]
|
||||||
|
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcLogoutDto
|
||||||
|
|
||||||
|
// Bind query parameters to the struct
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
if err := c.ShouldBindQuery(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if c.Request.Method == http.MethodPost {
|
||||||
|
// Bind form parameters to the struct
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
||||||
|
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
|
||||||
|
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
|
||||||
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
|
|
||||||
|
logoutCallbackURL, _ := url.Parse(callbackURL)
|
||||||
|
if input.State != "" {
|
||||||
|
q := logoutCallbackURL.Query()
|
||||||
|
q.Set("state", input.State)
|
||||||
|
logoutCallbackURL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, logoutCallbackURL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||||
|
clientId := c.Param("id")
|
||||||
|
client, err := oc.oidcService.GetClient(clientId)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a different DTO based on the user's role
|
||||||
|
if c.GetBool("userIsAdmin") {
|
||||||
|
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||||
|
err = dto.MapStruct(client, &clientDto)
|
||||||
|
if err == nil {
|
||||||
|
c.JSON(http.StatusOK, clientDto)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientDto := dto.PublicOidcClientDto{}
|
||||||
|
err = dto.MapStruct(client, &clientDto)
|
||||||
|
if err == nil {
|
||||||
|
c.JSON(http.StatusOK, clientDto)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
|
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, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientsDto []dto.OidcClientDto
|
||||||
|
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": clientsDto,
|
||||||
|
"pagination": pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcClientCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||||
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, clientDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||||
|
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcClientCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||||
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, clientDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||||
|
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||||
|
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", mimeType)
|
||||||
|
c.File(imagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||||
|
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidcClientDto dto.OidcClientDto
|
||||||
|
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, oidcClientDto)
|
||||||
|
}
|
||||||
44
backend/internal/controller/test_controller.go
Normal file
44
backend/internal/controller/test_controller.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||||
|
testController := &TestController{TestService: testService}
|
||||||
|
|
||||||
|
group.POST("/test/reset", testController.resetAndSeedHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestController struct {
|
||||||
|
TestService *service.TestService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||||
|
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetAppConfig(); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.TestService.SetJWTKeys()
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
317
backend/internal/controller/user_controller.go
Normal file
317
backend/internal/controller/user_controller.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||||
|
uc := UserController{
|
||||||
|
userService: userService,
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
||||||
|
group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler)
|
||||||
|
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
|
||||||
|
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
|
||||||
|
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
|
||||||
|
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
|
||||||
|
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
|
||||||
|
|
||||||
|
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
|
||||||
|
group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler)
|
||||||
|
group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
|
||||||
|
group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateUserProfilePictureHandler)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
|
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, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var usersDto []dto.UserDto
|
||||||
|
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": usersDto,
|
||||||
|
"pagination": pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||||
|
user, err := uc.userService.GetUser(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||||
|
user, err := uc.userService.GetUser(c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||||
|
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||||
|
var input dto.UserCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := uc.userService.CreateUser(input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||||
|
uc.updateUser(c, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||||
|
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
||||||
|
c.Error(&common.AccountEditNotAllowedError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc.updateUser(c, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
picture, size, err := uc.userService.GetProfilePicture(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
picture, size, err := uc.userService.GetProfilePicture(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
|
var input dto.OneTimeAccessTokenCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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"), c.ClientIP(), c.Request.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
|
||||||
|
maxAge := sessionDurationInMinutesParsed * 60
|
||||||
|
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||||
|
user, token, err := uc.userService.SetupInitialAdmin()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
|
||||||
|
maxAge := sessionDurationInMinutesParsed * 60
|
||||||
|
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
|
var input dto.UserCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
if updateOwnUser {
|
||||||
|
userID = c.GetString("userID")
|
||||||
|
} else {
|
||||||
|
userID = c.Param("id")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
155
backend/internal/controller/user_group_controller.go
Normal file
155
backend/internal/controller/user_group_controller.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
||||||
|
ugc := UserGroupController{
|
||||||
|
UserGroupService: userGroupService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
|
||||||
|
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
|
||||||
|
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
|
||||||
|
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
|
||||||
|
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
|
||||||
|
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupController struct {
|
||||||
|
UserGroupService *service.UserGroupService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||||
|
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, 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
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupsDto[i] = groupDto
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": groupsDto,
|
||||||
|
"pagination": pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||||
|
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||||
|
var input dto.UserGroupCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ugc.UserGroupService.Create(input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, groupDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||||
|
var input dto.UserGroupCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||||
|
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||||
|
var input dto.UserGroupUpdateUsersDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupDto)
|
||||||
|
}
|
||||||
175
backend/internal/controller/webauthn_controller.go
Normal file
175
backend/internal/controller/webauthn_controller.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
group.GET("/webauthn/login/start", wc.beginLoginHandler)
|
||||||
|
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
|
||||||
|
|
||||||
|
group.POST("/webauthn/logout", jwtAuthMiddleware.Add(false), wc.logoutHandler)
|
||||||
|
|
||||||
|
group.GET("/webauthn/credentials", jwtAuthMiddleware.Add(false), wc.listCredentialsHandler)
|
||||||
|
group.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler)
|
||||||
|
group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebauthnController struct {
|
||||||
|
webAuthnService *service.WebAuthnService
|
||||||
|
appConfigService *service.AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
options, err := wc.webAuthnService.BeginRegistration(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
|
||||||
|
c.JSON(http.StatusOK, options.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(&common.MissingSessionIdError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, credentialDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||||
|
options, err := wc.webAuthnService.BeginLogin()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
|
||||||
|
c.JSON(http.StatusOK, options.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(&common.MissingSessionIdError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value)
|
||||||
|
maxAge := sessionDurationInMinutesParsed * 60
|
||||||
|
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialDtos []dto.WebauthnCredentialDto
|
||||||
|
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, credentialDtos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
credentialID := c.Param("id")
|
||||||
|
|
||||||
|
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
credentialID := c.Param("id")
|
||||||
|
|
||||||
|
var input dto.WebauthnCredentialUpdateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, credentialDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
||||||
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
47
backend/internal/controller/well_known_controller.go
Normal file
47
backend/internal/controller/well_known_controller.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
|
||||||
|
wkc := &WellKnownController{jwtService: jwtService}
|
||||||
|
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||||
|
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WellKnownController struct {
|
||||||
|
jwtService *service.JwtService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||||
|
jwk, err := wkc.jwtService.GetJWK()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||||
|
appUrl := common.EnvConfig.AppURL
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"issuer": appUrl,
|
||||||
|
"authorization_endpoint": appUrl + "/authorize",
|
||||||
|
"token_endpoint": appUrl + "/api/oidc/token",
|
||||||
|
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||||
|
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||||
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
|
"scopes_supported": []string{"openid", "profile", "email"},
|
||||||
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture"},
|
||||||
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
46
backend/internal/dto/app_config_dto.go
Normal file
46
backend/internal/dto/app_config_dto.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type PublicAppConfigVariableDto struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppConfigVariableDto struct {
|
||||||
|
PublicAppConfigVariableDto
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
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"`
|
||||||
|
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
||||||
|
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
||||||
|
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
||||||
|
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
||||||
|
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
||||||
|
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||||
|
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||||
|
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||||
|
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||||
|
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||||
|
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
|
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||||
|
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||||
|
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
|
||||||
|
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||||
|
}
|
||||||
19
backend/internal/dto/audit_log_dto.go
Normal file
19
backend/internal/dto/audit_log_dto.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLogDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|
||||||
|
Event model.AuditLogEvent `json:"event"`
|
||||||
|
IpAddress string `json:"ipAddress"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
UserID string `json:"userID"`
|
||||||
|
Data model.AuditLogData `json:"data"`
|
||||||
|
}
|
||||||
11
backend/internal/dto/custom_claim_dto.go
Normal file
11
backend/internal/dto/custom_claim_dto.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type CustomClaimDto struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimCreateDto struct {
|
||||||
|
Key string `json:"key" binding:"required,claimKey"`
|
||||||
|
Value string `json:"value" binding:"required"`
|
||||||
|
}
|
||||||
117
backend/internal/dto/dto_mapper.go
Normal file
117
backend/internal/dto/dto_mapper.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MapStructList maps a list of source structs to a list of destination structs
|
||||||
|
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
||||||
|
*destination = make([]D, 0, len(source))
|
||||||
|
|
||||||
|
for _, item := range source {
|
||||||
|
var destItem D
|
||||||
|
if err := MapStruct(item, &destItem); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*destination = append(*destination, destItem)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapStruct maps a source struct to a destination struct
|
||||||
|
func MapStruct[S any, D any](source S, destination *D) error {
|
||||||
|
// Ensure destination is a non-nil pointer
|
||||||
|
destValue := reflect.ValueOf(destination)
|
||||||
|
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
|
||||||
|
return errors.New("destination must be a non-nil pointer to a struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure source is a struct
|
||||||
|
sourceValue := reflect.ValueOf(source)
|
||||||
|
if sourceValue.Kind() != reflect.Struct {
|
||||||
|
return errors.New("source must be a struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapStructInternal(sourceValue, destValue.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
||||||
|
// Loop through the fields of the destination struct
|
||||||
|
for i := 0; i < destVal.NumField(); i++ {
|
||||||
|
destField := destVal.Field(i)
|
||||||
|
destFieldType := destVal.Type().Field(i)
|
||||||
|
|
||||||
|
if destFieldType.Anonymous {
|
||||||
|
// Recursively handle embedded structs
|
||||||
|
if err := mapStructInternal(sourceVal, destField); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceField := sourceVal.FieldByName(destFieldType.Name)
|
||||||
|
|
||||||
|
// If the source field is valid and can be assigned to the destination field
|
||||||
|
if sourceField.IsValid() && destField.CanSet() {
|
||||||
|
// Handle direct assignment for simple types
|
||||||
|
if sourceField.Type() == destField.Type() {
|
||||||
|
destField.Set(sourceField)
|
||||||
|
|
||||||
|
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
|
||||||
|
// Handle slices
|
||||||
|
if sourceField.Type().Elem() == destField.Type().Elem() {
|
||||||
|
// Direct assignment for slices of primitive types or non-struct elements
|
||||||
|
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||||
|
|
||||||
|
for j := 0; j < sourceField.Len(); j++ {
|
||||||
|
newSlice.Index(j).Set(sourceField.Index(j))
|
||||||
|
}
|
||||||
|
|
||||||
|
destField.Set(newSlice)
|
||||||
|
|
||||||
|
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
|
||||||
|
// Recursively map slices of structs
|
||||||
|
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||||
|
|
||||||
|
for j := 0; j < sourceField.Len(); j++ {
|
||||||
|
// Get the element from both source and destination slice
|
||||||
|
sourceElem := sourceField.Index(j)
|
||||||
|
destElem := reflect.New(destField.Type().Elem()).Elem()
|
||||||
|
|
||||||
|
// Recursively map the struct elements
|
||||||
|
if err := mapStructInternal(sourceElem, destElem); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the mapped element in the new slice
|
||||||
|
newSlice.Index(j).Set(destElem)
|
||||||
|
}
|
||||||
|
|
||||||
|
destField.Set(newSlice)
|
||||||
|
}
|
||||||
|
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
|
||||||
|
// Recursively map nested structs
|
||||||
|
if err := mapStructInternal(sourceField, destField); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Type switch for specific type conversions
|
||||||
|
switch sourceField.Interface().(type) {
|
||||||
|
case datatype.DateTime:
|
||||||
|
// Convert datatype.DateTime to time.Time
|
||||||
|
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
|
||||||
|
dateValue := sourceField.Interface().(datatype.DateTime)
|
||||||
|
destField.Set(reflect.ValueOf(dateValue.ToTime()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
70
backend/internal/dto/oidc_dto.go
Normal file
70
backend/internal/dto/oidc_dto.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type PublicOidcClientDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientDto struct {
|
||||||
|
PublicOidcClientDto
|
||||||
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientWithAllowedUserGroupsDto struct {
|
||||||
|
PublicOidcClientDto
|
||||||
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientCreateDto struct {
|
||||||
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
|
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||||
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
|
Scope string `json:"scope" binding:"required"`
|
||||||
|
CallbackURL string `json:"callbackURL"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
CallbackURL string `json:"callbackURL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationRequiredDto struct {
|
||||||
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
|
Scope string `json:"scope" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcCreateTokensDto struct {
|
||||||
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
|
Code string `form:"code" binding:"required"`
|
||||||
|
ClientID string `form:"client_id"`
|
||||||
|
ClientSecret string `form:"client_secret"`
|
||||||
|
CodeVerifier string `form:"code_verifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcUpdateAllowedUserGroupsDto struct {
|
||||||
|
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcLogoutDto struct {
|
||||||
|
IdTokenHint string `form:"id_token_hint"`
|
||||||
|
ClientId string `form:"client_id"`
|
||||||
|
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
|
||||||
|
State string `form:"state"`
|
||||||
|
}
|
||||||
33
backend/internal/dto/user_dto.go
Normal file
33
backend/internal/dto/user_dto.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type UserDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email" `
|
||||||
|
FirstName string `json:"firstName"`
|
||||||
|
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=2,max=50"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
35
backend/internal/dto/user_group_dto.go
Normal file
35
backend/internal/dto/user_group_dto.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserGroupDtoWithUsers struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FriendlyName string `json:"friendlyName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
|
Users []UserDto `json:"users"`
|
||||||
|
LdapID *string `json:"ldapId"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupDtoWithUserCount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FriendlyName string `json:"friendlyName"`
|
||||||
|
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=2,max=50"`
|
||||||
|
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||||
|
LdapID string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupUpdateUsersDto struct {
|
||||||
|
UserIDs []string `json:"userIds" binding:"required"`
|
||||||
|
}
|
||||||
37
backend/internal/dto/validations.go
Normal file
37
backend/internal/dto/validations.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
|
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||||
|
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||||
|
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||||
|
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-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]*$"
|
||||||
|
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
|
if err := v.RegisterValidation("username", validateUsername); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/internal/dto/webauthn_dto.go
Normal file
23
backend/internal/dto/webauthn_dto.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebauthnCredentialDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CredentialID string `json:"credentialID"`
|
||||||
|
AttestationType string `json:"attestationType"`
|
||||||
|
Transport []protocol.AuthenticatorTransport `json:"transport"`
|
||||||
|
|
||||||
|
BackupEligible bool `json:"backupEligible"`
|
||||||
|
BackupState bool `json:"backupState"`
|
||||||
|
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebauthnCredentialUpdateDto struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=30"`
|
||||||
|
}
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/common/middleware"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"reflect"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterConfigurationRoutes(group *gin.RouterGroup) {
|
|
||||||
group.GET("/application-configuration", listApplicationConfigurationHandler)
|
|
||||||
group.GET("/application-configuration/all", middleware.JWTAuth(true), listAllApplicationConfigurationHandler)
|
|
||||||
group.PUT("/application-configuration", updateApplicationConfigurationHandler)
|
|
||||||
|
|
||||||
group.GET("/application-configuration/logo", getLogoHandler)
|
|
||||||
group.GET("/application-configuration/background-image", getBackgroundImageHandler)
|
|
||||||
group.GET("/application-configuration/favicon", getFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/logo", middleware.JWTAuth(true), updateLogoHandler)
|
|
||||||
group.PUT("/application-configuration/favicon", middleware.JWTAuth(true), updateFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/background-image", middleware.JWTAuth(true), updateBackgroundImageHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listApplicationConfigurationHandler(c *gin.Context) {
|
|
||||||
listApplicationConfiguration(c, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listAllApplicationConfigurationHandler(c *gin.Context) {
|
|
||||||
listApplicationConfiguration(c, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateApplicationConfigurationHandler(c *gin.Context) {
|
|
||||||
var input model.ApplicationConfigurationUpdateDto
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedConfigVariables := make([]model.ApplicationConfigurationVariable, 10)
|
|
||||||
|
|
||||||
tx := common.DB.Begin()
|
|
||||||
rt := reflect.ValueOf(input).Type()
|
|
||||||
rv := reflect.ValueOf(input)
|
|
||||||
|
|
||||||
// Loop over the input struct fields and update the related configuration variables
|
|
||||||
for i := 0; i < rt.NumField(); i++ {
|
|
||||||
field := rt.Field(i)
|
|
||||||
key := field.Tag.Get("json")
|
|
||||||
value := rv.FieldByName(field.Name).String()
|
|
||||||
|
|
||||||
// Get the existing configuration variable from the db
|
|
||||||
var applicationConfigurationVariable model.ApplicationConfigurationVariable
|
|
||||||
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, fmt.Sprintf("Invalid configuration variable '%s'", value))
|
|
||||||
} else {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the value of the existing configuration variable and save it
|
|
||||||
applicationConfigurationVariable.Value = value
|
|
||||||
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedConfigVariables[i] = applicationConfigurationVariable
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.Commit()
|
|
||||||
|
|
||||||
if err := common.LoadDbConfigFromDb(); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, savedConfigVariables)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLogoHandler(c *gin.Context) {
|
|
||||||
imagType := common.DbConfig.LogoImageType.Value
|
|
||||||
getImage(c, "logo", imagType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFaviconHandler(c *gin.Context) {
|
|
||||||
getImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := common.DbConfig.BackgroundImageType.Value
|
|
||||||
getImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateLogoHandler(c *gin.Context) {
|
|
||||||
imageType := common.DbConfig.LogoImageType.Value
|
|
||||||
updateImage(c, "logo", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateFaviconHandler(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
if fileType != "ico" {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imagType := common.DbConfig.BackgroundImageType.Value
|
|
||||||
updateImage(c, "background", imagType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getImage(c *gin.Context, name string, imageType string) {
|
|
||||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
|
||||||
mimeType := utils.GetImageMimeType(imageType)
|
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
|
||||||
c.File(imagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateImage(c *gin.Context, imageName string, oldImageType string) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "File type not supported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the old image if it has a different file type
|
|
||||||
if fileType != oldImageType {
|
|
||||||
oldImagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType)
|
|
||||||
if err := os.Remove(oldImagePath); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType)
|
|
||||||
err = c.SaveUploadedFile(file, imagePath)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the file type in the database
|
|
||||||
key := fmt.Sprintf("%sImageType", imageName)
|
|
||||||
err = common.DB.Model(&model.ApplicationConfigurationVariable{}).Where("key = ?", key).Update("value", fileType).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.LoadDbConfigFromDb(); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listApplicationConfiguration(c *gin.Context, showAll bool) {
|
|
||||||
var configuration []model.ApplicationConfigurationVariable
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if showAll {
|
|
||||||
err = common.DB.Find(&configuration).Error
|
|
||||||
} else {
|
|
||||||
err = common.DB.Find(&configuration, "is_public = true").Error
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, configuration)
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/common/middleware"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterOIDCRoutes(group *gin.RouterGroup) {
|
|
||||||
group.POST("/oidc/authorize", middleware.JWTAuth(false), authorizeHandler)
|
|
||||||
group.POST("/oidc/authorize/new-client", middleware.JWTAuth(false), authorizeNewClientHandler)
|
|
||||||
group.POST("/oidc/token", createIDTokenHandler)
|
|
||||||
|
|
||||||
group.GET("/oidc/clients", middleware.JWTAuth(true), listClientsHandler)
|
|
||||||
group.POST("/oidc/clients", middleware.JWTAuth(true), createClientHandler)
|
|
||||||
group.GET("/oidc/clients/:id", getClientHandler)
|
|
||||||
group.PUT("/oidc/clients/:id", middleware.JWTAuth(true), updateClientHandler)
|
|
||||||
group.DELETE("/oidc/clients/:id", middleware.JWTAuth(true), deleteClientHandler)
|
|
||||||
|
|
||||||
group.POST("/oidc/clients/:id/secret", middleware.JWTAuth(true), createClientSecretHandler)
|
|
||||||
|
|
||||||
group.GET("/oidc/clients/:id/logo", getClientLogoHandler)
|
|
||||||
group.DELETE("/oidc/clients/:id/logo", deleteClientLogoHandler)
|
|
||||||
group.POST("/oidc/clients/:id/logo", middleware.JWTAuth(true), middleware.LimitFileSize(2<<20), updateClientLogoHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthorizeRequest struct {
|
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
|
||||||
Scope string `json:"scope" binding:"required"`
|
|
||||||
Nonce string `json:"nonce"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func authorizeHandler(c *gin.Context) {
|
|
||||||
var parsedBody AuthorizeRequest
|
|
||||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
|
||||||
common.DB.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", parsedBody.ClientID, c.GetString("userID"))
|
|
||||||
|
|
||||||
// If the record isn't found or the scope is different return an error
|
|
||||||
// The client will have to call the authorizeNewClientHandler
|
|
||||||
if userAuthorizedOIDCClient.Scope != parsedBody.Scope {
|
|
||||||
utils.HandlerError(c, http.StatusForbidden, "missing authorization")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
|
|
||||||
}
|
|
||||||
|
|
||||||
// authorizeNewClientHandler authorizes a new client for the user
|
|
||||||
// a new client is a new client when the user has not authorized the client before
|
|
||||||
func authorizeNewClientHandler(c *gin.Context) {
|
|
||||||
var parsedBody model.AuthorizeNewClientDto
|
|
||||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
|
||||||
UserID: c.GetString("userID"),
|
|
||||||
ClientID: parsedBody.ClientID,
|
|
||||||
Scope: parsedBody.Scope,
|
|
||||||
}
|
|
||||||
err := common.DB.Create(&userAuthorizedClient).Error
|
|
||||||
|
|
||||||
if err != nil && errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
||||||
err = common.DB.Model(&userAuthorizedClient).Update("scope", parsedBody.Scope).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func createIDTokenHandler(c *gin.Context) {
|
|
||||||
var body model.OidcIdTokenDto
|
|
||||||
|
|
||||||
if err := c.ShouldBind(&body); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Currently only authorization_code grant type is supported
|
|
||||||
if body.GrantType != "authorization_code" {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "grant type not supported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID := body.ClientID
|
|
||||||
clientSecret := body.ClientSecret
|
|
||||||
|
|
||||||
// Client id and secret can also be passed over the Authorization header
|
|
||||||
if clientID == "" || clientSecret == "" {
|
|
||||||
var ok bool
|
|
||||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
|
||||||
if !ok {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the client
|
|
||||||
var client model.OidcClient
|
|
||||||
err := common.DB.First(&client, "id = ?", clientID, clientSecret).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "OIDC OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if client secret is correct
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid client secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
|
||||||
err = common.DB.Preload("User").First(&authorizationCodeMetaData, "code = ?", body.Code).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the client id matches the client id in the authorization code and if the code has expired
|
|
||||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idToken, e := common.GenerateIDToken(authorizationCodeMetaData.User, clientID, authorizationCodeMetaData.Scope, authorizationCodeMetaData.Nonce)
|
|
||||||
if e != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the authorization code after it has been used
|
|
||||||
common.DB.Delete(&authorizationCodeMetaData)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"id_token": idToken})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientHandler(c *gin.Context) {
|
|
||||||
clientId := c.Param("id")
|
|
||||||
|
|
||||||
var client model.OidcClient
|
|
||||||
err := common.DB.First(&client, "id = ?", clientId).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listClientsHandler(c *gin.Context) {
|
|
||||||
var clients []model.OidcClient
|
|
||||||
searchTerm := c.Query("search")
|
|
||||||
|
|
||||||
query := common.DB.Model(&model.OidcClient{})
|
|
||||||
|
|
||||||
if searchTerm != "" {
|
|
||||||
searchPattern := "%" + searchTerm + "%"
|
|
||||||
query = query.Where("name LIKE ?", searchPattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
pagination, err := utils.Paginate(c, query, &clients)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"data": clients,
|
|
||||||
"pagination": pagination,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func createClientHandler(c *gin.Context) {
|
|
||||||
var input model.OidcClientCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := model.OidcClient{
|
|
||||||
Name: input.Name,
|
|
||||||
CallbackURL: input.CallbackURL,
|
|
||||||
CreatedByID: c.GetString("userID"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Create(&client).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteClientHandler(c *gin.Context) {
|
|
||||||
var client model.OidcClient
|
|
||||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Delete(&client).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateClientHandler(c *gin.Context) {
|
|
||||||
var input model.OidcClientCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var client model.OidcClient
|
|
||||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.Name = input.Name
|
|
||||||
client.CallbackURL = input.CallbackURL
|
|
||||||
|
|
||||||
if err := common.DB.Save(&client).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusNoContent, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createClientSecretHandler creates a new secret for the client and revokes the old one
|
|
||||||
func createClientSecretHandler(c *gin.Context) {
|
|
||||||
var client model.OidcClient
|
|
||||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.Secret = string(hashedSecret)
|
|
||||||
if err := common.DB.Save(&client).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"secret": clientSecret})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientLogoHandler(c *gin.Context) {
|
|
||||||
var client model.OidcClient
|
|
||||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if client.ImageType == nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "image not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imageType := *client.ImageType
|
|
||||||
|
|
||||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType)
|
|
||||||
mimeType := utils.GetImageMimeType(imageType)
|
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
|
||||||
c.File(imagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateClientLogoHandler(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "file type not supported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, c.Param("id"), fileType)
|
|
||||||
err = c.SaveUploadedFile(file, imagePath)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var client model.OidcClient
|
|
||||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the old image if it has a different file type
|
|
||||||
if client.ImageType != nil && fileType != *client.ImageType {
|
|
||||||
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
|
||||||
if err := os.Remove(oldImagePath); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.ImageType = &fileType
|
|
||||||
if err := common.DB.Save(&client).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteClientLogoHandler(c *gin.Context) {
|
|
||||||
var client model.OidcClient
|
|
||||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if client.ImageType == nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "image not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
|
||||||
if err := os.Remove(imagePath); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.ImageType = nil
|
|
||||||
if err := common.DB.Save(&client).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
|
||||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
|
||||||
Code: randomString,
|
|
||||||
ClientID: clientID,
|
|
||||||
UserID: userID,
|
|
||||||
Scope: scope,
|
|
||||||
Nonce: nonce,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Create(&oidcAuthorizationCode).Error; err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return randomString, nil
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterTestRoutes(group *gin.RouterGroup) {
|
|
||||||
group.POST("/test/reset", resetAndSeedHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetAndSeedHandler(c *gin.Context) {
|
|
||||||
if err := resetDatabase(); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := resetApplicationImages(); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := seedDatabase(); err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{"message": "Database reset and seeded"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedDatabase seeds the database with initial data and uses a transaction to ensure atomicity.
|
|
||||||
func seedDatabase() error {
|
|
||||||
return common.DB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
users := []model.User{
|
|
||||||
{
|
|
||||||
Base: model.Base{
|
|
||||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
|
||||||
},
|
|
||||||
Username: "tim",
|
|
||||||
Email: "tim.cook@test.com",
|
|
||||||
FirstName: "Tim",
|
|
||||||
LastName: "Cook",
|
|
||||||
IsAdmin: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Base: model.Base{
|
|
||||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
|
||||||
},
|
|
||||||
Username: "craig",
|
|
||||||
Email: "craig.federighi@test.com",
|
|
||||||
FirstName: "Craig",
|
|
||||||
LastName: "Federighi",
|
|
||||||
IsAdmin: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, user := range users {
|
|
||||||
if err := tx.Create(&user).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
oidcClients := []model.OidcClient{
|
|
||||||
{
|
|
||||||
Base: model.Base{
|
|
||||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
|
||||||
},
|
|
||||||
Name: "Nextcloud",
|
|
||||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
|
||||||
CallbackURL: "http://nextcloud/auth/callback",
|
|
||||||
ImageType: utils.StringPointer("png"),
|
|
||||||
CreatedByID: users[0].ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Base: model.Base{
|
|
||||||
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
|
||||||
},
|
|
||||||
Name: "Immich",
|
|
||||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
|
||||||
CallbackURL: "http://immich/auth/callback",
|
|
||||||
CreatedByID: users[0].ID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, client := range oidcClients {
|
|
||||||
if err := tx.Create(&client).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
authCode := model.OidcAuthorizationCode{
|
|
||||||
Code: "auth-code",
|
|
||||||
Scope: "openid profile",
|
|
||||||
Nonce: "nonce",
|
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
||||||
UserID: users[0].ID,
|
|
||||||
ClientID: oidcClients[0].ID,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&authCode).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken := model.OneTimeAccessToken{
|
|
||||||
Token: "one-time-token",
|
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
||||||
UserID: users[0].ID,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&accessToken).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
|
||||||
Scope: "openid profile email",
|
|
||||||
UserID: users[0].ID,
|
|
||||||
ClientID: oidcClients[0].ID,
|
|
||||||
}
|
|
||||||
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
webauthnCredentials := []model.WebauthnCredential{
|
|
||||||
{
|
|
||||||
Name: "Passkey 1",
|
|
||||||
CredentialID: "test-credential-1",
|
|
||||||
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg=="),
|
|
||||||
AttestationType: "none",
|
|
||||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
|
||||||
UserID: users[0].ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Passkey 2",
|
|
||||||
CredentialID: "test-credential-2",
|
|
||||||
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA=="),
|
|
||||||
AttestationType: "none",
|
|
||||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
|
||||||
UserID: users[0].ID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, credential := range webauthnCredentials {
|
|
||||||
if err := tx.Create(&credential).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webauthnSession := model.WebauthnSession{
|
|
||||||
Challenge: "challenge",
|
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
||||||
UserVerification: "preferred",
|
|
||||||
}
|
|
||||||
if err := tx.Create(&webauthnSession).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetDatabase resets the database by deleting all rows from each table.
|
|
||||||
func resetDatabase() error {
|
|
||||||
err := common.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
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range tables {
|
|
||||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
common.InitDbConfig()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetApplicationImages resets the application images by removing existing images and replacing them with the default ones
|
|
||||||
func resetApplicationImages() error {
|
|
||||||
|
|
||||||
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
|
||||||
log.Printf("Error removing directory: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil {
|
|
||||||
log.Printf("Error copying directory: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
|
||||||
func getCborPublicKey(base64PublicKey string) []byte {
|
|
||||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to decode base64 key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to parse public key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
|
|
||||||
if !ok {
|
|
||||||
log.Fatalf("Not an ECDSA public key")
|
|
||||||
}
|
|
||||||
|
|
||||||
coseKey := map[int]interface{}{
|
|
||||||
1: 2, // Key type: EC2
|
|
||||||
3: -7, // Algorithm: ECDSA with SHA-256
|
|
||||||
-1: 1, // Curve: P-256
|
|
||||||
-2: ecdsaPubKey.X.Bytes(), // X coordinate
|
|
||||||
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
|
|
||||||
}
|
|
||||||
|
|
||||||
cborPublicKey, err := cbor.Marshal(coseKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to encode CBOR: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cborPublicKey
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/common/middleware"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterUserRoutes(group *gin.RouterGroup) {
|
|
||||||
group.GET("/users", middleware.JWTAuth(true), listUsersHandler)
|
|
||||||
group.GET("/users/me", middleware.JWTAuth(false), getCurrentUserHandler)
|
|
||||||
group.GET("/users/:id", middleware.JWTAuth(true), getUserHandler)
|
|
||||||
group.POST("/users", middleware.JWTAuth(true), createUserHandler)
|
|
||||||
group.PUT("/users/:id", middleware.JWTAuth(true), updateUserHandler)
|
|
||||||
group.PUT("/users/me", middleware.JWTAuth(false), updateCurrentUserHandler)
|
|
||||||
group.DELETE("/users/:id", middleware.JWTAuth(true), deleteUserHandler)
|
|
||||||
|
|
||||||
group.POST("/users/:id/one-time-access-token", middleware.JWTAuth(true), createOneTimeAccessTokenHandler)
|
|
||||||
group.POST("/one-time-access-token/:token", middleware.RateLimiter(rate.Every(10*time.Second), 5), exchangeOneTimeAccessTokenHandler)
|
|
||||||
group.POST("/one-time-access-token/setup", getSetupAccessTokenHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listUsersHandler(c *gin.Context) {
|
|
||||||
var users []model.User
|
|
||||||
searchTerm := c.Query("search")
|
|
||||||
|
|
||||||
query := common.DB.Model(&model.User{})
|
|
||||||
|
|
||||||
if searchTerm != "" {
|
|
||||||
searchPattern := "%" + searchTerm + "%"
|
|
||||||
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
pagination, err := utils.Paginate(c, query, &users)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"data": users,
|
|
||||||
"pagination": pagination,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserHandler(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
if err := common.DB.Where("id = ?", c.Param("id")).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "User not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCurrentUserHandler(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
if err := common.DB.Where("id = ?", c.GetString("userID")).First(&user).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteUserHandler(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
if err := common.DB.Where("id = ?", c.Param("id")).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "User not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Delete(&user).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createUserHandler(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
if err := c.ShouldBindJSON(&user); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Create(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
||||||
if err := checkDuplicatedFields(user); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUserHandler(c *gin.Context) {
|
|
||||||
updateUser(c, c.Param("id"), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateCurrentUserHandler(c *gin.Context) {
|
|
||||||
updateUser(c, c.GetString("userID"), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createOneTimeAccessTokenHandler(c *gin.Context) {
|
|
||||||
var input model.OneTimeAccessTokenCreateDto
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
oneTimeAccessToken := model.OneTimeAccessToken{
|
|
||||||
UserID: input.UserID,
|
|
||||||
ExpiresAt: input.ExpiresAt,
|
|
||||||
Token: randomString,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Create(&oneTimeAccessToken).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"token": oneTimeAccessToken.Token})
|
|
||||||
}
|
|
||||||
|
|
||||||
func exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
|
||||||
var oneTimeAccessToken model.OneTimeAccessToken
|
|
||||||
if err := common.DB.Where("token = ? AND expires_at > ?", c.Param("token"), utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusForbidden, "Token is invalid or expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := common.GenerateAccessToken(oneTimeAccessToken.User)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
log.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Delete(&oneTimeAccessToken).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, oneTimeAccessToken.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSetupAccessTokenHandler creates the initial admin user and returns an access token for the user
|
|
||||||
// This handler is only available if there are no users in the database
|
|
||||||
func getSetupAccessTokenHandler(c *gin.Context) {
|
|
||||||
var userCount int64
|
|
||||||
if err := common.DB.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
|
||||||
log.Fatal("failed to count users", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there are more than one user, we don't need to create the admin user
|
|
||||||
if userCount > 1 {
|
|
||||||
utils.HandlerError(c, http.StatusForbidden, "Setup already completed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = model.User{
|
|
||||||
FirstName: "Admin",
|
|
||||||
LastName: "Admin",
|
|
||||||
Username: "admin",
|
|
||||||
Email: "admin@admin.com",
|
|
||||||
IsAdmin: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the initial admin user if it doesn't exist
|
|
||||||
if err := common.DB.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
|
|
||||||
log.Fatal("failed to create admin user", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user already has credentials, the setup is already completed
|
|
||||||
if len(user.Credentials) > 0 {
|
|
||||||
utils.HandlerError(c, http.StatusForbidden, "Setup already completed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := common.GenerateAccessToken(user)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
log.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUser(c *gin.Context, userID string, updateOwnUser bool) {
|
|
||||||
var user model.User
|
|
||||||
if err := common.DB.Where("id = ?", userID).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "User not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var updatedUser model.User
|
|
||||||
if err := c.ShouldBindJSON(&updatedUser); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user.FirstName = updatedUser.FirstName
|
|
||||||
user.LastName = updatedUser.LastName
|
|
||||||
user.Email = updatedUser.Email
|
|
||||||
user.Username = updatedUser.Username
|
|
||||||
user.Username = updatedUser.Username
|
|
||||||
if !updateOwnUser {
|
|
||||||
user.IsAdmin = updatedUser.IsAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Save(user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
||||||
if err := checkDuplicatedFields(user); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkDuplicatedFields(user model.User) error {
|
|
||||||
var existingUser model.User
|
|
||||||
|
|
||||||
if common.DB.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
|
||||||
return errors.New("email is already taken")
|
|
||||||
}
|
|
||||||
|
|
||||||
if common.DB.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
|
||||||
return errors.New("username is already taken")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/common/middleware"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterRoutes(group *gin.RouterGroup) {
|
|
||||||
group.GET("/webauthn/register/start", middleware.JWTAuth(false), beginRegistrationHandler)
|
|
||||||
group.POST("/webauthn/register/finish", middleware.JWTAuth(false), verifyRegistrationHandler)
|
|
||||||
|
|
||||||
group.GET("/webauthn/login/start", beginLoginHandler)
|
|
||||||
group.POST("/webauthn/login/finish", middleware.RateLimiter(rate.Every(10*time.Second), 5), verifyLoginHandler)
|
|
||||||
|
|
||||||
group.POST("/webauthn/logout", middleware.JWTAuth(false), logoutHandler)
|
|
||||||
|
|
||||||
group.GET("/webauthn/credentials", middleware.JWTAuth(false), listCredentialsHandler)
|
|
||||||
group.PATCH("/webauthn/credentials/:id", middleware.JWTAuth(false), updateCredentialHandler)
|
|
||||||
group.DELETE("/webauthn/credentials/:id", middleware.JWTAuth(false), deleteCredentialHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginRegistrationHandler(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
err := common.DB.Preload("Credentials").Find(&user, "id = ?", c.GetString("userID")).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
log.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
options, session, err := common.WebAuthn.BeginRegistration(&user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()))
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the webauthn session so we can retrieve it in the verifyRegistrationHandler
|
|
||||||
sessionToStore := &model.WebauthnSession{
|
|
||||||
ExpiresAt: session.Expires,
|
|
||||||
Challenge: session.Challenge,
|
|
||||||
UserVerification: string(session.UserVerification),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = common.DB.Create(&sessionToStore).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie("session_id", sessionToStore.ID, int(common.WebAuthn.Config.Timeouts.Registration.Timeout.Seconds()), "/", "", false, true)
|
|
||||||
c.JSON(http.StatusOK, options.Response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyRegistrationHandler(c *gin.Context) {
|
|
||||||
sessionID, err := c.Cookie("session_id")
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve the session that was previously created by the beginRegistrationHandler
|
|
||||||
var storedSession model.WebauthnSession
|
|
||||||
err = common.DB.First(&storedSession, "id = ?", sessionID).Error
|
|
||||||
|
|
||||||
session := webauthn.SessionData{
|
|
||||||
Challenge: storedSession.Challenge,
|
|
||||||
Expires: storedSession.ExpiresAt,
|
|
||||||
UserID: []byte(c.GetString("userID")),
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
err = common.DB.Find(&user, "id = ?", c.GetString("userID")).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credential, err := common.WebAuthn.FinishRegistration(&user, session, c.Request)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credentialToStore := model.WebauthnCredential{
|
|
||||||
Name: "New Passkey",
|
|
||||||
CredentialID: string(credential.ID),
|
|
||||||
AttestationType: credential.AttestationType,
|
|
||||||
PublicKey: credential.PublicKey,
|
|
||||||
Transport: credential.Transport,
|
|
||||||
UserID: user.ID,
|
|
||||||
BackupEligible: credential.Flags.BackupEligible,
|
|
||||||
BackupState: credential.Flags.BackupState,
|
|
||||||
}
|
|
||||||
if err := common.DB.Create(&credentialToStore).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, credentialToStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginLoginHandler(c *gin.Context) {
|
|
||||||
options, session, err := common.WebAuthn.BeginDiscoverableLogin()
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the webauthn session so we can retrieve it in the verifyLoginHandler
|
|
||||||
sessionToStore := &model.WebauthnSession{
|
|
||||||
ExpiresAt: session.Expires,
|
|
||||||
Challenge: session.Challenge,
|
|
||||||
UserVerification: string(session.UserVerification),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = common.DB.Create(&sessionToStore).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie("session_id", sessionToStore.ID, int(common.WebAuthn.Config.Timeouts.Registration.Timeout.Seconds()), "/", "", false, true)
|
|
||||||
c.JSON(http.StatusOK, options.Response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyLoginHandler(c *gin.Context) {
|
|
||||||
sessionID, err := c.Cookie("session_id")
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "Invalid body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve the session that was previously created by the beginLoginHandler
|
|
||||||
var storedSession model.WebauthnSession
|
|
||||||
if err := common.DB.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := webauthn.SessionData{
|
|
||||||
Challenge: storedSession.Challenge,
|
|
||||||
Expires: storedSession.ExpiresAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
var user *model.User
|
|
||||||
_, err = common.WebAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
|
||||||
if err := common.DB.Preload("Credentials").First(&user, "id = ?", string(userHandle)).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return user, nil
|
|
||||||
}, session, credentialAssertionData)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), gorm.ErrRecordNotFound.Error()) {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "no user with this passkey exists")
|
|
||||||
} else {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = common.DB.Find(&user, "id = ?", c.GetString("userID")).Error
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := common.GenerateAccessToken(*user)
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listCredentialsHandler(c *gin.Context) {
|
|
||||||
var credentials []model.WebauthnCredential
|
|
||||||
if err := common.DB.Find(&credentials, "user_id = ?", c.GetString("userID")).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, credentials)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteCredentialHandler(c *gin.Context) {
|
|
||||||
var passkeyCount int64
|
|
||||||
if err := common.DB.Model(&model.WebauthnCredential{}).Where("user_id = ?", c.GetString("userID")).Count(&passkeyCount).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if passkeyCount == 1 {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "You must have at least one passkey")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var credential model.WebauthnCredential
|
|
||||||
if err := common.DB.First(&credential, "id = ? AND user_id = ?", c.Param("id"), c.GetString("userID")).Error; err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "Credential not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := common.DB.Delete(&credential).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateCredentialHandler(c *gin.Context) {
|
|
||||||
var credential model.WebauthnCredential
|
|
||||||
if err := common.DB.Where("id = ? AND user_id = ?", c.Param("id"), c.GetString("userID")).First(&credential).Error; err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusNotFound, "Credential not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var input struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credential.Name = input.Name
|
|
||||||
|
|
||||||
if err := common.DB.Save(&credential).Error; err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logoutHandler(c *gin.Context) {
|
|
||||||
c.SetCookie("access_token", "", 0, "/", "", false, true)
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterWellKnownRoutes(group *gin.RouterGroup) {
|
|
||||||
group.GET("/.well-known/jwks.json", jwks)
|
|
||||||
group.GET("/.well-known/openid-configuration", openIDConfiguration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func jwks(c *gin.Context) {
|
|
||||||
jwk, err := common.GetJWK()
|
|
||||||
if err != nil {
|
|
||||||
utils.UnknownHandlerError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
|
||||||
}
|
|
||||||
|
|
||||||
func openIDConfiguration(c *gin.Context) {
|
|
||||||
appUrl := common.EnvConfig.AppURL
|
|
||||||
config := map[string]interface{}{
|
|
||||||
"issuer": appUrl,
|
|
||||||
"authorization_endpoint": appUrl + "/authorize",
|
|
||||||
"token_endpoint": appUrl + "/api/oidc/token",
|
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
|
||||||
"scopes_supported": []string{"openid", "profile", "email"},
|
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"},
|
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
|
||||||
"subject_types_supported": []string{"public"},
|
|
||||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, config)
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,55 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-co-op/gocron/v2"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/model"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterJobs() {
|
func RegisterDbCleanupJobs(db *gorm.DB) {
|
||||||
scheduler, err := gocron.NewScheduler()
|
scheduler, err := gocron.NewScheduler()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create a new scheduler: %s", err)
|
log.Fatalf("Failed to create a new scheduler: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", clearWebauthnSessions)
|
jobs := &Jobs{db: db}
|
||||||
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", clearOneTimeAccessTokens)
|
|
||||||
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", clearOidcAuthorizationCodes)
|
|
||||||
|
|
||||||
|
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
|
||||||
|
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
|
||||||
|
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
|
||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
type Jobs struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
|
func (j *Jobs) clearWebauthnSessions() 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 < ?", 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 < ?", 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 < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||||
_, err := scheduler.NewJob(
|
_, err := scheduler.NewJob(
|
||||||
gocron.CronJob(interval, false),
|
gocron.CronJob(interval, false),
|
||||||
gocron.NewTask(job),
|
gocron.NewTask(job),
|
||||||
@@ -42,16 +67,3 @@ func registerJob(scheduler gocron.Scheduler, name string, interval string, job f
|
|||||||
log.Fatalf("Failed to register job %q: %v", name, err)
|
log.Fatalf("Failed to register job %q: %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearWebauthnSessions() error {
|
|
||||||
return common.DB.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearOneTimeAccessTokens() error {
|
|
||||||
return common.DB.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearOidcAuthorizationCodes() error {
|
|
||||||
return common.DB.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
39
backend/internal/job/ldap_job.go
Normal file
39
backend/internal/job/ldap_job.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"github.com/pocket-id/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
|
||||||
|
}
|
||||||
33
backend/internal/middleware/cors.go
Normal file
33
backend/internal/middleware/cors.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CorsMiddleware struct{}
|
||||||
|
|
||||||
|
func NewCorsMiddleware() *CorsMiddleware {
|
||||||
|
return &CorsMiddleware{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Allow all origins for the token endpoint
|
||||||
|
if c.FullPath() == "/api/oidc/token" {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
} else {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/internal/middleware/error_handler.go
Normal file
98
backend/internal/middleware/error_handler.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorHandlerMiddleware struct{}
|
||||||
|
|
||||||
|
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||||
|
return &ErrorHandlerMiddleware{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Next()
|
||||||
|
for _, err := range c.Errors {
|
||||||
|
|
||||||
|
// Check for record not found errors
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for validation errors
|
||||||
|
var validationErrors validator.ValidationErrors
|
||||||
|
if errors.As(err, &validationErrors) {
|
||||||
|
message := handleValidationError(validationErrors)
|
||||||
|
errorResponse(c, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for slice validation errors
|
||||||
|
var sliceValidationErrors binding.SliceValidationError
|
||||||
|
if errors.As(err, &sliceValidationErrors) {
|
||||||
|
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||||
|
message := handleValidationError(validationErrors)
|
||||||
|
errorResponse(c, http.StatusBadRequest, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var appErr common.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||||
|
// Capitalize the first letter of the message
|
||||||
|
message = strings.ToUpper(message[:1]) + message[1:]
|
||||||
|
c.JSON(statusCode, gin.H{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||||
|
var errorMessages []string
|
||||||
|
|
||||||
|
for _, ve := range validationErrors {
|
||||||
|
fieldName := ve.Field()
|
||||||
|
var errorMessage string
|
||||||
|
switch ve.Tag() {
|
||||||
|
case "required":
|
||||||
|
errorMessage = fmt.Sprintf("%s is required", fieldName)
|
||||||
|
case "email":
|
||||||
|
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||||
|
case "username":
|
||||||
|
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||||
|
case "url":
|
||||||
|
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||||
|
case "min":
|
||||||
|
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
|
||||||
|
case "max":
|
||||||
|
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
|
||||||
|
default:
|
||||||
|
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessages = append(errorMessages, errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all the error messages into a single string
|
||||||
|
combinedErrors := strings.Join(errorMessages, ", ")
|
||||||
|
|
||||||
|
return combinedErrors
|
||||||
|
}
|
||||||
@@ -2,16 +2,24 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LimitFileSize(maxSize int64) gin.HandlerFunc {
|
type FileSizeLimitMiddleware struct{}
|
||||||
|
|
||||||
|
func NewFileSizeLimitMiddleware() *FileSizeLimitMiddleware {
|
||||||
|
return &FileSizeLimitMiddleware{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||||
utils.HandlerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||||
|
c.Error(err)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
60
backend/internal/middleware/jwt_auth.go
Normal file
60
backend/internal/middleware/jwt_auth.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JwtAuthMiddleware struct {
|
||||||
|
jwtService *service.JwtService
|
||||||
|
ignoreUnauthenticated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
|
||||||
|
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Extract the token from the cookie or the Authorization header
|
||||||
|
token, err := c.Cookie(cookie.AccessTokenCookieName)
|
||||||
|
if err != nil {
|
||||||
|
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
||||||
|
if len(authorizationHeaderSplitted) == 2 {
|
||||||
|
token = authorizationHeaderSplitted[1]
|
||||||
|
} else if m.ignoreUnauthenticated {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
c.Error(&common.NotSignedInError{})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||||
|
if err != nil && m.ignoreUnauthenticated {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.Error(&common.NotSignedInError{})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user is an admin
|
||||||
|
if adminOnly && !claims.IsAdmin {
|
||||||
|
c.Error(&common.MissingPermissionError{})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("userID", claims.Subject)
|
||||||
|
c.Set("userIsAdmin", claims.IsAdmin)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang-rest-api-template/internal/common"
|
|
||||||
"golang-rest-api-template/internal/utils"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimiter is a Gin middleware for rate limiting based on client IP
|
type RateLimitMiddleware struct{}
|
||||||
func RateLimiter(limit rate.Limit, burst int) gin.HandlerFunc {
|
|
||||||
|
func NewRateLimitMiddleware() *RateLimitMiddleware {
|
||||||
|
return &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
|
// Start the cleanup routine
|
||||||
go cleanupClients()
|
go cleanupClients(&mu, clients)
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
@@ -26,9 +34,9 @@ func RateLimiter(limit rate.Limit, burst int) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limiter := getLimiter(ip, limit, burst)
|
limiter := getLimiter(ip, limit, burst, &mu, clients)
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
utils.HandlerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
c.Error(&common.TooManyRequestsError{})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -42,12 +50,8 @@ type client struct {
|
|||||||
lastSeen time.Time
|
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
|
// 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 {
|
for {
|
||||||
time.Sleep(time.Minute)
|
time.Sleep(time.Minute)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -61,7 +65,7 @@ func cleanupClients() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
|
// 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()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
51
backend/internal/model/app_config.go
Normal file
51
backend/internal/model/app_config.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type AppConfigVariable struct {
|
||||||
|
Key string `gorm:"primaryKey;not null"`
|
||||||
|
Type string
|
||||||
|
IsPublic bool
|
||||||
|
IsInternal bool
|
||||||
|
Value string
|
||||||
|
DefaultValue string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppConfig struct {
|
||||||
|
// General
|
||||||
|
AppName AppConfigVariable
|
||||||
|
SessionDuration AppConfigVariable
|
||||||
|
EmailsVerified AppConfigVariable
|
||||||
|
AllowOwnAccountEdit AppConfigVariable
|
||||||
|
// Internal
|
||||||
|
BackgroundImageType AppConfigVariable
|
||||||
|
LogoLightImageType AppConfigVariable
|
||||||
|
LogoDarkImageType AppConfigVariable
|
||||||
|
// Email
|
||||||
|
SmtpHost AppConfigVariable
|
||||||
|
SmtpPort AppConfigVariable
|
||||||
|
SmtpFrom AppConfigVariable
|
||||||
|
SmtpUser AppConfigVariable
|
||||||
|
SmtpPassword AppConfigVariable
|
||||||
|
SmtpTls AppConfigVariable
|
||||||
|
SmtpSkipCertVerify AppConfigVariable
|
||||||
|
EmailLoginNotificationEnabled AppConfigVariable
|
||||||
|
EmailOneTimeAccessEnabled AppConfigVariable
|
||||||
|
// LDAP
|
||||||
|
LdapEnabled AppConfigVariable
|
||||||
|
LdapUrl AppConfigVariable
|
||||||
|
LdapBindDn AppConfigVariable
|
||||||
|
LdapBindPassword AppConfigVariable
|
||||||
|
LdapBase AppConfigVariable
|
||||||
|
LdapUserSearchFilter AppConfigVariable
|
||||||
|
LdapUserGroupSearchFilter AppConfigVariable
|
||||||
|
LdapSkipCertVerify AppConfigVariable
|
||||||
|
LdapAttributeUserUniqueIdentifier AppConfigVariable
|
||||||
|
LdapAttributeUserUsername AppConfigVariable
|
||||||
|
LdapAttributeUserEmail AppConfigVariable
|
||||||
|
LdapAttributeUserFirstName AppConfigVariable
|
||||||
|
LdapAttributeUserLastName AppConfigVariable
|
||||||
|
LdapAttributeUserProfilePicture AppConfigVariable
|
||||||
|
LdapAttributeGroupMember AppConfigVariable
|
||||||
|
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
||||||
|
LdapAttributeGroupName AppConfigVariable
|
||||||
|
LdapAttributeAdminGroup AppConfigVariable
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
type ApplicationConfigurationVariable struct {
|
|
||||||
Key string `gorm:"primaryKey;not null" json:"key"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
IsPublic bool `json:"-"`
|
|
||||||
IsInternal bool `json:"-"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApplicationConfiguration struct {
|
|
||||||
AppName ApplicationConfigurationVariable
|
|
||||||
BackgroundImageType ApplicationConfigurationVariable
|
|
||||||
LogoImageType ApplicationConfigurationVariable
|
|
||||||
SessionDuration ApplicationConfigurationVariable
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApplicationConfigurationUpdateDto struct {
|
|
||||||
AppName string `json:"appName" binding:"required"`
|
|
||||||
}
|
|
||||||
53
backend/internal/model/audit_log.go
Normal file
53
backend/internal/model/audit_log.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Event AuditLogEvent `sortable:"true"`
|
||||||
|
IpAddress string `sortable:"true"`
|
||||||
|
Country string `sortable:"true"`
|
||||||
|
City string `sortable:"true"`
|
||||||
|
UserAgent string `sortable:"true"`
|
||||||
|
UserID string
|
||||||
|
Data AuditLogData
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogData map[string]string
|
||||||
|
|
||||||
|
type AuditLogEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|
||||||
|
func (e *AuditLogEvent) Scan(value interface{}) error {
|
||||||
|
*e = AuditLogEvent(value.(string))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e AuditLogEvent) Value() (driver.Value, error) {
|
||||||
|
return string(e), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *AuditLogData) Scan(value interface{}) error {
|
||||||
|
if v, ok := value.([]byte); ok {
|
||||||
|
return json.Unmarshal(v, d)
|
||||||
|
} else {
|
||||||
|
return errors.New("type assertion to []byte failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d AuditLogData) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(d)
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
model "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Base contains common columns for all tables.
|
// Base contains common columns for all tables.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
ID string `gorm:"primaryKey;not null" json:"id"`
|
ID string `gorm:"primaryKey;not null"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt model.DateTime `sortable:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Base) BeforeCreate(db *gorm.DB) (err error) {
|
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||||
if b.ID == "" {
|
if b.ID == "" {
|
||||||
b.ID = uuid.New().String()
|
b.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
b.CreatedAt = model.DateTime(time.Now())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
11
backend/internal/model/custom_claim.go
Normal file
11
backend/internal/model/custom_claim.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type CustomClaim struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
|
||||||
|
UserID *string
|
||||||
|
UserGroupID *string
|
||||||
|
}
|
||||||
@@ -1,29 +1,54 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserAuthorizedOidcClient struct {
|
type UserAuthorizedOidcClient struct {
|
||||||
Scope string
|
Scope string
|
||||||
UserID string `json:"userId" gorm:"primary_key;"`
|
UserID string `gorm:"primary_key;"`
|
||||||
|
User User
|
||||||
|
|
||||||
ClientID string `json:"clientId" gorm:"primary_key;"`
|
ClientID string `gorm:"primary_key;"`
|
||||||
Client OidcClient
|
Client OidcClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcAuthorizationCode struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Code string
|
||||||
|
Scope string
|
||||||
|
Nonce string
|
||||||
|
CodeChallenge *string
|
||||||
|
CodeChallengeMethodSha256 *bool
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
|
UserID string
|
||||||
|
User User
|
||||||
|
|
||||||
|
ClientID string
|
||||||
|
}
|
||||||
|
|
||||||
type OidcClient struct {
|
type OidcClient struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Name string `json:"name"`
|
Name string `sortable:"true"`
|
||||||
Secret string `json:"-"`
|
Secret string
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURLs UrlList
|
||||||
ImageType *string `json:"-"`
|
LogoutCallbackURLs UrlList
|
||||||
HasLogo bool `gorm:"-" json:"hasLogo"`
|
ImageType *string
|
||||||
|
HasLogo bool `gorm:"-"`
|
||||||
|
IsPublic bool
|
||||||
|
PkceEnabled bool
|
||||||
|
|
||||||
CreatedByID string
|
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
CreatedBy User
|
CreatedByID string
|
||||||
|
CreatedBy User
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||||
@@ -32,34 +57,16 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcAuthorizationCode struct {
|
type UrlList []string
|
||||||
Base
|
|
||||||
|
|
||||||
Code string
|
func (cu *UrlList) Scan(value interface{}) error {
|
||||||
Scope string
|
if v, ok := value.([]byte); ok {
|
||||||
Nonce string
|
return json.Unmarshal(v, cu)
|
||||||
ExpiresAt time.Time
|
} else {
|
||||||
|
return errors.New("type assertion to []byte failed")
|
||||||
UserID string
|
}
|
||||||
User User
|
|
||||||
|
|
||||||
ClientID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
func (cu UrlList) Value() (driver.Value, error) {
|
||||||
Name string `json:"name" binding:"required"`
|
return json.Marshal(cu)
|
||||||
CallbackURL string `json:"callbackURL" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthorizeNewClientDto struct {
|
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
|
||||||
Scope string `json:"scope" binding:"required"`
|
|
||||||
Nonce string `json:"nonce"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OidcIdTokenDto struct {
|
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
|
||||||
Code string `form:"code" binding:"required"`
|
|
||||||
ClientID string `form:"client_id"`
|
|
||||||
ClientSecret string `form:"client_secret"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
53
backend/internal/model/types/date_time.go
Normal file
53
backend/internal/model/types/date_time.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package datatype
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
*date = DateTime(value.(time.Time))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) Value() (driver.Value, error) {
|
||||||
|
if common.EnvConfig.DbProvider == common.DbProviderSqlite {
|
||||||
|
return time.Time(date).Unix(), nil
|
||||||
|
} else {
|
||||||
|
return time.Time(date), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) UTC() time.Time {
|
||||||
|
return time.Time(date).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) ToTime() time.Time {
|
||||||
|
return time.Time(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormDataType gorm common data type
|
||||||
|
func (date DateTime) GormDataType() string {
|
||||||
|
return "date"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) GobEncode() ([]byte, error) {
|
||||||
|
return time.Time(date).GobEncode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date *DateTime) GobDecode(b []byte) error {
|
||||||
|
return (*time.Time)(date).GobDecode(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date DateTime) MarshalJSON() ([]byte, error) {
|
||||||
|
return time.Time(date).MarshalJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (date *DateTime) UnmarshalJSON(b []byte) error {
|
||||||
|
return (*time.Time)(date).UnmarshalJSON(b)
|
||||||
|
}
|
||||||
@@ -3,19 +3,22 @@ package model
|
|||||||
import (
|
import (
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"time"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Username string `json:"username"`
|
Username string `sortable:"true"`
|
||||||
Email string `json:"email" `
|
Email string `sortable:"true"`
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `sortable:"true"`
|
||||||
LastName string `json:"lastName"`
|
LastName string `sortable:"true"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `sortable:"true"`
|
||||||
|
LdapID *string
|
||||||
|
|
||||||
Credentials []WebauthnCredential `json:"-"`
|
CustomClaims []CustomClaim
|
||||||
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
|
Credentials []WebauthnCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||||
@@ -31,7 +34,7 @@ func (u User) WebAuthnCredentials() []webauthn.Credential {
|
|||||||
|
|
||||||
for i, credential := range u.Credentials {
|
for i, credential := range u.Credentials {
|
||||||
credentials[i] = webauthn.Credential{
|
credentials[i] = webauthn.Credential{
|
||||||
ID: []byte(credential.CredentialID),
|
ID: credential.CredentialID,
|
||||||
AttestationType: credential.AttestationType,
|
AttestationType: credential.AttestationType,
|
||||||
PublicKey: credential.PublicKey,
|
PublicKey: credential.PublicKey,
|
||||||
Transport: credential.Transport,
|
Transport: credential.Transport,
|
||||||
@@ -57,21 +60,13 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
return descriptors
|
return descriptors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||||
|
|
||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string `json:"token"`
|
Token string
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string `json:"userId"`
|
UserID string
|
||||||
User User
|
User User
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTokenCreateDto struct {
|
|
||||||
UserID string `json:"userId" binding:"required"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginUserDto struct {
|
|
||||||
Username string `json:"username" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|||||||
10
backend/internal/model/user_group.go
Normal file
10
backend/internal/model/user_group.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type UserGroup struct {
|
||||||
|
Base
|
||||||
|
FriendlyName string `sortable:"true"`
|
||||||
|
Name string `sortable:"true"`
|
||||||
|
LdapID *string
|
||||||
|
Users []User `gorm:"many2many:user_groups_users;"`
|
||||||
|
CustomClaims []CustomClaim
|
||||||
|
}
|
||||||
@@ -4,26 +4,28 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebauthnSession struct {
|
type WebauthnSession struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Challenge string
|
Challenge string
|
||||||
ExpiresAt time.Time
|
ExpiresAt datatype.DateTime
|
||||||
UserVerification string
|
UserVerification string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebauthnCredential struct {
|
type WebauthnCredential struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Name string `json:"name"`
|
Name string
|
||||||
CredentialID string `json:"credentialID"`
|
CredentialID []byte
|
||||||
PublicKey []byte `json:"-"`
|
PublicKey []byte
|
||||||
AttestationType string `json:"attestationType"`
|
AttestationType string
|
||||||
Transport AuthenticatorTransportList `json:"-"`
|
Transport AuthenticatorTransportList
|
||||||
|
|
||||||
BackupEligible bool `json:"backupEligible"`
|
BackupEligible bool `json:"backupEligible"`
|
||||||
BackupState bool `json:"backupState"`
|
BackupState bool `json:"backupState"`
|
||||||
@@ -31,6 +33,18 @@ type WebauthnCredential struct {
|
|||||||
UserID string
|
UserID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicKeyCredentialCreationOptions struct {
|
||||||
|
Response protocol.PublicKeyCredentialCreationOptions
|
||||||
|
SessionID string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicKeyCredentialRequestOptions struct {
|
||||||
|
Response protocol.PublicKeyCredentialRequestOptions
|
||||||
|
SessionID string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport
|
type AuthenticatorTransportList []protocol.AuthenticatorTransport
|
||||||
|
|
||||||
// Scan and Value methods for GORM to handle the custom type
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|||||||
395
backend/internal/service/app_config_service.go
Normal file
395
backend/internal/service/app_config_service.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppConfigService struct {
|
||||||
|
DbConfig *model.AppConfig
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
||||||
|
service := &AppConfigService{
|
||||||
|
DbConfig: &defaultDbConfig,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
if err := service.InitDbConfig(); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize app config service: %v", err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultDbConfig = model.AppConfig{
|
||||||
|
// General
|
||||||
|
AppName: model.AppConfigVariable{
|
||||||
|
Key: "appName",
|
||||||
|
Type: "string",
|
||||||
|
IsPublic: true,
|
||||||
|
DefaultValue: "Pocket ID",
|
||||||
|
},
|
||||||
|
SessionDuration: model.AppConfigVariable{
|
||||||
|
Key: "sessionDuration",
|
||||||
|
Type: "number",
|
||||||
|
DefaultValue: "60",
|
||||||
|
},
|
||||||
|
EmailsVerified: model.AppConfigVariable{
|
||||||
|
Key: "emailsVerified",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "false",
|
||||||
|
},
|
||||||
|
AllowOwnAccountEdit: model.AppConfigVariable{
|
||||||
|
Key: "allowOwnAccountEdit",
|
||||||
|
Type: "bool",
|
||||||
|
IsPublic: true,
|
||||||
|
DefaultValue: "true",
|
||||||
|
},
|
||||||
|
// Internal
|
||||||
|
BackgroundImageType: model.AppConfigVariable{
|
||||||
|
Key: "backgroundImageType",
|
||||||
|
Type: "string",
|
||||||
|
IsInternal: true,
|
||||||
|
DefaultValue: "jpg",
|
||||||
|
},
|
||||||
|
LogoLightImageType: model.AppConfigVariable{
|
||||||
|
Key: "logoLightImageType",
|
||||||
|
Type: "string",
|
||||||
|
IsInternal: true,
|
||||||
|
DefaultValue: "svg",
|
||||||
|
},
|
||||||
|
LogoDarkImageType: model.AppConfigVariable{
|
||||||
|
Key: "logoDarkImageType",
|
||||||
|
Type: "string",
|
||||||
|
IsInternal: true,
|
||||||
|
DefaultValue: "svg",
|
||||||
|
},
|
||||||
|
// Email
|
||||||
|
SmtpHost: model.AppConfigVariable{
|
||||||
|
Key: "smtpHost",
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
SmtpPort: model.AppConfigVariable{
|
||||||
|
Key: "smtpPort",
|
||||||
|
Type: "number",
|
||||||
|
},
|
||||||
|
SmtpFrom: model.AppConfigVariable{
|
||||||
|
Key: "smtpFrom",
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
SmtpUser: model.AppConfigVariable{
|
||||||
|
Key: "smtpUser",
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
SmtpPassword: model.AppConfigVariable{
|
||||||
|
Key: "smtpPassword",
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
SmtpTls: model.AppConfigVariable{
|
||||||
|
Key: "smtpTls",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "true",
|
||||||
|
},
|
||||||
|
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||||
|
Key: "smtpSkipCertVerify",
|
||||||
|
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",
|
||||||
|
IsPublic: true,
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
LdapUserSearchFilter: model.AppConfigVariable{
|
||||||
|
Key: "ldapUserSearchFilter",
|
||||||
|
Type: "string",
|
||||||
|
DefaultValue: "(objectClass=person)",
|
||||||
|
},
|
||||||
|
LdapUserGroupSearchFilter: model.AppConfigVariable{
|
||||||
|
Key: "ldapUserGroupSearchFilter",
|
||||||
|
Type: "string",
|
||||||
|
DefaultValue: "(objectClass=groupOfNames)",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
LdapAttributeUserProfilePicture: model.AppConfigVariable{
|
||||||
|
Key: "ldapAttributeUserProfilePicture",
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
LdapAttributeGroupMember: model.AppConfigVariable{
|
||||||
|
Key: "ldapAttributeGroupMember",
|
||||||
|
Type: "string",
|
||||||
|
DefaultValue: "member",
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
return nil, &common.UiConfigDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := s.db.Begin()
|
||||||
|
rt := reflect.ValueOf(input).Type()
|
||||||
|
rv := reflect.ValueOf(input)
|
||||||
|
|
||||||
|
var savedConfigVariables []model.AppConfigVariable
|
||||||
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
|
field := rt.Field(i)
|
||||||
|
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()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
appConfigVariable.Value = value
|
||||||
|
if err := tx.Save(&appConfigVariable).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
|
||||||
|
if err := s.LoadDbConfigFromDb(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedConfigVariables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) UpdateImageType(imageName string, fileType string) error {
|
||||||
|
key := fmt.Sprintf("%sImageType", imageName)
|
||||||
|
err := s.db.Model(&model.AppConfigVariable{}).Where("key = ?", key).Update("value", fileType).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.LoadDbConfigFromDb()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
||||||
|
var configuration []model.AppConfigVariable
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if showAll {
|
||||||
|
err = s.db.Find(&configuration).Error
|
||||||
|
} else {
|
||||||
|
err = s.db.Find(&configuration, "is_public = true").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range configuration {
|
||||||
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
// Set the value to the environment variable if the UI config is disabled
|
||||||
|
configuration[i].Value = s.getConfigVariableFromEnvironmentVariable(configuration[i].Key, configuration[i].DefaultValue)
|
||||||
|
|
||||||
|
} else if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||||
|
// Set the value to the default value if it is empty
|
||||||
|
configuration[i].Value = configuration[i].DefaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, imageName string, oldImageType string) error {
|
||||||
|
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
||||||
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
|
if mimeType == "" {
|
||||||
|
return &common.FileTypeNotSupportedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the old image if it has a different file type
|
||||||
|
if fileType != oldImageType {
|
||||||
|
oldImagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType)
|
||||||
|
if err := os.Remove(oldImagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType)
|
||||||
|
if err := utils.SaveFile(uploadedFile, imagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the file type in the database
|
||||||
|
if err := s.UpdateImageType(imageName, fileType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDbConfig creates the default configuration values in the database if they do not exist,
|
||||||
|
// updates existing configurations if they differ from the default, and deletes any configurations
|
||||||
|
// that are not in the default configuration.
|
||||||
|
func (s *AppConfigService) InitDbConfig() error {
|
||||||
|
// Reflect to get the underlying value of DbConfig and its default configuration
|
||||||
|
defaultConfigReflectValue := reflect.ValueOf(defaultDbConfig)
|
||||||
|
defaultKeys := make(map[string]struct{})
|
||||||
|
|
||||||
|
// Iterate over the fields of DbConfig
|
||||||
|
for i := 0; i < defaultConfigReflectValue.NumField(); i++ {
|
||||||
|
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.AppConfigVariable)
|
||||||
|
|
||||||
|
defaultKeys[defaultConfigVar.Key] = struct{}{}
|
||||||
|
|
||||||
|
var storedConfigVar model.AppConfigVariable
|
||||||
|
if err := s.db.First(&storedConfigVar, "key = ?", defaultConfigVar.Key).Error; err != nil {
|
||||||
|
// If the configuration does not exist, create it
|
||||||
|
if err := s.db.Create(&defaultConfigVar).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing configuration if it differs from the default
|
||||||
|
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
|
||||||
|
storedConfigVar.Type = defaultConfigVar.Type
|
||||||
|
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
||||||
|
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
||||||
|
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
|
||||||
|
if err := s.db.Save(&storedConfigVar).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any configurations not in the default keys
|
||||||
|
var allConfigVars []model.AppConfigVariable
|
||||||
|
if err := s.db.Find(&allConfigVars).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, config := range allConfigVars {
|
||||||
|
if _, exists := defaultKeys[config.Key]; !exists {
|
||||||
|
if err := s.db.Delete(&config).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.LoadDbConfigFromDb()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
|
||||||
|
func (s *AppConfigService) LoadDbConfigFromDb() error {
|
||||||
|
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
|
||||||
|
|
||||||
|
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
||||||
|
dbConfigField := dbConfigReflectValue.Field(i)
|
||||||
|
currentConfigVar := dbConfigField.Interface().(model.AppConfigVariable)
|
||||||
|
var storedConfigVar model.AppConfigVariable
|
||||||
|
if err := s.db.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
storedConfigVar.Value = s.getConfigVariableFromEnvironmentVariable(currentConfigVar.Key, storedConfigVar.DefaultValue)
|
||||||
|
} else if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||||
|
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) getConfigVariableFromEnvironmentVariable(key, fallbackValue string) string {
|
||||||
|
environmentVariableName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||||
|
|
||||||
|
if value, exists := os.LookupEnv(environmentVariableName); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
99
backend/internal/service/audit_log_service.go
Normal file
99
backend/internal/service/audit_log_service.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
userAgentParser "github.com/mileusna/useragent"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLogService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
emailService *EmailService
|
||||||
|
geoliteService *GeoLiteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||||
|
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new audit log entry in the database
|
||||||
|
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||||
|
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get IP location: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog := model.AuditLog{
|
||||||
|
Event: event,
|
||||||
|
IpAddress: ipAddress,
|
||||||
|
Country: country,
|
||||||
|
City: city,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
UserID: userID,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the audit log in the database
|
||||||
|
if err := s.db.Create(&auditLog).Error; err != nil {
|
||||||
|
log.Printf("Failed to create audit log: %v\n", err)
|
||||||
|
return model.AuditLog{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return auditLog
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
||||||
|
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
|
||||||
|
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
|
||||||
|
|
||||||
|
// Count the number of times the user has logged in from the same device
|
||||||
|
var count int64
|
||||||
|
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to count audit logs: %v\n", err)
|
||||||
|
return createdAuditLog
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
err := SendEmail(s.emailService, email.Address{
|
||||||
|
Name: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
}, NewLoginTemplate, &NewLoginTemplateData{
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
Country: createdAuditLog.Country,
|
||||||
|
City: createdAuditLog.City,
|
||||||
|
Device: s.DeviceStringFromUserAgent(userAgent),
|
||||||
|
DateTime: createdAuditLog.CreatedAt.UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdAuditLog
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLogsForUser retrieves all audit logs for a given user ID
|
||||||
|
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)
|
||||||
|
|
||||||
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
||||||
|
return logs, pagination, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
||||||
|
ua := userAgentParser.Parse(userAgent)
|
||||||
|
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||||
|
}
|
||||||
197
backend/internal/service/custom_claim_service.go
Normal file
197
backend/internal/service/custom_claim_service.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reserved claims
|
||||||
|
var reservedClaims = map[string]struct{}{
|
||||||
|
"given_name": {},
|
||||||
|
"family_name": {},
|
||||||
|
"name": {},
|
||||||
|
"email": {},
|
||||||
|
"preferred_username": {},
|
||||||
|
"groups": {},
|
||||||
|
"sub": {},
|
||||||
|
"iss": {},
|
||||||
|
"aud": {},
|
||||||
|
"exp": {},
|
||||||
|
"iat": {},
|
||||||
|
"auth_time": {},
|
||||||
|
"nonce": {},
|
||||||
|
"acr": {},
|
||||||
|
"amr": {},
|
||||||
|
"azp": {},
|
||||||
|
"nbf": {},
|
||||||
|
"jti": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomClaimService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
|
||||||
|
return &CustomClaimService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
|
||||||
|
func isReservedClaim(key string) bool {
|
||||||
|
_, ok := reservedClaims[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// idType is the type of the id used to identify the user or user group
|
||||||
|
type idType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserID idType = "user_id"
|
||||||
|
UserGroupID idType = "user_group_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||||
|
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
return s.updateCustomClaims(UserID, userID, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||||
|
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCustomClaims updates the custom claims for a user or user group
|
||||||
|
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
|
// Check for duplicate keys in the claims slice
|
||||||
|
seenKeys := make(map[string]bool)
|
||||||
|
for _, claim := range claims {
|
||||||
|
if seenKeys[claim.Key] {
|
||||||
|
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||||
|
}
|
||||||
|
seenKeys[claim.Key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingClaims []model.CustomClaim
|
||||||
|
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete claims that are not in the new list
|
||||||
|
for _, existingClaim := range existingClaims {
|
||||||
|
found := false
|
||||||
|
for _, claim := range claims {
|
||||||
|
if claim.Key == existingClaim.Key {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
err = s.db.Delete(&existingClaim).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update claims
|
||||||
|
for _, claim := range claims {
|
||||||
|
if isReservedClaim(claim.Key) {
|
||||||
|
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||||
|
}
|
||||||
|
customClaim := model.CustomClaim{
|
||||||
|
Key: claim.Key,
|
||||||
|
Value: claim.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if idType == UserID {
|
||||||
|
customClaim.UserID = &value
|
||||||
|
} else if idType == UserGroupID {
|
||||||
|
customClaim.UserGroupID = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the claim if it already exists or create a new one
|
||||||
|
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the updated claims
|
||||||
|
var updatedClaims []model.CustomClaim
|
||||||
|
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
|
||||||
|
var customClaims []model.CustomClaim
|
||||||
|
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
|
||||||
|
return customClaims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
|
||||||
|
var customClaims []model.CustomClaim
|
||||||
|
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
|
||||||
|
return customClaims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
|
||||||
|
// prioritizing the user's claims over user group claims with the same key.
|
||||||
|
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
|
||||||
|
// Get the custom claims of the user
|
||||||
|
customClaims, err := s.GetCustomClaimsForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user's claims in a map to prioritize and prevent duplicates
|
||||||
|
claimsMap := make(map[string]model.CustomClaim)
|
||||||
|
for _, claim := range customClaims {
|
||||||
|
claimsMap[claim.Key] = claim
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user groups of the user
|
||||||
|
var userGroupsOfUser []model.UserGroup
|
||||||
|
err = s.db.Preload("CustomClaims").
|
||||||
|
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||||
|
Where("user_groups_users.user_id = ?", userID).
|
||||||
|
Find(&userGroupsOfUser).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only non-duplicate custom claims from user groups
|
||||||
|
for _, userGroup := range userGroupsOfUser {
|
||||||
|
for _, groupClaim := range userGroup.CustomClaims {
|
||||||
|
// Only add claim if it does not exist in the user's claims
|
||||||
|
if _, exists := claimsMap[groupClaim.Key]; !exists {
|
||||||
|
claimsMap[groupClaim.Key] = groupClaim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the claimsMap back to a slice
|
||||||
|
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
|
||||||
|
for _, claim := range claimsMap {
|
||||||
|
finalClaims = append(finalClaims, claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalClaims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSuggestions returns a list of custom claim keys that have been used before
|
||||||
|
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
|
||||||
|
var customClaimsKeys []string
|
||||||
|
|
||||||
|
err := s.db.Model(&model.CustomClaim{}).
|
||||||
|
Group("key").
|
||||||
|
Order("COUNT(*) DESC").
|
||||||
|
Pluck("key", &customClaimsKeys).Error
|
||||||
|
|
||||||
|
return customClaimsKeys, err
|
||||||
|
}
|
||||||
287
backend/internal/service/email_service.go
Normal file
287
backend/internal/service/email_service.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
htemplate "html/template"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
ttemplate "text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var netDialer = &net.Dialer{
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailService struct {
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
db *gorm.DB
|
||||||
|
htmlTemplates map[string]*htemplate.Template
|
||||||
|
textTemplates map[string]*ttemplate.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
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(emailTemplatesPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EmailService{
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
db: db,
|
||||||
|
htmlTemplates: htmlTemplates,
|
||||||
|
textTemplates: textTemplates,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) SendTestEmail(recipientUserId string) error {
|
||||||
|
var user model.User
|
||||||
|
if err := srv.db.First(&user, "id = ?", recipientUserId).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendEmail(srv,
|
||||||
|
email.Address{
|
||||||
|
Email: user.Email,
|
||||||
|
Name: user.FullName(),
|
||||||
|
}, TestTemplate, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||||
|
data := &email.TemplateData[V]{
|
||||||
|
AppName: srv.appConfigService.DbConfig.AppName.Value,
|
||||||
|
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
|
||||||
|
Data: tData,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, boundary, err := prepareBody(srv, template, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare email body for '%s': %w", template.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the email message
|
||||||
|
c := email.NewComposer()
|
||||||
|
c.AddHeader("Subject", template.Title(data))
|
||||||
|
c.AddAddressHeader("From", []email.Address{
|
||||||
|
{
|
||||||
|
Email: srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||||
|
Name: srv.appConfigService.DbConfig.AppName.Value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.AddAddressHeader("To", []email.Address{toEmail})
|
||||||
|
c.AddHeaderRaw("Content-Type",
|
||||||
|
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
||||||
|
)
|
||||||
|
c.Body(body)
|
||||||
|
|
||||||
|
// Connect to the SMTP server
|
||||||
|
client, err := srv.getSmtpClient()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
||||||
|
return fmt.Errorf("send email content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
|
||||||
|
port := srv.appConfigService.DbConfig.SmtpPort.Value
|
||||||
|
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
|
||||||
|
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the SMTP server
|
||||||
|
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
|
||||||
|
client, err = srv.connectToSmtpServer(smtpAddress)
|
||||||
|
} else if port == "465" {
|
||||||
|
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||||
|
smtpAddress,
|
||||||
|
tlsConfig,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||||
|
smtpAddress,
|
||||||
|
tlsConfig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the authentication if user or password are set
|
||||||
|
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
|
||||||
|
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
|
||||||
|
|
||||||
|
if smtpUser != "" || smtpPassword != "" {
|
||||||
|
auth := smtp.PlainAuth("",
|
||||||
|
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||||
|
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||||
|
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
|
)
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to authenticate SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServer(serverAddr string) (*smtp.Client, error) {
|
||||||
|
conn, err := netDialer.Dial("tcp", serverAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := srv.sendHelloCommand(client); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := srv.sendHelloCommand(client); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||||
|
conn, err := netDialer.Dial("tcp", serverAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := srv.sendHelloCommand(client); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.StartTLS(tlsConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err == nil {
|
||||||
|
if err := client.Hello(hostname); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
|
||||||
|
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
|
||||||
|
return fmt.Errorf("failed to set sender: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(toEmail.Email); err != nil {
|
||||||
|
return fmt.Errorf("failed to set recipient: %w", err)
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start data: %w", err)
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(c.String()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write email data: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close data writer: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (string, string, error) {
|
||||||
|
body := bytes.NewBuffer(nil)
|
||||||
|
mpart := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
// prepare text part
|
||||||
|
var textHeader = textproto.MIMEHeader{}
|
||||||
|
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8")
|
||||||
|
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
textPart, err := mpart.CreatePart(textHeader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create text part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
textQp := quotedprintable.NewWriter(textPart)
|
||||||
|
err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textQp, "root", data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("execute text template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare html part
|
||||||
|
var htmlHeader = textproto.MIMEHeader{}
|
||||||
|
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
|
||||||
|
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
htmlPart, err := mpart.CreatePart(htmlHeader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create html part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlQp := quotedprintable.NewWriter(htmlPart)
|
||||||
|
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("execute html template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mpart.Close()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("close multipart: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.String(), mpart.Boundary(), nil
|
||||||
|
}
|
||||||
58
backend/internal/service/email_service_templates.go
Normal file
58
backend/internal/service/email_service_templates.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
How to add new template:
|
||||||
|
- pick unique and descriptive template ${name} (for example "login-with-new-device")
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- backend app must be restarted to reread all the template files
|
||||||
|
- root "." object in templates is `email.TemplateData`
|
||||||
|
- xxxxTemplateData structure is visible under .Data in templates
|
||||||
|
*/
|
||||||
|
|
||||||
|
var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||||
|
Path: "login-with-new-device",
|
||||||
|
Title: func(data *email.TemplateData[NewLoginTemplateData]) string {
|
||||||
|
return fmt.Sprintf("New device login with %s", data.AppName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return "Test email"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewLoginTemplateData struct {
|
||||||
|
IPAddress string
|
||||||
|
Country string
|
||||||
|
City string
|
||||||
|
Device string
|
||||||
|
DateTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type OneTimeAccessTemplateData = struct {
|
||||||
|
Link string
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is list of all template paths used for preloading templates
|
||||||
|
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}
|
||||||
222
backend/internal/service/geolite_service.go
Normal file
222
backend/internal/service/geolite_service.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oschwald/maxminddb-golang/v2"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeoLiteService struct {
|
||||||
|
disableUpdater bool
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var localhostIPNets = []*net.IPNet{
|
||||||
|
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
||||||
|
{IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128)}, // ::1/128
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateLanIPNets = []*net.IPNet{
|
||||||
|
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
||||||
|
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
||||||
|
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
||||||
|
}
|
||||||
|
|
||||||
|
var tailscaleIPNets = []*net.IPNet{
|
||||||
|
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
|
||||||
|
func NewGeoLiteService() *GeoLiteService {
|
||||||
|
service := &GeoLiteService{}
|
||||||
|
|
||||||
|
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
|
||||||
|
// Warn the user, and disable the updater.
|
||||||
|
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
|
||||||
|
service.disableUpdater = true
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := service.updateDatabase(); err != nil {
|
||||||
|
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocationByIP returns the country and city of the given IP address.
|
||||||
|
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
||||||
|
// Check the IP address against known private IP ranges
|
||||||
|
if ip := net.ParseIP(ipAddress); ip != nil {
|
||||||
|
for _, ipNet := range tailscaleIPNets {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return "Internal Network", "Tailscale", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ipNet := range privateLanIPNets {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return "Internal Network", "LAN/Docker/k8s", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ipNet := range localhostIPNets {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return "Internal Network", "localhost", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race condition between reading and writing the database.
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(ipAddress)
|
||||||
|
|
||||||
|
var record struct {
|
||||||
|
City struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"city"`
|
||||||
|
Country struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Lookup(addr).Decode(&record)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
||||||
|
func (s *GeoLiteService) updateDatabase() error {
|
||||||
|
if s.disableUpdater {
|
||||||
|
// Avoid updating the GeoLite2 City database.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.isDatabaseUpToDate() {
|
||||||
|
log.Println("GeoLite2 City database is up-to-date.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Updating GeoLite2 City database...")
|
||||||
|
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||||
|
|
||||||
|
// Download the database tar.gz file
|
||||||
|
resp, err := http.Get(downloadUrl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download database: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to download database, received HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the database file directly to the target path
|
||||||
|
if err := s.extractDatabase(resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("GeoLite2 City database successfully updated.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDatabaseUpToDate checks if the database file is older than 14 days.
|
||||||
|
func (s *GeoLiteService) isDatabaseUpToDate() bool {
|
||||||
|
info, err := os.Stat(common.EnvConfig.GeoLiteDBPath)
|
||||||
|
if err != nil {
|
||||||
|
// If the file doesn't exist, treat it as not up-to-date
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(info.ModTime()) < 14*24*time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
|
||||||
|
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
|
||||||
|
gzr, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
|
||||||
|
tarReader := tar.NewReader(gzr)
|
||||||
|
|
||||||
|
// Iterate over the files in the tar archive
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read tar archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file is the GeoLite2-City.mmdb file
|
||||||
|
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
|
||||||
|
// extract to a temporary file to avoid having a corrupted db in case of write failure.
|
||||||
|
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
|
||||||
|
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary database file: %w", err)
|
||||||
|
}
|
||||||
|
tempName := tmpFile.Name()
|
||||||
|
|
||||||
|
// Write the file contents directly to the target location
|
||||||
|
if _, err := io.Copy(tmpFile, tarReader); err != nil {
|
||||||
|
// if fails to write, then cleanup and throw an error
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tempName)
|
||||||
|
return fmt.Errorf("failed to write database file: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
// ensure the database is not corrupted
|
||||||
|
db, err := maxminddb.Open(tempName)
|
||||||
|
if err != nil {
|
||||||
|
// if fails to write, then cleanup and throw an error
|
||||||
|
os.Remove(tempName)
|
||||||
|
return fmt.Errorf("failed to open downloaded database file: %w", err)
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
// ensure we lock the structure before we overwrite the database
|
||||||
|
// to prevent race conditions between reading and writing the mmdb.
|
||||||
|
s.mutex.Lock()
|
||||||
|
// replace the old file with the new file
|
||||||
|
err = os.Rename(tempName, common.EnvConfig.GeoLiteDBPath)
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// if cannot overwrite via rename, then cleanup and throw an error
|
||||||
|
os.Remove(tempName)
|
||||||
|
return fmt.Errorf("failed to replace database file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("GeoLite2-City.mmdb not found in archive")
|
||||||
|
}
|
||||||
299
backend/internal/service/jwt_service.go
Normal file
299
backend/internal/service/jwt_service.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
privateKeyPath = "data/keys/jwt_private_key.pem"
|
||||||
|
publicKeyPath = "data/keys/jwt_public_key.pem"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JwtService struct {
|
||||||
|
PublicKey *rsa.PublicKey
|
||||||
|
PrivateKey *rsa.PrivateKey
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJwtService(appConfigService *AppConfigService) *JwtService {
|
||||||
|
service := &JwtService{
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure keys are generated or loaded
|
||||||
|
if err := service.loadOrGenerateKeys(); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize jwt service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessTokenJWTClaims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
IsAdmin bool `json:"isAdmin,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWK struct {
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Use string `json:"use"`
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
N string `json:"n"`
|
||||||
|
E string `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrGenerateKeys loads RSA keys from the given paths or generates them if they do not exist.
|
||||||
|
func (s *JwtService) loadOrGenerateKeys() error {
|
||||||
|
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
||||||
|
if err := s.generateKeys(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("can't read jwt private key: " + err.Error())
|
||||||
|
}
|
||||||
|
s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("can't parse jwt private key: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("can't read jwt public key: " + err.Error())
|
||||||
|
}
|
||||||
|
s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("can't parse jwt public key: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||||
|
sessionDurationInMinutes, _ := strconv.Atoi(s.appConfigService.DbConfig.SessionDuration.Value)
|
||||||
|
claim := AccessTokenJWTClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: user.ID,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
|
||||||
|
},
|
||||||
|
IsAdmin: user.IsAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
|
return token.SignedString(s.PrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return s.PublicKey, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("couldn't handle this token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, isValid := token.Claims.(*AccessTokenJWTClaims)
|
||||||
|
if !isValid {
|
||||||
|
return nil, errors.New("can't parse claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
|
||||||
|
return nil, errors.New("audience doesn't match")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"aud": clientID,
|
||||||
|
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||||
|
"iat": jwt.NewNumericDate(time.Now()),
|
||||||
|
"iss": common.EnvConfig.AppURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range userClaims {
|
||||||
|
claims[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if nonce != "" {
|
||||||
|
claims["nonce"] = nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
|
return token.SignedString(s.PrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||||
|
claim := jwt.RegisteredClaims{
|
||||||
|
Subject: user.ID,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Audience: jwt.ClaimStrings{clientID},
|
||||||
|
Issuer: common.EnvConfig.AppURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
|
return token.SignedString(s.PrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return s.PublicKey, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, errors.New("couldn't handle this token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
|
||||||
|
if !isValid {
|
||||||
|
return nil, errors.New("can't parse claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return s.PublicKey, nil
|
||||||
|
}, jwt.WithIssuer(common.EnvConfig.AppURL))
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, errors.New("couldn't handle this token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
|
||||||
|
if !isValid {
|
||||||
|
return nil, errors.New("can't parse claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
||||||
|
func (s *JwtService) GetJWK() (JWK, error) {
|
||||||
|
if s.PublicKey == nil {
|
||||||
|
return JWK{}, errors.New("public key is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return JWK{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jwk := JWK{
|
||||||
|
Kid: kid,
|
||||||
|
Kty: "RSA",
|
||||||
|
Use: "sig",
|
||||||
|
Alg: "RS256",
|
||||||
|
N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
|
||||||
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
|
||||||
|
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
|
||||||
|
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to marshal public key: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the public key
|
||||||
|
hash := sha256.New()
|
||||||
|
hash.Write(pubASN1)
|
||||||
|
hashed := hash.Sum(nil)
|
||||||
|
|
||||||
|
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||||
|
shortHash := hashed[:8]
|
||||||
|
|
||||||
|
// Return Base64 encoded truncated hash as Key ID
|
||||||
|
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
||||||
|
func (s *JwtService) generateKeys() error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
||||||
|
return errors.New("failed to create directories for keys: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to generate private key: " + err.Error())
|
||||||
|
}
|
||||||
|
s.PrivateKey = privateKey
|
||||||
|
|
||||||
|
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey := &privateKey.PublicKey
|
||||||
|
s.PublicKey = publicKey
|
||||||
|
|
||||||
|
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// savePEMKey saves a PEM encoded key to a file.
|
||||||
|
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
|
||||||
|
keyFile, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to create key file: " + err.Error())
|
||||||
|
}
|
||||||
|
defer keyFile.Close()
|
||||||
|
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: keyType,
|
||||||
|
Bytes: keyBytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := keyFile.Write(keyPEM); err != nil {
|
||||||
|
return errors.New("failed to write key file: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
304
backend/internal/service/ldap_service.go
Normal file
304
backend/internal/service/ldap_service.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/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
|
||||||
|
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
|
||||||
|
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
|
||||||
|
|
||||||
|
searchAttrs := []string{
|
||||||
|
nameAttribute,
|
||||||
|
uniqueIdentifierAttribute,
|
||||||
|
groupMemberOfAttribute,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(groupMemberOfAttribute)
|
||||||
|
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).Where("ldap_id IS NOT NULL").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
|
||||||
|
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
|
||||||
|
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
|
||||||
|
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
|
||||||
|
|
||||||
|
searchAttrs := []string{
|
||||||
|
"memberOf",
|
||||||
|
"sn",
|
||||||
|
"cn",
|
||||||
|
uniqueIdentifierAttribute,
|
||||||
|
usernameAttribute,
|
||||||
|
emailAttribute,
|
||||||
|
firstNameAttribute,
|
||||||
|
lastNameAttribute,
|
||||||
|
profilePictureAttribute,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save profile picture
|
||||||
|
if pictureString := value.GetAttributeValue(profilePictureAttribute); pictureString != "" {
|
||||||
|
if err := s.SaveProfilePicture(databaseUser.ID, pictureString); err != nil {
|
||||||
|
log.Printf("Error saving profile picture for 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) SaveProfilePicture(userId string, pictureString string) error {
|
||||||
|
var reader io.Reader
|
||||||
|
|
||||||
|
if _, err := url.ParseRequestURI(pictureString); err == nil {
|
||||||
|
// If the photo is a URL, download it
|
||||||
|
response, err := http.Get(pictureString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download profile picture: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
reader = response.Body
|
||||||
|
|
||||||
|
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
|
||||||
|
// If the photo is a base64 encoded string, decode it
|
||||||
|
reader = bytes.NewReader(decodedPhoto)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// If the photo is a string, we assume that it's a binary string
|
||||||
|
reader = bytes.NewReader([]byte("pictureString"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the profile picture
|
||||||
|
if err := s.userService.UpdateProfilePicture(userId, reader); err != nil {
|
||||||
|
return fmt.Errorf("failed to update profile picture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
569
backend/internal/service/oidc_service.go
Normal file
569
backend/internal/service/oidc_service.go
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OidcService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
jwtService *JwtService
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
auditLogService *AuditLogService
|
||||||
|
customClaimService *CustomClaimService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
|
||||||
|
return &OidcService{
|
||||||
|
db: db,
|
||||||
|
jwtService: jwtService,
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
auditLogService: auditLogService,
|
||||||
|
customClaimService: customClaimService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.Preload("AllowedUserGroups").First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client is not public, the code challenge must be provided
|
||||||
|
if client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
||||||
|
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user group is allowed to authorize the client
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsUserGroupAllowedToAuthorize(user, client) {
|
||||||
|
return "", "", &common.OidcAccessDeniedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has already authorized the client with the given scope
|
||||||
|
hasAuthorizedClient, err := s.HasAuthorizedClient(input.ClientID, userID, input.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user has not authorized the client, create a new authorization in the database
|
||||||
|
if !hasAuthorizedClient {
|
||||||
|
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||||
|
UserID: userID,
|
||||||
|
ClientID: input.ClientID,
|
||||||
|
Scope: input.Scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
// The client has already been authorized but with a different scope so we need to update the scope
|
||||||
|
if err := s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error; err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the authorization code
|
||||||
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the authorization event
|
||||||
|
if hasAuthorizedClient {
|
||||||
|
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||||
|
} else {
|
||||||
|
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, callbackURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAuthorizedClient checks if the user has already authorized the client with the given scope
|
||||||
|
func (s *OidcService) HasAuthorizedClient(clientID, userID, scope string) (bool, error) {
|
||||||
|
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
|
||||||
|
if err := s.db.First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if userAuthorizedOidcClient.Scope != scope {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
|
||||||
|
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
|
||||||
|
if len(client.AllowedUserGroups) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
isAllowedToAuthorize := false
|
||||||
|
for _, userGroup := range client.AllowedUserGroups {
|
||||||
|
for _, userGroupUser := range user.UserGroups {
|
||||||
|
if userGroup.ID == userGroupUser.ID {
|
||||||
|
isAllowedToAuthorize = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowedToAuthorize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
|
||||||
|
if grantType != "authorization_code" {
|
||||||
|
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the client secret if the client is not public
|
||||||
|
if !client.IsPublic {
|
||||||
|
if clientID == "" || clientSecret == "" {
|
||||||
|
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", &common.OidcClientSecretInvalidError{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||||
|
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := s.jwtService.GenerateIDToken(userClaims, clientID, authorizationCodeMetaData.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID)
|
||||||
|
|
||||||
|
s.db.Delete(&authorizationCodeMetaData)
|
||||||
|
|
||||||
|
return idToken, accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.Preload("CreatedBy").Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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{})
|
||||||
|
if searchTerm != "" {
|
||||||
|
searchPattern := "%" + searchTerm + "%"
|
||||||
|
query = query.Where("name LIKE ?", searchPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
|
||||||
|
if err != nil {
|
||||||
|
return nil, utils.PaginationResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients, pagination, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||||
|
client := model.OidcClient{
|
||||||
|
Name: input.Name,
|
||||||
|
CallbackURLs: input.CallbackURLs,
|
||||||
|
LogoutCallbackURLs: input.LogoutCallbackURLs,
|
||||||
|
CreatedByID: userID,
|
||||||
|
IsPublic: input.IsPublic,
|
||||||
|
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&client).Error; err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Name = input.Name
|
||||||
|
client.CallbackURLs = input.CallbackURLs
|
||||||
|
client.LogoutCallbackURLs = input.LogoutCallbackURLs
|
||||||
|
client.IsPublic = input.IsPublic
|
||||||
|
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||||
|
|
||||||
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) DeleteClient(clientID string) error {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Delete(&client).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) CreateClientSecret(clientID string) (string, error) {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Secret = string(hashedSecret)
|
||||||
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.ImageType == nil {
|
||||||
|
return "", "", errors.New("image not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageType := *client.ImageType
|
||||||
|
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType)
|
||||||
|
mimeType := utils.GetImageMimeType(imageType)
|
||||||
|
|
||||||
|
return imagePath, mimeType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
||||||
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
|
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||||
|
return &common.FileTypeNotSupportedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
||||||
|
if err := utils.SaveFile(file, imagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.ImageType != nil && fileType != *client.ImageType {
|
||||||
|
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||||
|
if err := os.Remove(oldImagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.ImageType = &fileType
|
||||||
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) DeleteClientLogo(clientID string) error {
|
||||||
|
var client model.OidcClient
|
||||||
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.ImageType == nil {
|
||||||
|
return errors.New("image not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||||
|
if err := os.Remove(imagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.ImageType = nil
|
||||||
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) {
|
||||||
|
var authorizedOidcClient model.UserAuthorizedOidcClient
|
||||||
|
if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := authorizedOidcClient.User
|
||||||
|
scope := authorizedOidcClient.Scope
|
||||||
|
|
||||||
|
claims := map[string]interface{}{
|
||||||
|
"sub": user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(scope, "email") {
|
||||||
|
claims["email"] = user.Email
|
||||||
|
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(scope, "groups") {
|
||||||
|
userGroups := make([]string, len(user.UserGroups))
|
||||||
|
for i, group := range user.UserGroups {
|
||||||
|
userGroups[i] = group.Name
|
||||||
|
}
|
||||||
|
claims["groups"] = userGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
profileClaims := map[string]interface{}{
|
||||||
|
"given_name": user.FirstName,
|
||||||
|
"family_name": user.LastName,
|
||||||
|
"name": user.FullName(),
|
||||||
|
"preferred_username": user.Username,
|
||||||
|
"picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(scope, "profile") {
|
||||||
|
// Add profile claims
|
||||||
|
for k, v := range profileClaims {
|
||||||
|
claims[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom claims
|
||||||
|
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, customClaim := range customClaims {
|
||||||
|
// The value of the custom claim can be a JSON object or a string
|
||||||
|
var jsonValue interface{}
|
||||||
|
json.Unmarshal([]byte(customClaim.Value), &jsonValue)
|
||||||
|
if jsonValue != nil {
|
||||||
|
// It's JSON so we store it as an object
|
||||||
|
claims[customClaim.Key] = jsonValue
|
||||||
|
} else {
|
||||||
|
// Marshalling failed, so we store it as a string
|
||||||
|
claims[customClaim.Key] = customClaim.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(scope, "email") {
|
||||||
|
claims["email"] = user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
|
||||||
|
client, err = s.GetClient(id)
|
||||||
|
if err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the user groups based on UserGroupIDs in input
|
||||||
|
var groups []model.UserGroup
|
||||||
|
if len(input.UserGroupIDs) > 0 {
|
||||||
|
if err := s.db.Where("id IN (?)", input.UserGroupIDs).Find(&groups).Error; err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the current user groups with the new set of user groups
|
||||||
|
if err := s.db.Model(&client).Association("AllowedUserGroups").Replace(groups); err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated client
|
||||||
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
|
return model.OidcClient{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEndSession returns the logout callback URL for the client if all the validations pass
|
||||||
|
func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) (string, error) {
|
||||||
|
// If no ID token hint is provided, return an error
|
||||||
|
if input.IdTokenHint == "" {
|
||||||
|
return "", &common.TokenInvalidError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the ID token hint is provided, verify the ID token
|
||||||
|
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
|
||||||
|
if err != nil {
|
||||||
|
return "", &common.TokenInvalidError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request
|
||||||
|
if input.ClientId != "" && claims.Audience[0] != input.ClientId {
|
||||||
|
return "", &common.OidcClientIdNotMatchingError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId := claims.Audience[0]
|
||||||
|
|
||||||
|
// Check if the user has authorized the client before
|
||||||
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
|
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
|
||||||
|
return "", &common.OidcMissingAuthorizationError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client has no logout callback URLs, return an error
|
||||||
|
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
|
||||||
|
return "", &common.OidcNoCallbackURLError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbackURL, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||||
|
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||||
|
|
||||||
|
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||||
|
Code: randomString,
|
||||||
|
ClientID: clientID,
|
||||||
|
UserID: userID,
|
||||||
|
Scope: scope,
|
||||||
|
Nonce: nonce,
|
||||||
|
CodeChallenge: &codeChallenge,
|
||||||
|
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||||
|
if codeVerifier == "" || codeChallenge == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !codeChallengeMethodSha256 {
|
||||||
|
return codeVerifier == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the codeVerifier
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(codeVerifier))
|
||||||
|
codeVerifierHash := h.Sum(nil)
|
||||||
|
|
||||||
|
// Base64 URL encode the verifier hash
|
||||||
|
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
|
||||||
|
|
||||||
|
return encodedVerifierHash == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
|
if inputCallbackURL == "" {
|
||||||
|
return urls[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, callbackPattern := range urls {
|
||||||
|
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
||||||
|
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
return inputCallbackURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", &common.OidcInvalidCallbackURLError{}
|
||||||
|
}
|
||||||
361
backend/internal/service/test_service.go
Normal file
361
backend/internal/service/test_service.go
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
jwtService *JwtService
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
|
||||||
|
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestService) SeedDatabase() error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
users := []model.User{
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||||
|
},
|
||||||
|
Username: "tim",
|
||||||
|
Email: "tim.cook@test.com",
|
||||||
|
FirstName: "Tim",
|
||||||
|
LastName: "Cook",
|
||||||
|
IsAdmin: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||||
|
},
|
||||||
|
Username: "craig",
|
||||||
|
Email: "craig.federighi@test.com",
|
||||||
|
FirstName: "Craig",
|
||||||
|
LastName: "Federighi",
|
||||||
|
IsAdmin: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if err := tx.Create(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
|
||||||
|
},
|
||||||
|
Name: "developers",
|
||||||
|
FriendlyName: "Developers",
|
||||||
|
Users: []model.User{users[0], users[1]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
|
||||||
|
},
|
||||||
|
Name: "designers",
|
||||||
|
FriendlyName: "Designers",
|
||||||
|
Users: []model.User{users[0]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, group := range userGroups {
|
||||||
|
if err := tx.Create(&group).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcClients := []model.OidcClient{
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||||
|
},
|
||||||
|
Name: "Nextcloud",
|
||||||
|
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||||
|
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||||
|
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||||
|
ImageType: utils.StringPointer("png"),
|
||||||
|
CreatedByID: users[0].ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||||
|
},
|
||||||
|
Name: "Immich",
|
||||||
|
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||||
|
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||||
|
CreatedByID: users[1].ID,
|
||||||
|
AllowedUserGroups: []model.UserGroup{
|
||||||
|
userGroups[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, client := range oidcClients {
|
||||||
|
if err := tx.Create(&client).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authCode := model.OidcAuthorizationCode{
|
||||||
|
Code: "auth-code",
|
||||||
|
Scope: "openid profile",
|
||||||
|
Nonce: "nonce",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
|
UserID: users[0].ID,
|
||||||
|
ClientID: oidcClients[0].ID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&authCode).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := model.OneTimeAccessToken{
|
||||||
|
Token: "one-time-token",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
|
UserID: users[0].ID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&accessToken).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||||
|
Scope: "openid profile email",
|
||||||
|
UserID: users[0].ID,
|
||||||
|
ClientID: oidcClients[0].ID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// To generate a new key pair, run the following command:
|
||||||
|
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
|
||||||
|
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
|
||||||
|
|
||||||
|
publicKeyPasskey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||||
|
publicKeyPasskey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
webauthnCredentials := []model.WebauthnCredential{
|
||||||
|
{
|
||||||
|
Name: "Passkey 1",
|
||||||
|
CredentialID: []byte("test-credential-tim"),
|
||||||
|
PublicKey: publicKeyPasskey1,
|
||||||
|
AttestationType: "none",
|
||||||
|
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||||
|
UserID: users[0].ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Passkey 2",
|
||||||
|
CredentialID: []byte("test-credential-craig"),
|
||||||
|
PublicKey: publicKeyPasskey2,
|
||||||
|
AttestationType: "none",
|
||||||
|
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||||
|
UserID: users[1].ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, credential := range webauthnCredentials {
|
||||||
|
if err := tx.Create(&credential).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webauthnSession := model.WebauthnSession{
|
||||||
|
Challenge: "challenge",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
|
UserVerification: "preferred",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&webauthnSession).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestService) ResetDatabase() error {
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var tables []string
|
||||||
|
|
||||||
|
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(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestService) ResetApplicationImages() error {
|
||||||
|
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
||||||
|
log.Printf("Error removing directory: %v", err)
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestService) ResetAppConfig() error {
|
||||||
|
// Reseed the config variables
|
||||||
|
if err := s.appConfigService.InitDbConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all app config variables to their default values
|
||||||
|
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the app config from the database after resetting the values
|
||||||
|
return s.appConfigService.LoadDbConfigFromDb()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestService) SetJWTKeys() {
|
||||||
|
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
|
||||||
|
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
|
||||||
|
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
|
||||||
|
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
|
||||||
|
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
|
||||||
|
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
|
||||||
|
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
|
||||||
|
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
|
||||||
|
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
|
||||||
|
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
|
||||||
|
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
|
||||||
|
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
|
||||||
|
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
|
||||||
|
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
|
||||||
|
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
|
||||||
|
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
|
||||||
|
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
|
||||||
|
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
|
||||||
|
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
|
||||||
|
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
|
||||||
|
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
|
||||||
|
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
|
||||||
|
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
|
||||||
|
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
|
||||||
|
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
`
|
||||||
|
|
||||||
|
block, _ := pem.Decode([]byte(privateKeyString))
|
||||||
|
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
|
||||||
|
s.jwtService.PrivateKey = privateKey
|
||||||
|
s.jwtService.PublicKey = &privateKey.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||||
|
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||||
|
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
||||||
|
}
|
||||||
|
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not an ECDSA public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
coseKey := map[int]interface{}{
|
||||||
|
1: 2, // Key type: EC2
|
||||||
|
3: -7, // Algorithm: ECDSA with SHA-256
|
||||||
|
-1: 1, // Curve: P-256
|
||||||
|
-2: ecdsaPubKey.X.Bytes(), // X coordinate
|
||||||
|
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
|
||||||
|
}
|
||||||
|
|
||||||
|
cborPublicKey, err := cbor.Marshal(coseKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal COSE key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cborPublicKey, nil
|
||||||
|
}
|
||||||
139
backend/internal/service/user_group_service.go
Normal file
139
backend/internal/service/user_group_service.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserGroupService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
|
||||||
|
return &UserGroupService{db: db, appConfigService: appConfigService}
|
||||||
|
}
|
||||||
|
|
||||||
|
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+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
||||||
|
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
|
||||||
|
return group, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Delete(id string) error {
|
||||||
|
var group model.UserGroup
|
||||||
|
if err := s.db.Where("id = ?", id).First(&group).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
|
||||||
|
if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||||
|
return &common.LdapUserGroupUpdateError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Delete(&group).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||||
|
group = model.UserGroup{
|
||||||
|
FriendlyName: input.FriendlyName,
|
||||||
|
Name: input.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.LdapID != "" {
|
||||||
|
group.LdapID = &input.LdapID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||||
|
}
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow updating the group if it is an LDAP group and LDAP is enabled
|
||||||
|
if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||||
|
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Name = input.Name
|
||||||
|
group.FriendlyName = input.FriendlyName
|
||||||
|
|
||||||
|
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||||
|
}
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) {
|
||||||
|
group, err = s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the users based on UserIDs in input
|
||||||
|
var users []model.User
|
||||||
|
if len(input.UserIDs) > 0 {
|
||||||
|
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the current users with the new set of users
|
||||||
|
if err := s.db.Model(&group).Association("Users").Replace(users); err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated group
|
||||||
|
if err := s.db.Save(&group).Error; err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) GetUserCountOfGroup(id string) (int64, error) {
|
||||||
|
var group model.UserGroup
|
||||||
|
if err := s.db.Preload("Users").Where("id = ?", id).First(&group).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.db.Model(&group).Association("Users").Count(), nil
|
||||||
|
}
|
||||||
310
backend/internal/service/user_service.go
Normal file
310
backend/internal/service/user_service.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
jwtService *JwtService
|
||||||
|
auditLogService *AuditLogService
|
||||||
|
emailService *EmailService
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
|
||||||
|
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
|
||||||
|
var users []model.User
|
||||||
|
query := s.db.Model(&model.User{})
|
||||||
|
|
||||||
|
if searchTerm != "" {
|
||||||
|
searchPattern := "%" + searchTerm + "%"
|
||||||
|
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
|
||||||
|
return users, pagination, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) {
|
||||||
|
// Validate the user ID to prevent directory traversal
|
||||||
|
if err := uuid.Validate(userID); err != nil {
|
||||||
|
return nil, 0, &common.InvalidUUIDError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
|
||||||
|
file, err := os.Open(profilePicturePath)
|
||||||
|
if err == nil {
|
||||||
|
// Get the file size
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return file, fileInfo.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file does not exist, return the default profile picture
|
||||||
|
user, err := s.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultPicture, int64(defaultPicture.Len()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
|
||||||
|
// Validate the user ID to prevent directory traversal
|
||||||
|
if err := uuid.Validate(userID); err != nil {
|
||||||
|
return &common.InvalidUUIDError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the image to a smaller square image
|
||||||
|
profilePicture, err := profilepicture.CreateProfilePicture(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
profilePictureDir := fmt.Sprintf("%s/profile-pictures", common.EnvConfig.UploadPath)
|
||||||
|
if err := os.MkdirAll(profilePictureDir, os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the profile picture file
|
||||||
|
createdProfilePicture, err := os.Create(fmt.Sprintf("%s/%s.png", profilePictureDir, userID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer createdProfilePicture.Close()
|
||||||
|
|
||||||
|
// Copy the image to the file
|
||||||
|
_, err = io.Copy(createdProfilePicture, profilePicture)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) DeleteUser(userID string) error {
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow deleting the user if it is an LDAP user and LDAP is enabled
|
||||||
|
if user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||||
|
return &common.LdapUserUpdateError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Delete(&user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
|
||||||
|
user := model.User{
|
||||||
|
FirstName: input.FirstName,
|
||||||
|
LastName: input.LastName,
|
||||||
|
Email: input.Email,
|
||||||
|
Username: input.Username,
|
||||||
|
IsAdmin: input.IsAdmin,
|
||||||
|
}
|
||||||
|
if input.LdapID != "" {
|
||||||
|
user.LdapID = &input.LdapID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return model.User{}, s.checkDuplicatedFields(user)
|
||||||
|
}
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow updating the user if it is an LDAP group and LDAP is enabled
|
||||||
|
if !allowLdapUpdate && user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||||
|
return model.User{}, &common.LdapUserUpdateError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.FirstName = updatedUser.FirstName
|
||||||
|
user.LastName = updatedUser.LastName
|
||||||
|
user.Email = updatedUser.Email
|
||||||
|
user.Username = updatedUser.Username
|
||||||
|
if !updateOwnUser {
|
||||||
|
user.IsAdmin = updatedUser.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Save(&user).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return user, s.checkDuplicatedFields(user)
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
oneTimeAccessToken := model.OneTimeAccessToken{
|
||||||
|
UserID: userID,
|
||||||
|
ExpiresAt: datatype.DateTime(expiresAt),
|
||||||
|
Token: randomString,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&oneTimeAccessToken).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return oneTimeAccessToken.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
|
}
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Delete(&oneTimeAccessToken).Error; err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipAddress != "" && userAgent != "" {
|
||||||
|
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return oneTimeAccessToken.User, accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||||
|
var userCount int64
|
||||||
|
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
if userCount > 1 {
|
||||||
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
user := model.User{
|
||||||
|
FirstName: "Admin",
|
||||||
|
LastName: "Admin",
|
||||||
|
Username: "admin",
|
||||||
|
Email: "admin@admin.com",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Credentials) > 0 {
|
||||||
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := s.jwtService.GenerateAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) checkDuplicatedFields(user model.User) error {
|
||||||
|
var existingUser model.User
|
||||||
|
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
||||||
|
return &common.AlreadyInUseError{Property: "email"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
||||||
|
return &common.AlreadyInUseError{Property: "username"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
210
backend/internal/service/webauthn_service.go
Normal file
210
backend/internal/service/webauthn_service.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebAuthnService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
webAuthn *webauthn.WebAuthn
|
||||||
|
jwtService *JwtService
|
||||||
|
auditLogService *AuditLogService
|
||||||
|
appConfigService *AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||||
|
webauthnConfig := &webauthn.Config{
|
||||||
|
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
||||||
|
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
|
||||||
|
RPOrigins: []string{common.EnvConfig.AppURL},
|
||||||
|
Timeouts: webauthn.TimeoutsConfig{
|
||||||
|
Login: webauthn.TimeoutConfig{
|
||||||
|
Enforce: true,
|
||||||
|
Timeout: time.Second * 60,
|
||||||
|
TimeoutUVD: time.Second * 60,
|
||||||
|
},
|
||||||
|
Registration: webauthn.TimeoutConfig{
|
||||||
|
Enforce: true,
|
||||||
|
Timeout: time.Second * 60,
|
||||||
|
TimeoutUVD: time.Second * 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wa, _ := webauthn.New(webauthnConfig)
|
||||||
|
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService, appConfigService: appConfigService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
|
||||||
|
s.updateWebAuthnConfig()
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Preload("Credentials").Find(&user, "id = ?", userID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
options, session, err := s.webAuthn.BeginRegistration(&user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToStore := &model.WebauthnSession{
|
||||||
|
ExpiresAt: datatype.DateTime(session.Expires),
|
||||||
|
Challenge: session.Challenge,
|
||||||
|
UserVerification: string(session.UserVerification),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&sessionToStore).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.PublicKeyCredentialCreationOptions{
|
||||||
|
Response: options.Response,
|
||||||
|
SessionID: sessionToStore.ID,
|
||||||
|
Timeout: s.webAuthn.Config.Timeouts.Registration.Timeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
|
||||||
|
var storedSession model.WebauthnSession
|
||||||
|
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
||||||
|
return model.WebauthnCredential{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session := webauthn.SessionData{
|
||||||
|
Challenge: storedSession.Challenge,
|
||||||
|
Expires: storedSession.ExpiresAt.ToTime(),
|
||||||
|
UserID: []byte(userID),
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
|
||||||
|
return model.WebauthnCredential{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return model.WebauthnCredential{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialToStore := model.WebauthnCredential{
|
||||||
|
Name: "New Passkey",
|
||||||
|
CredentialID: credential.ID,
|
||||||
|
AttestationType: credential.AttestationType,
|
||||||
|
PublicKey: credential.PublicKey,
|
||||||
|
Transport: credential.Transport,
|
||||||
|
UserID: user.ID,
|
||||||
|
BackupEligible: credential.Flags.BackupEligible,
|
||||||
|
BackupState: credential.Flags.BackupState,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&credentialToStore).Error; err != nil {
|
||||||
|
return model.WebauthnCredential{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentialToStore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
|
||||||
|
options, session, err := s.webAuthn.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToStore := &model.WebauthnSession{
|
||||||
|
ExpiresAt: datatype.DateTime(session.Expires),
|
||||||
|
Challenge: session.Challenge,
|
||||||
|
UserVerification: string(session.UserVerification),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&sessionToStore).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.PublicKeyCredentialRequestOptions{
|
||||||
|
Response: options.Response,
|
||||||
|
SessionID: sessionToStore.ID,
|
||||||
|
Timeout: s.webAuthn.Config.Timeouts.Registration.Timeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
session := webauthn.SessionData{
|
||||||
|
Challenge: storedSession.Challenge,
|
||||||
|
Expires: storedSession.ExpiresAt.ToTime(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *model.User
|
||||||
|
_, err := s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
||||||
|
if err := s.db.Preload("Credentials").First(&user, "id = ?", string(userHandle)).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}, session, credentialAssertionData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := s.jwtService.GenerateAccessToken(*user)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
|
||||||
|
|
||||||
|
return *user, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {
|
||||||
|
var credentials []model.WebauthnCredential
|
||||||
|
if err := s.db.Find(&credentials, "user_id = ?", userID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error {
|
||||||
|
var credential model.WebauthnCredential
|
||||||
|
if err := s.db.First(&credential, "id = ? AND user_id = ?", credentialID, userID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Delete(&credential).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) {
|
||||||
|
var credential model.WebauthnCredential
|
||||||
|
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil {
|
||||||
|
return credential, err
|
||||||
|
}
|
||||||
|
|
||||||
|
credential.Name = name
|
||||||
|
|
||||||
|
if err := s.db.Save(&credential).Error; err != nil {
|
||||||
|
return credential, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime
|
||||||
|
func (s *WebAuthnService) updateWebAuthnConfig() {
|
||||||
|
s.webAuthn.Config.RPDisplayName = s.appConfigService.DbConfig.AppName.Value
|
||||||
|
}
|
||||||
13
backend/internal/utils/cookie/add_cookie.go
Normal file
13
backend/internal/utils/cookie/add_cookie.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
|
||||||
|
c.SetCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/", "", true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
|
||||||
|
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
|
||||||
|
}
|
||||||
17
backend/internal/utils/cookie/cookie_names.go
Normal file
17
backend/internal/utils/cookie/cookie_names.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package cookie
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AccessTokenCookieName = "__Host-access_token"
|
||||||
|
var SessionIdCookieName = "__Host-session"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
|
||||||
|
AccessTokenCookieName = "access_token"
|
||||||
|
SessionIdCookieName = "session"
|
||||||
|
}
|
||||||
|
}
|
||||||
213
backend/internal/utils/email/composer.go
Normal file
213
backend/internal/utils/email/composer.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxLineLength = 78
|
||||||
|
const continuePrefix = " "
|
||||||
|
const addressSeparator = ", "
|
||||||
|
|
||||||
|
type Composer struct {
|
||||||
|
isClosed bool
|
||||||
|
content strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComposer() *Composer {
|
||||||
|
return &Composer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) AddAddressHeader(name string, addresses []Address) {
|
||||||
|
c.content.WriteString(genAddressHeader(name, addresses, maxLineLength))
|
||||||
|
c.content.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func genAddressHeader(name string, addresses []Address, maxLength int) string {
|
||||||
|
hl := &headerLine{
|
||||||
|
maxLineLength: maxLength,
|
||||||
|
continuePrefix: continuePrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
hl.Write(name)
|
||||||
|
hl.Write(": ")
|
||||||
|
|
||||||
|
for i, addr := range addresses {
|
||||||
|
var email string
|
||||||
|
if i < len(addresses)-1 {
|
||||||
|
email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator)
|
||||||
|
} else {
|
||||||
|
email = fmt.Sprintf("<%s>", addr.Email)
|
||||||
|
}
|
||||||
|
writeHeaderQ(hl, addr.Name)
|
||||||
|
writeHeaderAtom(hl, " ")
|
||||||
|
writeHeaderAtom(hl, email)
|
||||||
|
}
|
||||||
|
hl.EndLine()
|
||||||
|
return hl.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) AddHeader(name, value string) {
|
||||||
|
if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength {
|
||||||
|
c.AddHeaderRaw(name, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.content.WriteString(genHeader(name, value, maxLineLength))
|
||||||
|
c.content.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func genHeader(name, value string, maxLength int) string {
|
||||||
|
// add content as raw header when it is printable ASCII and shorter than maxLineLength
|
||||||
|
hl := &headerLine{
|
||||||
|
maxLineLength: maxLength,
|
||||||
|
continuePrefix: continuePrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
hl.Write(name)
|
||||||
|
hl.Write(": ")
|
||||||
|
writeHeaderQ(hl, value)
|
||||||
|
hl.EndLine()
|
||||||
|
return hl.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
const qEncStart = "=?utf-8?q?"
|
||||||
|
const qEncEnd = "?="
|
||||||
|
|
||||||
|
type headerLine struct {
|
||||||
|
buffer strings.Builder
|
||||||
|
line strings.Builder
|
||||||
|
maxLineLength int
|
||||||
|
continuePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) FitsLine(length int) bool {
|
||||||
|
return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) Write(str string) {
|
||||||
|
h.line.WriteString(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) EndLineWith(str string) {
|
||||||
|
h.line.WriteString(str)
|
||||||
|
h.EndLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) EndLine() {
|
||||||
|
if h.line.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.buffer.Len() != 0 {
|
||||||
|
h.buffer.WriteString("\n")
|
||||||
|
h.buffer.WriteString(h.continuePrefix)
|
||||||
|
}
|
||||||
|
h.buffer.WriteString(h.line.String())
|
||||||
|
h.line.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) String() string {
|
||||||
|
return h.buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeaderQ(header *headerLine, value string) {
|
||||||
|
|
||||||
|
// current line does not fit event the first character - do \n
|
||||||
|
if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) {
|
||||||
|
header.EndLineWith("")
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Write(qEncStart)
|
||||||
|
|
||||||
|
for _, token := range convertRunes(value) {
|
||||||
|
if header.FitsLine(len(token) + len(qEncEnd)) {
|
||||||
|
header.Write(token)
|
||||||
|
} else {
|
||||||
|
header.EndLineWith(qEncEnd)
|
||||||
|
header.Write(qEncStart)
|
||||||
|
header.Write(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Write(qEncEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeaderAtom(header *headerLine, value string) {
|
||||||
|
if !header.FitsLine(len(value)) {
|
||||||
|
header.EndLine()
|
||||||
|
}
|
||||||
|
header.Write(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) AddHeaderRaw(name, value string) {
|
||||||
|
if c.isClosed {
|
||||||
|
panic("composer had already written body!")
|
||||||
|
}
|
||||||
|
header := fmt.Sprintf("%s: %s\n", name, value)
|
||||||
|
c.content.WriteString(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) Body(body string) {
|
||||||
|
c.content.WriteString("\n")
|
||||||
|
c.content.WriteString(body)
|
||||||
|
c.isClosed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) String() string {
|
||||||
|
return c.content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRunes(str string) []string {
|
||||||
|
var enc = make([]string, 0, len(str))
|
||||||
|
for _, r := range []rune(str) {
|
||||||
|
if r == ' ' {
|
||||||
|
enc = append(enc, "_")
|
||||||
|
} else if isPrintableASCIIRune(r) &&
|
||||||
|
r != '=' &&
|
||||||
|
r != '?' &&
|
||||||
|
r != '_' {
|
||||||
|
enc = append(enc, string(r))
|
||||||
|
} else {
|
||||||
|
enc = append(enc, string(toHex([]byte(string(r)))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
func toHex(in []byte) []byte {
|
||||||
|
enc := make([]byte, 0, len(in)*2)
|
||||||
|
for _, b := range in {
|
||||||
|
enc = append(enc, '=')
|
||||||
|
enc = append(enc, hex(b/16))
|
||||||
|
enc = append(enc, hex(b%16))
|
||||||
|
}
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
func hex(n byte) byte {
|
||||||
|
if n > 9 {
|
||||||
|
return n + (65 - 10)
|
||||||
|
} else {
|
||||||
|
return n + 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrintableASCII(str string) bool {
|
||||||
|
for _, r := range []rune(str) {
|
||||||
|
if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrintableASCIIRune(r rune) bool {
|
||||||
|
return r > 31 && r < 127
|
||||||
|
}
|
||||||
92
backend/internal/utils/email/composer_test.go
Normal file
92
backend/internal/utils/email/composer_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertRunes(t *testing.T) {
|
||||||
|
var testData = map[string]string{
|
||||||
|
"=??=_.": "=3D=3F=3F=3D=5F.",
|
||||||
|
"Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎": "P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3dy_=F0=9F=90=8E",
|
||||||
|
}
|
||||||
|
for input, expected := range testData {
|
||||||
|
got := strings.Join(convertRunes(input), "")
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("Input: '%s', expected '%s', got: '%s'", input, expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type genHeaderTestData struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
expected string
|
||||||
|
maxWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenHeaderQ(t *testing.T) {
|
||||||
|
var testData = []genHeaderTestData{
|
||||||
|
{
|
||||||
|
name: "Subject",
|
||||||
|
value: "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎",
|
||||||
|
expected: "Subject: =?utf-8?q?P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk?=\n" +
|
||||||
|
" =?utf-8?q?=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3?=\n" +
|
||||||
|
" =?utf-8?q?dy_=F0=9F=90=8E?=",
|
||||||
|
maxWidth: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, data := range testData {
|
||||||
|
got := genHeader(data.name, data.value, data.maxWidth)
|
||||||
|
if got != data.expected {
|
||||||
|
t.Errorf("Input: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.value, data.expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type genAddressHeaderTestData struct {
|
||||||
|
name string
|
||||||
|
addresses []Address
|
||||||
|
expected string
|
||||||
|
maxLength int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenAddressHeader(t *testing.T) {
|
||||||
|
var testData = []genAddressHeaderTestData{
|
||||||
|
{
|
||||||
|
name: "To",
|
||||||
|
addresses: []Address{
|
||||||
|
{
|
||||||
|
Name: "Oldřich Jánský",
|
||||||
|
Email: "olrd@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "To: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>",
|
||||||
|
maxLength: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subject",
|
||||||
|
addresses: []Address{
|
||||||
|
{
|
||||||
|
Name: "Oldřich Jánský",
|
||||||
|
Email: "olrd@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Jan Novák",
|
||||||
|
Email: "novak@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "Subject: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>, \n" +
|
||||||
|
" =?utf-8?q?Jan_Nov=C3=A1k?= <novak@example.com>",
|
||||||
|
maxLength: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, data := range testData {
|
||||||
|
got := genAddressHeader(data.name, data.addresses, data.maxLength)
|
||||||
|
if got != data.expected {
|
||||||
|
t.Errorf("Test: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.name, data.expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/internal/utils/email/email_service_templates.go
Normal file
98
backend/internal/utils/email/email_service_templates.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
htemplate "html/template"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
ttemplate "text/template"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Template[V any] struct {
|
||||||
|
Path string
|
||||||
|
Title func(data *TemplateData[V]) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateData[V any] struct {
|
||||||
|
AppName string
|
||||||
|
LogoURL string
|
||||||
|
Data *V
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateMap[V any] map[string]*V
|
||||||
|
|
||||||
|
func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V]) *U {
|
||||||
|
return templateMap[template.Path]
|
||||||
|
}
|
||||||
|
|
||||||
|
type clonable[V pareseable[V]] interface {
|
||||||
|
Clone() (V, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pareseable[V any] interface {
|
||||||
|
ParseFS(fs.FS, ...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 template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s%s", template, suffix)
|
||||||
|
templatePath := path.Join("email-templates", filename)
|
||||||
|
_, err = tmpl.ParseFS(templateFS, templatePath)
|
||||||
|
if err != nil {
|
||||||
|
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textTemplates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlTemplates, nil
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetFileExtension(filename string) string {
|
func GetFileExtension(filename string) string {
|
||||||
@@ -27,27 +30,8 @@ func GetImageMimeType(ext string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyDirectory(srcDir, destDir string) error {
|
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
|
||||||
files, err := os.ReadDir(srcDir)
|
srcFile, err := resources.FS.Open(srcFilePath)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -71,3 +55,24 @@ func copyFile(srcFilePath, destFilePath string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SaveFile(file *multipart.FileHeader, dst string) error {
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, src)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UnknownHandlerError(c *gin.Context, err error) {
|
|
||||||
log.Println(err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandlerError(c *gin.Context, statusCode int, message string) {
|
|
||||||
// Capitalize the first letter of the message
|
|
||||||
message = strings.ToUpper(message[:1]) + message[1:]
|
|
||||||
c.JSON(statusCode, gin.H{"error": message})
|
|
||||||
}
|
|
||||||
96
backend/internal/utils/image/profile_picture.go
Normal file
96
backend/internal/utils/image/profile_picture.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package profilepicture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const profilePictureSize = 300
|
||||||
|
|
||||||
|
// CreateProfilePicture resizes the profile picture to a square
|
||||||
|
func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) {
|
||||||
|
img, err := imaging.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = imaging.Encode(&buf, img, imaging.PNG)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDefaultProfilePicture creates a profile picture with the initials
|
||||||
|
func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) {
|
||||||
|
// Get the initials
|
||||||
|
initials := ""
|
||||||
|
if len(firstName) > 0 {
|
||||||
|
initials += string(firstName[0])
|
||||||
|
}
|
||||||
|
if len(lastName) > 0 {
|
||||||
|
initials += string(lastName[0])
|
||||||
|
}
|
||||||
|
initials = strings.ToUpper(initials)
|
||||||
|
|
||||||
|
// Create a blank image with a white background
|
||||||
|
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||||
|
|
||||||
|
// Load the font
|
||||||
|
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read font file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the font
|
||||||
|
fontFace, err := opentype.Parse(fontBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse font: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a font.Face with a specific size
|
||||||
|
fontSize := 160.0
|
||||||
|
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
|
||||||
|
Size: fontSize,
|
||||||
|
DPI: 72,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create font face: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a drawer for the image
|
||||||
|
drawer := &font.Drawer{
|
||||||
|
Dst: img,
|
||||||
|
Src: image.NewUniform(color.RGBA{R: 0, G: 0, B: 0, A: 255}), // Black text color
|
||||||
|
Face: face,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the initials
|
||||||
|
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
|
||||||
|
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
|
||||||
|
drawer.Dot = fixed.P(x, y)
|
||||||
|
|
||||||
|
// Draw the initials
|
||||||
|
drawer.DrawString(initials)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = imaging.Encode(&buf, img, imaging.PNG)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &buf, nil
|
||||||
|
}
|
||||||
@@ -1,21 +1,47 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"strconv"
|
"reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PaginationResponse struct {
|
type PaginationResponse struct {
|
||||||
TotalPages int64 `json:"totalPages"`
|
TotalPages int64 `json:"totalPages"`
|
||||||
TotalItems int64 `json:"totalItems"`
|
TotalItems int64 `json:"totalItems"`
|
||||||
CurrentPage int `json:"currentPage"`
|
CurrentPage int `json:"currentPage"`
|
||||||
|
ItemsPerPage int `json:"itemsPerPage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Paginate(c *gin.Context, db *gorm.DB, result interface{}) (PaginationResponse, error) {
|
type SortedPaginationRequest struct {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
Pagination struct {
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
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 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -29,17 +55,23 @@ func Paginate(c *gin.Context, db *gorm.DB, result interface{}) (PaginationRespon
|
|||||||
offset := (page - 1) * pageSize
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
var totalItems int64
|
var totalItems int64
|
||||||
if err := db.Count(&totalItems).Error; err != nil {
|
if err := query.Count(&totalItems).Error; err != nil {
|
||||||
return PaginationResponse{}, err
|
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
|
return PaginationResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
|
||||||
|
if totalItems == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
return PaginationResponse{
|
return PaginationResponse{
|
||||||
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
TotalPages: totalPages,
|
||||||
TotalItems: totalItems,
|
TotalItems: totalItems,
|
||||||
CurrentPage: page,
|
CurrentPage: page,
|
||||||
|
ItemsPerPage: pageSize,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
||||||
@@ -29,15 +32,44 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
|
|||||||
return string(result), nil
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostFromURL(rawURL string) string {
|
func GetHostnameFromURL(rawURL string) string {
|
||||||
parsedURL, err := url.Parse(rawURL)
|
parsedURL, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return parsedURL.Host
|
return parsedURL.Hostname()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StringPointer creates a string pointer from a string value
|
// StringPointer creates a string pointer from a string value
|
||||||
func StringPointer(s string) *string {
|
func StringPointer(s string) *string {
|
||||||
return &s
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CamelCaseToScreamingSnakeCase(s string) string {
|
||||||
|
// Insert underscores before uppercase letters (except the first one)
|
||||||
|
re := regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||||
|
snake := re.ReplaceAllString(s, `${1}_${2}`)
|
||||||
|
|
||||||
|
// Convert to uppercase
|
||||||
|
return strings.ToUpper(snake)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
func FormatDateForDb(time time.Time) string {
|
|
||||||
const layout = "2006-01-02 15:04:05.000-07:00"
|
|
||||||
return time.Format(layout)
|
|
||||||
}
|
|
||||||
14
backend/resources/email-templates/components/email_html.tmpl
Normal file
14
backend/resources/email-templates/components/email_html.tmpl
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{ define "root" }}
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
{{ template "style" . }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{{ template "base" . }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{- define "root" -}}
|
||||||
|
{{- template "base" . -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
This is automatically sent email from {{.AppName}}.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user