Compare commits

...

167 Commits

Author SHA1 Message Date
Elias Schneider
712ff396f4 release: 0.25.0 2025-01-19 20:18:07 +01:00
Elias Schneider
090eca202d fix: improve spacing of checkboxes on application configuration page 2025-01-19 19:15:11 +01:00
Elias Schneider
d4055af3f4 tests: adapt OIDC tests 2025-01-19 15:56:54 +01:00
Elias Schneider
692ff70c91 refactor: run formatter 2025-01-19 15:41:16 +01:00
Elias Schneider
d5dd118a3f feat: automatically authorize client if signed in 2025-01-19 15:39:55 +01:00
Elias Schneider
06b90eddd6 feat: allow sign in with email (#100) 2025-01-19 15:30:31 +01:00
Elias Schneider
e284e352e2 fix: don't panic if LDAP sync fails on startup 2025-01-19 13:09:16 +01:00
Kyle Mendell
5101b14eec feat: add LDAP sync (#106)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-19 13:02:07 +01:00
Elias Schneider
bc8f454ea1 fix: session duration ignored in cookie expiration 2025-01-18 23:27:55 +01:00
Chris Danis
fda08ac1cd fix: always set secure on cookie (#130) 2025-01-18 22:33:41 +01:00
Elias Schneider
05a98ebe87 fix: search input not displayed if response hasn't any items 2025-01-18 22:29:20 +01:00
Elias Schneider
6e3728ddc8 docs: add guide to setup Pocket ID with Caddy 2025-01-15 14:05:44 +01:00
Elias Schneider
5c57beb4d7 release: 0.24.1 2025-01-13 15:14:10 +01:00
Elias Schneider
2a984eeaf1 docs: add account recovery to README 2025-01-13 15:13:56 +01:00
Elias Schneider
be6e25a167 fix: remove restrictive validation for group names 2025-01-13 12:38:02 +01:00
Elias Schneider
888557171d fix: optional arguments not working with create-one-time-access-token.sh 2025-01-13 12:32:22 +01:00
Elias Schneider
4d337a20c5 fix: audit log table overflow if row data is long 2025-01-12 01:21:47 +01:00
Elias Schneider
69afd9ad9f release: 0.24.0 2025-01-11 23:46:39 +01:00
Elias Schneider
fd69830c26 feat: add sorting for tables 2025-01-11 20:32:22 +01:00
Elias Schneider
61d18a9d1b fix: pkce state not correctly reflected in oidc client info 2025-01-10 09:32:51 +01:00
Elias Schneider
a649c4b4a5 fix: send test email to the user that has requested it 2025-01-10 09:25:26 +01:00
Elias Schneider
82e475a923 release: 0.23.0 2025-01-03 16:34:23 +01:00
Elias Schneider
2d31fc2cc9 feat: use same table component for OIDC client list as all other lists 2025-01-03 16:19:15 +01:00
Elias Schneider
adcf3ddc66 feat: add PKCE for non public clients 2025-01-03 16:15:10 +01:00
Elias Schneider
785200de61 chore: include static assets in binary 2025-01-03 15:12:07 +01:00
Elias Schneider
ee885fbff5 release: 0.22.0 2025-01-01 23:13:53 +01:00
Elias Schneider
333a1a18d5 fix: make user validation consistent between pages 2025-01-01 23:13:16 +01:00
Elias Schneider
1ff20caa3c fix: allow first and last name of user to be between 1 and 50 characters 2025-01-01 22:48:51 +01:00
Elias Schneider
f6f2736bba fix: hash in callback url is incorrectly appended 2025-01-01 22:46:59 +01:00
Elias Schneider
993330d932 Merge remote-tracking branch 'origin/main' 2025-01-01 22:46:29 +01:00
Jan-Philipp Fischer
204313aacf docs: add "groups" scope to the oauth2-proxy sample configuration (#85) 2024-12-31 11:31:39 +01:00
Elias Schneider
0729ce9e1a fix: passkey can't be added if PUBLIC_APP_URL includes a port 2024-12-31 10:42:54 +01:00
Elias Schneider
2d0bd8dcbf feat: add warning if passkeys missing 2024-12-23 09:59:12 +01:00
Elias Schneider
ff75322e7d docs: improve text in README 2024-12-20 08:20:40 +01:00
Elias Schneider
daced661c4 release: 0.21.0 2024-12-17 19:58:55 +01:00
Elias Schneider
0716c38fb8 feat: improve error state design for login page 2024-12-17 19:36:47 +01:00
Elias Schneider
789d9394a5 fix: OIDC client logo gets removed if other properties get updated 2024-12-17 19:00:33 +01:00
Elias Schneider
aeda512cb7 release: 0.20.1 2024-12-13 09:12:37 +01:00
Elias Schneider
5480ab0f18 tests: add e2e test for one time access tokens 2024-12-13 09:03:52 +01:00
Elias Schneider
bad901ea2b fix: wrong date time datatype used for read operations with Postgres 2024-12-13 08:43:46 +01:00
Elias Schneider
34e35193f9 fix: create-one-time-access-token.sh script not compatible with postgres 2024-12-12 23:03:07 +01:00
Elias Schneider
232c13b5ca release: 0.20.0 2024-12-12 17:21:58 +01:00
Elias Schneider
9d20a98dbb feat: add support for Postgres database provider (#79) 2024-12-12 17:21:28 +01:00
Elias Schneider
e9d83dd6c3 docs: add ghcr.io Docker image to docker-compose.yml 2024-12-12 17:18:25 +01:00
Elias Schneider
3006bc9ef7 docs: add callback url to proxy-services.md 2024-12-03 20:35:47 +01:00
Elias Schneider
ae1e2f5e77 release: 0.19.0 2024-11-29 23:17:26 +01:00
soup
edce3d3371 feat(geolite): add Tailscale IP detection with CGNAT range check (#77)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-11-29 23:17:08 +01:00
Elias Schneider
9a8ec15678 docs: add demo link 2024-11-29 20:24:26 +01:00
Elias Schneider
62cdab2b59 release: 0.18.0 2024-11-28 12:34:15 +01:00
Elias Schneider
f2bfc73158 fix: email save toast shows two times 2024-11-28 12:28:39 +01:00
Elias Schneider
a9f4dada32 feat: allow empty user and password in SMTP configuration 2024-11-28 12:14:03 +01:00
Elias Schneider
f9fa2c6706 feat: add option to disable TLS for email sending 2024-11-28 12:13:23 +01:00
Elias Schneider
7d6b1d19e9 docs: add PUID and PGID to .env.example 2024-11-26 21:03:53 +01:00
Elias Schneider
31a6b57ec1 docs: improve MAXMIND_LICENSE_KEY documentation in readme 2024-11-26 20:45:34 +01:00
Elias Schneider
f11ed44733 release: 0.17.0 2024-11-26 20:35:54 +01:00
Elias Schneider
541481721f Merge remote-tracking branch 'origin/main' 2024-11-26 20:20:03 +01:00
Chris Danis
0e95e9c56f fix: don't try to create a new user if the Docker user is not root (#71) 2024-11-26 20:19:40 +01:00
Elias Schneider
fcf08a4d89 feat!: add option to specify the Max Mind license key for the Geolite2 db 2024-11-26 20:14:31 +01:00
Elias Schneider
0b4101ccce docs: fix OAuth2 proxy link in readme 2024-11-24 18:59:07 +01:00
Elias Schneider
27ea1fc2d3 release: 0.16.0 2024-11-24 18:55:51 +01:00
Alexander Lehmann
f637a89f57 feat: improve error message for invalid callback url 2024-11-24 18:54:46 +01:00
Elias Schneider
058084ed64 feat: add health check 2024-11-24 18:53:32 +01:00
Elias Schneider
9370292fe5 release: 0.15.0 2024-11-21 18:46:15 +01:00
Elias Schneider
46eef1fcb7 chore: make Docker image run without root user (#67) 2024-11-21 18:44:43 +01:00
Elias Schneider
e784093342 fix: mobile layout overflow on application configuration page 2024-11-21 18:41:21 +01:00
Elias Schneider
653d948f73 feat: add option to skip TLS certificate check and ability to send test email 2024-11-21 18:24:01 +01:00
Elias Schneider
a1302ef7bf refactor: move checkboxes with label in seperate component 2024-11-21 14:28:23 +01:00
Elias Schneider
5f44fef85f ci/cd: add Docker image to ghcr.io and add Docker metadata action 2024-11-21 13:11:08 +01:00
Elias Schneider
3613ac261c feat: add PKCE support 2024-11-17 17:13:38 +01:00
Elias Schneider
760c8e83bb docs: add info that PKCE isn't implemented yet 2024-11-15 11:20:28 +01:00
Elias Schneider
3f29325f45 release: 0.14.0 2024-11-11 18:26:15 +01:00
Elias Schneider
aca2240a50 feat: add audit log event for one time access token sign in 2024-11-11 18:25:57 +01:00
Elias Schneider
de45398903 fix: overflow of pagination control on mobile 2024-11-11 18:09:17 +01:00
Elias Schneider
3d3fb4d855 fix: time displayed incorrectly in audit log 2024-11-11 18:02:19 +01:00
Elias Schneider
725388fcc7 chore: fix build warnings 2024-11-02 00:04:27 +01:00
Elias Schneider
ad1d3560f9 release: 0.13.1 2024-11-01 23:52:30 +01:00
Elias Schneider
becfc0004a feat: add list empty indicator 2024-11-01 23:52:01 +01:00
Elias Schneider
376d747616 fix: errors in middleware do not abort the request 2024-11-01 23:41:57 +01:00
Elias Schneider
5b9f4d7326 fix: typo in Self-Account Editing description 2024-11-01 23:33:50 +01:00
Elias Schneider
0de4b55dc4 release: 0.13.0 2024-10-31 18:13:54 +01:00
Elias Schneider
78c88f5339 docs: add nginx configuration to README 2024-10-31 18:13:18 +01:00
Elias Schneider
60e7dafa01 Revert "fix: bad gateway error if nginx reverse proxy is in front"
This reverts commit 590cb02f6c.
2024-10-31 17:50:52 +01:00
Elias Schneider
2ccabf835c feat: add ability to define expiration of one time link 2024-10-31 17:22:58 +01:00
Elias Schneider
590cb02f6c fix: bad gateway error if nginx reverse proxy is in front 2024-10-31 14:15:57 +01:00
Elias Schneider
8c96ab9574 Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-10-30 11:53:44 +01:00
Elias Schneider
3484daf870 chore: change default port in dockerfile 2024-10-30 11:53:36 +01:00
Kevin Cayouette
cfbc0d6d35 docs: add Jellyfin Integration Guide (#51) 2024-10-28 18:55:16 +01:00
Elias Schneider
939601b6a4 release: 0.12.0 2024-10-28 18:51:17 +01:00
Elias Schneider
b9daa5d757 tests: fix custom claims test data 2024-10-28 18:50:55 +01:00
Elias Schneider
8304065652 feat: add option to disable self-account editing 2024-10-28 18:45:27 +01:00
Elias Schneider
7bfc3f43a5 feat: add validation to custom claim input 2024-10-28 18:34:25 +01:00
Elias Schneider
c056089c60 feat: custom claims (#53) 2024-10-28 18:11:54 +01:00
Elias Schneider
3350398abc tests: correctly reset app config in tests 2024-10-26 00:15:31 +02:00
Elias Schneider
0b0a6781ff ci/cd: fix html reporting of playwright 2024-10-26 00:15:01 +02:00
Elias Schneider
735dc70d5f tests: fix flaky playwright tests 2024-10-25 22:48:46 +02:00
Elias Schneider
47e164b4b5 release: 0.11.0 2024-10-25 21:53:25 +02:00
Elias Schneider
18c5103c20 fix: powered by link text color in light mode 2024-10-25 21:35:27 +02:00
Elias Schneider
5565f60d6d feat: add email_verified claim 2024-10-25 21:33:54 +02:00
Elias Schneider
bd4f87b2d2 release: 0.10.0 2024-10-23 11:54:47 +02:00
Elias Schneider
6560fd9279 chore: fix wrong file name of package.json in release script 2024-10-23 11:54:35 +02:00
Elias Schneider
29d632c151 fix: cache version information for 3 hours 2024-10-23 11:48:46 +02:00
Elias Schneider
2092007752 chore: dump frontend dependencies 2024-10-23 11:37:22 +02:00
Elias Schneider
0aff6181c9 chore: improve check of required tools in one time access token script 2024-10-23 10:50:49 +02:00
Elias Schneider
824c5cb4f3 fix: no DTO was returned from exchange one time access token endpoint 2024-10-23 10:30:25 +02:00
Elias Schneider
3a300a2b51 refactor: move development scripts into seperate folder 2024-10-23 10:26:18 +02:00
Elias Schneider
a1985ce1b2 feat: add script for creating one time access token 2024-10-23 10:03:17 +02:00
Elias Schneider
b39bc4f79a refactor: save dates as unix timestamps in database 2024-10-23 10:02:11 +02:00
Elias Schneider
0a07344139 fix: improve text for initial admin account setup 2024-10-22 20:41:35 +02:00
Elias Schneider
f3f0e1d56d fix: increase callback url count 2024-10-18 20:52:56 +02:00
Elias Schneider
70ad0b4f39 feat: add version information to footer and update link if new update is available 2024-10-18 20:48:59 +02:00
Elias Schneider
2587058ded release: 0.9.0 2024-10-18 08:23:55 +02:00
Elias Schneider
ff06bf0b34 feat: add environment variable to change the caddy port in Docker 2024-10-18 08:23:06 +02:00
Elias Schneider
11ed661f86 feat: use improve table for users and audit logs 2024-10-16 08:49:19 +02:00
Elias Schneider
29748cc6c7 fix: allow copy to clipboard for client secret 2024-10-13 15:55:17 +02:00
Elias Schneider
edfb99d221 release: 0.8.1 2024-10-11 20:53:47 +02:00
Elias Schneider
282ff82b0c fix: add key id to JWK 2024-10-11 20:52:31 +02:00
Elias Schneider
9d5f83da78 chore: dump dependencies 2024-10-04 14:15:04 +02:00
Elias Schneider
896da812a3 ci/cd: create dummy GeoLite2 City database for e2e tests 2024-10-04 12:17:32 +02:00
Elias Schneider
d2b3b7647d release: 0.8.0 2024-10-04 12:11:43 +02:00
Elias Schneider
025378d14e feat: add location based on ip to the audit log 2024-10-04 12:11:10 +02:00
Elias Schneider
e033ba6d45 release: 0.7.1 2024-10-03 22:20:37 +02:00
Elias Schneider
e09562824a fix: initials don't get displayed if Gravatar avatar doesn't exist 2024-10-03 22:20:22 +02:00
Elias Schneider
08f7fd16a9 release: 0.7.0 2024-10-03 11:31:11 +02:00
Elias Schneider
be45eed125 feat!: add ability to set light and dark mode logo 2024-10-03 11:27:31 +02:00
Elias Schneider
9e94a436cc release: 0.6.0 2024-10-02 11:13:46 +02:00
Elias Schneider
f82020ccfb feat: add copy to clipboard option for OIDC client information 2024-10-02 11:03:30 +02:00
Elias Schneider
a4a90a16a9 fix: only return user groups if it is explicitly requested 2024-10-02 10:41:10 +02:00
Elias Schneider
365734ec5d feat: add gravatar profile picture integration 2024-10-02 10:02:28 +02:00
Elias Schneider
d02d8931a0 tests: add user group tests 2024-10-02 09:38:57 +02:00
Elias Schneider
24c948e6a6 feat: add user groups 2024-10-02 08:43:44 +02:00
Elias Schneider
7a54d3ae20 refactor: format caddyfiles 2024-09-27 11:10:33 +02:00
Elias Schneider
5e1d19e0a4 release: 0.5.3 2024-09-26 09:36:33 +02:00
edbourque0
d6a9bb4c09 fix: add space to "Firstname" and "Lastname" label (#31)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-09-26 09:33:02 +02:00
Elias Schneider
3c67765992 fix: port environment variables get ignored in caddyfile 2024-09-26 09:08:59 +02:00
Elias Schneider
6bb613e0e7 chore: set the go version to 1.23.1 2024-09-19 08:56:30 +02:00
Elias Schneider
7be115f7da release: 0.5.2 2024-09-19 08:52:16 +02:00
Elias Schneider
924bb1468b fix: updated application name doesn't apply to webauthn credential 2024-09-19 08:51:45 +02:00
Elias Schneider
4553458939 release: 0.5.1 2024-09-16 23:19:13 +02:00
Elias Schneider
9c2848db1d fix: debounce oidc client and user search 2024-09-16 23:18:55 +02:00
oidq
64cf56276a feat(email): improve email templating (#27) 2024-09-16 23:10:08 +02:00
Elias Schneider
1f0ec08290 release: 0.5.0 2024-09-09 10:30:12 +02:00
Elias Schneider
9121239dd7 feat: add audit log with email notification (#26) 2024-09-09 10:29:41 +02:00
Elias Schneider
4010ee27d6 release: 0.4.1 2024-09-06 09:24:42 +02:00
Elias Schneider
4e7574a297 feat: add name claim to userinfo endpoint and id token 2024-09-06 09:19:13 +02:00
Elias Schneider
8038a111dd fix: show error message if error occurs while authorizing new client 2024-09-06 08:58:23 +02:00
Elias Schneider
c6f83a581a fix: limit width of content on large screens 2024-09-06 08:57:48 +02:00
Elias Schneider
8ad632e6c1 release: 0.4.0 2024-09-03 22:42:22 +02:00
Elias Schneider
903b0b3918 feat: add support for more username formats 2024-09-03 22:35:18 +02:00
Elias Schneider
fd21ce5aac feat: add setup details to oidc client details 2024-09-03 22:24:29 +02:00
Elias Schneider
e7861df95a fix: non pointer passed to create user 2024-08-28 08:43:44 +02:00
Elias Schneider
8e27320649 refactor: rename user service 2024-08-28 08:22:27 +02:00
Elias Schneider
2b9413c757 fix: typo in hasLogo property of oidc dto 2024-08-28 08:21:46 +02:00
Elias Schneider
fd5a881cfb Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-27 23:27:03 +02:00
Elias Schneider
28ed064668 fix: oidc client logo not displayed on authorize page 2024-08-27 23:26:56 +02:00
Elias Schneider
5446b46b65 Merge pull request #17 from stonith404/imgbot
[ImgBot] Optimize images
2024-08-26 13:59:58 +02:00
ImgBotApp
0ce6045657 [ImgBot] Optimize images
*Total -- 4,659.87kb -> 4,503.61kb (3.35%)

/frontend/tests/assets/nextcloud-logo.png -- 163.47kb -> 87.99kb (46.17%)
/frontend/tests/assets/pingvin-share-logo.png -- 85.61kb -> 58.04kb (32.2%)
/backend/images/logo.svg -- 0.68kb -> 0.53kb (22.56%)
/frontend/tests/assets/clouds.jpg -- 577.56kb -> 528.14kb (8.56%)
/backend/images/background.jpg -- 3,832.55kb -> 3,828.92kb (0.09%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-08-26 11:10:18 +00:00
Elias Schneider
3fe24a04de release: 0.3.1 2024-08-24 12:59:23 +02:00
Elias Schneider
6769cc8c10 tests: fix missing host in cleanup request 2024-08-24 12:50:27 +02:00
Elias Schneider
97f7fc4e28 fix: empty lists don't get returned correctly from the api 2024-08-24 12:44:02 +02:00
Elias Schneider
fc47c2a2a4 chore: upgrade dependencies 2024-08-24 12:43:22 +02:00
Elias Schneider
f1a6c8db85 release: 0.3.0 2024-08-24 01:20:18 +02:00
Elias Schneider
552d7ccfa5 fix: db migration for multiple callback urls 2024-08-24 01:12:33 +02:00
Elias Schneider
e45b0b3ed0 Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-24 00:49:11 +02:00
Elias Schneider
8166e2ead7 feat: add support for multiple callback urls 2024-08-24 00:49:08 +02:00
Elias Schneider
ae7aeb0945 refactor: use dtos in controllers 2024-08-23 17:04:19 +02:00
Elias Schneider
16f273ffce docs: compress screenshot in README 2024-08-23 16:39:09 +02:00
Elias Schneider
9f49e5577e docs: add proxy guide 2024-08-20 22:40:28 +02:00
339 changed files with 10632 additions and 3088 deletions

18
.dockerignore Normal file
View 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

View File

@@ -1 +1,6 @@
# See the README for more information: https://github.com/stonith404/pocket-id?tab=readme-ov-file#environment-variables
PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000
PGID=1000

View File

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

View File

@@ -11,24 +11,43 @@ jobs:
- name: checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
${{ 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 Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: 'Login to GitHub Container Registry'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Build and push
uses: docker/build-push-action@v4
with:
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

View File

@@ -5,21 +5,43 @@ on:
pull_request:
branches: [main]
jobs:
build-and-test:
build:
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
tags: stonith404/pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
test-sqlite:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Build Docker Image
run: docker build -t stonith404/pocket-id .
- name: Run Docker Container
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker Image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
@@ -29,17 +51,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 \
stonith404/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 \
stonith404/pocket-id:test
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: frontend/tests/.output
name: playwright-report-postgres
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15

4
.gitignore vendored
View File

@@ -34,4 +34,6 @@ vite.config.ts.timestamp-*
# Application specific
data
/frontend/tests/.auth
pocket-id-backend
/frontend/tests/.report
pocket-id-backend
/backend/GeoLite2-City.mmdb

View File

@@ -1 +1 @@
0.2.1
0.25.0

View File

@@ -1,3 +1,360 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.24.1...v) (2025-01-19)
### Features
* add LDAP sync ([#106](https://github.com/stonith404/pocket-id/issues/106)) ([5101b14](https://github.com/stonith404/pocket-id/commit/5101b14eec68a9507e1730994178d0ebe8185876))
* allow sign in with email ([#100](https://github.com/stonith404/pocket-id/issues/100)) ([06b90ed](https://github.com/stonith404/pocket-id/commit/06b90eddd645cce57813f2536e4a6a8836548f2b))
* automatically authorize client if signed in ([d5dd118](https://github.com/stonith404/pocket-id/commit/d5dd118a3f4ad6eed9ca496c458201bb10f148a0))
### Bug Fixes
* always set secure on cookie ([#130](https://github.com/stonith404/pocket-id/issues/130)) ([fda08ac](https://github.com/stonith404/pocket-id/commit/fda08ac1cd88842e25dc47395ed1288a5cfac4f8))
* don't panic if LDAP sync fails on startup ([e284e35](https://github.com/stonith404/pocket-id/commit/e284e352e2b95fac1d098de3d404e8531de4b869))
* improve spacing of checkboxes on application configuration page ([090eca2](https://github.com/stonith404/pocket-id/commit/090eca202d198852e6fbf4e6bebaf3b5ada13944))
* search input not displayed if response hasn't any items ([05a98eb](https://github.com/stonith404/pocket-id/commit/05a98ebe87d7a88e8b96b144c53250a40d724ec3))
* session duration ignored in cookie expiration ([bc8f454](https://github.com/stonith404/pocket-id/commit/bc8f454ea173ecc60e06450a1d22e24207f76714))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.0...v) (2025-01-13)
### Bug Fixes
* audit log table overflow if row data is long ([4d337a2](https://github.com/stonith404/pocket-id/commit/4d337a20c5cb92ef80bb7402f9b99b08e3ad0b6b))
* optional arguments not working with `create-one-time-access-token.sh` ([8885571](https://github.com/stonith404/pocket-id/commit/888557171d61589211b10f70dce405126216ad61))
* remove restrictive validation for group names ([be6e25a](https://github.com/stonith404/pocket-id/commit/be6e25a167de8bf07075b46f09d9fc1fa6c74426))
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)
### Features
* add sorting for tables ([fd69830](https://github.com/stonith404/pocket-id/commit/fd69830c2681985e4fd3c5336a2b75c9fb7bc5d4))
### Bug Fixes
* pkce state not correctly reflected in oidc client info ([61d18a9](https://github.com/stonith404/pocket-id/commit/61d18a9d1b167ff59a59523ff00d00ca8f23258d))
* send test email to the user that has requested it ([a649c4b](https://github.com/stonith404/pocket-id/commit/a649c4b4a543286123f4d1f3c411fe1a7e2c6d71))
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)
### Features
* add PKCE for non public clients ([adcf3dd](https://github.com/stonith404/pocket-id/commit/adcf3ddc6682794e136a454ef9e69ddd130626a8))
* use same table component for OIDC client list as all other lists ([2d31fc2](https://github.com/stonith404/pocket-id/commit/2d31fc2cc9201bb93d296faae622f52c6dcdfebc))
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
### Features
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
### Bug Fixes
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
### Features
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
### Bug Fixes
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
### Bug Fixes
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
### Features
* add support for Postgres database provider ([#79](https://github.com/stonith404/pocket-id/issues/79)) ([9d20a98](https://github.com/stonith404/pocket-id/commit/9d20a98dbbc322fa6f0644e8b31e6b97769887ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.18.0...v) (2024-11-29)
### Features
* **geolite:** add Tailscale IP detection with CGNAT range check ([#77](https://github.com/stonith404/pocket-id/issues/77)) ([edce3d3](https://github.com/stonith404/pocket-id/commit/edce3d337129c9c6e8a60df2122745984ba0f3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.17.0...v) (2024-11-28)
### Features
* add option to disable TLS for email sending ([f9fa2c6](https://github.com/stonith404/pocket-id/commit/f9fa2c6706a8bf949fe5efd6664dec8c80e18659))
* allow empty user and password in SMTP configuration ([a9f4dad](https://github.com/stonith404/pocket-id/commit/a9f4dada321841d3611b15775307228b34e7793f))
### Bug Fixes
* email save toast shows two times ([f2bfc73](https://github.com/stonith404/pocket-id/commit/f2bfc731585ad7424eb8c4c41c18368fc0f75ffc))
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
### ⚠ BREAKING CHANGES
* add option to specify the Max Mind license key for the Geolite2 db
### Features
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
### Bug Fixes
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
### Features
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
### Features
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
### Bug Fixes
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
### Features
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
### Bug Fixes
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
### Features
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
### Bug Fixes
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
### Features
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
### Features
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
### Features
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
### Bug Fixes
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
### Features
* add script for creating one time access token ([a1985ce](https://github.com/stonith404/pocket-id/commit/a1985ce1b200550e91c5cb42a8d19899dcec831e))
* add version information to footer and update link if new update is available ([70ad0b4](https://github.com/stonith404/pocket-id/commit/70ad0b4f39699fd81ffdfd5c8d6839f49348be78))
### Bug Fixes
* cache version information for 3 hours ([29d632c](https://github.com/stonith404/pocket-id/commit/29d632c1514d6edacdfebe6deae4c95fc5a0f621))
* improve text for initial admin account setup ([0a07344](https://github.com/stonith404/pocket-id/commit/0a0734413943b1fff27d8f4ccf07587e207e2189))
* increase callback url count ([f3f0e1d](https://github.com/stonith404/pocket-id/commit/f3f0e1d56d7656bdabbd745a4eaf967f63193b6c))
* no DTO was returned from exchange one time access token endpoint ([824c5cb](https://github.com/stonith404/pocket-id/commit/824c5cb4f3d6be7f940c1758112fbe9322df5768))
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
### Features
* add environment variable to change the caddy port in Docker ([ff06bf0](https://github.com/stonith404/pocket-id/commit/ff06bf0b34496ce472ba6d3ebd4ea249f21c0ec3))
* use improve table for users and audit logs ([11ed661](https://github.com/stonith404/pocket-id/commit/11ed661f86a512f78f66d604a10c1d47d39f2c39))
### Bug Fixes
* allow copy to clipboard for client secret ([29748cc](https://github.com/stonith404/pocket-id/commit/29748cc6c7b7e5a6b54bfe837e0b1a98fa1ad594))
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)
### Bug Fixes
* add key id to JWK ([282ff82](https://github.com/stonith404/pocket-id/commit/282ff82b0c7e2414b3528c8ca325758245b8ae61))
## [](https://github.com/stonith404/pocket-id/compare/v0.7.1...v) (2024-10-04)
### Features
* add location based on ip to the audit log ([025378d](https://github.com/stonith404/pocket-id/commit/025378d14edd2d72da76e90799a0ccdd42cf672c))
## [](https://github.com/stonith404/pocket-id/compare/v0.7.0...v) (2024-10-03)
### Bug Fixes
* initials don't get displayed if Gravatar avatar doesn't exist ([e095628](https://github.com/stonith404/pocket-id/commit/e09562824a794bc7d240e9d229709d4b389db7d5))
## [](https://github.com/stonith404/pocket-id/compare/v0.6.0...v) (2024-10-03)
### ⚠ BREAKING CHANGES
* add ability to set light and dark mode logo
### Features
* add ability to set light and dark mode logo ([be45eed](https://github.com/stonith404/pocket-id/commit/be45eed125e33e9930572660a034d5f12dc310ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.3...v) (2024-10-02)
### Features
* add copy to clipboard option for OIDC client information ([f82020c](https://github.com/stonith404/pocket-id/commit/f82020ccfb0d4fbaa1dd98182188149d8085252a))
* add gravatar profile picture integration ([365734e](https://github.com/stonith404/pocket-id/commit/365734ec5d8966c2ab877c60cfb176b9cdc36880))
* add user groups ([24c948e](https://github.com/stonith404/pocket-id/commit/24c948e6a66f283866f6c8369c16fa6cbcfa626c))
### Bug Fixes
* only return user groups if it is explicitly requested ([a4a90a1](https://github.com/stonith404/pocket-id/commit/a4a90a16a9726569a22e42560184319b25fd7ca6))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.2...v) (2024-09-26)
### Bug Fixes
* add space to "Firstname" and "Lastname" label ([#31](https://github.com/stonith404/pocket-id/issues/31)) ([d6a9bb4](https://github.com/stonith404/pocket-id/commit/d6a9bb4c09efb8102da172e49c36c070b341f0fc))
* port environment variables get ignored in caddyfile ([3c67765](https://github.com/stonith404/pocket-id/commit/3c67765992d7369a79812bc8cd216c9ba12fd96e))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.1...v) (2024-09-19)
### Bug Fixes
* updated application name doesn't apply to webauthn credential ([924bb14](https://github.com/stonith404/pocket-id/commit/924bb1468bbd8e42fa6a530ef740be73ce3b3914))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.0...v) (2024-09-16)
### Features
* **email:** improve email templating ([#27](https://github.com/stonith404/pocket-id/issues/27)) ([64cf562](https://github.com/stonith404/pocket-id/commit/64cf56276a07169bc601a11be905c1eea67c4750))
### Bug Fixes
* debounce oidc client and user search ([9c2848d](https://github.com/stonith404/pocket-id/commit/9c2848db1d93c230afc6c5f64e498e9f6df8c8a7))
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
### Features
* add audit log with email notification ([#26](https://github.com/stonith404/pocket-id/issues/26)) ([9121239](https://github.com/stonith404/pocket-id/commit/9121239dd7c14a2107a984f9f94f54227489a63a))
## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06)
### Features
* add name claim to userinfo endpoint and id token ([4e7574a](https://github.com/stonith404/pocket-id/commit/4e7574a297307395603267c7a3285d538d4111d8))
### Bug Fixes
* limit width of content on large screens ([c6f83a5](https://github.com/stonith404/pocket-id/commit/c6f83a581ad385391d77fec7eeb385060742f097))
* show error message if error occurs while authorizing new client ([8038a11](https://github.com/stonith404/pocket-id/commit/8038a111dd7fa8f5d421b29c3bc0c11d865dc71b))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.1...v) (2024-09-03)
### Features
* add setup details to oidc client details ([fd21ce5](https://github.com/stonith404/pocket-id/commit/fd21ce5aac1daeba04e4e7399a0720338ea710c2))
* add support for more username formats ([903b0b3](https://github.com/stonith404/pocket-id/commit/903b0b39181c208e9411ee61849d2671e7c56dc5))
### Bug Fixes
* non pointer passed to create user ([e7861df](https://github.com/stonith404/pocket-id/commit/e7861df95a6beecab359d1c56f4383373f74bb73))
* oidc client logo not displayed on authorize page ([28ed064](https://github.com/stonith404/pocket-id/commit/28ed064668afeec8f80adda59ba94f1fc2fbce17))
* typo in hasLogo property of oidc dto ([2b9413c](https://github.com/stonith404/pocket-id/commit/2b9413c7575e1322f8547490a9b02a1836bad549))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.0...v) (2024-08-24)
### Bug Fixes
* empty lists don't get returned correctly from the api ([97f7fc4](https://github.com/stonith404/pocket-id/commit/97f7fc4e288c2bb49210072a7a151b58ef44f5b5))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.1...v) (2024-08-23)
### Features
* add support for multiple callback urls ([8166e2e](https://github.com/stonith404/pocket-id/commit/8166e2ead7fc71a0b7a45950b05c5c65a60833b6))
### Bug Fixes
* db migration for multiple callback urls ([552d7cc](https://github.com/stonith404/pocket-id/commit/552d7ccfa58d7922ecb94bdfe6a86651b4cf2745))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19)

View File

@@ -61,7 +61,7 @@ You're all set!
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 Caddyfile` in the root folder.
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
### Testing

View File

@@ -1,10 +0,0 @@
:80 {
reverse_proxy /api/* http://localhost:8080
reverse_proxy /.well-known/* http://localhost:8080
reverse_proxy /* http://localhost:3000
log {
output file /var/log/caddy/access.log
level WARN
}
}

View File

@@ -8,7 +8,7 @@ RUN npm run build
RUN npm prune --production
# Stage 2: Build Backend
FROM golang:1.22-alpine AS backend-builder
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app/backend
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
@@ -21,8 +21,11 @@ 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
COPY ./Caddyfile /etc/caddy/Caddyfile
# 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
COPY --from=frontend-builder /app/frontend/build ./frontend/build
@@ -30,13 +33,12 @@ COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts
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"]

View File

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

View File

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

3
backend/.gitignore vendored
View File

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

View File

@@ -1,39 +1,48 @@
module github.com/stonith404/pocket-id/backend
go 1.22
go 1.23.1
require (
github.com/caarlos0/env/v11 v11.2.0
github.com/caarlos0/env/v11 v11.2.2
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.11.0
github.com/go-webauthn/webauthn v0.11.0
github.com/go-co-op/gocron/v2 v2.12.1
github.com/go-playground/validator/v10 v10.22.1
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.25.0
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
golang.org/x/crypto v0.31.0
golang.org/x/time v0.6.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.12.1 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-ldap/ldap/v3 v3.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/go-webauthn/x v0.1.12 // 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/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
@@ -41,23 +50,24 @@ require (
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // 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
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
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.9.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.16.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.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,10 +1,17 @@
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
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.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs=
github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo=
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=
@@ -13,36 +20,56 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
github.com/go-co-op/gocron/v2 v2.11.0/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.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
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.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-webauthn/webauthn v0.11.0 h1:2U0jWuGeoiI+XSZkHPFRtwaYtqmMUsqABtlfSq1rODo=
github.com/go-webauthn/webauthn v0.11.0/go.mod h1:57ZrqsZzD/eboQDVtBkvTdfqFYAh/7IwzdPT+sPWqB0=
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
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-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/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.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
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/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=
@@ -50,11 +77,29 @@ github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -79,35 +124,50 @@ 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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -116,36 +176,120 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
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=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -1,17 +0,0 @@
<svg id="a"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1015 1015">
<path 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" />
<style>
@media (prefers-color-scheme: dark) {
#a path {
fill: #ffffff;
}
}
@media (prefers-color-scheme: light) {
#a path {
fill: #000000;
}
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 696 B

View File

@@ -3,26 +3,57 @@ package bootstrap
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path"
"strings"
)
// initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
files, err := os.ReadDir(dirPath)
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
// Skip if files already exist
if len(files) > 1 {
return
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
// Copy files from source to destination
err = utils.CopyDirectory("./images", dirPath)
if err != nil {
log.Fatalf("Error copying directory: %v", err)
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}
}
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
for _, destinationFile := range destinationFiles {
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
if sourceFileWithoutExtension == destinationFileWithoutExtension {
return true
}
}
return false
}
func getImageNameWithoutExtension(fileName string) string {
splitted := strings.Split(fileName, ".")
return strings.Join(splitted[:len(splitted)-1], ".")
}

View File

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

View File

@@ -2,9 +2,15 @@ package bootstrap
import (
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/resources"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -23,32 +29,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(),
})

View File

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

View File

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

View File

@@ -1,18 +1,178 @@
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")
ErrInvalidBody = errors.New("invalid request body")
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")
ErrFileTypeNotSupported = errors.New("file type not supported")
ErrInvalidCredentials = errors.New("no user found with provided credentials")
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 OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type InvalidCredentialsError struct{}
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct {
MaxSize string
}
func (e *FileTooLargeError) Error() string {
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
}
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string {
return "You don't have permission to perform this action"
}
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id or secret not provided"
}
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type WrongFileTypeError struct {
ExpectedFileType string
}
func (e *WrongFileTypeError) Error() string {
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
}
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
type MissingSessionIdError struct{}
func (e *MissingSessionIdError) Error() string {
return "Missing session id"
}
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
Key string
}
func (e *ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
}
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
Key string
}
func (e *DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
return "Invalid code verifier"
}
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }

View File

@@ -1,28 +1,32 @@
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/model"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
func NewApplicationConfigurationController(
func NewAppConfigController(
group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) {
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
acc := &ApplicationConfigurationController{
acc := &AppConfigController{
appConfigService: appConfigService,
emailService: emailService,
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listApplicationConfigurationHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllApplicationConfigurationHandler)
group.PUT("/application-configuration", acc.updateApplicationConfigurationHandler)
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
@@ -30,88 +34,135 @@ func NewApplicationConfigurationController(
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
}
type ApplicationConfigurationController struct {
type AppConfigController struct {
appConfigService *service.AppConfigService
emailService *service.EmailService
ldapService *service.LdapService
}
func (acc *ApplicationConfigurationController) listApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(false)
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(200, configuration)
}
func (acc *ApplicationConfigurationController) listAllApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
if err != nil {
utils.UnknownHandlerError(c, err)
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(200, configuration)
c.JSON(200, configVariablesDto)
}
func (acc *ApplicationConfigurationController) updateApplicationConfigurationHandler(c *gin.Context) {
var input model.AppConfigUpdateDto
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input)
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, savedConfigVariables)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, configVariablesDto)
}
func (acc *ApplicationConfigurationController) getLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.getImage(c, "logo", imageType)
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
var imageName string
var imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
}
acc.getImage(c, imageName, imageType)
}
func (acc *ApplicationConfigurationController) getFaviconHandler(c *gin.Context) {
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
}
func (acc *ApplicationConfigurationController) getBackgroundImageHandler(c *gin.Context) {
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.getImage(c, "background", imageType)
}
func (acc *ApplicationConfigurationController) updateLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.updateImage(c, "logo", imageType)
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
var imageName string
var imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
}
acc.updateImage(c, imageName, imageType)
}
func (acc *ApplicationConfigurationController) updateFaviconHandler(c *gin.Context) {
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico")
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
acc.updateImage(c, "favicon", "ico")
}
func (acc *ApplicationConfigurationController) updateBackgroundImageHandler(c *gin.Context) {
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.updateImage(c, "background", imageType)
}
func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name string, imageType string) {
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)
@@ -119,20 +170,37 @@ func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name str
c.File(imagePath)
}
func (acc *ApplicationConfigurationController) updateImage(c *gin.Context, imageName string, oldImageType string) {
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID)
if err != nil {
c.Error(err)
return
}

View File

@@ -0,0 +1,59 @@
package controller
import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
)
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
alc := AuditLogController{
auditLogService: auditLogService,
}
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
userID := c.GetString("userID")
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
}
// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
c.Error(err)
return
}
// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, gin.H{
"data": logsDtos,
"pagination": pagination,
})
}

View File

@@ -0,0 +1,78 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"net/http"
)
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions()
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
if err != nil {
c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
userId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
if err != nil {
c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}

View File

@@ -1,15 +1,12 @@
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/model"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strconv"
"strings"
)
@@ -18,7 +15,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
group.POST("/oidc/token", oc.createIDTokenHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
@@ -40,72 +37,69 @@ type OidcController struct {
}
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var parsedBody model.AuthorizeRequest
if err := c.ShouldBindJSON(&parsedBody); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
code, err := oc.oidcService.Authorize(parsedBody, c.GetString("userID"))
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.HandlerError(c, http.StatusForbidden, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"code": code})
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
var parsedBody model.AuthorizeNewClientDto
if err := c.ShouldBindJSON(&parsedBody); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
code, err := oc.oidcService.AuthorizeNewClient(parsedBody, c.GetString("userID"))
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"code": code})
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
var body model.OidcIdTokenDto
func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
if err := c.ShouldBind(&body); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
return
}
clientID := body.ClientID
clientSecret := body.ClientSecret
clientID := input.ClientID
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.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
return
}
if clientID == "" && clientSecret == "" {
clientID, clientSecret, _ = c.Request.BasicAuth()
}
idToken, accessToken, err := oc.oidcService.CreateTokens(body.Code, body.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.HandlerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
@@ -116,14 +110,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil {
utils.HandlerError(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.UnknownHandlerError(c, err)
c.Error(err)
return
}
@@ -134,50 +128,82 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, client)
// 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
}
}
c.Error(err)
}
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.UnknownHandlerError(c, err)
c.Error(err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": clients,
"data": clientsDto,
"pagination": pagination,
})
}
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusCreated, client)
var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, clientDto)
}
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
c.Error(err)
return
}
@@ -185,25 +211,31 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
}
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusNoContent, client)
var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
@@ -213,7 +245,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
@@ -224,17 +256,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
@@ -244,7 +272,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}

View File

@@ -3,7 +3,7 @@ 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"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
@@ -18,19 +18,24 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(200, gin.H{"message": "Database reset and seeded"})
if err := tc.TestService.ResetAppConfig(); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -1,22 +1,22 @@
package controller
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
"net/http"
"strconv"
"time"
)
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService) {
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, 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)
@@ -30,51 +30,75 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
}
type UserController struct {
UserService *service.UserService
userService *service.UserService
appConfigService *service.AppConfigService
}
func (uc *UserController) listUsersHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": users,
"data": usersDto,
"pagination": pagination,
})
}
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.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID"))
user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
utils.UnknownHandlerError(c, err)
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
return
}
@@ -82,22 +106,25 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
}
func (uc *UserController) createUserHandler(c *gin.Context) {
var user model.User
if err := c.ShouldBindJSON(&user); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
if err := uc.UserService.CreateUser(&user); err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.HandlerError(c, http.StatusConflict, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
user, err := uc.userService.CreateUser(input)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
func (uc *UserController) updateUserHandler(c *gin.Context) {
@@ -105,59 +132,83 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
}
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 model.OneTimeAccessTokenCreateDto
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
utils.UnknownHandlerError(c, err)
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.HandlerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
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) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin()
user, token, err := uc.userService.SetupInitialAdmin()
if err != nil {
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var updatedUser model.User
if err := c.ShouldBindJSON(&updatedUser); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
@@ -168,15 +219,17 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id")
}
user, err := uc.UserService.UpdateUser(userID, updatedUser, 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.HandlerError(c, http.StatusConflict, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}

View File

@@ -0,0 +1,154 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
ugc := UserGroupController{
UserGroupService: userGroupService,
}
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
}
type UserGroupController struct {
UserGroupService *service.UserGroupService
}
func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
}
// Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
if err != nil {
c.Error(err)
return
}
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, gin.H{
"data": groupsDto,
"pagination": pagination,
})
}
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id"))
if err != nil {
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
group, err := ugc.UserGroupService.Create(input)
if err != nil {
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, groupDto)
}
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
if err != nil {
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
if err != nil {
c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}

View File

@@ -1,23 +1,21 @@
package controller
import (
"errors"
"github.com/go-webauthn/webauthn/protocol"
"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/model"
"log"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"time"
"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"
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, jwtService *service.JwtService) {
wc := &WebauthnController{webAuthnService: webauthnService, jwtService: jwtService}
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
@@ -32,94 +30,100 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
}
type WebauthnController struct {
webAuthnService *service.WebAuthnService
jwtService *service.JwtService
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.UnknownHandlerError(c, err)
log.Println(err)
c.Error(err)
return
}
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.HandlerError(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.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, credential)
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin()
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
userID := c.GetString("userID")
user, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData)
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) {
utils.HandlerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.UnknownHandlerError(c, err)
}
c.Error(err)
return
}
token, err := wc.jwtService.GenerateAccessToken(*user)
if err != nil {
utils.UnknownHandlerError(c, err)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
utils.AddAccessTokenCookie(c, wc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.JSON(http.StatusOK, credentials)
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDtos)
}
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
@@ -128,7 +132,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
@@ -139,22 +143,28 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input model.WebauthnCredentialUpdateDto
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
c.Error(err)
return
}
err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
c.Status(http.StatusNoContent)
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
c.SetCookie("access_token", "", 0, "/", "", false, true)
utils.AddAccessTokenCookie(c, "0", "")
c.Status(http.StatusNoContent)
}

View File

@@ -4,7 +4,6 @@ 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"
)
@@ -21,7 +20,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK()
if err != nil {
utils.UnknownHandlerError(c, err)
c.Error(err)
return
}
@@ -37,7 +36,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},

View File

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

View File

@@ -0,0 +1,19 @@
package dto
import (
"github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type AuditLogDto struct {
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Data model.AuditLogData `json:"data"`
}

View 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"`
}

View File

@@ -0,0 +1,116 @@
package dto
import (
"errors"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"reflect"
"time"
)
// MapStructList maps a list of source structs to a list of destination structs
func MapStructList[S any, D any](source []S, destination *[]D) error {
*destination = make([]D, 0, len(source))
for _, item := range source {
var destItem D
if err := MapStruct(item, &destItem); err != nil {
return err
}
*destination = append(*destination, destItem)
}
return nil
}
// MapStruct maps a source struct to a destination struct
func MapStruct[S any, D any](source S, destination *D) error {
// Ensure destination is a non-nil pointer
destValue := reflect.ValueOf(destination)
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
return errors.New("destination must be a non-nil pointer to a struct")
}
// Ensure source is a struct
sourceValue := reflect.ValueOf(source)
if sourceValue.Kind() != reflect.Struct {
return errors.New("source must be a struct")
}
return mapStructInternal(sourceValue, destValue.Elem())
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
continue
}
sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types
if sourceField.Type() == destField.Type() {
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
// Direct assignment for slices of primitive types or non-struct elements
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
// Recursively map slices of structs
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
// Get the element from both source and destination slice
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
// Recursively map the struct elements
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
// Set the mapped element in the new slice
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
// Recursively map nested structs
if err := mapStructInternal(sourceField, destField); err != nil {
return err
}
} else {
// Type switch for specific type conversions
switch sourceField.Interface().(type) {
case datatype.DateTime:
// Convert datatype.DateTime to time.Time
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
}
}
}
return nil
}

View File

@@ -0,0 +1,44 @@
package dto
type PublicOidcClientDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CreatedBy UserDto `json:"createdBy"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
}
type 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"`
}

View File

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

View File

@@ -0,0 +1,39 @@
package dto
import (
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=2,max=255"`
LdapID string `json:"-"`
}
type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"`
}
type AssignUserToGroupDto struct {
UserID string `json:"userId" binding:"required"`
}

View File

@@ -0,0 +1,54 @@
package dto
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
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("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("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
}

View File

@@ -0,0 +1,23 @@
package dto
import (
"github.com/go-webauthn/webauthn/protocol"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type WebauthnCredentialDto struct {
ID string `json:"id"`
Name string `json:"name"`
CredentialID string `json:"credentialID"`
AttestationType string `json:"attestationType"`
Transport []protocol.AuthenticatorTransport `json:"transport"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=30"`
}

View File

@@ -4,13 +4,13 @@ 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"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"log"
"time"
)
func RegisterJobs(db *gorm.DB) {
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
@@ -21,7 +21,6 @@ func RegisterJobs(db *gorm.DB) {
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
scheduler.Start()
}
@@ -29,17 +28,24 @@ type Jobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", 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 < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

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

View File

@@ -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/stonith404/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()
}
}

View File

@@ -0,0 +1,99 @@
package middleware
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/stonith404/pocket-id/backend/internal/common"
"gorm.io/gorm"
"net/http"
"strings"
)
type ErrorHandlerMiddleware struct{}
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
return &ErrorHandlerMiddleware{}
}
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
for _, err := range c.Errors {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
errorResponse(c, http.StatusNotFound, "Record not found")
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
// Check for slice validation errors
var sliceValidationErrors binding.SliceValidationError
if errors.As(err, &sliceValidationErrors) {
if errors.As(sliceValidationErrors[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
var appErr common.AppError
if errors.As(err, &appErr) {
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
}
}
func errorResponse(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
var errorMessages []string
for _, ve := range validationErrors {
fieldName := ve.Field()
var errorMessage string
switch ve.Tag() {
case "required":
errorMessage = fmt.Sprintf("%s is required", fieldName)
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
case "urlList":
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
}
errorMessages = append(errorMessages, errorMessage)
}
// Join all the error messages into a single string
combinedErrors := strings.Join(errorMessages, ", ")
return combinedErrors
}

View File

@@ -3,7 +3,7 @@ package middleware
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/common"
"net/http"
)
@@ -17,7 +17,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.HandlerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err)
c.Abort()
return
}

View File

@@ -2,18 +2,18 @@ package middleware
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"
"strings"
)
type JwtAuthMiddleware struct {
jwtService *service.JwtService
jwtService *service.JwtService
ignoreUnauthenticated bool
}
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService}
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
}
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
@@ -24,23 +24,29 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplitted) == 2 {
token = authorizationHeaderSplitted[1]
} else if m.ignoreUnauthenticated {
c.Next()
return
} else {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
}
claims, err := m.jwtService.VerifyAccessToken(token)
if err != nil {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
if err != nil && m.ignoreUnauthenticated {
c.Next()
return
} else if err != nil {
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
// Check if the user is an admin
if adminOnly && !claims.IsAdmin {
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
c.Error(&common.MissingPermissionError{})
c.Abort()
return
}

View File

@@ -2,8 +2,6 @@ package middleware
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"sync"
"time"
@@ -18,8 +16,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine
go cleanupClients()
go cleanupClients(&mu, clients)
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -31,9 +33,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.HandlerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
c.Error(&common.TooManyRequestsError{})
c.Abort()
return
}
@@ -47,12 +49,8 @@ type client struct {
lastSeen time.Time
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() {
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for {
time.Sleep(time.Minute)
mu.Lock()
@@ -66,7 +64,7 @@ func cleanupClients() {
}
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock()
defer mu.Unlock()

View File

@@ -1,21 +1,47 @@
package model
type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null" json:"key"`
Type string `json:"type"`
IsPublic bool `json:"-"`
IsInternal bool `json:"-"`
Value string `json:"value"`
Key string `gorm:"primaryKey;not null"`
Type string
IsPublic bool
IsInternal bool
Value string
DefaultValue string
}
type AppConfig struct {
// General
AppName AppConfigVariable
BackgroundImageType AppConfigVariable
LogoImageType AppConfigVariable
SessionDuration AppConfigVariable
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable
// Internal
BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
// Email
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable
LdapBindDn AppConfigVariable
LdapBindPassword AppConfigVariable
LdapBase AppConfigVariable
LdapSkipCertVerify AppConfigVariable
LdapAttributeUserUniqueIdentifier AppConfigVariable
LdapAttributeUserUsername AppConfigVariable
LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable
}

View File

@@ -0,0 +1,53 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
)
type AuditLog struct {
Base
Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
UserID string
Data AuditLogData
}
type AuditLogData map[string]string
type AuditLogEvent string
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
)
// Scan and Value methods for GORM to handle the custom type
func (e *AuditLogEvent) Scan(value interface{}) error {
*e = AuditLogEvent(value.(string))
return nil
}
func (e AuditLogEvent) Value() (driver.Value, error) {
return string(e), nil
}
func (d *AuditLogData) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
return json.Unmarshal(v, d)
} else {
return errors.New("type assertion to []byte failed")
}
}
func (d AuditLogData) Value() (driver.Value, error) {
return json.Marshal(d)
}

View File

@@ -2,19 +2,21 @@ package model
import (
"github.com/google/uuid"
model "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"time"
)
// Base contains common columns for all tables.
type Base struct {
ID string `gorm:"primaryKey;not null" json:"id"`
CreatedAt time.Time `json:"createdAt"`
ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime `sortable:"true"`
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
if b.ID == "" {
b.ID = uuid.New().String()
}
b.CreatedAt = model.DateTime(time.Now())
return
}

View File

@@ -0,0 +1,11 @@
package model
type CustomClaim struct {
Base
Key string
Value string
UserID *string
UserGroupID *string
}

View File

@@ -1,27 +1,48 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"time"
)
type UserAuthorizedOidcClient struct {
Scope string
UserID string `json:"userId" gorm:"primary_key;"`
UserID string `gorm:"primary_key;"`
User User
ClientID string `json:"clientId" gorm:"primary_key;"`
ClientID string `gorm:"primary_key;"`
Client OidcClient
}
type OidcAuthorizationCode struct {
Base
Code string
Scope string
Nonce string
CodeChallenge *string
CodeChallengeMethodSha256 *bool
ExpiresAt datatype.DateTime
UserID string
User User
ClientID string
}
type OidcClient struct {
Base
Name string `json:"name"`
Secret string `json:"-"`
CallbackURL string `json:"callbackURL"`
ImageType *string `json:"-"`
HasLogo bool `gorm:"-" json:"hasLogo"`
Name string `sortable:"true"`
Secret string
CallbackURLs CallbackURLs
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
CreatedByID string
CreatedBy User
@@ -33,40 +54,16 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil
}
type OidcAuthorizationCode struct {
Base
type CallbackURLs []string
Code string
Scope string
Nonce string
ExpiresAt time.Time
UserID string
User User
ClientID string
func (cu *CallbackURLs) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
return json.Unmarshal(v, cu)
} else {
return errors.New("type assertion to []byte failed")
}
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required"`
CallbackURL string `json:"callbackURL" binding:"required"`
}
type AuthorizeNewClientDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
Nonce string `json:"nonce"`
}
type OidcIdTokenDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
}
type AuthorizeRequest struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
Nonce string `json:"nonce"`
func (cu CallbackURLs) Value() (driver.Value, error) {
return json.Marshal(cu)
}

View File

@@ -0,0 +1,52 @@
package datatype
import (
"database/sql/driver"
"github.com/stonith404/pocket-id/backend/internal/common"
"time"
)
// DateTime custom type for time.Time to store date as unix timestamp 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)
}

View File

@@ -3,19 +3,22 @@ package model
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"time"
"github.com/stonith404/pocket-id/backend/internal/model/types"
)
type User struct {
Base
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
LdapID *string
Credentials []WebauthnCredential `json:"-"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
}
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
@@ -31,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,
@@ -57,21 +60,13 @@ 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 `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
Token string
ExpiresAt datatype.DateTime
UserID string `json:"userId"`
UserID string
User User
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type LoginUserDto struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

View File

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

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"github.com/go-webauthn/webauthn/protocol"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"time"
)
@@ -12,18 +13,18 @@ type WebauthnSession struct {
Base
Challenge string
ExpiresAt time.Time
ExpiresAt datatype.DateTime
UserVerification string
}
type WebauthnCredential struct {
Base
Name string `json:"name"`
CredentialID string `json:"credentialID"`
PublicKey []byte `json:"-"`
AttestationType string `json:"attestationType"`
Transport AuthenticatorTransportList `json:"-"`
Name string
CredentialID []byte
PublicKey []byte
AttestationType string
Transport AuthenticatorTransportList
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
@@ -32,15 +33,15 @@ type WebauthnCredential struct {
}
type PublicKeyCredentialCreationOptions struct {
Response protocol.PublicKeyCredentialCreationOptions `json:"response"`
SessionID string `json:"session_id"`
Timeout time.Duration `json:"timeout"`
Response protocol.PublicKeyCredentialCreationOptions
SessionID string
Timeout time.Duration
}
type PublicKeyCredentialRequestOptions struct {
Response protocol.PublicKeyCredentialRequestOptions `json:"response"`
SessionID string `json:"session_id"`
Timeout time.Duration `json:"timeout"`
Response protocol.PublicKeyCredentialRequestOptions
SessionID string
Timeout time.Duration
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport
@@ -58,7 +59,3 @@ func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
return json.Marshal(atl)
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name"`
}

View File

@@ -2,14 +2,16 @@ package service
import (
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"log"
"mime/multipart"
"os"
"reflect"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type AppConfigService struct {
@@ -29,32 +31,152 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
}
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",
},
LogoImageType: model.AppConfigVariable{
Key: "logoImageType",
Type: "string",
IsInternal: true,
Value: "svg",
LogoLightImageType: model.AppConfigVariable{
Key: "logoLightImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
LogoDarkImageType: model.AppConfigVariable{
Key: "logoDarkImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
SmtpTls: model.AppConfigVariable{
Key: "smtpTls",
Type: "bool",
DefaultValue: "true",
},
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
}
func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin()
@@ -66,24 +188,31 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigU
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
var applicationConfigurationVariable model.AppConfigVariable
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
applicationConfigurationVariable.Value = value
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
appConfigVariable.Value = value
if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable)
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
}
tx.Commit()
if err := s.loadDbConfigFromDb(); err != nil {
if err := s.LoadDbConfigFromDb(); err != nil {
return nil, err
}
@@ -97,10 +226,10 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
return err
}
return s.loadDbConfigFromDb()
return s.LoadDbConfigFromDb()
}
func (s *AppConfigService) ListApplicationConfiguration(showAll bool) ([]model.AppConfigVariable, error) {
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable
var err error
@@ -114,6 +243,13 @@ func (s *AppConfigService) ListApplicationConfiguration(showAll bool) ([]model.A
return nil, err
}
// Set the value to the default value if it is empty
for i := range configuration {
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
configuration[i].Value = configuration[i].DefaultValue
}
}
return configuration, nil
}
@@ -121,7 +257,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
@@ -169,10 +305,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
}
@@ -192,10 +329,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++ {
@@ -206,6 +344,10 @@ func (s *AppConfigService) loadDbConfigFromDb() error {
return err
}
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
storedConfigVar.Value = storedConfigVar.DefaultValue
}
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
}

View File

@@ -0,0 +1,98 @@
package service
import (
userAgentParser "github.com/mileusna/useragent"
"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"
)
type AuditLogService struct {
db *gorm.DB
appConfigService *AppConfigService
emailService *EmailService
geoliteService *GeoLiteService
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil {
log.Printf("Failed to get IP location: %v\n", err)
}
auditLog := model.AuditLog{
Event: event,
IpAddress: ipAddress,
Country: country,
City: city,
UserAgent: userAgent,
UserID: userID,
Data: data,
}
// Save the audit log in the database
if err := s.db.Create(&auditLog).Error; err != nil {
log.Printf("Failed to create audit log: %v\n", err)
return model.AuditLog{}
}
return auditLog
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
// Count the number of times the user has logged in from the same device
var count int64
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
return createdAuditLog
}
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,
Country: createdAuditLog.Country,
City: createdAuditLog.City,
Device: s.DeviceStringFromUserAgent(userAgent),
DateTime: createdAuditLog.CreatedAt.UTC(),
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
}
return createdAuditLog
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
return logs, pagination, err
}
func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}

View File

@@ -0,0 +1,197 @@
package service
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/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
}

View File

@@ -0,0 +1,242 @@
package service
import (
"bytes"
"crypto/tls"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/smtp"
"net/textproto"
ttemplate "text/template"
"time"
)
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct {
appConfigService *AppConfigService
db *gorm.DB
htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template
}
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
textTemplates, err := email.PrepareTextTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
return &EmailService{
appConfigService: appConfigService,
db: db,
htmlTemplates: htmlTemplates,
textTemplates: textTemplates,
}, nil
}
func (srv *EmailService) SendTestEmail(recipientUserId string) error {
var user model.User
if err := srv.db.First(&user, "id = ?", recipientUserId).Error; err != nil {
return err
}
return SendEmail(srv,
email.Address{
Email: user.Email,
Name: user.FullName(),
}, TestTemplate, nil)
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
Data: tData,
}
body, boundary, err := prepareBody(srv, template, data)
if err != nil {
return fmt.Errorf("prepare email body for '%s': %w", template.Path, err)
}
// Construct the email message
c := email.NewComposer()
c.AddHeader("Subject", template.Title(data))
c.AddAddressHeader("From", []email.Address{
{
Email: srv.appConfigService.DbConfig.SmtpFrom.Value,
Name: srv.appConfigService.DbConfig.AppName.Value,
},
})
c.AddAddressHeader("To", []email.Address{toEmail})
c.AddHeaderRaw("Content-Type",
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
)
c.Body(body)
// Set up the TLS configuration
tlsConfig := &tls.Config{
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
}
// Connect to the SMTP server
port := srv.appConfigService.DbConfig.SmtpPort.Value
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
var client *smtp.Client
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
client, err = smtp.Dial(smtpAddress)
} else if port == "465" {
client, err = srv.connectToSmtpServerUsingImplicitTLS(
smtpAddress,
tlsConfig,
)
} else {
client, err = srv.connectToSmtpServerUsingStartTLS(
smtpAddress,
tlsConfig,
)
}
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer client.Close()
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
// Set up the authentication if user or password are set
if smtpUser != "" || smtpPassword != "" {
auth := smtp.PlainAuth("",
srv.appConfigService.DbConfig.SmtpUser.Value,
srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value,
)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate SMTP client: %w", err)
}
}
// Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err)
}
return nil
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
tlsDialer := &tls.Dialer{
NetDialer: netDialer,
Config: tlsConfig,
}
conn, err := tlsDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, nil
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.StartTLS(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to start TLS: %w", err)
}
return client, nil
}
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(toEmail.Email); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data: %w", err)
}
_, err = w.Write([]byte(c.String()))
if err != nil {
return fmt.Errorf("failed to write email data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
}
func prepareBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (string, string, error) {
body := bytes.NewBuffer(nil)
mpart := multipart.NewWriter(body)
// prepare text part
var textHeader = textproto.MIMEHeader{}
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8")
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
textPart, err := mpart.CreatePart(textHeader)
if err != nil {
return "", "", fmt.Errorf("create text part: %w", err)
}
textQp := quotedprintable.NewWriter(textPart)
err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textQp, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err)
}
// prepare html part
var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil {
return "", "", fmt.Errorf("create html part: %w", err)
}
htmlQp := quotedprintable.NewWriter(htmlPart)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err)
}
err = mpart.Close()
if err != nil {
return "", "", fmt.Errorf("close multipart: %w", err)
}
return body.String(), mpart.Boundary(), nil
}

View File

@@ -0,0 +1,57 @@
package service
import (
"fmt"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"time"
)
/**
How to add new template:
- pick unique and descriptive template ${name} (for example "login-with-new-device")
- in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
- Path *must* be ${name}
- add xxxTemplate.Path to "emailTemplatePaths" at the end
Notes:
- backend app must be restarted to reread all the template files
- root "." object in templates is `email.TemplateData`
- xxxxTemplateData structure is visible under .Data in templates
*/
var NewLoginTemplate = email.Template[NewLoginTemplateData]{
Path: "login-with-new-device",
Title: func(data *email.TemplateData[NewLoginTemplateData]) string {
return fmt.Sprintf("New device login with %s", data.AppName)
},
}
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
Path: "one-time-access",
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
return "One time access"
},
}
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
return "Test email"
},
}
type NewLoginTemplateData struct {
IPAddress string
Country string
City string
Device string
DateTime time.Time
}
type OneTimeAccessTemplateData = struct {
Link string
}
// this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}

View File

@@ -0,0 +1,152 @@
package service
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"time"
"github.com/oschwald/maxminddb-golang/v2"
"github.com/stonith404/pocket-id/backend/internal/common"
)
type GeoLiteService struct{}
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
func NewGeoLiteService() *GeoLiteService {
service := &GeoLiteService{}
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 if IP is in Tailscale's CGNAT range (100.64.0.0/10)
if ip := net.ParseIP(ipAddress); ip != nil {
if ip.To4() != nil && ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127 {
return "Internal Network", "Tailscale", nil
}
}
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
if err != nil {
return "", "", err
}
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.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date.")
return nil
}
log.Println("Updating GeoLite2 City database...")
// Download and extract the database
downloadUrl := fmt.Sprintf(
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
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" {
outFile, err := os.Create(common.EnvConfig.GeoLiteDBPath)
if err != nil {
return fmt.Errorf("failed to create target database file: %w", err)
}
defer outFile.Close()
// Write the file contents directly to the target location
if _, err := io.Copy(outFile, tarReader); err != nil {
return fmt.Errorf("failed to write database file: %w", err)
}
return nil
}
}
return errors.New("GeoLite2-City.mmdb not found in archive")
}

View File

@@ -3,6 +3,7 @@ package service
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
@@ -11,7 +12,6 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"log"
"math/big"
"os"
@@ -51,6 +51,7 @@ type AccessTokenJWTClaims struct {
}
type JWK struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
@@ -94,11 +95,19 @@ 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)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid
return token.SignedString(s.privateKey)
}
@@ -115,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
@@ -137,9 +146,17 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
claims["nonce"] = nonce
}
kid, err := s.generateKeyID(s.publicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid
return token.SignedString(s.privateKey)
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{
Subject: user.ID,
@@ -148,7 +165,15 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Audience: jwt.ClaimStrings{clientID},
Issuer: common.EnvConfig.AppURL,
}
kid, err := s.generateKeyID(s.publicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid
return token.SignedString(s.privateKey)
}
@@ -174,7 +199,13 @@ func (s *JwtService) GetJWK() (JWK, error) {
return JWK{}, errors.New("public key is not initialized")
}
kid, err := s.generateKeyID(s.publicKey)
if err != nil {
return JWK{}, err
}
jwk := JWK{
Kid: kid,
Kty: "RSA",
Use: "sig",
Alg: "RS256",
@@ -185,6 +216,25 @@ func (s *JwtService) GetJWK() (JWK, error) {
return jwk, nil
}
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", errors.New("failed to marshal public key: " + err.Error())
}
// Compute SHA-256 hash of the public key
hash := sha256.New()
hash.Write(pubASN1)
hashed := hash.Sum(nil)
// Truncate the hash to the first 8 bytes for a shorter Key ID
shortHash := hashed[:8]
// Return Base64 encoded truncated hash as Key ID
return base64.RawURLEncoding.EncodeToString(shortHash), nil
}
// generateKeys generates a new RSA key pair and saves them to the specified paths.
func (s *JwtService) generateKeys() error {
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {

View File

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

View File

@@ -1,67 +1,111 @@
package service
import (
"crypto/sha256"
"encoding/base64"
"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"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"mime/multipart"
"os"
"slices"
"strings"
"time"
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
}
func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService {
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
return &OidcService{
db: db,
jwtService: jwtService,
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
}
}
func (s *OidcService) Authorize(req model.AuthorizeRequest, userID string) (string, error) {
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", req.ClientID, userID)
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Scope != req.Scope {
return "", common.ErrOidcMissingAuthorization
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", &common.OidcMissingAuthorizationError{}
}
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
if err != nil {
return "", "", err
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
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(req model.AuthorizeNewClientDto, userID string) (string, error) {
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 {
return "", "", err
}
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
if err != nil {
return "", "", err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: req.ClientID,
Scope: req.Scope,
ClientID: input.ClientID,
Scope: input.Scope,
}
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
err = s.db.Model(&userAuthorizedClient).Update("scope", req.Scope).Error
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
} else {
return "", err
return "", "", err
}
}
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
}
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
if grantType != "authorization_code" {
return "", "", common.ErrOidcGrantTypeNotSupported
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil {
return "", "", err
}
if clientID == "" || clientSecret == "" {
return "", "", common.ErrOidcMissingClientCredentials
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, codeVerifier string) (string, string, error) {
if grantType != "authorization_code" {
return "", "", &common.OidcGrantTypeNotSupportedError{}
}
var client model.OidcClient
@@ -69,19 +113,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)
@@ -101,24 +159,24 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
return idToken, accessToken, nil
}
func (s *OidcService) GetClient(clientID string) (*model.OidcClient, error) {
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return nil, err
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
return &client, nil
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.Model(&model.OidcClient{})
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
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
}
@@ -126,34 +184,38 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
return clients, pagination, nil
}
func (s *OidcService) CreateClient(input model.OidcClientCreateDto, userID string) (*model.OidcClient, error) {
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{
Name: input.Name,
CallbackURL: input.CallbackURL,
CreatedByID: userID,
Name: input.Name,
CallbackURLs: input.CallbackURLs,
CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
}
if err := s.db.Create(&client).Error; err != nil {
return nil, err
return model.OidcClient{}, err
}
return &client, nil
return client, nil
}
func (s *OidcService) UpdateClient(clientID string, input model.OidcClientCreateDto) (*model.OidcClient, error) {
func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return nil, err
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
client.Name = input.Name
client.CallbackURL = input.CallbackURL
client.CallbackURLs = input.CallbackURLs
client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
if err := s.db.Save(&client).Error; err != nil {
return nil, err
return model.OidcClient{}, err
}
return &client, nil
return client, nil
}
func (s *OidcService) DeleteClient(clientID string) error {
@@ -213,7 +275,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)
@@ -266,7 +328,7 @@ func (s *OidcService) DeleteClientLogo(clientID string) error {
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.Preload("User").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
return nil, err
}
@@ -279,18 +341,39 @@ 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") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"name": user.FullName(),
"preferred_username": user.Username,
}
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 {
claims[customClaim.Key] = customClaim.Value
}
}
if strings.Contains(scope, "email") {
claims["email"] = user.Email
@@ -299,19 +382,23 @@ 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) 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 {
@@ -320,3 +407,34 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
return randomString, nil
}
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
}
if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge
}
// Compute SHA-256 hash of the codeVerifier
h := sha256.New()
h.Write([]byte(codeVerifier))
codeVerifierHash := h.Sum(nil)
// Base64 URL encode the verifier hash
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
return encodedVerifierHash == codeChallenge
}
func (s *OidcService) getCallbackURL(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
}
return "", &common.OidcInvalidCallbackURLError{}
}

View File

@@ -6,8 +6,11 @@ import (
"encoding/base64"
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path/filepath"
"time"
"github.com/go-webauthn/webauthn/protocol"
@@ -56,25 +59,72 @@ 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: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
},
Name: "developers",
FriendlyName: "Developers",
Users: []model.User{users[0], users[1]},
},
{
Base: model.Base{
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
},
Name: "designers",
FriendlyName: "Designers",
Users: []model.User{users[0]},
},
}
for _, group := range userGroups {
if err := tx.Create(&group).Error; err != nil {
return err
}
}
oidcClients := []model.OidcClient{
{
Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURL: "http://nextcloud/auth/callback",
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
},
{
Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURL: "http://immich/auth/callback",
CreatedByID: users[0].ID,
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
CreatedByID: users[0].ID,
},
}
for _, client := range oidcClients {
@@ -87,7 +137,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,
}
@@ -97,7 +147,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 {
@@ -113,15 +163,15 @@ func (s *TestService) SeedDatabase() error {
return err
}
publicKey1, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKey2, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
if err != nil {
return err
}
webauthnCredentials := []model.WebauthnCredential{
{
Name: "Passkey 1",
CredentialID: "test-credential-1",
CredentialID: []byte("test-credential-1"),
PublicKey: publicKey1,
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
@@ -129,7 +179,7 @@ func (s *TestService) SeedDatabase() error {
},
{
Name: "Passkey 2",
CredentialID: "test-credential-2",
CredentialID: []byte("test-credential-2"),
PublicKey: publicKey2,
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
@@ -144,7 +194,7 @@ func (s *TestService) SeedDatabase() error {
webauthnSession := model.WebauthnSession{
Challenge: "challenge",
ExpiresAt: time.Now().Add(1 * time.Hour),
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserVerification: "preferred",
}
if err := tx.Create(&webauthnSession).Error; err != nil {
@@ -158,21 +208,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
}
@@ -182,16 +247,41 @@ 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()
}
// 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)

View File

@@ -0,0 +1,133 @@
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"
)
type UserGroupService struct {
db *gorm.DB
}
func NewUserGroupService(db *gorm.DB) *UserGroupService {
return &UserGroupService{db: db}
}
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
// As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
return groups, response, err
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
return groups, response, err
}
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
return group, err
}
func (s *UserGroupService) Delete(id string) error {
var group model.UserGroup
if err := s.db.Where("id = ?", id).First(&group).Error; err != nil {
return err
}
if group.LdapID != nil {
return &common.LdapUserGroupUpdateError{}
}
return s.db.Delete(&group).Error
}
func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
group = model.UserGroup{
FriendlyName: input.FriendlyName,
Name: input.Name,
LdapID: &input.LdapID,
}
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
}
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allowLdapUpdate bool) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
if group.LdapID != nil && !allowLdapUpdate {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
}
group.Name = input.Name
group.FriendlyName = input.FriendlyName
group.LdapID = &input.LdapID
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
}
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
// Fetch the users based on UserIDs in input
var users []model.User
if len(input.UserIDs) > 0 {
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
return model.UserGroup{}, err
}
}
// Replace the current users with the new set of users
if err := s.db.Model(&group).Association("Users").Replace(users); err != nil {
return model.UserGroup{}, err
}
// Save the updated group
if err := s.db.Save(&group).Error; err != nil {
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) GetUserCountOfGroup(id string) (int64, error) {
var group model.UserGroup
if err := s.db.Preload("Users").Where("id = ?", id).First(&group).Error; err != nil {
return 0, err
}
return s.db.Model(&group).Association("Users").Count(), nil
}

View File

@@ -0,0 +1,234 @@
package service
import (
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"log"
"net/url"
"strings"
"time"
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
}
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.Model(&model.User{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err
}
func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err
}
func (s *UserService) DeleteUser(userID string) error {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return err
}
if user.LdapID != nil {
return &common.LdapUserUpdateError{}
}
return s.db.Delete(&user).Error
}
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
LdapID: &input.LdapID,
}
if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user)
}
return model.User{}, err
}
return user, nil
}
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err
}
if user.LdapID != nil && !allowLdapUpdate {
return model.User{}, &common.LdapUserUpdateError{}
}
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
}
if err := s.db.Save(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return user, s.checkDuplicatedFields(user)
}
return user, err
}
return user, nil
}
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if err != nil {
return err
}
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
go func() {
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link,
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
return nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", err
}
oneTimeAccessToken := model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
if err := s.db.Create(&oneTimeAccessToken).Error; err != nil {
return "", err
}
return oneTimeAccessToken.Token, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
if err := s.db.Delete(&oneTimeAccessToken).Error; err != nil {
return model.User{}, "", err
}
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
}
return oneTimeAccessToken.User, accessToken, nil
}
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount > 1 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
user := model.User{
FirstName: "Admin",
LastName: "Admin",
Username: "admin",
Email: "admin@admin.com",
IsAdmin: true,
}
if err := s.db.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
return model.User{}, "", err
}
if len(user.Credentials) > 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserService) checkDuplicatedFields(user model.User) error {
var existingUser model.User
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
return &common.AlreadyInUseError{Property: "email"}
}
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
return &common.AlreadyInUseError{Property: "username"}
}
return nil
}

View File

@@ -1,165 +0,0 @@
package service
import (
"errors"
"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"
"time"
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
}
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
return &UserService{db: db, jwtService: jwtService}
}
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.Model(&model.User{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.Paginate(page, pageSize, 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
return user, err
}
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
}
return s.db.Delete(&user).Error
}
func (s *UserService) CreateUser(user *model.User) error {
if err := s.db.Create(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return s.checkDuplicatedFields(*user)
}
return err
}
return nil
}
func (s *UserService) UpdateUser(userID string, updatedUser model.User, updateOwnUser bool) (model.User, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err
}
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
}
if err := s.db.Save(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return user, s.checkDuplicatedFields(user)
}
return user, err
}
return user, nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", err
}
oneTimeAccessToken := model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: expiresAt,
Token: randomString,
}
if err := s.db.Create(&oneTimeAccessToken).Error; err != nil {
return "", err
}
return oneTimeAccessToken.Token, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(token 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 errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", common.ErrTokenInvalidOrExpired
}
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
if err := s.db.Delete(&oneTimeAccessToken).Error; err != nil {
return model.User{}, "", err
}
return oneTimeAccessToken.User, accessToken, nil
}
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount > 1 {
return model.User{}, "", common.ErrSetupAlreadyCompleted
}
user := model.User{
FirstName: "Admin",
LastName: "Admin",
Username: "admin",
Email: "admin@admin.com",
IsAdmin: true,
}
if err := s.db.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
return model.User{}, "", err
}
if len(user.Credentials) > 0 {
return model.User{}, "", common.ErrSetupAlreadyCompleted
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserService) checkDuplicatedFields(user model.User) error {
var existingUser model.User
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
return common.ErrEmailTaken
}
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
return common.ErrUsernameTaken
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"net/http"
@@ -12,14 +13,17 @@ import (
)
type WebAuthnService struct {
db *gorm.DB
webAuthn *webauthn.WebAuthn
db *gorm.DB
webAuthn *webauthn.WebAuthn
jwtService *JwtService
auditLogService *AuditLogService
appConfigService *AppConfigService
}
func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAuthnService {
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{
@@ -34,12 +38,13 @@ func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAut
},
},
}
wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{db: db, webAuthn: wa}
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService, appConfigService: appConfigService}
}
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
s.updateWebAuthnConfig()
var user model.User
if err := s.db.Preload("Credentials").Find(&user, "id = ?", userID).Error; err != nil {
return nil, err
@@ -51,7 +56,7 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
}
sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires,
ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
@@ -67,31 +72,31 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
}, nil
}
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (*model.WebauthnCredential, error) {
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt,
Expires: storedSession.ExpiresAt.ToTime(),
UserID: []byte(userID),
}
var user model.User
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
CredentialID: string(credential.ID),
CredentialID: credential.ID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
Transport: credential.Transport,
@@ -100,10 +105,10 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
BackupState: credential.Flags.BackupState,
}
if err := s.db.Create(&credentialToStore).Error; err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
return &credentialToStore, nil
return credentialToStore, nil
}
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
@@ -113,7 +118,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}
sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires,
ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
@@ -129,15 +134,15 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil
}
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (*model.User, 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 nil, err
return model.User{}, "", err
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt,
Expires: storedSession.ExpiresAt.ToTime(),
}
var user *model.User
@@ -149,14 +154,17 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData)
if err != nil {
return nil, common.ErrInvalidCredentials
return model.User{}, "", err
}
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return nil, err
token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err
}
return user, nil
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
return *user, token, nil
}
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {
@@ -180,17 +188,22 @@ func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error {
return nil
}
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) error {
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) {
var credential model.WebauthnCredential
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil {
return err
return credential, err
}
credential.Name = name
if err := s.db.Save(&credential).Error; err != nil {
return err
return credential, err
}
return nil
return credential, nil
}
// updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime
func (s *WebAuthnService) updateWebAuthnConfig() {
s.webAuthn.Config.RPDisplayName = s.appConfigService.DbConfig.AppName.Value
}

View File

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

View File

@@ -0,0 +1,213 @@
package email
import (
"fmt"
"strings"
"unicode"
)
const maxLineLength = 78
const continuePrefix = " "
const addressSeparator = ", "
type Composer struct {
isClosed bool
content strings.Builder
}
func NewComposer() *Composer {
return &Composer{}
}
type Address struct {
Name string
Email string
}
func (c *Composer) AddAddressHeader(name string, addresses []Address) {
c.content.WriteString(genAddressHeader(name, addresses, maxLineLength))
c.content.WriteString("\n")
}
func genAddressHeader(name string, addresses []Address, maxLength int) string {
hl := &headerLine{
maxLineLength: maxLength,
continuePrefix: continuePrefix,
}
hl.Write(name)
hl.Write(": ")
for i, addr := range addresses {
var email string
if i < len(addresses)-1 {
email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator)
} else {
email = fmt.Sprintf("<%s>", addr.Email)
}
writeHeaderQ(hl, addr.Name)
writeHeaderAtom(hl, " ")
writeHeaderAtom(hl, email)
}
hl.EndLine()
return hl.String()
}
func (c *Composer) AddHeader(name, value string) {
if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength {
c.AddHeaderRaw(name, value)
return
}
c.content.WriteString(genHeader(name, value, maxLineLength))
c.content.WriteString("\n")
}
func genHeader(name, value string, maxLength int) string {
// add content as raw header when it is printable ASCII and shorter than maxLineLength
hl := &headerLine{
maxLineLength: maxLength,
continuePrefix: continuePrefix,
}
hl.Write(name)
hl.Write(": ")
writeHeaderQ(hl, value)
hl.EndLine()
return hl.String()
}
const qEncStart = "=?utf-8?q?"
const qEncEnd = "?="
type headerLine struct {
buffer strings.Builder
line strings.Builder
maxLineLength int
continuePrefix string
}
func (h *headerLine) FitsLine(length int) bool {
return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength
}
func (h *headerLine) Write(str string) {
h.line.WriteString(str)
}
func (h *headerLine) EndLineWith(str string) {
h.line.WriteString(str)
h.EndLine()
}
func (h *headerLine) EndLine() {
if h.line.Len() == 0 {
return
}
if h.buffer.Len() != 0 {
h.buffer.WriteString("\n")
h.buffer.WriteString(h.continuePrefix)
}
h.buffer.WriteString(h.line.String())
h.line.Reset()
}
func (h *headerLine) String() string {
return h.buffer.String()
}
func writeHeaderQ(header *headerLine, value string) {
// current line does not fit event the first character - do \n
if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) {
header.EndLineWith("")
}
header.Write(qEncStart)
for _, token := range convertRunes(value) {
if header.FitsLine(len(token) + len(qEncEnd)) {
header.Write(token)
} else {
header.EndLineWith(qEncEnd)
header.Write(qEncStart)
header.Write(token)
}
}
header.Write(qEncEnd)
}
func writeHeaderAtom(header *headerLine, value string) {
if !header.FitsLine(len(value)) {
header.EndLine()
}
header.Write(value)
}
func (c *Composer) AddHeaderRaw(name, value string) {
if c.isClosed {
panic("composer had already written body!")
}
header := fmt.Sprintf("%s: %s\n", name, value)
c.content.WriteString(header)
}
func (c *Composer) Body(body string) {
c.content.WriteString("\n")
c.content.WriteString(body)
c.isClosed = true
}
func (c *Composer) String() string {
return c.content.String()
}
func convertRunes(str string) []string {
var enc = make([]string, 0, len(str))
for _, r := range []rune(str) {
if r == ' ' {
enc = append(enc, "_")
} else if isPrintableASCIIRune(r) &&
r != '=' &&
r != '?' &&
r != '_' {
enc = append(enc, string(r))
} else {
enc = append(enc, string(toHex([]byte(string(r)))))
}
}
return enc
}
func toHex(in []byte) []byte {
enc := make([]byte, 0, len(in)*2)
for _, b := range in {
enc = append(enc, '=')
enc = append(enc, hex(b/16))
enc = append(enc, hex(b%16))
}
return enc
}
func hex(n byte) byte {
if n > 9 {
return n + (65 - 10)
} else {
return n + 48
}
}
func isPrintableASCII(str string) bool {
for _, r := range []rune(str) {
if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
return false
}
}
return true
}
func isPrintableASCIIRune(r rune) bool {
return r > 31 && r < 127
}

View File

@@ -0,0 +1,92 @@
package email
import (
"strings"
"testing"
)
func TestConvertRunes(t *testing.T) {
var testData = map[string]string{
"=??=_.": "=3D=3F=3F=3D=5F.",
"Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎": "P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3dy_=F0=9F=90=8E",
}
for input, expected := range testData {
got := strings.Join(convertRunes(input), "")
if got != expected {
t.Errorf("Input: '%s', expected '%s', got: '%s'", input, expected, got)
}
}
}
type genHeaderTestData struct {
name string
value string
expected string
maxWidth int
}
func TestGenHeaderQ(t *testing.T) {
var testData = []genHeaderTestData{
{
name: "Subject",
value: "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎",
expected: "Subject: =?utf-8?q?P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk?=\n" +
" =?utf-8?q?=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3?=\n" +
" =?utf-8?q?dy_=F0=9F=90=8E?=",
maxWidth: 80,
},
}
for _, data := range testData {
got := genHeader(data.name, data.value, data.maxWidth)
if got != data.expected {
t.Errorf("Input: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.value, data.expected, got)
}
}
}
type genAddressHeaderTestData struct {
name string
addresses []Address
expected string
maxLength int
}
func TestGenAddressHeader(t *testing.T) {
var testData = []genAddressHeaderTestData{
{
name: "To",
addresses: []Address{
{
Name: "Oldřich Jánský",
Email: "olrd@example.com",
},
},
expected: "To: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>",
maxLength: 80,
},
{
name: "Subject",
addresses: []Address{
{
Name: "Oldřich Jánský",
Email: "olrd@example.com",
},
{
Name: "Jan Novák",
Email: "novak@example.com",
},
},
expected: "Subject: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>, \n" +
" =?utf-8?q?Jan_Nov=C3=A1k?= <novak@example.com>",
maxLength: 80,
},
}
for _, data := range testData {
got := genAddressHeader(data.name, data.addresses, data.maxLength)
if got != data.expected {
t.Errorf("Test: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.name, data.expected, got)
}
}
}

View File

@@ -0,0 +1,97 @@
package email
import (
"fmt"
"github.com/stonith404/pocket-id/backend/resources"
htemplate "html/template"
"io/fs"
"path"
ttemplate "text/template"
)
type Template[V any] struct {
Path string
Title func(data *TemplateData[V]) string
}
type TemplateData[V any] struct {
AppName string
LogoURL string
Data *V
}
type TemplateMap[V any] map[string]*V
func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V]) *U {
return templateMap[template.Path]
}
type clonable[V pareseable[V]] interface {
Clone() (V, error)
}
type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err)
}
filename := fmt.Sprintf("%s%s", template, suffix)
templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil {
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
}
return tmpl, nil
}
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
}
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
}
return textTemplates, nil
}
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
}
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
}
return htmlTemplates, nil
}

View File

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

View File

@@ -1,27 +0,0 @@
package utils
import (
"errors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"log"
"net/http"
"strings"
)
func UnknownHandlerError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
HandlerError(c, http.StatusNotFound, "Record not found")
return
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
}
func HandlerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -2,15 +2,46 @@ package utils
import (
"gorm.io/gorm"
"reflect"
)
type PaginationResponse struct {
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
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
}
@@ -24,17 +55,23 @@ 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),
TotalItems: totalItems,
CurrentPage: page,
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,
}, nil
}

View File

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

View File

@@ -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)
}

View File

@@ -0,0 +1,14 @@
{{ define "root" }}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ template "style" . }}
</head>
<body>
<div class="container">
{{ template "base" . }}
</div>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,7 @@
{{- define "root" -}}
{{- template "base" . -}}
{{- end }}
--
This is automatically sent email from {{.AppName}}.

View File

@@ -0,0 +1,95 @@
{{ 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;
}
.button {
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
outline: none;
border: none;
text-decoration: none;
}
.button-container {
text-align: center;
margin-top: 24px;
}
</style>
{{ end }}

View File

@@ -0,0 +1,36 @@
{{ 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>
<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 -}}

View File

@@ -0,0 +1,15 @@
{{ define "base" -}}
New Sign-In Detected
====================
{{ if and .Data.City .Data.Country }}
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
{{ end }}
IP Address: {{ .Data.IPAddress }}
Device: {{ .Data.Device }}
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
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.
{{ end -}}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
{{ define "base" -}}
This is a test email.
{{ end -}}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path 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"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 434 B

View File

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

View File

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

View File

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

View File

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

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