mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 15:22:56 +03:00
Compare commits
244 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f8068053 | ||
|
|
9ef2ddf796 | ||
|
|
d1b9f3a44e | ||
|
|
62915d863a | ||
|
|
74ba8390f4 | ||
|
|
31198feec2 | ||
|
|
e5ec264bfd | ||
|
|
c822192124 | ||
|
|
f2d61e964c | ||
|
|
f1256322b6 | ||
|
|
7885ae011c | ||
|
|
6a8dd84ca9 | ||
|
|
eb1426ed26 | ||
|
|
a9713cf6a1 | ||
|
|
8e344f1151 | ||
|
|
04efc36115 | ||
|
|
2ee0bad2c0 | ||
|
|
d0da532240 | ||
|
|
8d55c7c393 | ||
|
|
0f14a93e1d | ||
|
|
37b24bed91 | ||
|
|
66090f36a8 | ||
|
|
ff34e3b925 | ||
|
|
91f254c7bb | ||
|
|
85db96b0ef | ||
|
|
12d60fea23 | ||
|
|
2d733fc79f | ||
|
|
a421d01e0c | ||
|
|
1026ee4f5b | ||
|
|
cddfe8fa4c | ||
|
|
ef25f6b6b8 | ||
|
|
1652cc65f3 | ||
|
|
4bafee4f58 | ||
|
|
e46471cc2d | ||
|
|
fde951b543 | ||
|
|
01a9de0b04 | ||
|
|
a1131bca9a | ||
|
|
9a167d4076 | ||
|
|
887c5e462a | ||
|
|
20eba1378e | ||
|
|
a6ae7ae287 | ||
|
|
840a672fc3 | ||
|
|
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 |
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,2 +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
|
||||
TRUST_PROXY=false
|
||||
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
|
||||
attributes:
|
||||
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
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord
|
||||
url: https://discord.gg/8wudU9KaxM
|
||||
about: For help and chatting with the community
|
||||
|
||||
@@ -6,25 +6,37 @@ on:
|
||||
|
||||
jobs:
|
||||
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:
|
||||
- name: checkout code
|
||||
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
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
|
||||
- name: 'Login to GitHub Container Registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Download GeoLite2 City database
|
||||
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||
registry: ghcr.io
|
||||
username: ${{github.repository_owner}}
|
||||
password: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
@@ -32,6 +44,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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-to: type=gha,mode=max
|
||||
|
||||
133
.github/workflows/e2e-tests.yml
vendored
133
.github/workflows/e2e-tests.yml
vendored
@@ -2,27 +2,55 @@ name: E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
build:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: 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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Create dummy GeoLite2 City database
|
||||
run: touch ./backend/GeoLite2-City.mmdb
|
||||
|
||||
- name: Build Docker Image
|
||||
run: docker build -t stonith404/pocket-id .
|
||||
- name: Run Docker Container
|
||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
@@ -32,17 +60,94 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Docker Container with Sqlite DB
|
||||
run: |
|
||||
docker run -d --name pocket-id-sqlite \
|
||||
-p 80:80 \
|
||||
-e APP_ENV=test \
|
||||
pocket-id/pocket-id:test
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
|
||||
- name: Get container logs
|
||||
- uses: actions/upload-artifact@v4
|
||||
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
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/tests/.output
|
||||
name: playwright-report-postgres
|
||||
path: frontend/tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -34,5 +34,17 @@ vite.config.ts.timestamp-*
|
||||
# Application specific
|
||||
data
|
||||
/frontend/tests/.auth
|
||||
/frontend/tests/.report
|
||||
pocket-id-backend
|
||||
/backend/GeoLite2-City.mmdb
|
||||
/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*
|
||||
|
||||
459
CHANGELOG.md
459
CHANGELOG.md
@@ -1,3 +1,462 @@
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.38.0...v) (2025-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* api key authentication ([#291](https://github.com/pocket-id/pocket-id/issues/291)) ([62915d8](https://github.com/pocket-id/pocket-id/commit/62915d863a4adc09cf467b75c414a045be43c2bb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* alternative login method link on mobile ([9ef2ddf](https://github.com/pocket-id/pocket-id/commit/9ef2ddf7963c6959992f3a5d6816840534e926e9))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.37.0...v) (2025-03-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add env variable to disable update check ([31198fe](https://github.com/pocket-id/pocket-id/commit/31198feec2ae77dd6673c42b42002871ddd02d37))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* redirection not correctly if signing in with email code ([e5ec264](https://github.com/pocket-id/pocket-id/commit/e5ec264bfd535752565bcc107099a9df5cb8aba7))
|
||||
* typo in account settings ([#307](https://github.com/pocket-id/pocket-id/issues/307)) ([c822192](https://github.com/pocket-id/pocket-id/commit/c8221921245deb3008f655740d1a9460dcdab2fc))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.36.0...v) (2025-03-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **account:** add ability to sign in with login code ([#271](https://github.com/pocket-id/pocket-id/issues/271)) ([eb1426e](https://github.com/pocket-id/pocket-id/commit/eb1426ed2684b5ddd185db247a8e082b28dfd014))
|
||||
* increase default item count per page ([a9713cf](https://github.com/pocket-id/pocket-id/commit/a9713cf6a1e3c879dc773889b7983e51bbe3c45b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add back setup page ([6a8dd84](https://github.com/pocket-id/pocket-id/commit/6a8dd84ca9396ff3369385af22f7e1f081bec2b2))
|
||||
* add timeout to update check ([04efc36](https://github.com/pocket-id/pocket-id/commit/04efc3611568a0b0127b542b8cc252d9e783af46))
|
||||
* make sorting consistent around tables ([8e344f1](https://github.com/pocket-id/pocket-id/commit/8e344f1151628581b637692a1de0e48e7235a22d))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.6...v) (2025-03-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* display groups on the account page ([#296](https://github.com/pocket-id/pocket-id/issues/296)) ([0f14a93](https://github.com/pocket-id/pocket-id/commit/0f14a93e1d6a723b0994ba475b04702646f04464))
|
||||
* enable sd_notify support ([#277](https://github.com/pocket-id/pocket-id/issues/277)) ([91f254c](https://github.com/pocket-id/pocket-id/commit/91f254c7bb067646c42424c5c62ebcd90a0c8792))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default sorting on tables ([#299](https://github.com/pocket-id/pocket-id/issues/299)) ([ff34e3b](https://github.com/pocket-id/pocket-id/commit/ff34e3b925321c80e9d7d42d0fd50e397d198435))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.5...v) (2025-03-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* support `LOGIN` authentication method for SMTP ([#292](https://github.com/pocket-id/pocket-id/issues/292)) ([2d733fc](https://github.com/pocket-id/pocket-id/commit/2d733fc79faefca23d54b22768029c3ba3427410))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.4...v) (2025-03-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* profile picture orientation if image is rotated with EXIF ([1026ee4](https://github.com/pocket-id/pocket-id/commit/1026ee4f5b5c7fda78b65c94a5d0f899525defd1))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.3...v) (2025-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `groups` scope and claim to well known endpoint ([4bafee4](https://github.com/pocket-id/pocket-id/commit/4bafee4f58f5a76898cf66d6192916d405eea389))
|
||||
* profile picture of other user can't be updated ([#273](https://github.com/pocket-id/pocket-id/issues/273)) ([ef25f6b](https://github.com/pocket-id/pocket-id/commit/ef25f6b6b84b52f1310d366d40aa3769a6fe9bef))
|
||||
* support POST for OIDC userinfo endpoint ([1652cc6](https://github.com/pocket-id/pocket-id/commit/1652cc65f3f966d018d81a1ae22abb5ff1b4c47b))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.2...v) (2025-02-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add option to manually select SMTP TLS method ([#268](https://github.com/pocket-id/pocket-id/issues/268)) ([01a9de0](https://github.com/pocket-id/pocket-id/commit/01a9de0b04512c62d0f223de33d711f93c49b9cc))
|
||||
* **ldap:** sync error if LDAP user collides with an existing user ([fde951b](https://github.com/pocket-id/pocket-id/commit/fde951b543281fedf9f602abae26b50881e3d157))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.1...v) (2025-02-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delete profile picture if user gets deleted ([9a167d4](https://github.com/pocket-id/pocket-id/commit/9a167d4076872e5e3e5d78d2a66ef7203ca5261b))
|
||||
* updating profile picture of other user updates own profile picture ([887c5e4](https://github.com/pocket-id/pocket-id/commit/887c5e462a50c8fb579ca6804f1a643d8af78fe8))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add validation that `PUBLIC_APP_URL` can't contain a path ([a6ae7ae](https://github.com/pocket-id/pocket-id/commit/a6ae7ae28713f7fc8018ae2aa7572986df3e1a5b))
|
||||
* binary profile picture can't be imported from LDAP ([840a672](https://github.com/pocket-id/pocket-id/commit/840a672fc35ca8476caf86d7efaba9d54bce86aa))
|
||||
|
||||
## [](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)
|
||||
|
||||
|
||||
|
||||
@@ -55,19 +55,19 @@ The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in Ty
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
You're all set!
|
||||
|
||||
### 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.
|
||||
|
||||
#### Setup
|
||||
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
||||
|
||||
You're all set!
|
||||
|
||||
### Testing
|
||||
|
||||
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
||||
|
||||
The tests can be run like this:
|
||||
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`
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY ./frontend/package*.json ./
|
||||
RUN npm ci
|
||||
@@ -20,8 +20,11 @@ WORKDIR /app/backend/cmd
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
||||
|
||||
# Stage 3: Production Image
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache caddy
|
||||
FROM node:22-alpine
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk add --no-cache caddy curl su-exec
|
||||
COPY ./reverse-proxy /etc/caddy/
|
||||
|
||||
WORKDIR /app
|
||||
@@ -30,15 +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=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/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
|
||||
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||
|
||||
COPY ./scripts ./scripts
|
||||
RUN chmod +x ./scripts/*.sh
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
ENV APP_ENV=production
|
||||
|
||||
# Use a shell form to run both the frontend and backend
|
||||
CMD ["sh", "./scripts/docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
154
README.md
154
README.md
@@ -2,6 +2,8 @@
|
||||
|
||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||
|
||||
→ Try out the [Demo](https://demo.pocket-id.org)
|
||||
|
||||
<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.
|
||||
@@ -10,157 +12,9 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
|
||||
## Setup
|
||||
|
||||
> [!WARNING]
|
||||
> Pocket ID is in its early stages and may contain bugs.
|
||||
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.
|
||||
|
||||
### Before you start
|
||||
|
||||
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
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`.
|
||||
|
||||
### Unraid
|
||||
|
||||
Pocket ID is available as a template on the Community Apps store.
|
||||
|
||||
### Stand-alone Installation
|
||||
|
||||
Required tools:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 20
|
||||
- [Go](https://golang.org/doc/install) >= 1.23
|
||||
- [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
|
||||
|
||||
# Optional: Download the GeoLite2 city database.
|
||||
# If not downloaded the ip location in the audit log will be empty.
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# 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`
|
||||
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
||||
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
|
||||
|
||||
### Proxy Services with Pocket ID
|
||||
|
||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
||||
|
||||
See the [guide](docs/proxy-services.md) for more information.
|
||||
|
||||
### 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
|
||||
|
||||
# Optional: Update the GeoLite2 city database
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# 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. |
|
||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
Visit the [documentation](https://docs.pocket-id.org) for the setup guide and more information.
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
APP_ENV=production
|
||||
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
|
||||
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)
|
||||
# vendor/
|
||||
./data
|
||||
./data
|
||||
.env
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
{{ define "style" }}
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 32px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid p {
|
||||
margin: 0;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
@@ -1,36 +0,0 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<div class="grid">
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<div>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="message">
|
||||
This sign-in was detected from a new device or location. If you recognize this activity, you can
|
||||
safely ignore this message. If not, please review your account and security settings.
|
||||
</p>
|
||||
</div>
|
||||
{{ end -}}
|
||||
@@ -1,50 +1,63 @@
|
||||
module github.com/stonith404/pocket-id/backend
|
||||
module github.com/pocket-id/pocket-id/backend
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.2.2
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.21.3
|
||||
github.com/fxamacker/cbor/v2 v2.7.0
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.12.1
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/go-co-op/gocron/v2 v2.15.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-migrate/migrate/v4 v4.18.1
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/time v0.6.0
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||
golang.org/x/crypto v0.32.0
|
||||
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 (
|
||||
github.com/bytedance/sonic v1.12.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/bytedance/sonic v1.12.8 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/disintegration/gift v1.1.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // 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/universal-translator v0.18.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.16 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-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/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/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/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -54,11 +67,12 @@ require (
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
golang.org/x/arch v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // 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
|
||||
)
|
||||
|
||||
269
backend/go.sum
269
backend/go.sum
@@ -1,73 +1,135 @@
|
||||
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
|
||||
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa 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.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
|
||||
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
|
||||
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/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
|
||||
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
|
||||
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
|
||||
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
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/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
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/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
||||
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
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/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.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -79,22 +141,34 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
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/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.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/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -105,49 +179,134 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
|
||||
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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
|
||||
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
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=
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
|
||||
<path fill="white" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 434 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
|
||||
<path fill="black" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 434 B |
@@ -1,18 +1,21 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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 := os.ReadDir("./images")
|
||||
sourceFiles, err := resources.FS.ReadDir("images")
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
}
|
||||
@@ -27,10 +30,10 @@ func initApplicationImages() {
|
||||
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
|
||||
continue
|
||||
}
|
||||
srcFilePath := "./images/" + sourceFile.Name()
|
||||
destFilePath := dirPath + "/" + sourceFile.Name()
|
||||
srcFilePath := path.Join("images", sourceFile.Name())
|
||||
destFilePath := path.Join(dirPath, sourceFile.Name())
|
||||
|
||||
err := utils.CopyFile(srcFilePath, destFilePath)
|
||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error copying file: %v", err)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@ package bootstrap
|
||||
|
||||
import (
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/stonith404/pocket-id/backend/internal/job"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func Bootstrap() {
|
||||
initApplicationImages()
|
||||
|
||||
db := newDatabase()
|
||||
appConfigService := service.NewAppConfigService(db)
|
||||
|
||||
initApplicationImages()
|
||||
job.RegisterJobs(db)
|
||||
initRouter(db, appConfigService)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,22 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"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) {
|
||||
@@ -23,32 +30,63 @@ func newDatabase() (db *gorm.DB) {
|
||||
log.Fatalf("failed to get sql.DB: %v", err)
|
||||
}
|
||||
|
||||
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://migrations",
|
||||
"postgres", driver)
|
||||
// Choose the correct driver for the database provider
|
||||
var driver database.Driver
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
|
||||
case common.DbProviderPostgres:
|
||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||
default:
|
||||
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create migration instance: %v", err)
|
||||
log.Fatalf("failed to create migration driver: %v", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
log.Fatalf("failed to apply migrations: %v", err)
|
||||
// Run migrations
|
||||
if err := migrateDatabase(driver); err != nil {
|
||||
log.Fatalf("failed to run migrations: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
dbPath := common.EnvConfig.DBPath
|
||||
func migrateDatabase(driver database.Driver) error {
|
||||
// Use the embedded migrations
|
||||
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embedded migration source: %v", err)
|
||||
}
|
||||
|
||||
// Use in-memory database for testing
|
||||
if common.EnvConfig.AppEnv == "test" {
|
||||
dbPath = "file::memory:?cache=shared"
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration instance: %v", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply migrations: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
// Choose the correct database provider
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
|
||||
case common.DbProviderPostgres:
|
||||
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
db, err = gorm.Open(dialector, &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: getLogger(),
|
||||
})
|
||||
|
||||
@@ -2,14 +2,16 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/controller"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -29,36 +31,47 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// Initialize services
|
||||
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
||||
emailService, err := service.NewEmailService(appConfigService, db)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create email service: %s", err)
|
||||
}
|
||||
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
||||
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)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
userGroupService := service.NewUserGroupService(db)
|
||||
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)
|
||||
apiKeyService := service.NewApiKeyService(db)
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
||||
|
||||
// Setup global middleware
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
|
||||
|
||||
// Initialize middleware
|
||||
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
|
||||
job.RegisterLdapJobs(ldapService, appConfigService)
|
||||
job.RegisterDbCleanupJobs(db)
|
||||
|
||||
// Initialize middleware for specific routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
|
||||
// Set up API routes
|
||||
apiGroup := r.Group("/api")
|
||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||
controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService)
|
||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
@@ -69,8 +82,20 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
baseGroup := r.Group("/")
|
||||
controller.NewWellKnownController(baseGroup, jwtService)
|
||||
|
||||
// Run the server
|
||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
||||
// Get the listener
|
||||
l, err := net.Listen("tcp", common.EnvConfig.Host+":"+common.EnvConfig.Port)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Notify systemd that we are ready
|
||||
if err := systemd.SdNotifyReady(); err != nil {
|
||||
log.Println("Unable to notify systemd that the service is ready: ", err)
|
||||
// continue to serve anyway since it's not that important
|
||||
}
|
||||
|
||||
// Serve requests
|
||||
if err := r.RunListener(l); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,73 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"log"
|
||||
)
|
||||
|
||||
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"`
|
||||
DBPath string `env:"DB_PATH"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
|
||||
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",
|
||||
DBPath: "data/pocket-id.db",
|
||||
UploadPath: "data/uploads",
|
||||
AppURL: "http://localhost",
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
EmailTemplatesPath: "./email-templates",
|
||||
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")
|
||||
}
|
||||
|
||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||
if err != nil {
|
||||
log.Fatal("PUBLIC_APP_URL is not a valid URL")
|
||||
}
|
||||
if parsedAppUrl.Path != "" {
|
||||
log.Fatal("PUBLIC_APP_URL must not contain a path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,257 @@
|
||||
package common
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUsernameTaken = errors.New("username is already taken")
|
||||
ErrEmailTaken = errors.New("email is already taken")
|
||||
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
||||
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
|
||||
ErrOidcMissingAuthorization = errors.New("missing authorization")
|
||||
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
|
||||
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
|
||||
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
|
||||
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
|
||||
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
|
||||
ErrFileTypeNotSupported = errors.New("file type not supported")
|
||||
ErrInvalidCredentials = errors.New("no user found with provided credentials")
|
||||
ErrNameAlreadyInUse = errors.New("name is already in use")
|
||||
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 MissingAccessToken struct{}
|
||||
|
||||
func (e *MissingAccessToken) Error() string { return "Missing access token" }
|
||||
func (e *MissingAccessToken) 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{}
|
||||
|
||||
type OneTimeAccessDisabledError struct{}
|
||||
|
||||
func (e *OneTimeAccessDisabledError) Error() string {
|
||||
return "One-time access is disabled"
|
||||
}
|
||||
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
func (e *InvalidAPIKeyError) Error() string {
|
||||
return "Invalid Api Key"
|
||||
}
|
||||
|
||||
type NoAPIKeyProvidedError struct{}
|
||||
|
||||
func (e *NoAPIKeyProvidedError) Error() string {
|
||||
return "No API Key Provided"
|
||||
}
|
||||
|
||||
type APIKeyNotFoundError struct{}
|
||||
|
||||
func (e *APIKeyNotFoundError) Error() string {
|
||||
return "API Key Not Found"
|
||||
}
|
||||
|
||||
type APIKeyExpirationDateError struct{}
|
||||
|
||||
func (e *APIKeyExpirationDateError) Error() string {
|
||||
return "API Key expiration time must be in the future"
|
||||
}
|
||||
|
||||
125
backend/internal/controller/api_key_controller.go
Normal file
125
backend/internal/controller/api_key_controller.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
|
||||
|
||||
// ApiKeyController manages API keys for authenticated users
|
||||
type ApiKeyController struct {
|
||||
apiKeyService *service.ApiKeyService
|
||||
}
|
||||
|
||||
// NewApiKeyController creates a new controller for API key management
|
||||
// @Summary API key management controller
|
||||
// @Description Initializes API endpoints for managing API keys
|
||||
// @Tags API Keys
|
||||
func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
|
||||
uc := &ApiKeyController{apiKeyService: apiKeyService}
|
||||
|
||||
apiKeyGroup := group.Group("/api-keys")
|
||||
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
|
||||
{
|
||||
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// listApiKeysHandler godoc
|
||||
// @Summary List API keys
|
||||
// @Description Get a paginated list of API keys belonging to the current user
|
||||
// @Tags API Keys
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||
// @Router /api-keys [get]
|
||||
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
|
||||
if err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var apiKeysDto []dto.ApiKeyDto
|
||||
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
|
||||
Data: apiKeysDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// createApiKeyHandler godoc
|
||||
// @Summary Create API key
|
||||
// @Description Create a new API key for the current user
|
||||
// @Tags API Keys
|
||||
// @Param api_key body dto.ApiKeyCreateDto true "API key information"
|
||||
// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
|
||||
// @Router /api-keys [post]
|
||||
func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
|
||||
var input dto.ApiKeyCreateDto
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
|
||||
if err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var apiKeyDto dto.ApiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{
|
||||
ApiKey: apiKeyDto,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
// revokeApiKeyHandler godoc
|
||||
// @Summary Revoke API key
|
||||
// @Description Revoke (delete) an existing API key by ID
|
||||
// @Tags API Keys
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api-keys/{id} [delete]
|
||||
func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
apiKeyID := ctx.Param("id")
|
||||
|
||||
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,95 +1,147 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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"
|
||||
)
|
||||
|
||||
// NewAppConfigController creates a new controller for application configuration endpoints
|
||||
// @Summary Create a new application configuration controller
|
||||
// @Description Initialize routes for application configuration
|
||||
// @Tags Application Configuration
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||
appConfigService *service.AppConfigService) {
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
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/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||
group.PUT("/application-configuration", authMiddleware.Add(), 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.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
|
||||
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
|
||||
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
|
||||
|
||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
emailService *service.EmailService
|
||||
ldapService *service.LdapService
|
||||
}
|
||||
|
||||
// listAppConfigHandler godoc
|
||||
// @Summary List public application configurations
|
||||
// @Description Get all public application configurations
|
||||
// @Tags Application Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||
// @Failure 500 {object} object "{"error": "error message"}"
|
||||
// @Router /application-configuration [get]
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
configuration, err := acc.appConfigService.ListAppConfig(false)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configVariablesDto)
|
||||
}
|
||||
|
||||
// listAllAppConfigHandler godoc
|
||||
// @Summary List all application configurations
|
||||
// @Description Get all application configurations including private ones
|
||||
// @Tags Application Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/all [get]
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
configuration, err := acc.appConfigService.ListAppConfig(true)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, configVariablesDto)
|
||||
}
|
||||
|
||||
// updateAppConfigHandler godoc
|
||||
// @Summary Update application configurations
|
||||
// @Description Update application configuration settings
|
||||
// @Tags Application Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration [put]
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
}
|
||||
|
||||
// getLogoHandler godoc
|
||||
// @Summary Get logo image
|
||||
// @Description Get the logo image for the application
|
||||
// @Tags Application Configuration
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Produce image/svg+xml
|
||||
// @Success 200 {file} binary "Logo image"
|
||||
// @Router /application-configuration/logo [get]
|
||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||
|
||||
@@ -107,15 +159,42 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||
acc.getImage(c, imageName, imageType)
|
||||
}
|
||||
|
||||
// getFaviconHandler godoc
|
||||
// @Summary Get favicon
|
||||
// @Description Get the favicon for the application
|
||||
// @Tags Application Configuration
|
||||
// @Produce image/x-icon
|
||||
// @Success 200 {file} binary "Favicon image"
|
||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
||||
// @Router /application-configuration/favicon [get]
|
||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||
acc.getImage(c, "favicon", "ico")
|
||||
}
|
||||
|
||||
// getBackgroundImageHandler godoc
|
||||
// @Summary Get background image
|
||||
// @Description Get the background image for the application
|
||||
// @Tags Application Configuration
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Background image"
|
||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
||||
// @Router /application-configuration/background-image [get]
|
||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||
acc.getImage(c, "background", imageType)
|
||||
}
|
||||
|
||||
// updateLogoHandler godoc
|
||||
// @Summary Update logo
|
||||
// @Description Update the application logo
|
||||
// @Tags Application Configuration
|
||||
// @Accept multipart/form-data
|
||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||
// @Param file formData file true "Logo image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/logo [put]
|
||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||
|
||||
@@ -133,26 +212,45 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
acc.updateImage(c, imageName, imageType)
|
||||
}
|
||||
|
||||
// updateFaviconHandler godoc
|
||||
// @Summary Update favicon
|
||||
// @Description Update the application favicon
|
||||
// @Tags Application Configuration
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Favicon file (.ico)"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/favicon [put]
|
||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if fileType != "ico" {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
|
||||
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||
return
|
||||
}
|
||||
acc.updateImage(c, "favicon", "ico")
|
||||
}
|
||||
|
||||
// updateBackgroundImageHandler godoc
|
||||
// @Summary Update background image
|
||||
// @Description Update the application background image
|
||||
// @Tags Application Configuration
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Background image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/background-image [put]
|
||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||
acc.updateImage(c, "background", imageType)
|
||||
}
|
||||
|
||||
// getImage is a helper function to serve image files
|
||||
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)
|
||||
@@ -161,20 +259,53 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
// updateImage is a helper function to update image files
|
||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// syncLdapHandler godoc
|
||||
// @Summary Synchronize LDAP
|
||||
// @Description Manually trigger LDAP synchronization
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/sync-ldap [post]
|
||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
err := acc.ldapService.SyncAll()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// testEmailHandler godoc
|
||||
// @Summary Send test email
|
||||
// @Description Send a test email to verify email configuration
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/test-email [post]
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err := acc.emailService.SendTestEmail(userID)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
||||
// NewAuditLogController creates a new controller for audit log management
|
||||
// @Summary Audit log controller
|
||||
// @Description Initializes API endpoints for accessing audit logs
|
||||
// @Tags Audit Logs
|
||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
|
||||
alc := AuditLogController{
|
||||
auditLogService: auditLogService,
|
||||
}
|
||||
|
||||
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
|
||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||
}
|
||||
|
||||
type AuditLogController struct {
|
||||
auditLogService *service.AuditLogService
|
||||
}
|
||||
|
||||
// listAuditLogsForUserHandler godoc
|
||||
// @Summary List audit logs
|
||||
// @Description Get a paginated list of audit logs for the current user
|
||||
// @Tags Audit Logs
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /audit-logs [get]
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
// Fetch audit logs for the user
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +57,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,8 +67,8 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": logsDtos,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
120
backend/internal/controller/custom_claim_controller.go
Normal file
120
backend/internal/controller/custom_claim_controller.go
Normal file
@@ -0,0 +1,120 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// NewCustomClaimController creates a new controller for custom claim management
|
||||
// @Summary Custom claim management controller
|
||||
// @Description Initializes all custom claim-related API endpoints
|
||||
// @Tags Custom Claims
|
||||
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||
|
||||
customClaimsGroup := group.Group("/custom-claims")
|
||||
customClaimsGroup.Use(authMiddleware.Add())
|
||||
{
|
||||
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
|
||||
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
|
||||
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||
}
|
||||
}
|
||||
|
||||
type CustomClaimController struct {
|
||||
customClaimService *service.CustomClaimService
|
||||
}
|
||||
|
||||
// getSuggestionsHandler godoc
|
||||
// @Summary Get custom claim suggestions
|
||||
// @Description Get a list of suggested custom claim names
|
||||
// @Tags Custom Claims
|
||||
// @Produce json
|
||||
// @Success 200 {array} string "List of suggested custom claim names"
|
||||
// @Failure 401 {object} object "Unauthorized"
|
||||
// @Failure 403 {object} object "Forbidden"
|
||||
// @Failure 500 {object} object "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /custom-claims/suggestions [get]
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserHandler godoc
|
||||
// @Summary Update custom claims for a user
|
||||
// @Description Update or create custom claims for a specific user
|
||||
// @Tags Custom Claims
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userId path string true "User ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Router /custom-claims/user/{userId} [put]
|
||||
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)
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroupHandler godoc
|
||||
// @Summary Update custom claims for a user group
|
||||
// @Description Update or create custom claims for a specific user group
|
||||
// @Tags Custom Claims
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userGroupId path string true "User Group ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Security BearerAuth
|
||||
// @Router /custom-claims/user-group/{userGroupId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userGroupId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, 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)
|
||||
}
|
||||
@@ -1,37 +1,50 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
"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) {
|
||||
// NewOidcController creates a new controller for OIDC related endpoints
|
||||
// @Summary OIDC controller
|
||||
// @Description Initializes all OIDC-related API endpoints for authentication and client management
|
||||
// @Tags OIDC
|
||||
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, 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/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
|
||||
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
|
||||
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
|
||||
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||
group.POST("/oidc/userinfo", oc.userInfoHandler)
|
||||
group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||
group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), 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.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
|
||||
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
|
||||
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
|
||||
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
|
||||
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
|
||||
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
|
||||
|
||||
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
|
||||
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
|
||||
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), 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)
|
||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||
}
|
||||
|
||||
type OidcController struct {
|
||||
@@ -39,22 +52,26 @@ type OidcController struct {
|
||||
jwtService *service.JwtService
|
||||
}
|
||||
|
||||
// authorizeHandler godoc
|
||||
// @Summary Authorize OIDC client
|
||||
// @Description Start the OIDC authorization process for a client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
||||
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/authorize [post]
|
||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
||||
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
||||
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,36 +83,53 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
// authorizationConfirmationRequiredHandler godoc
|
||||
// @Summary Check if authorization confirmation is required
|
||||
// @Description Check if the user needs to confirm authorization for the client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
||||
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/authorization-required [post]
|
||||
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
||||
var input dto.AuthorizationRequiredDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.AuthorizeOidcClientResponseDto{
|
||||
Code: code,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
||||
}
|
||||
|
||||
// createTokensHandler godoc
|
||||
// @Summary Create OIDC tokens
|
||||
// @Description Exchange authorization code for ID and access tokens
|
||||
// @Tags OIDC
|
||||
// @Accept application/x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
||||
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
|
||||
// @Param code formData string true "Authorization code"
|
||||
// @Param grant_type formData string true "Grant type (must be 'authorization_code')"
|
||||
// @Param code_verifier formData string false "PKCE code verifier"
|
||||
// @Success 200 {object} object "{ \"id_token\": \"string\", \"access_token\": \"string\", \"token_type\": \"Bearer\" }"
|
||||
// @Router /oidc/token [post]
|
||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
var input dto.OidcIdTokenDto
|
||||
// Disable cors for this endpoint
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
var input dto.OidcCreateTokensDto
|
||||
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,168 +137,337 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
clientSecret := input.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.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
||||
return
|
||||
}
|
||||
if clientID == "" && clientSecret == "" {
|
||||
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||
}
|
||||
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
|
||||
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
|
||||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
|
||||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
|
||||
}
|
||||
|
||||
// userInfoHandler godoc
|
||||
// @Summary Get user information
|
||||
// @Description Get user information based on the access token
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object "User claims based on requested scopes"
|
||||
// @Security OAuth2AccessToken
|
||||
// @Router /oidc/userinfo [get]
|
||||
func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
||||
authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authHeaderSplit) != 2 {
|
||||
c.Error(&common.MissingAccessToken{})
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeaderSplit[1]
|
||||
|
||||
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
||||
if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
userID := jwtClaims.Subject
|
||||
clientId := jwtClaims.Audience[0]
|
||||
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
}
|
||||
|
||||
// userInfoHandler godoc (POST method)
|
||||
// @Summary Get user information (POST method)
|
||||
// @Description Get user information based on the access token using POST
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object "User claims based on requested scopes"
|
||||
// @Security OAuth2AccessToken
|
||||
// @Router /oidc/userinfo [post]
|
||||
func (oc *OidcController) userInfoHandlerPost(c *gin.Context) {
|
||||
// Implementation is the same as GET
|
||||
}
|
||||
|
||||
// EndSessionHandler godoc
|
||||
// @Summary End OIDC session
|
||||
// @Description End user session and handle OIDC logout
|
||||
// @Tags OIDC
|
||||
// @Accept application/x-www-form-urlencoded
|
||||
// @Produce html
|
||||
// @Param id_token_hint query string false "ID token"
|
||||
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
||||
// @Param state query string false "State parameter to include in the redirect"
|
||||
// @Success 302 "Redirect to post-logout URL or application logout page"
|
||||
// @Router /oidc/end-session [get]
|
||||
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())
|
||||
}
|
||||
|
||||
// EndSessionHandler godoc (POST method)
|
||||
// @Summary End OIDC session (POST method)
|
||||
// @Description End user session and handle OIDC logout using POST
|
||||
// @Tags OIDC
|
||||
// @Accept application/x-www-form-urlencoded
|
||||
// @Produce html
|
||||
// @Param id_token_hint formData string false "ID token"
|
||||
// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
|
||||
// @Param state formData string false "State parameter to include in the redirect"
|
||||
// @Success 302 "Redirect to post-logout URL or application logout page"
|
||||
// @Router /oidc/end-session [post]
|
||||
func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
|
||||
// Implementation is the same as GET
|
||||
}
|
||||
|
||||
// getClientMetaDataHandler godoc
|
||||
// @Summary Get client metadata
|
||||
// @Description Get OIDC client metadata for discovery and configuration
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
|
||||
// @Router /oidc/clients/{id}/meta [get]
|
||||
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(clientId)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
clientDto := dto.OidcClientMetaDataDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
// getClientHandler godoc
|
||||
// @Summary Get OIDC client
|
||||
// @Description Get detailed information about an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id} [get]
|
||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(clientId)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Return a different DTO based on the user's role
|
||||
if c.GetBool("userIsAdmin") {
|
||||
clientDto := dto.OidcClientDto{}
|
||||
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
|
||||
}
|
||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
// listClientsHandler godoc
|
||||
// @Summary List OIDC clients
|
||||
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
||||
// @Tags OIDC
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("name")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients [get]
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
searchTerm := c.Query("search")
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientsDto []dto.OidcClientDto
|
||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": clientsDto,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
|
||||
Data: clientsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// createClientHandler godoc
|
||||
// @Summary Create OIDC client
|
||||
// @Description Create a new OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients [post]
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clientDto)
|
||||
}
|
||||
|
||||
// deleteClientHandler godoc
|
||||
// @Summary Delete OIDC client
|
||||
// @Description Delete an OIDC client by ID
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id} [delete]
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateClientHandler godoc
|
||||
// @Summary Update OIDC client
|
||||
// @Description Update an existing OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id} [put]
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
}
|
||||
|
||||
// createClientSecretHandler godoc
|
||||
// @Summary Create client secret
|
||||
// @Description Generate a new secret for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id}/secret [post]
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
||||
}
|
||||
|
||||
// getClientLogoHandler godoc
|
||||
// @Summary Get client logo
|
||||
// @Description Get the logo image for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Produce image/svg+xml
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {file} binary "Logo image"
|
||||
// @Router /oidc/clients/{id}/logo [get]
|
||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -272,32 +475,79 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
// updateClientLogoHandler godoc
|
||||
// @Summary Update client logo
|
||||
// @Description Upload or update the logo for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept multipart/form-data
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id}/logo [post]
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteClientLogoHandler godoc
|
||||
// @Summary Delete client logo
|
||||
// @Description Delete the logo for an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id}/logo [delete]
|
||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateAllowedUserGroupsHandler godoc
|
||||
// @Summary Update allowed user groups
|
||||
// @Description Update the user groups allowed to access an OIDC client
|
||||
// @Tags OIDC
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||
// @Security BearerAuth
|
||||
// @Router /oidc/clients/{id}/allowed-user-groups [put]
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||
@@ -19,19 +19,26 @@ type TestController struct {
|
||||
|
||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetAppConfig(); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
tc.TestService.SetJWTKeys()
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1,195 +1,478 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
// NewUserController creates a new controller for user management endpoints
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
// @Tags Users
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||
uc := UserController{
|
||||
UserService: userService,
|
||||
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", authMiddleware.Add(), uc.listUsersHandler)
|
||||
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler)
|
||||
group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler)
|
||||
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
|
||||
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
|
||||
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
|
||||
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
|
||||
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
|
||||
|
||||
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
|
||||
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
||||
|
||||
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
|
||||
group.GET("/users/me/profile-picture.png", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserProfilePictureHandler)
|
||||
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
|
||||
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
|
||||
|
||||
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
|
||||
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||
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
|
||||
userService *service.UserService
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
||||
// getUserGroupsHandler godoc
|
||||
// @Summary Get user groups
|
||||
// @Description Retrieve all groups a specific user belongs to
|
||||
// @Tags Users,User Groups
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {array} dto.UserGroupDtoWithUsers
|
||||
// @Router /users/{id}/groups [get]
|
||||
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
groups, err := uc.userService.GetUserGroups(userID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupsDto []dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStructList(groups, &groupsDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupsDto)
|
||||
}
|
||||
|
||||
// listUsersHandler godoc
|
||||
// @Summary List users
|
||||
// @Description Get a paginated list of users with optional search and sorting
|
||||
// @Tags Users
|
||||
// @Param search query string false "Search term to filter users"
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
||||
// @Router /users [get]
|
||||
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 {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": usersDto,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
|
||||
Data: usersDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// getUserHandler godoc
|
||||
// @Summary Get user by ID
|
||||
// @Description Retrieve detailed information about a specific user
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /users/{id} [get]
|
||||
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||
user, err := uc.UserService.GetUser(c.Param("id"))
|
||||
user, err := uc.userService.GetUser(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// getCurrentUserHandler godoc
|
||||
// @Summary Get current user
|
||||
// @Description Retrieve information about the currently authenticated user
|
||||
// @Tags Users
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /users/me [get]
|
||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
||||
user, err := uc.userService.GetUser(c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// deleteUserHandler godoc
|
||||
// @Summary Delete user
|
||||
// @Description Delete a specific user by ID
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /users/{id} [delete]
|
||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// createUserHandler godoc
|
||||
// @Summary Create user
|
||||
// @Description Create a new user
|
||||
// @Tags Users
|
||||
// @Param user body dto.UserCreateDto true "User information"
|
||||
// @Success 201 {object} dto.UserDto
|
||||
// @Router /users [post]
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := uc.UserService.CreateUser(input)
|
||||
user, err := uc.userService.CreateUser(input)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, userDto)
|
||||
}
|
||||
|
||||
// updateUserHandler godoc
|
||||
// @Summary Update user
|
||||
// @Description Update an existing user by ID
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Param user body dto.UserCreateDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /users/{id} [put]
|
||||
func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||
uc.updateUser(c, false)
|
||||
}
|
||||
|
||||
// updateCurrentUserHandler godoc
|
||||
// @Summary Update current user
|
||||
// @Description Update the currently authenticated user's information
|
||||
// @Tags Users
|
||||
// @Param user body dto.UserCreateDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /users/me [put]
|
||||
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) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
var input dto.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
// getUserProfilePictureHandler godoc
|
||||
// @Summary Get user profile picture
|
||||
// @Description Retrieve a specific user's profile picture
|
||||
// @Tags Users
|
||||
// @Produce image/png
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {file} binary "PNG image"
|
||||
// @Router /users/{id}/profile-picture.png [get]
|
||||
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
|
||||
}
|
||||
|
||||
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||
}
|
||||
|
||||
// getCurrentUserProfilePictureHandler godoc
|
||||
// @Summary Get current user's profile picture
|
||||
// @Description Retrieve the currently authenticated user's profile picture
|
||||
// @Tags Users
|
||||
// @Produce image/png
|
||||
// @Success 200 {file} binary "PNG image"
|
||||
// @Router /users/me/profile-picture.png [get]
|
||||
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
picture, size, err := uc.userService.GetProfilePicture(userID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||
}
|
||||
|
||||
// updateUserProfilePictureHandler godoc
|
||||
// @Summary Update user profile picture
|
||||
// @Description Update a specific user's profile picture
|
||||
// @Tags Users
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /users/{id}/profile-picture [put]
|
||||
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
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)
|
||||
}
|
||||
|
||||
// updateCurrentUserProfilePictureHandler godoc
|
||||
// @Summary Update current user's profile picture
|
||||
// @Description Update the currently authenticated user's profile picture
|
||||
// @Tags Users
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /users/me/profile-picture [put]
|
||||
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, own bool) {
|
||||
var input dto.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
}
|
||||
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) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrTokenInvalidOrExpired) {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
// createOwnOneTimeAccessTokenHandler godoc
|
||||
// @Summary Create one-time access token for current user
|
||||
// @Description Generate a one-time access token for the currently authenticated user
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
|
||||
// @Success 201 {object} object "{ \"token\": \"string\" }"
|
||||
// @Router /users/{id}/one-time-access-token [post]
|
||||
func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
uc.createOneTimeAccessTokenHandler(c, true)
|
||||
}
|
||||
|
||||
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
uc.createOneTimeAccessTokenHandler(c, false)
|
||||
}
|
||||
|
||||
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
||||
var input dto.OneTimeAccessEmailDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, user)
|
||||
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.UserService.SetupInitialAdmin()
|
||||
// exchangeOneTimeAccessTokenHandler godoc
|
||||
// @Summary Exchange one-time access token
|
||||
// @Description Exchange a one-time access token for a session token
|
||||
// @Tags Users
|
||||
// @Param token path string true "One-time access token"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /one-time-access-token/{token} [post]
|
||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
|
||||
maxAge := sessionDurationInMinutesParsed * 60
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// getSetupAccessTokenHandler godoc
|
||||
// @Summary Setup initial admin
|
||||
// @Description Generate setup access token for initial admin user configuration
|
||||
// @Tags Users
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /one-time-access-token/setup [post]
|
||||
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)
|
||||
}
|
||||
|
||||
// updateUserGroups godoc
|
||||
// @Summary Update user groups
|
||||
// @Description Update the groups a specific user belongs to
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /users/{id}/user-groups [put]
|
||||
func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
var input dto.UserUpdateUserGroupDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
|
||||
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)
|
||||
}
|
||||
|
||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,19 +483,15 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
userID = c.Param("id")
|
||||
}
|
||||
|
||||
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
||||
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,160 +1,224 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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) {
|
||||
// NewUserGroupController creates a new controller for user group management
|
||||
// @Summary User group management controller
|
||||
// @Description Initializes all user group-related API endpoints
|
||||
// @Tags User Groups
|
||||
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, 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)
|
||||
userGroupsGroup := group.Group("/user-groups")
|
||||
userGroupsGroup.Use(authMiddleware.Add())
|
||||
{
|
||||
userGroupsGroup.GET("", ugc.list)
|
||||
userGroupsGroup.GET("/:id", ugc.get)
|
||||
userGroupsGroup.POST("", ugc.create)
|
||||
userGroupsGroup.PUT("/:id", ugc.update)
|
||||
userGroupsGroup.DELETE("/:id", ugc.delete)
|
||||
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
|
||||
}
|
||||
}
|
||||
|
||||
type UserGroupController struct {
|
||||
UserGroupService *service.UserGroupService
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List user groups
|
||||
// @Description Get a paginated list of user groups with optional search and sorting
|
||||
// @Tags User Groups
|
||||
// @Param search query string false "Search term to filter user groups by name"
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("name")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
||||
// @Router /user-groups [get]
|
||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
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
|
||||
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
|
||||
for i, group := range groups {
|
||||
var groupDto dto.UserGroupDtoWithUserCount
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
groupsDto[i] = groupDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": groupsDto,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
|
||||
Data: groupsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get user group by ID
|
||||
// @Description Retrieve detailed information about a specific user group including its users
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||
// @Security BearerAuth
|
||||
// @Router /user-groups/{id} [get]
|
||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create user group
|
||||
// @Description Create a new user group
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
||||
// @Security BearerAuth
|
||||
// @Router /user-groups [post]
|
||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Create(input)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, groupDto)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update user group
|
||||
// @Description Update an existing user group by ID
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
||||
// @Security BearerAuth
|
||||
// @Router /user-groups/{id} [put]
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
||||
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, groupDto)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete user group
|
||||
// @Description Delete a specific user group by ID
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /user-groups/{id} [delete]
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateUsers godoc
|
||||
// @Summary Update users in a group
|
||||
// @Description Update the list of users belonging to a specific user group
|
||||
// @Tags User Groups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||
// @Security BearerAuth
|
||||
// @Router /user-groups/{id}/users [put]
|
||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
var input dto.UserGroupUpdateUsersDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,70 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"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/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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) {
|
||||
wc := &WebauthnController{webAuthnService: webauthnService}
|
||||
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
|
||||
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
|
||||
func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
|
||||
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
|
||||
group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
|
||||
group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), 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.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), 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)
|
||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||
}
|
||||
|
||||
type WebauthnController struct {
|
||||
webAuthnService *service.WebAuthnService
|
||||
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 {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
|
||||
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("session_id")
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,46 +74,43 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||
options, err := wc.webAuthnService.BeginLogin()
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
|
||||
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("session_id")
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrInvalidCredentials) {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value)
|
||||
maxAge := sessionDurationInMinutesParsed * 60
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
@@ -119,13 +118,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDtos []dto.WebauthnCredentialDto
|
||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +137,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
||||
|
||||
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,19 +150,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
||||
|
||||
var input dto.WebauthnCredentialUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -171,6 +170,6 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
||||
c.SetCookie("access_token", "", 0, "/", "", false, true)
|
||||
cookie.AddAccessTokenCookie(c, 0, "")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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"
|
||||
)
|
||||
|
||||
// NewWellKnownController creates a new controller for OIDC discovery endpoints
|
||||
// @Summary OIDC Discovery controller
|
||||
// @Description Initializes OIDC discovery and JWKS endpoints
|
||||
// @Tags Well Known
|
||||
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
|
||||
wkc := &WellKnownController{jwtService: jwtService}
|
||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||
@@ -18,16 +22,29 @@ type WellKnownController struct {
|
||||
jwtService *service.JwtService
|
||||
}
|
||||
|
||||
// jwksHandler godoc
|
||||
// @Summary Get JSON Web Key Set (JWKS)
|
||||
// @Description Returns the JSON Web Key Set used for token verification
|
||||
// @Tags Well Known
|
||||
// @Produce json
|
||||
// @Success 200 {object} object "{ \"keys\": []interface{} }"
|
||||
// @Router /.well-known/jwks.json [get]
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
jwk, err := wkc.jwtService.GetJWK()
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
||||
}
|
||||
|
||||
// openIDConfigurationHandler godoc
|
||||
// @Summary Get OpenID Connect discovery configuration
|
||||
// @Description Returns the OpenID Connect discovery document with endpoints and capabilities
|
||||
// @Tags Well Known
|
||||
// @Success 200 {object} object "OpenID Connect configuration"
|
||||
// @Router /.well-known/openid-configuration [get]
|
||||
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
appUrl := common.EnvConfig.AppURL
|
||||
config := map[string]interface{}{
|
||||
@@ -35,9 +52,10 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
"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", "preferred_username"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
|
||||
25
backend/internal/dto/api_key_dto.go
Normal file
25
backend/internal/dto/api_key_dto.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type ApiKeyCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=50"`
|
||||
Description string `json:"description"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type ApiKeyDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ApiKeyResponseDto struct {
|
||||
ApiKey ApiKeyDto `json:"apiKey"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -12,12 +12,35 @@ type AppConfigVariableDto struct {
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
||||
SmtHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
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" binding:"required,oneof=none starttls tls"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"time"
|
||||
"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 time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
|
||||
Event model.AuditLogEvent `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -3,6 +3,9 @@ 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
|
||||
@@ -95,7 +98,18 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
package dto
|
||||
|
||||
type PublicOidcClientDto struct {
|
||||
type OidcClientMetaDataDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
}
|
||||
|
||||
type OidcClientDto struct {
|
||||
PublicOidcClientDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
CreatedBy UserDto `json:"createdBy"`
|
||||
OidcClientMetaDataDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
OidcClientDto
|
||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||
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"`
|
||||
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 {
|
||||
@@ -29,9 +41,26 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
10
backend/internal/dto/pagination_dto.go
Normal file
10
backend/internal/dto/pagination_dto.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package dto
|
||||
|
||||
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
type Pagination = utils.PaginationResponse
|
||||
|
||||
type Paginated[T any] struct {
|
||||
Data []T `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
@@ -3,23 +3,36 @@ 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"`
|
||||
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"`
|
||||
UserGroups []UserGroupDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=3,max=20"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
|
||||
LastName string `json:"lastName" binding:"required,min=3,max=30"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"required,min=1,max=50"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailDto struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
RedirectPath string `json:"redirectPath"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type UserGroupDto struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserGroupDtoWithUsers struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
Users []UserDto `json:"users"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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"`
|
||||
UserCount int64 `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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=3,max=30"`
|
||||
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"`
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
type UserGroupUpdateUsersDto struct {
|
||||
UserIDs []string `json:"userIds" binding:"required"`
|
||||
}
|
||||
|
||||
type AssignUserToGroupDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -4,21 +4,9 @@ import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
|
||||
urls := fl.Field().Interface().([]string)
|
||||
for _, u := range urls {
|
||||
_, err := url.ParseRequestURI(u)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
@@ -28,27 +16,21 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
return matched
|
||||
}
|
||||
|
||||
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
||||
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores
|
||||
regex := "^[a-z0-9_]+$"
|
||||
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("urlList", validateUrlList); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
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("userGroupName", validateUserGroupName); err != nil {
|
||||
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"time"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type WebauthnCredentialDto struct {
|
||||
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"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(db *gorm.DB) {
|
||||
func RegisterDbCleanupJobs(db *gorm.DB) {
|
||||
scheduler, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create a new scheduler: %s", err)
|
||||
@@ -30,22 +31,22 @@ type Jobs struct {
|
||||
|
||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||
func (j *Jobs) clearWebauthnSessions() error {
|
||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).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 < ?", utils.FormatDateForDb(time.Now())).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 < ?", utils.FormatDateForDb(time.Now())).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 < ?", utils.FormatDateForDb(time.Now().AddDate(0, 0, -90))).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) {
|
||||
|
||||
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
|
||||
}
|
||||
50
backend/internal/middleware/api_key_auth.go
Normal file
50
backend/internal/middleware/api_key_auth.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
type ApiKeyAuthMiddleware struct {
|
||||
apiKeyService *service.ApiKeyService
|
||||
jwtService *service.JwtService
|
||||
}
|
||||
|
||||
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware {
|
||||
return &ApiKeyAuthMiddleware{
|
||||
apiKeyService: apiKeyService,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, isAdmin, err := m.Verify(c, adminRequired)
|
||||
if err != nil {
|
||||
c.Abort()
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
|
||||
apiKey := c.GetHeader("X-API-KEY")
|
||||
|
||||
user, err := m.apiKeyService.ValidateApiKey(apiKey)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminRequired && !user.IsAdmin {
|
||||
return "", false, &common.MissingPermissionError{}
|
||||
}
|
||||
|
||||
return user.ID, user.IsAdmin, nil
|
||||
}
|
||||
89
backend/internal/middleware/auth_middleware.go
Normal file
89
backend/internal/middleware/auth_middleware.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication
|
||||
type AuthMiddleware struct {
|
||||
apiKeyMiddleware *ApiKeyAuthMiddleware
|
||||
jwtMiddleware *JwtAuthMiddleware
|
||||
options AuthOptions
|
||||
}
|
||||
|
||||
type AuthOptions struct {
|
||||
AdminRequired bool
|
||||
SuccessOptional bool
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(
|
||||
apiKeyService *service.ApiKeyService,
|
||||
jwtService *service.JwtService,
|
||||
) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
|
||||
jwtMiddleware: NewJwtAuthMiddleware(jwtService),
|
||||
options: AuthOptions{
|
||||
AdminRequired: true,
|
||||
SuccessOptional: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithAdminNotRequired allows the middleware to continue with the request even if the user is not an admin
|
||||
func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware {
|
||||
// Create a new instance to avoid modifying the original
|
||||
clone := &AuthMiddleware{
|
||||
apiKeyMiddleware: m.apiKeyMiddleware,
|
||||
jwtMiddleware: m.jwtMiddleware,
|
||||
options: m.options,
|
||||
}
|
||||
clone.options.AdminRequired = false
|
||||
return clone
|
||||
}
|
||||
|
||||
// WithSuccessOptional allows the middleware to continue with the request even if authentication fails
|
||||
func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
|
||||
// Create a new instance to avoid modifying the original
|
||||
clone := &AuthMiddleware{
|
||||
apiKeyMiddleware: m.apiKeyMiddleware,
|
||||
jwtMiddleware: m.jwtMiddleware,
|
||||
options: m.options,
|
||||
}
|
||||
clone.options.SuccessOptional = true
|
||||
return clone
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// First try JWT auth
|
||||
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
// JWT auth succeeded, continue with the request
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// JWT auth failed, try API key auth
|
||||
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
// API key auth succeeded, continue with the request
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if m.options.SuccessOptional {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Both JWT and API key auth failed
|
||||
c.Abort()
|
||||
c.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type CorsMiddleware struct{}
|
||||
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
|
||||
}
|
||||
|
||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||
return cors.New(cors.Config{
|
||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
||||
AllowMethods: []string{"*"},
|
||||
AllowHeaders: []string{"*"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
})
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,68 @@
|
||||
package utils
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"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"
|
||||
)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
type ErrorHandlerMiddleware struct{}
|
||||
|
||||
func ControllerError(c *gin.Context, err error) {
|
||||
// Check for record not found errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
CustomControllerError(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
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"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
CustomControllerError(c, http.StatusBadRequest, message)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
log.Println(err)
|
||||
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 {
|
||||
@@ -53,8 +84,6 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
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())
|
||||
case "urlList":
|
||||
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||
}
|
||||
@@ -67,9 +96,3 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
|
||||
return combinedErrors
|
||||
}
|
||||
|
||||
func CustomControllerError(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})
|
||||
}
|
||||
@@ -2,9 +2,10 @@ package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type FileSizeLimitMiddleware struct{}
|
||||
@@ -17,7 +18,8 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
utils.CustomControllerError(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()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
"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
|
||||
jwtService *service.JwtService
|
||||
}
|
||||
|
||||
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
|
||||
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
|
||||
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
|
||||
return &JwtAuthMiddleware{jwtService: jwtService}
|
||||
}
|
||||
|
||||
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
||||
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Extract the token from the cookie or the Authorization header
|
||||
token, err := c.Cookie("access_token")
|
||||
|
||||
userID, isAdmin, err := m.Verify(c, adminRequired)
|
||||
if err != nil {
|
||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authorizationHeaderSplitted) == 2 {
|
||||
token = authorizationHeaderSplitted[1]
|
||||
} else if m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||
if err != nil && m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
utils.CustomControllerError(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.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
|
||||
// Extract the token from the cookie
|
||||
token, err := c.Cookie(cookie.AccessTokenCookieName)
|
||||
if err != nil {
|
||||
// Try to extract the token from the Authorization header if it's not in the cookie
|
||||
authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authorizationHeaderSplit) != 2 {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
}
|
||||
token = authorizationHeaderSplit[1]
|
||||
}
|
||||
|
||||
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminRequired && !claims.IsAdmin {
|
||||
return "", false, &common.MissingPermissionError{}
|
||||
}
|
||||
|
||||
return claims.Subject, claims.IsAdmin, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -18,8 +17,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
|
||||
}
|
||||
|
||||
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
// Map to store the rate limiters per IP
|
||||
var clients = make(map[string]*client)
|
||||
var mu sync.Mutex
|
||||
|
||||
// Start the cleanup routine
|
||||
go cleanupClients()
|
||||
go cleanupClients(&mu, clients)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
@@ -31,9 +34,9 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
limiter := getLimiter(ip, limit, burst)
|
||||
limiter := getLimiter(ip, limit, burst, &mu, clients)
|
||||
if !limiter.Allow() {
|
||||
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
||||
c.Error(&common.TooManyRequestsError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -47,12 +50,8 @@ type client struct {
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// Map to store the rate limiters per IP
|
||||
var clients = make(map[string]*client)
|
||||
var mu sync.Mutex
|
||||
|
||||
// Cleanup routine to remove stale clients that haven't been seen for a while
|
||||
func cleanupClients() {
|
||||
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
mu.Lock()
|
||||
@@ -66,7 +65,7 @@ func cleanupClients() {
|
||||
}
|
||||
|
||||
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
|
||||
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
|
||||
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
|
||||
18
backend/internal/model/api_key.go
Normal file
18
backend/internal/model/api_key.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Key string
|
||||
Description *string
|
||||
ExpiresAt datatype.DateTime `sortable:"true"`
|
||||
LastUsedAt *datatype.DateTime `sortable:"true"`
|
||||
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
@@ -1,24 +1,51 @@
|
||||
package model
|
||||
|
||||
type AppConfigVariable struct {
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Type string
|
||||
IsPublic bool
|
||||
IsInternal bool
|
||||
Value string
|
||||
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
|
||||
SessionDuration AppConfigVariable
|
||||
|
||||
EmailEnabled AppConfigVariable
|
||||
SmtpHost AppConfigVariable
|
||||
SmtpPort AppConfigVariable
|
||||
SmtpFrom AppConfigVariable
|
||||
SmtpUser AppConfigVariable
|
||||
SmtpPassword 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
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
type AuditLog struct {
|
||||
Base
|
||||
|
||||
Event AuditLogEvent
|
||||
IpAddress string
|
||||
Country string
|
||||
City string
|
||||
UserAgent string
|
||||
Event AuditLogEvent `sortable:"true"`
|
||||
IpAddress string `sortable:"true"`
|
||||
Country string `sortable:"true"`
|
||||
City string `sortable:"true"`
|
||||
UserAgent string `sortable:"true"`
|
||||
UserID string
|
||||
Data AuditLogData
|
||||
}
|
||||
@@ -23,9 +23,10 @@ type AuditLogData map[string]string
|
||||
type AuditLogEvent string
|
||||
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||
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
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Base contains common columns for all tables.
|
||||
type Base struct {
|
||||
ID string `gorm:"primaryKey;not null"`
|
||||
CreatedAt time.Time
|
||||
ID string `gorm:"primaryKey;not null"`
|
||||
CreatedAt datatype.DateTime `sortable:"true"`
|
||||
}
|
||||
|
||||
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.New().String()
|
||||
}
|
||||
b.CreatedAt = datatype.DateTime(time.Now())
|
||||
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
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserAuthorizedOidcClient struct {
|
||||
@@ -20,10 +21,12 @@ type UserAuthorizedOidcClient struct {
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt time.Time
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
CodeChallenge *string
|
||||
CodeChallengeMethodSha256 *bool
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
@@ -34,14 +37,18 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string
|
||||
Secret string
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
@@ -50,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CallbackURLs []string
|
||||
type UrlList []string
|
||||
|
||||
func (cu *CallbackURLs) Scan(value interface{}) error {
|
||||
func (cu *UrlList) Scan(value interface{}) error {
|
||||
if v, ok := value.([]byte); ok {
|
||||
return json.Unmarshal(v, cu)
|
||||
} else {
|
||||
@@ -60,6 +67,6 @@ func (cu *CallbackURLs) Scan(value interface{}) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (cu CallbackURLs) Value() (driver.Value, error) {
|
||||
func (cu UrlList) Value() (driver.Value, error) {
|
||||
return json.Marshal(cu)
|
||||
}
|
||||
|
||||
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,20 +3,22 @@ package model
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"time"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
IsAdmin bool
|
||||
Username string `sortable:"true"`
|
||||
Email string `sortable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true"`
|
||||
LdapID *string
|
||||
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
@@ -32,7 +34,7 @@ func (u User) WebAuthnCredentials() []webauthn.Credential {
|
||||
|
||||
for i, credential := range u.Credentials {
|
||||
credentials[i] = webauthn.Credential{
|
||||
ID: []byte(credential.CredentialID),
|
||||
ID: credential.CredentialID,
|
||||
AttestationType: credential.AttestationType,
|
||||
PublicKey: credential.PublicKey,
|
||||
Transport: credential.Transport,
|
||||
@@ -58,10 +60,12 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
return descriptors
|
||||
}
|
||||
|
||||
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
|
||||
@@ -2,7 +2,9 @@ package model
|
||||
|
||||
type UserGroup struct {
|
||||
Base
|
||||
FriendlyName string
|
||||
Name string `gorm:"unique"`
|
||||
FriendlyName string `sortable:"true"`
|
||||
Name string `sortable:"true"`
|
||||
LdapID *string
|
||||
Users []User `gorm:"many2many:user_groups_users;"`
|
||||
CustomClaims []CustomClaim
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type WebauthnSession struct {
|
||||
Base
|
||||
|
||||
Challenge string
|
||||
ExpiresAt time.Time
|
||||
ExpiresAt datatype.DateTime
|
||||
UserVerification string
|
||||
}
|
||||
|
||||
@@ -20,7 +22,7 @@ type WebauthnCredential struct {
|
||||
Base
|
||||
|
||||
Name string
|
||||
CredentialID string
|
||||
CredentialID []byte
|
||||
PublicKey []byte
|
||||
AttestationType string
|
||||
Transport AuthenticatorTransportList
|
||||
|
||||
102
backend/internal/service/api_key_service.go
Normal file
102
backend/internal/service/api_key_service.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"log"
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ApiKeyService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewApiKeyService(db *gorm.DB) *ApiKeyService {
|
||||
return &ApiKeyService{db: db}
|
||||
}
|
||||
|
||||
func (s *ApiKeyService) ListApiKeys(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
|
||||
query := s.db.Where("user_id = ?", userID).Model(&model.ApiKey{})
|
||||
|
||||
var apiKeys []model.ApiKey
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
return apiKeys, pagination, nil
|
||||
}
|
||||
|
||||
func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) {
|
||||
// Check if expiration is in the future
|
||||
if !input.ExpiresAt.ToTime().After(time.Now()) {
|
||||
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
|
||||
}
|
||||
|
||||
// Generate a secure random API key
|
||||
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return model.ApiKey{}, "", err
|
||||
}
|
||||
|
||||
apiKey := model.ApiKey{
|
||||
Name: input.Name,
|
||||
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
||||
Description: &input.Description,
|
||||
ExpiresAt: datatype.DateTime(input.ExpiresAt),
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&apiKey).Error; err != nil {
|
||||
return model.ApiKey{}, "", err
|
||||
}
|
||||
|
||||
// Return the raw token only once - it cannot be retrieved later
|
||||
return apiKey, token, nil
|
||||
}
|
||||
|
||||
func (s *ApiKeyService) RevokeApiKey(userID, apiKeyID string) error {
|
||||
var apiKey model.ApiKey
|
||||
if err := s.db.Where("id = ? AND user_id = ?", apiKeyID, userID).First(&apiKey).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.APIKeyNotFoundError{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Delete(&apiKey).Error
|
||||
}
|
||||
|
||||
func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
|
||||
if apiKey == "" {
|
||||
return model.User{}, &common.NoAPIKeyProvidedError{}
|
||||
}
|
||||
|
||||
var key model.ApiKey
|
||||
hashedKey := utils.CreateSha256Hash(apiKey)
|
||||
|
||||
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
|
||||
hashedKey, time.Now()).Preload("User").First(&key).Error; err != nil {
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, &common.InvalidAPIKeyError{}
|
||||
}
|
||||
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Update last used time
|
||||
now := datatype.DateTime(time.Now())
|
||||
key.LastUsedAt = &now
|
||||
if err := s.db.Save(&key).Error; err != nil {
|
||||
log.Printf("Failed to update last used time: %v", err)
|
||||
}
|
||||
|
||||
return key.User, nil
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"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 {
|
||||
@@ -26,44 +27,54 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
||||
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,
|
||||
Value: "Pocket ID",
|
||||
Key: "appName",
|
||||
Type: "string",
|
||||
IsPublic: true,
|
||||
DefaultValue: "Pocket ID",
|
||||
},
|
||||
SessionDuration: model.AppConfigVariable{
|
||||
Key: "sessionDuration",
|
||||
Type: "number",
|
||||
Value: "60",
|
||||
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,
|
||||
Value: "jpg",
|
||||
Key: "backgroundImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
DefaultValue: "jpg",
|
||||
},
|
||||
LogoLightImageType: model.AppConfigVariable{
|
||||
Key: "logoLightImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "svg",
|
||||
Key: "logoLightImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
DefaultValue: "svg",
|
||||
},
|
||||
LogoDarkImageType: model.AppConfigVariable{
|
||||
Key: "logoDarkImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "svg",
|
||||
},
|
||||
EmailEnabled: model.AppConfigVariable{
|
||||
Key: "emailEnabled",
|
||||
Type: "bool",
|
||||
Value: "false",
|
||||
Key: "logoDarkImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
DefaultValue: "svg",
|
||||
},
|
||||
// Email
|
||||
SmtpHost: model.AppConfigVariable{
|
||||
Key: "smtpHost",
|
||||
Type: "string",
|
||||
@@ -84,20 +95,130 @@ var defaultDbConfig = model.AppConfig{
|
||||
Key: "smtpPassword",
|
||||
Type: "string",
|
||||
},
|
||||
SmtpTls: model.AppConfigVariable{
|
||||
Key: "smtpTls",
|
||||
Type: "string",
|
||||
DefaultValue: "none",
|
||||
},
|
||||
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) {
|
||||
var savedConfigVariables []model.AppConfigVariable
|
||||
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()
|
||||
@@ -115,7 +236,7 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
|
||||
|
||||
tx.Commit()
|
||||
|
||||
if err := s.loadDbConfigFromDb(); err != nil {
|
||||
if err := s.LoadDbConfigFromDb(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -129,7 +250,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
|
||||
return err
|
||||
}
|
||||
|
||||
return s.loadDbConfigFromDb()
|
||||
return s.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
||||
@@ -146,6 +267,17 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
||||
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
|
||||
}
|
||||
|
||||
@@ -153,7 +285,7 @@ func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, image
|
||||
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
||||
mimeType := utils.GetImageMimeType(fileType)
|
||||
if mimeType == "" {
|
||||
return common.ErrFileTypeNotSupported
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
// Delete the old image if it has a different file type
|
||||
@@ -201,10 +333,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
||||
}
|
||||
|
||||
// Update existing configuration if it differs from the default
|
||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
|
||||
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
|
||||
}
|
||||
@@ -224,10 +357,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.loadDbConfigFromDb()
|
||||
return s.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromDb() error {
|
||||
// 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++ {
|
||||
@@ -238,8 +372,25 @@ func (s *AppConfigService) loadDbConfigFromDb() error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/netip"
|
||||
|
||||
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) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
|
||||
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.GetIpLocation(ipAddress)
|
||||
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get IP location: %v\n", err)
|
||||
}
|
||||
@@ -48,8 +48,8 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
|
||||
}
|
||||
|
||||
// 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, data model.AuditLogData) model.AuditLog {
|
||||
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
|
||||
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
|
||||
@@ -59,8 +59,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
|
||||
return createdAuditLog
|
||||
}
|
||||
|
||||
// If the user hasn't logged in from the same device before, send an email
|
||||
if count <= 1 {
|
||||
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
|
||||
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
|
||||
go func() {
|
||||
var user model.User
|
||||
s.db.Where("id = ?", userID).First(&user)
|
||||
@@ -85,11 +85,11 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
|
||||
}
|
||||
|
||||
// ListAuditLogsForUser retrieves all audit logs for a given user ID
|
||||
func (s *AuditLogService) ListAuditLogsForUser(userID string, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) {
|
||||
func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
|
||||
var logs []model.AuditLog
|
||||
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
|
||||
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID)
|
||||
|
||||
pagination, err := utils.Paginate(page, pageSize, query, &logs)
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
||||
return logs, pagination, err
|
||||
}
|
||||
|
||||
@@ -97,29 +97,3 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
||||
ua := userAgentParser.Parse(userAgent)
|
||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||
}
|
||||
|
||||
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
|
||||
db, err := maxminddb.Open("GeoLite2-City.mmdb")
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,49 +2,64 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
"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"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
ttemplate "text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
}
|
||||
|
||||
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
}
|
||||
|
||||
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths)
|
||||
textTemplates, err := email.PrepareTextTemplates(emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
}
|
||||
|
||||
return &EmailService{
|
||||
appConfigService: appConfigService,
|
||||
db: db,
|
||||
htmlTemplates: htmlTemplates,
|
||||
textTemplates: textTemplates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||
// Check if SMTP settings are set
|
||||
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
||||
return errors.New("email not enabled")
|
||||
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",
|
||||
@@ -71,24 +86,117 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
||||
)
|
||||
c.Body(body)
|
||||
|
||||
// Set up the authentication information.
|
||||
auth := smtp.PlainAuth("",
|
||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
)
|
||||
// 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
|
||||
err = smtp.SendMail(
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
|
||||
auth,
|
||||
srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||
[]string{toEmail.Email},
|
||||
[]byte(c.String()),
|
||||
)
|
||||
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 based on TLS setting
|
||||
switch srv.appConfigService.DbConfig.SmtpTls.Value {
|
||||
case "none":
|
||||
client, err = smtp.Dial(smtpAddress)
|
||||
case "tls":
|
||||
client, err = smtp.DialTLS(smtpAddress, tlsConfig)
|
||||
case "starttls":
|
||||
client, err = smtp.DialStartTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", srv.appConfigService.DbConfig.SmtpTls.Value)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client.CommandTimeout = 10 * time.Second
|
||||
|
||||
// Send the HELO command
|
||||
if err := srv.sendHelloCommand(client); err != nil {
|
||||
return nil, fmt.Errorf("failed to send HELO command: %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 != "" {
|
||||
// Authenticate with plain auth
|
||||
auth := sasl.NewPlainClient("", smtpUser, smtpPassword)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
// If the server does not support plain auth, try login auth
|
||||
var smtpErr *smtp.SMTPError
|
||||
ok := errors.As(err, &smtpErr)
|
||||
if ok && smtpErr.Code == smtp.ErrAuthUnknownMechanism.Code {
|
||||
auth = sasl.NewLoginClient(smtpUser, smtpPassword)
|
||||
err = client.Auth(auth)
|
||||
}
|
||||
// Both plain and login auth failed
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return client, err
|
||||
}
|
||||
|
||||
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 {
|
||||
// Set the sender
|
||||
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value, nil); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
// Set the recipient
|
||||
if err := client.Rcpt(toEmail.Email, nil); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
// Get a writer to write the email data
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start data: %w", err)
|
||||
}
|
||||
|
||||
// Write the email content
|
||||
_, err = w.Write([]byte(c.String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write email data: %w", err)
|
||||
}
|
||||
|
||||
// Close the writer
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -2,14 +2,15 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"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/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
|
||||
- in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
|
||||
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
|
||||
- Path *must* be ${name}
|
||||
- add xxxTemplate.Path to "emailTemplatePaths" at the end
|
||||
@@ -27,6 +28,20 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||
},
|
||||
}
|
||||
|
||||
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
||||
Path: "one-time-access",
|
||||
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
||||
return "Login Code"
|
||||
},
|
||||
}
|
||||
|
||||
var TestTemplate = email.Template[struct{}]{
|
||||
Path: "test",
|
||||
Title: func(data *email.TemplateData[struct{}]) string {
|
||||
return "Test email"
|
||||
},
|
||||
}
|
||||
|
||||
type NewLoginTemplateData struct {
|
||||
IPAddress string
|
||||
Country string
|
||||
@@ -35,5 +50,11 @@ type NewLoginTemplateData struct {
|
||||
DateTime time.Time
|
||||
}
|
||||
|
||||
type OneTimeAccessTemplateData = struct {
|
||||
Code string
|
||||
LoginLink string
|
||||
LoginLinkWithCode string
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
||||
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")
|
||||
}
|
||||
@@ -8,11 +8,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
@@ -20,6 +15,10 @@ import (
|
||||
"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 (
|
||||
@@ -28,8 +27,8 @@ const (
|
||||
)
|
||||
|
||||
type JwtService struct {
|
||||
publicKey *rsa.PublicKey
|
||||
privateKey *rsa.PrivateKey
|
||||
PublicKey *rsa.PublicKey
|
||||
PrivateKey *rsa.PrivateKey
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
@@ -72,7 +71,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
|
||||
if err != nil {
|
||||
return errors.New("can't read jwt private key: " + err.Error())
|
||||
}
|
||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
if err != nil {
|
||||
return errors.New("can't parse jwt private key: " + err.Error())
|
||||
}
|
||||
@@ -81,7 +80,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
|
||||
if err != nil {
|
||||
return errors.New("can't read jwt public key: " + err.Error())
|
||||
}
|
||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
if err != nil {
|
||||
return errors.New("can't parse jwt public key: " + err.Error())
|
||||
}
|
||||
@@ -96,12 +95,12 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
Subject: user.ID,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
|
||||
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
|
||||
},
|
||||
IsAdmin: user.IsAdmin,
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||
}
|
||||
@@ -109,12 +108,12 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
token.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
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
|
||||
return s.PublicKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("couldn't handle this token")
|
||||
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
|
||||
return nil, errors.New("can't parse claims")
|
||||
}
|
||||
|
||||
if !slices.Contains(claims.Audience, utils.GetHostFromURL(common.EnvConfig.AppURL)) {
|
||||
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
|
||||
return nil, errors.New("audience doesn't match")
|
||||
}
|
||||
return claims, nil
|
||||
@@ -147,7 +146,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||
}
|
||||
@@ -155,7 +154,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
return token.SignedString(s.PrivateKey)
|
||||
}
|
||||
|
||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||
@@ -167,7 +166,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
||||
Issuer: common.EnvConfig.AppURL,
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||
}
|
||||
@@ -175,12 +174,12 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
token.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
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
|
||||
return s.PublicKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("couldn't handle this token")
|
||||
@@ -194,13 +193,30 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
|
||||
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 {
|
||||
if s.PublicKey == nil {
|
||||
return JWK{}, errors.New("public key is not initialized")
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
kid, err := s.generateKeyID(s.PublicKey)
|
||||
if err != nil {
|
||||
return JWK{}, err
|
||||
}
|
||||
@@ -210,8 +226,8 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
||||
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()),
|
||||
N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
|
||||
}
|
||||
|
||||
return jwk, nil
|
||||
@@ -246,14 +262,14 @@ func (s *JwtService) generateKeys() error {
|
||||
if err != nil {
|
||||
return errors.New("failed to generate private key: " + err.Error())
|
||||
}
|
||||
s.privateKey = privateKey
|
||||
s.PrivateKey = privateKey
|
||||
|
||||
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey := &privateKey.PublicKey
|
||||
s.publicKey = publicKey
|
||||
s.PublicKey = publicKey
|
||||
|
||||
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
|
||||
return err
|
||||
@@ -281,32 +297,3 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadKeys loads RSA keys from the given paths.
|
||||
func (s *JwtService) loadKeys() 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 fmt.Errorf("can't read jwt private key: %w", err)
|
||||
}
|
||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't parse jwt private key: %w", err)
|
||||
}
|
||||
|
||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't read jwt public key: %w", err)
|
||||
}
|
||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't parse jwt public key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
309
backend/internal/service/ldap_service.go
Normal file
309
backend/internal/service/ldap_service.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"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 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
|
||||
err := s.db.Where("username = ? AND ldap_id IS NOT NULL", singleMember).First(&databaseUser).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// The user collides with a non-LDAP user, so we skip it
|
||||
continue
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
membersUserId = append(membersUserId, databaseUser.ID)
|
||||
}
|
||||
|
||||
syncGroup := dto.UserGroupCreateDto{
|
||||
Name: value.GetAttributeValue(nameAttribute),
|
||||
FriendlyName: value.GetAttributeValue(nameAttribute),
|
||||
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
|
||||
}
|
||||
|
||||
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, membersUserId); 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, membersUserId)
|
||||
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.userService.DeleteUser(user.ID); 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
|
||||
}
|
||||
@@ -1,102 +1,153 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"slices"
|
||||
"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
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
customClaimService *CustomClaimService
|
||||
}
|
||||
|
||||
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
|
||||
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
|
||||
return &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
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 userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", common.ErrOidcMissingAuthorization
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
|
||||
|
||||
return code, callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||
if err := s.db.Preload("AllowedUserGroups").First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
||||
// 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
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: input.ClientID,
|
||||
Scope: input.Scope,
|
||||
// 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 err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
// Create the authorization code
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||
// 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
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||
if grantType != "authorization_code" {
|
||||
return "", "", common.ErrOidcGrantTypeNotSupported
|
||||
// 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 clientID == "" || clientSecret == "" {
|
||||
return "", "", common.ErrOidcMissingClientCredentials
|
||||
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
|
||||
@@ -104,19 +155,33 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
return "", "", common.ErrOidcClientSecretInvalid
|
||||
// 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
|
||||
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||
if err != nil {
|
||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
|
||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
|
||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
||||
// 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)
|
||||
@@ -138,13 +203,13 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
|
||||
|
||||
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
|
||||
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, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
|
||||
func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
|
||||
var clients []model.OidcClient
|
||||
|
||||
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
|
||||
@@ -153,7 +218,7 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
|
||||
query = query.Where("name LIKE ?", searchPattern)
|
||||
}
|
||||
|
||||
pagination, err := utils.Paginate(page, pageSize, query, &clients)
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
@@ -163,9 +228,12 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
|
||||
|
||||
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||
client := model.OidcClient{
|
||||
Name: input.Name,
|
||||
CallbackURLs: input.CallbackURLs,
|
||||
CreatedByID: userID,
|
||||
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 {
|
||||
@@ -183,6 +251,9 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
||||
|
||||
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
|
||||
@@ -248,7 +319,7 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
|
||||
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||
return common.ErrFileTypeNotSupported
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
||||
@@ -314,6 +385,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
|
||||
if strings.Contains(scope, "email") {
|
||||
claims["email"] = user.Email
|
||||
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
|
||||
}
|
||||
|
||||
if strings.Contains(scope, "groups") {
|
||||
@@ -327,14 +399,35 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
profileClaims := map[string]interface{}{
|
||||
"given_name": user.FirstName,
|
||||
"family_name": user.LastName,
|
||||
"name": user.FirstName + " " + 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
|
||||
@@ -343,19 +436,90 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
||||
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: time.Now().Add(15 * time.Minute),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
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 {
|
||||
@@ -365,13 +529,41 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
||||
return randomString, nil
|
||||
}
|
||||
|
||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||
if inputCallbackURL == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
}
|
||||
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
|
||||
return inputCallbackURL, nil
|
||||
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||
if codeVerifier == "" || codeChallenge == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return "", common.ErrOidcInvalidCallbackURL
|
||||
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{}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,32 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"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/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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) *TestService {
|
||||
return &TestService{db: db, 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 {
|
||||
@@ -56,10 +62,33 @@ func (s *TestService) SeedDatabase() error {
|
||||
}
|
||||
}
|
||||
|
||||
oneTimeAccessTokens := []model.OneTimeAccessToken{{
|
||||
Base: model.Base{
|
||||
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
|
||||
},
|
||||
Token: "HPe6k6uiDRRVuAQV",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
|
||||
},
|
||||
Token: "YCGDtftvsvYWiXd0",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, token := range oneTimeAccessTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
userGroups := []model.UserGroup{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e",
|
||||
ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
|
||||
},
|
||||
Name: "developers",
|
||||
FriendlyName: "Developers",
|
||||
@@ -85,11 +114,12 @@ func (s *TestService) SeedDatabase() error {
|
||||
Base: model.Base{
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
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{
|
||||
@@ -97,8 +127,11 @@ func (s *TestService) SeedDatabase() error {
|
||||
},
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
||||
CreatedByID: users[0].ID,
|
||||
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||
CreatedByID: users[1].ID,
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[1],
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
@@ -111,7 +144,7 @@ func (s *TestService) SeedDatabase() error {
|
||||
Code: "auth-code",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
}
|
||||
@@ -121,7 +154,7 @@ func (s *TestService) SeedDatabase() error {
|
||||
|
||||
accessToken := model.OneTimeAccessToken{
|
||||
Token: "one-time-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
}
|
||||
if err := tx.Create(&accessToken).Error; err != nil {
|
||||
@@ -137,27 +170,31 @@ func (s *TestService) SeedDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey1, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKey2, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||
// 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: "test-credential-1",
|
||||
PublicKey: publicKey1,
|
||||
CredentialID: []byte("test-credential-tim"),
|
||||
PublicKey: publicKeyPasskey1,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Name: "Passkey 2",
|
||||
CredentialID: "test-credential-2",
|
||||
PublicKey: publicKey2,
|
||||
CredentialID: []byte("test-credential-craig"),
|
||||
PublicKey: publicKeyPasskey2,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
UserID: users[1].ID,
|
||||
},
|
||||
}
|
||||
for _, credential := range webauthnCredentials {
|
||||
@@ -168,13 +205,25 @@ func (s *TestService) SeedDatabase() error {
|
||||
|
||||
webauthnSession := model.WebauthnSession{
|
||||
Challenge: "challenge",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserVerification: "preferred",
|
||||
}
|
||||
if err := tx.Create(&webauthnSession).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiKey := model.ApiKey{
|
||||
Base: model.Base{
|
||||
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
||||
},
|
||||
Name: "Test API Key",
|
||||
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
|
||||
UserID: users[0].ID,
|
||||
}
|
||||
if err := tx.Create(&apiKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -182,21 +231,36 @@ func (s *TestService) SeedDatabase() error {
|
||||
func (s *TestService) ResetDatabase() error {
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var tables []string
|
||||
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
|
||||
return err
|
||||
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
// Query to get all tables for SQLite
|
||||
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case common.DbProviderPostgres:
|
||||
// Query to get all tables for PostgreSQL
|
||||
if err := tx.Raw(`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename != 'schema_migrations';
|
||||
`).Scan(&tables).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
|
||||
// Delete all rows from all tables
|
||||
for _, table := range tables {
|
||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
||||
if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.appConfigService.InitDbConfig()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -206,16 +270,78 @@ func (s *TestService) ResetApplicationImages() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil {
|
||||
log.Printf("Error copying directory: %v", err)
|
||||
files, err := resources.FS.ReadDir("images")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
srcFilePath := filepath.Join("images", file.Name())
|
||||
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
|
||||
|
||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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 getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||
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)
|
||||
|
||||
@@ -2,34 +2,48 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
|
||||
"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
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
||||
return &UserGroupService{db: db}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
query := s.db.Model(&model.UserGroup{})
|
||||
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
|
||||
|
||||
if name != "" {
|
||||
query = query.Where("name LIKE ?", "%"+name+"%")
|
||||
}
|
||||
|
||||
response, err = utils.Paginate(page, pageSize, query, &groups)
|
||||
// As userCount is not a column we need to manually sort it
|
||||
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
|
||||
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
|
||||
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
|
||||
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
|
||||
Group("user_groups.id").
|
||||
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
|
||||
|
||||
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
|
||||
return groups, response, err
|
||||
}
|
||||
|
||||
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
|
||||
return groups, response, err
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
||||
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
|
||||
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
|
||||
return group, err
|
||||
}
|
||||
|
||||
@@ -39,6 +53,11 @@ func (s *UserGroupService) Delete(id string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -48,43 +67,52 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
|
||||
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.ErrNameAlreadyInUse
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
}
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allowLdapUpdate bool) (group model.UserGroup, err error) {
|
||||
group, err = s.Get(id)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
// 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.ErrNameAlreadyInUse
|
||||
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) {
|
||||
func (s *UserGroupService) UpdateUsers(id string, userIds []string) (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
|
||||
// Fetch the users based on the userIds
|
||||
var users []model.User
|
||||
if len(input.UserIDs) > 0 {
|
||||
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
|
||||
if len(userIds) > 0 {
|
||||
if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,39 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||
|
||||
"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
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService}
|
||||
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, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
||||
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
|
||||
var users []model.User
|
||||
query := s.db.Model(&model.User{})
|
||||
|
||||
@@ -28,22 +43,106 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
|
||||
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
pagination, err := utils.Paginate(page, pageSize, query, &users)
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
|
||||
return users, pagination, err
|
||||
}
|
||||
|
||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.Where("id = ?", userID).First(&user).Error
|
||||
err := s.db.Preload("UserGroups").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) GetUserGroups(userID string) ([]model.UserGroup, error) {
|
||||
var user model.User
|
||||
if err := s.db.Preload("UserGroups").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user.UserGroups, 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{}
|
||||
}
|
||||
|
||||
// Delete the profile picture
|
||||
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
|
||||
if err := os.Remove(profilePicturePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Delete(&user).Error
|
||||
}
|
||||
|
||||
@@ -55,6 +154,10 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
|
||||
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)
|
||||
@@ -64,11 +167,17 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {
|
||||
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -87,15 +196,69 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
||||
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
|
||||
if isDisabled {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
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(15*time.Minute))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s/lc", common.EnvConfig.AppURL)
|
||||
linkWithCode := fmt.Sprintf("%s/%s", link, oneTimeAccessToken)
|
||||
|
||||
// Add redirect path to the link
|
||||
if strings.HasPrefix(redirectPath, "/") {
|
||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := SendEmail(s.emailService, email.Address{
|
||||
Name: user.Username,
|
||||
Email: user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
})
|
||||
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)
|
||||
tokenLength := 16
|
||||
|
||||
// If expires at is less than 15 minutes, use an 6 character token instead of 16
|
||||
if expiresAt.Sub(time.Now()) <= 15*time.Minute {
|
||||
tokenLength = 6
|
||||
}
|
||||
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oneTimeAccessToken := model.OneTimeAccessToken{
|
||||
UserID: userID,
|
||||
ExpiresAt: expiresAt,
|
||||
ExpiresAt: datatype.DateTime(expiresAt),
|
||||
Token: randomString,
|
||||
}
|
||||
|
||||
@@ -106,11 +269,11 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
||||
return oneTimeAccessToken.Token, nil
|
||||
}
|
||||
|
||||
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
||||
func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
if err := s.db.Where("token = ? AND expires_at > ?", token, utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", common.ErrTokenInvalidOrExpired
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -123,16 +286,47 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if ipAddress != "" && userAgent != "" {
|
||||
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
|
||||
}
|
||||
|
||||
return oneTimeAccessToken.User, accessToken, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) {
|
||||
user, err = s.GetUser(id)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Fetch the groups based on userGroupIds
|
||||
var groups []model.UserGroup
|
||||
if len(userGroupIds) > 0 {
|
||||
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the current groups with the new set of groups
|
||||
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Save the updated user
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
return user, 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.ErrSetupAlreadyCompleted
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
@@ -148,7 +342,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||
}
|
||||
|
||||
if len(user.Credentials) > 0 {
|
||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
}
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(user)
|
||||
@@ -162,11 +356,11 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||
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.ErrEmailTaken
|
||||
return &common.AlreadyInUseError{Property: "email"}
|
||||
}
|
||||
|
||||
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
||||
return common.ErrUsernameTaken
|
||||
return &common.AlreadyInUseError{Property: "username"}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"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 {
|
||||
@@ -22,7 +24,7 @@ type WebAuthnService struct {
|
||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||
webauthnConfig := &webauthn.Config{
|
||||
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
||||
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
|
||||
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
|
||||
RPOrigins: []string{common.EnvConfig.AppURL},
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{
|
||||
@@ -55,7 +57,7 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
|
||||
}
|
||||
|
||||
sessionToStore := &model.WebauthnSession{
|
||||
ExpiresAt: session.Expires,
|
||||
ExpiresAt: datatype.DateTime(session.Expires),
|
||||
Challenge: session.Challenge,
|
||||
UserVerification: string(session.UserVerification),
|
||||
}
|
||||
@@ -79,7 +81,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
||||
|
||||
session := webauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
UserID: []byte(userID),
|
||||
}
|
||||
|
||||
@@ -95,7 +97,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
||||
|
||||
credentialToStore := model.WebauthnCredential{
|
||||
Name: "New Passkey",
|
||||
CredentialID: string(credential.ID),
|
||||
CredentialID: credential.ID,
|
||||
AttestationType: credential.AttestationType,
|
||||
PublicKey: credential.PublicKey,
|
||||
Transport: credential.Transport,
|
||||
@@ -117,7 +119,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
sessionToStore := &model.WebauthnSession{
|
||||
ExpiresAt: session.Expires,
|
||||
ExpiresAt: datatype.DateTime(session.Expires),
|
||||
Challenge: session.Challenge,
|
||||
UserVerification: string(session.UserVerification),
|
||||
}
|
||||
@@ -133,7 +135,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
||||
func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
||||
var storedSession model.WebauthnSession
|
||||
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
||||
return model.User{}, "", err
|
||||
@@ -141,7 +143,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
||||
|
||||
session := webauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
}
|
||||
|
||||
var user *model.User
|
||||
@@ -156,16 +158,12 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(*user)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
|
||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
|
||||
|
||||
return *user, token, nil
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"io/fs"
|
||||
"path"
|
||||
ttemplate "text/template"
|
||||
)
|
||||
|
||||
const templateComponentsDir = "components"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
type Template[V any] struct {
|
||||
Path string
|
||||
@@ -35,36 +35,37 @@ type pareseable[V any] interface {
|
||||
ParseFS(fs.FS, ...string) (V, error)
|
||||
}
|
||||
|
||||
func prepareTemplate[V pareseable[V]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) {
|
||||
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
|
||||
tmpl, err := rootTemplate.Clone()
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("clone root html template: %w", err)
|
||||
return *new(V), fmt.Errorf("clone root template: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s%s", template, suffix)
|
||||
_, err = tmpl.ParseFS(templateDir, filename)
|
||||
templatePath := path.Join("email-templates", filename)
|
||||
_, err = tmpl.ParseFS(templateFS, templatePath)
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err)
|
||||
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) {
|
||||
components := path.Join(templateComponentsDir, "*_text.tmpl")
|
||||
rootTmpl, err := ttemplate.ParseFS(templateDir, components)
|
||||
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
|
||||
components := path.Join("email-templates", "components", "*_text.tmpl")
|
||||
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||
}
|
||||
|
||||
var textTemplates = make(map[string]*ttemplate.Template, len(templates))
|
||||
textTemplates := make(map[string]*ttemplate.Template, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
rootTmplClone, err := rootTmpl.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone root template: %w", err)
|
||||
}
|
||||
|
||||
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl")
|
||||
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||
}
|
||||
@@ -73,21 +74,21 @@ func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*tt
|
||||
return textTemplates, nil
|
||||
}
|
||||
|
||||
func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) {
|
||||
components := path.Join(templateComponentsDir, "*_html.tmpl")
|
||||
rootTmpl, err := htemplate.ParseFS(templateDir, components)
|
||||
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
|
||||
components := path.Join("email-templates", "components", "*_html.tmpl")
|
||||
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||
}
|
||||
|
||||
var htmlTemplates = make(map[string]*htemplate.Template, len(templates))
|
||||
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
rootTmplClone, err := rootTmpl.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone root template: %w", err)
|
||||
}
|
||||
|
||||
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl")
|
||||
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func GetFileExtension(filename string) string {
|
||||
@@ -28,27 +30,8 @@ func GetImageMimeType(ext string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func CopyDirectory(srcDir, destDir string) error {
|
||||
files, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
srcFilePath := filepath.Join(srcDir, file.Name())
|
||||
destFilePath := filepath.Join(destDir, file.Name())
|
||||
|
||||
err := CopyFile(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyFile(srcFilePath, destFilePath string) error {
|
||||
srcFile, err := os.Open(srcFilePath)
|
||||
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
|
||||
srcFile, err := resources.FS.Open(srcFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
11
backend/internal/utils/hash_util.go
Normal file
11
backend/internal/utils/hash_util.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func CreateSha256Hash(input string) string {
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
97
backend/internal/utils/image/profile_picture.go
Normal file
97
backend/internal/utils/image/profile_picture.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package profilepicture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/disintegration/imageorient"
|
||||
"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 := imageorient.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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type PaginationResponse struct {
|
||||
@@ -11,13 +12,42 @@ type PaginationResponse struct {
|
||||
ItemsPerPage int `json:"itemsPerPage"`
|
||||
}
|
||||
|
||||
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
type SortedPaginationRequest struct {
|
||||
Pagination struct {
|
||||
Page int `form:"pagination[page]"`
|
||||
Limit int `form:"pagination[limit]"`
|
||||
} `form:"pagination"`
|
||||
Sort struct {
|
||||
Column string `form:"sort[column]"`
|
||||
Direction string `form:"sort[direction]"`
|
||||
} `form:"sort"`
|
||||
}
|
||||
|
||||
func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
pagination := sortedPaginationRequest.Pagination
|
||||
sort := sortedPaginationRequest.Sort
|
||||
|
||||
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
|
||||
|
||||
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
|
||||
isSortable := sortField.Tag.Get("sortable") == "true"
|
||||
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
|
||||
|
||||
if sortFieldFound && isSortable && isValidSortOrder {
|
||||
query = query.Order(CamelCaseToSnakeCase(sort.Column) + " " + sort.Direction)
|
||||
}
|
||||
|
||||
return Paginate(pagination.Page, pagination.Limit, query, result)
|
||||
|
||||
}
|
||||
|
||||
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
pageSize = 20
|
||||
} else if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
@@ -25,16 +55,21 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var totalItems int64
|
||||
if err := db.Count(&totalItems).Error; err != nil {
|
||||
if err := query.Count(&totalItems).Error; err != nil {
|
||||
return PaginationResponse{}, err
|
||||
}
|
||||
|
||||
if err := db.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
|
||||
if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
|
||||
return PaginationResponse{}, err
|
||||
}
|
||||
|
||||
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
|
||||
if totalItems == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return PaginationResponse{
|
||||
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
||||
TotalPages: totalPages,
|
||||
TotalItems: totalItems,
|
||||
CurrentPage: page,
|
||||
ItemsPerPage: pageSize,
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
||||
@@ -29,15 +32,44 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func GetHostFromURL(rawURL string) string {
|
||||
func GetHostnameFromURL(rawURL string) string {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsedURL.Host
|
||||
return parsedURL.Hostname()
|
||||
}
|
||||
|
||||
// StringPointer creates a string pointer from a string value
|
||||
func StringPointer(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func CapitalizeFirstLetter(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func CamelCaseToSnakeCase(s string) string {
|
||||
var result []rune
|
||||
for i, r := range s {
|
||||
if unicode.IsUpper(r) && i > 0 {
|
||||
result = append(result, '_')
|
||||
}
|
||||
result = append(result, unicode.ToLower(r))
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
33
backend/internal/utils/systemd/sdnotify.go
Normal file
33
backend/internal/utils/systemd/sdnotify.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package systemd
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
)
|
||||
|
||||
// SdNotifyReady sends a message to the systemd daemon to notify that service is ready to operate.
|
||||
// It is common to ignore the error.
|
||||
func SdNotifyReady() error {
|
||||
socketAddr := &net.UnixAddr{
|
||||
Name: os.Getenv("NOTIFY_SOCKET"),
|
||||
Net: "unixgram",
|
||||
}
|
||||
|
||||
if socketAddr.Name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
if _, err = conn.Write([]byte("READY=1")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
92
backend/resources/email-templates/components/style_html.tmpl
Normal file
92
backend/resources/email-templates/components/style_html.tmpl
Normal file
@@ -0,0 +1,92 @@
|
||||
{{ define "style" }}
|
||||
<style>
|
||||
/* Reset styles for email clients */
|
||||
body, table, td, p, a {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
body {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 32px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
margin: auto 0 auto auto;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid td {
|
||||
width: 50%;
|
||||
padding-bottom: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.button {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
padding: 0.7rem 1.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,40 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<table class="grid">
|
||||
<tr>
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<td>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</td>
|
||||
{{ end }}
|
||||
<td>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="message">
|
||||
This sign-in was detected from a new device or location. If you recognize this activity, you can
|
||||
safely ignore this message. If not, please review your account and security settings.
|
||||
</p>
|
||||
</div>
|
||||
{{ end -}}
|
||||
17
backend/resources/email-templates/one-time-access_html.tmpl
Normal file
17
backend/resources/email-templates/one-time-access_html.tmpl
Normal file
@@ -0,0 +1,17 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Login Code</h2>
|
||||
<p class="message">
|
||||
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in 15 minutes.
|
||||
</p>
|
||||
<div class="button-container">
|
||||
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end -}}
|
||||
10
backend/resources/email-templates/one-time-access_text.tmpl
Normal file
10
backend/resources/email-templates/one-time-access_text.tmpl
Normal file
@@ -0,0 +1,10 @@
|
||||
{{ define "base" -}}
|
||||
Login Code
|
||||
====================
|
||||
|
||||
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes.
|
||||
|
||||
{{ .Data.LoginLinkWithCode }}
|
||||
|
||||
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}".
|
||||
{{ end -}}
|
||||
11
backend/resources/email-templates/test_html.tmpl
Normal file
11
backend/resources/email-templates/test_html.tmpl
Normal file
@@ -0,0 +1,11 @@
|
||||
{{ define "base" -}}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>This is a test email.</p>
|
||||
</div>
|
||||
{{ end -}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user