Compare commits

...

284 Commits

Author SHA1 Message Date
Elias Schneider
a1131bca9a release: 0.35.2 2025-02-24 09:40:48 +01:00
Elias Schneider
9a167d4076 fix: delete profile picture if user gets deleted 2025-02-24 09:40:14 +01:00
Elias Schneider
887c5e462a fix: updating profile picture of other user updates own profile picture 2025-02-24 09:35:44 +01:00
Elias Schneider
20eba1378e release: 0.35.1 2025-02-22 14:59:43 +01:00
Elias Schneider
a6ae7ae287 fix: add validation that PUBLIC_APP_URL can't contain a path 2025-02-22 14:59:10 +01:00
Elias Schneider
840a672fc3 fix: binary profile picture can't be imported from LDAP 2025-02-22 14:51:21 +01:00
Elias Schneider
7446f853fc release: 0.35.0 2025-02-19 14:29:24 +01:00
Elias Schneider
652ee6ad5d feat: add ability to upload a profile picture (#244) 2025-02-19 14:28:45 +01:00
Elias Schneider
dca9e7a11a fix: emails do not get rendered correctly in Gmail 2025-02-19 13:54:36 +01:00
Elias Schneider
816c198a42 fix: app config strings starting with a number are parsed incorrectly 2025-02-18 21:36:08 +01:00
Elias Schneider
339837bec4 release: 0.34.0 2025-02-16 18:29:18 +01:00
Kyle Mendell
39b46e99a9 feat: add LDAP group membership attribute (#236)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-16 18:27:07 +01:00
Elias Schneider
dc9e64de3d release: 0.33.0 2025-02-14 17:10:14 +01:00
Elias Schneider
6207e10279 Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-02-14 17:09:39 +01:00
Elias Schneider
7550333fe2 feat: add end session endpoint (#232) 2025-02-14 17:09:27 +01:00
Elias Schneider
3de1301fa8 fix: layout of OIDC client details page on mobile 2025-02-14 16:03:17 +01:00
Elias Schneider
c3980d3d28 fix: alignment of OIDC client details 2025-02-14 15:53:30 +01:00
Elias Schneider
4d0fff821e fix: show "Sync Now" and "Test Email" button even if UI config is disabled 2025-02-14 13:32:01 +01:00
Elias Schneider
2e66211b7f release: 0.32.0 2025-02-13 21:02:20 +01:00
Giovanni
2071d002fc feat: add ability to set custom Geolite DB URL 2025-02-13 21:01:43 +01:00
Elias Schneider
0d071694cd release: 0.31.0 2025-02-12 16:29:43 +01:00
Kyle Mendell
39e403d00f feat: add warning for only having one passkey configured (#220)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-12 16:29:08 +01:00
Elias Schneider
4e858420e9 feat: add ability to override the UI configuration with environment variables 2025-02-12 14:20:52 +01:00
Kyle Mendell
2d78349b38 fix: user linking in ldap group sync (#222) 2025-02-11 17:25:00 +01:00
Kyle Mendell
9ed2adb0f8 feat: display source in user and group table (#225)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-11 17:24:10 +01:00
Elias Schneider
43790dc1be ci/cd: downgrade ubuntu version of Docker build action runner 2025-02-09 15:16:13 +01:00
Elias Schneider
7fbc356d8d ci/cd: remove Docker Hub registry 2025-02-09 15:11:10 +01:00
Elias Schneider
9b77e8b7c1 release: 0.30.0 2025-02-08 18:19:11 +01:00
Jonas Claes
bea115866f feat: update host configuration to allow external access (#218) 2025-02-08 18:17:57 +01:00
Kyle Mendell
626f87d592 feat: add custom ldap search filters (#216) 2025-02-08 18:16:57 +01:00
Elias Schneider
0751540d7d chore: remove docs from repository 2025-02-06 17:22:43 +01:00
Elias Schneider
7c04bda5b7 docs: improve mobile layout of landing page 2025-02-06 16:57:16 +01:00
Elias Schneider
98add37390 docs: add docs root path redirection 2025-02-06 16:50:38 +01:00
Elias Schneider
3dda2e16e9 docs: improve landing page 2025-02-06 16:42:29 +01:00
Kyle Mendell
3a6fce5c4b docs: add landing page (#203)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-06 16:41:44 +01:00
Elias Schneider
07ee087c3d Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-02-06 12:12:55 +01:00
Elias Schneider
d66cf70d50 ci/cd: add missing permissions to "Build and Push Docker Image" 2025-02-06 12:12:49 +01:00
RobinMicek
fb8cc0bb22 docs: fix freshrss callback url (#212) 2025-02-06 07:37:14 +01:00
Elias Schneider
0bae7e4f53 chore: fix old docker image references 2025-02-05 18:46:31 +01:00
Elias Schneider
974b7b3c34 release: 0.29.0 2025-02-05 18:29:08 +01:00
Elias Schneider
15cde6ac66 feat: add JSON support in custom claims 2025-02-05 18:28:21 +01:00
Elias Schneider
e864d5dcbf feat: add option to disable Caddy in the Docker container 2025-02-05 18:14:49 +01:00
Elias Schneider
c6ab2b252c chore: replace stonith404 with pocket-id after org migration 2025-02-05 18:08:01 +01:00
Kyle Mendell
7350e3486d docs: enhance documentation (#205)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-05 17:18:01 +01:00
Elias Schneider
96303ded2b release: 0.28.1 2025-02-04 18:19:20 +01:00
Elias Schneider
d06257ec9b fix: don't return error page if version info fetching failed 2025-02-04 18:19:06 +01:00
Elias Schneider
19ef4833e9 docs: fix reauthentication in caddy-security example 2025-02-04 17:57:36 +01:00
Elias Schneider
e2c38138be release: 0.28.0 2025-02-03 18:41:42 +01:00
Elias Schneider
13b02a072f feat: map allowed groups to OIDC clients (#202) 2025-02-03 18:41:15 +01:00
Logan
430421e98b docs: add example for adding Pocket ID to FreshRSS (#200) 2025-02-03 09:10:10 +01:00
Elias Schneider
61e71ad43b fix: missing user service dependency 2025-02-03 09:08:20 +01:00
Elias Schneider
4db44e4818 Merge remote-tracking branch 'origin/main' 2025-02-03 08:58:35 +01:00
Elias Schneider
9ab178712a feat: allow LDAP users and groups to be deleted if LDAP gets disabled 2025-02-03 08:58:20 +01:00
Elias Schneider
ecd74b794f fix: non LDAP user group can't be updated after update 2025-02-03 08:37:46 +01:00
Kyle Mendell
5afd651434 docs: add helper scripts install for proxmox (#197)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-02 18:59:04 +01:00
Elias Schneider
2d3cba6308 docs: add new demo.pocket-id.org domain to the README 2025-02-01 19:40:44 +01:00
Elias Schneider
e607fe424a docs: add custom pocket-id.org domain 2025-02-01 19:31:01 +01:00
PrtmPhlp
8ae446322a docs: Added Gitea and Memos example (#194) 2025-02-01 16:26:59 +01:00
Andrew Pearson
37a835b44e fix(caddy): trusted_proxies for IPv6 enabled hosts (#189) 2025-02-01 01:02:34 +01:00
Jeffrey Garcia
75f531fbc6 docs: Add Immich and Headscale client examples (#191) 2025-02-01 01:00:54 +01:00
Elias Schneider
28346da731 refactor: run formatter 2025-01-28 22:27:50 +01:00
Elias Schneider
a1b20f0e74 ci/cd: ignore irrelevant paths for e2e tests 2025-01-28 22:27:07 +01:00
Elias Schneider
7497f4ad40 ci/cd: add auto deployment for docs website 2025-01-28 22:25:16 +01:00
Kyle Mendell
b530d646ac docs: add version label to navbar (#186)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-28 22:19:16 +01:00
Elias Schneider
77985800ae fix: use cursor pointer on clickable elements 2025-01-28 19:22:29 +01:00
Elias Schneider
ea21eba281 release: 0.27.2 2025-01-27 17:30:13 +01:00
Elias Schneider
66edb18f2c Merge branch 'main' of https://github.com/stonith404/pocket-id 2025-01-27 12:01:16 +01:00
Elias Schneider
dab37c5967 chore: downgrade formsnap 2025-01-27 12:01:07 +01:00
Kyle Mendell
781ff7ae7b fix: smtp hello for tls connections (#180)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-27 11:37:50 +01:00
Elias Schneider
04c7f180de chore: upgrade frontend and backend dependencies 2025-01-27 11:01:48 +01:00
Elias Schneider
5c452ceef0 chore: upgrade to Tailwind 4 2025-01-27 10:21:11 +01:00
Elias Schneider
8cd834a503 chore: upgrade to Nodejs 22 2025-01-27 09:48:20 +01:00
Elias Schneider
a65ce56b42 docs: add missing env file flag to frontend start command 2025-01-27 09:43:43 +01:00
Daniel Breedeveld
4a97986f52 docs: fix typos and improve clarity in proxmox.md (#183) 2025-01-27 09:02:55 +01:00
Elias Schneider
a879bfa418 release: 0.27.1 2025-01-24 12:07:10 +01:00
Elias Schneider
164ce6a3d7 fix: add __HOST prefix to cookies (#175) 2025-01-24 12:01:27 +01:00
Chris Danis
ef1aeb7152 docs: make CONTRIBUTING instructions work & fix example envs (#152) 2025-01-24 10:51:26 +01:00
Elias Schneider
47c39f6d38 fix: use OS hostname for SMTP EHLO message 2025-01-24 10:36:16 +01:00
Elias Schneider
2884021055 chore: remove duplicate text from issue template 2025-01-23 19:57:50 +01:00
Kyle Mendell
def39b8703 chore: bug template update (#133)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-23 19:10:48 +01:00
Elias Schneider
d071641890 docs: remove duplicate contribute.md 2025-01-23 18:10:28 +01:00
Elias Schneider
397544c0f3 fix: send hostname derived from PUBLIC_APP_URL with SMTP EHLO command 2025-01-23 17:55:36 +01:00
Kyle Mendell
1fb99e5d52 docs: add more client-examples (#166) 2025-01-23 11:37:41 +01:00
Elias Schneider
7b403552ba chore: add GitHub release creation to create-release.sh script 2025-01-23 08:56:28 +01:00
Elias Schneider
440a9f1ba0 release: 0.27.0 2025-01-22 18:51:06 +01:00
Kyle Mendell
d02f4753f3 fix: add save changes dialog before sending test email (#165)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-22 18:49:25 +01:00
Kyle Mendell
ede7d8fc15 docs: fix open-webui docs page (#162) 2025-01-21 19:07:12 +01:00
imgbot[bot]
e4e6c9b680 refactor: optimize images (#161)
Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-01-21 18:59:16 +01:00
Kyle Mendell
c12bf2955b docs: add docusaurus docs (#118)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-21 18:46:42 +01:00
Elias Schneider
c211d3fc67 docs: add delay_start to caddy security 2025-01-20 17:54:38 +01:00
Kamil Kosek
d87eb416cd docs: create sample-configurations.md (#142)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-20 11:54:35 +01:00
Giovanni
f7710f2988 fix: ensure the downloaded GeoLite2 DB is not corrupted & prevent RW race condition (#138) 2025-01-20 11:50:58 +01:00
Chris Danis
72923bb86d feat: display private IP ranges correctly in audit log (#139) 2025-01-20 11:36:12 +01:00
Elias Schneider
6e44b5e367 release: 0.26.0 2025-01-20 11:23:52 +01:00
Elias Schneider
8a1db0cb4a feat: support wildcard callback URLs 2025-01-20 11:19:23 +01:00
Elias Schneider
3f02d08109 fix: non LDAP users get created with a empty LDAP ID string 2025-01-20 10:41:01 +01:00
Elias Schneider
715040ba04 release: 0.25.1 2025-01-19 22:14:29 +01:00
Elias Schneider
a8b9d60a86 fix: disable account details inputs if user is imported from LDAP 2025-01-19 22:14:20 +01:00
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
Elias Schneider
d92b80b80f release: 0.2.1 2024-08-19 23:16:02 +02:00
Elias Schneider
aaed71e1c8 tests: fix update general configuration test 2024-08-19 23:15:19 +02:00
Elias Schneider
4780548843 fix: session duration can't be updated 2024-08-19 23:10:14 +02:00
Elias Schneider
a5dfdd2178 release: 0.2.0 2024-08-19 19:15:21 +02:00
Elias Schneider
9eec7a3e9e feat: change default logo 2024-08-19 19:07:45 +02:00
Elias Schneider
fdc1921f5d feat: add user info endpoint to support more oidc clients 2024-08-19 18:48:18 +02:00
Elias Schneider
601f6c488a refactor: use dependency injection in backend 2024-08-17 21:57:14 +02:00
Elias Schneider
0595d73ea5 feat: add INTERNAL_BACKEND_URL env variable 2024-08-17 14:57:10 +02:00
Elias Schneider
74f4c22800 docs: add note that https is required 2024-08-17 00:51:29 +02:00
Elias Schneider
b49063d692 docs: add Unraid to README 2024-08-13 23:32:53 +02:00
Elias Schneider
5bbb92ae19 release: 0.1.3 2024-08-13 23:19:52 +02:00
Elias Schneider
cc407e17d4 fix: add missing passkey flags to make icloud passkeys work 2024-08-13 23:19:36 +02:00
Elias Schneider
5749d0532f fix: logo not white in dark mode 2024-08-13 23:19:04 +02:00
Elias Schneider
089005d1af release: 0.1.2 2024-08-13 21:06:40 +02:00
Elias Schneider
4a808c86ac fix: background image on mobile 2024-08-13 21:06:29 +02:00
Elias Schneider
83954926f5 fix: disable search engine indexing 2024-08-13 20:55:50 +02:00
Elias Schneider
475b932f9d feat: add option to change session duration 2024-08-13 20:51:10 +02:00
Elias Schneider
df0cd38dee fix: a non admin user was able to make himself an admin 2024-08-13 20:18:41 +02:00
Elias Schneider
7b4418958e fix: background image not loading 2024-08-13 18:32:21 +02:00
389 changed files with 15131 additions and 6211 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/pocket-id/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

@@ -34,4 +34,23 @@ body:
- type: markdown
attributes:
value: |
Before submitting, please check if the issues hasn't been raised before.
### Additional Information
- type: textarea
id: extra-information
validations:
required: true
attributes:
label: "Version and Environment"
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
placeholder: "e.g., v0.24.1"
- type: textarea
id: log-files
validations:
required: false
attributes:
label: "Log Output"
description: "Output of log files when the issue occured to help us diagnose the issue."
- type: markdown
attributes:
value: |
**Before submitting, please check if the issue hasn't been raised before.**

View File

@@ -6,29 +6,50 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
permissions:
contents: read
packages: write
steps:
- name: checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to Docker registry
uses: docker/login-action@v2
- name: Login to 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

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

15
.gitignore vendored
View File

@@ -34,4 +34,17 @@ vite.config.ts.timestamp-*
# Application specific
data
/frontend/tests/.auth
pocket-id-backend
/frontend/tests/.report
pocket-id-backend
/backend/GeoLite2-City.mmdb
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -1 +1 @@
0.1.1
0.35.2

View File

@@ -1,3 +1,557 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.1...v) (2025-02-24)
### Bug Fixes
* delete profile picture if user gets deleted ([9a167d4](https://github.com/pocket-id/pocket-id/commit/9a167d4076872e5e3e5d78d2a66ef7203ca5261b))
* updating profile picture of other user updates own profile picture ([887c5e4](https://github.com/pocket-id/pocket-id/commit/887c5e462a50c8fb579ca6804f1a643d8af78fe8))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)
### Bug Fixes
* add validation that `PUBLIC_APP_URL` can't contain a path ([a6ae7ae](https://github.com/pocket-id/pocket-id/commit/a6ae7ae28713f7fc8018ae2aa7572986df3e1a5b))
* binary profile picture can't be imported from LDAP ([840a672](https://github.com/pocket-id/pocket-id/commit/840a672fc35ca8476caf86d7efaba9d54bce86aa))
## [](https://github.com/pocket-id/pocket-id/compare/v0.34.0...v) (2025-02-19)
### Features
* add ability to upload a profile picture ([#244](https://github.com/pocket-id/pocket-id/issues/244)) ([652ee6a](https://github.com/pocket-id/pocket-id/commit/652ee6ad5d6c46f0d35c955ff7bb9bdac6240ca6))
### Bug Fixes
* app config strings starting with a number are parsed incorrectly ([816c198](https://github.com/pocket-id/pocket-id/commit/816c198a42c189cb1f2d94885d2e3623e47e2848))
* emails do not get rendered correctly in Gmail ([dca9e7a](https://github.com/pocket-id/pocket-id/commit/dca9e7a11a3ba5d3b43a937f11cb9d16abad2db5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
### Features
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
### Features
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
### Bug Fixes
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
### Features
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)
### Features
* add ability to override the UI configuration with environment variables ([4e85842](https://github.com/pocket-id/pocket-id/commit/4e858420e9d9713e19f3b35c45c882403717f72f))
* add warning for only having one passkey configured ([#220](https://github.com/pocket-id/pocket-id/issues/220)) ([39e403d](https://github.com/pocket-id/pocket-id/commit/39e403d00f3870f9e960427653a1d9697da27a6f))
* display source in user and group table ([#225](https://github.com/pocket-id/pocket-id/issues/225)) ([9ed2adb](https://github.com/pocket-id/pocket-id/commit/9ed2adb0f8da13725fd9a4ef6a7798c377d13513))
### Bug Fixes
* user linking in ldap group sync ([#222](https://github.com/pocket-id/pocket-id/issues/222)) ([2d78349](https://github.com/pocket-id/pocket-id/commit/2d78349b381d7ca10f47d3c03cef685a576b1b49))
## [](https://github.com/pocket-id/pocket-id/compare/v0.29.0...v) (2025-02-08)
### Features
* add custom ldap search filters ([#216](https://github.com/pocket-id/pocket-id/issues/216)) ([626f87d](https://github.com/pocket-id/pocket-id/commit/626f87d59211f4129098b91dc1d020edb4aca692))
* update host configuration to allow external access ([#218](https://github.com/pocket-id/pocket-id/issues/218)) ([bea1158](https://github.com/pocket-id/pocket-id/commit/bea115866fd8e4b15d3281c422d2fb72312758b1))
## [](https://github.com/pocket-id/pocket-id/compare/v0.28.1...v) (2025-02-05)
### Features
* add JSON support in custom claims ([15cde6a](https://github.com/pocket-id/pocket-id/commit/15cde6ac66bc857ac28df545a37c1f4341977595))
* add option to disable Caddy in the Docker container ([e864d5d](https://github.com/pocket-id/pocket-id/commit/e864d5dcbff1ef28dc6bf120e4503093a308c5c8))
## [](https://github.com/stonith404/pocket-id/compare/v0.28.0...v) (2025-02-04)
### Bug Fixes
* don't return error page if version info fetching failed ([d06257e](https://github.com/stonith404/pocket-id/commit/d06257ec9b5e46e25e40c174b4bef02dca0a1ea3))
## [](https://github.com/stonith404/pocket-id/compare/v0.27.2...v) (2025-02-03)
### Features
* allow LDAP users and groups to be deleted if LDAP gets disabled ([9ab1787](https://github.com/stonith404/pocket-id/commit/9ab178712aa3cc71546a89226e67b7ba91245251))
* map allowed groups to OIDC clients ([#202](https://github.com/stonith404/pocket-id/issues/202)) ([13b02a0](https://github.com/stonith404/pocket-id/commit/13b02a072f20ce10e12fd8b897cbf42a908f3291))
### Bug Fixes
* **caddy:** trusted_proxies for IPv6 enabled hosts ([#189](https://github.com/stonith404/pocket-id/issues/189)) ([37a835b](https://github.com/stonith404/pocket-id/commit/37a835b44e308622f6862de494738dd2bfb58ef0))
* missing user service dependency ([61e71ad](https://github.com/stonith404/pocket-id/commit/61e71ad43b8f0f498133d3eb2381382e7bc642b9))
* non LDAP user group can't be updated after update ([ecd74b7](https://github.com/stonith404/pocket-id/commit/ecd74b794f1ffb7da05bce0046fb8d096b039409))
* use cursor pointer on clickable elements ([7798580](https://github.com/stonith404/pocket-id/commit/77985800ae9628104e03e7f2e803b7ed9eaaf4e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.27.1...v) (2025-01-27)
### Bug Fixes
* smtp hello for tls connections ([#180](https://github.com/stonith404/pocket-id/issues/180)) ([781ff7a](https://github.com/stonith404/pocket-id/commit/781ff7ae7b84b13892e7a565b7a78f20c52ee2c9))
## [](https://github.com/stonith404/pocket-id/compare/v0.27.0...v) (2025-01-24)
### Bug Fixes
* add `__HOST` prefix to cookies ([#175](https://github.com/stonith404/pocket-id/issues/175)) ([164ce6a](https://github.com/stonith404/pocket-id/commit/164ce6a3d7fa8ae5275c94302952cf318e3b3113))
* send hostname derived from `PUBLIC_APP_URL` with SMTP EHLO command ([397544c](https://github.com/stonith404/pocket-id/commit/397544c0f3f2b49f1f34ae53e6b9daf194d1ae28))
* use OS hostname for SMTP EHLO message ([47c39f6](https://github.com/stonith404/pocket-id/commit/47c39f6d382c496cb964262adcf76cc8dbb96da3))
## [](https://github.com/stonith404/pocket-id/compare/v0.26.0...v) (2025-01-22)
### Features
* display private IP ranges correctly in audit log ([#139](https://github.com/stonith404/pocket-id/issues/139)) ([72923bb](https://github.com/stonith404/pocket-id/commit/72923bb86dc5d07d56aea98cf03320667944b553))
### Bug Fixes
* add save changes dialog before sending test email ([#165](https://github.com/stonith404/pocket-id/issues/165)) ([d02f475](https://github.com/stonith404/pocket-id/commit/d02f4753f3fbda75cd415ebbfe0702765c38c144))
* ensure the downloaded GeoLite2 DB is not corrupted & prevent RW race condition ([#138](https://github.com/stonith404/pocket-id/issues/138)) ([f7710f2](https://github.com/stonith404/pocket-id/commit/f7710f298898d322885c1c83680e26faaa0bb800))
## [](https://github.com/stonith404/pocket-id/compare/v0.25.1...v) (2025-01-20)
### Features
* support wildcard callback URLs ([8a1db0c](https://github.com/stonith404/pocket-id/commit/8a1db0cb4a5d4b32b4fdc19d41fff688a7c71a56))
### Bug Fixes
* non LDAP users get created with a empty LDAP ID string ([3f02d08](https://github.com/stonith404/pocket-id/commit/3f02d081098ad2caaa60a56eea4705639f80d01f))
## [](https://github.com/stonith404/pocket-id/compare/v0.25.0...v) (2025-01-19)
### Bug Fixes
* disable account details inputs if user is imported from LDAP ([a8b9d60](https://github.com/stonith404/pocket-id/commit/a8b9d60a86e80c10d6fba07072b1d32cec400ecb))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.1...v) (2025-01-19)
### Features
* add LDAP sync ([#106](https://github.com/stonith404/pocket-id/issues/106)) ([5101b14](https://github.com/stonith404/pocket-id/commit/5101b14eec68a9507e1730994178d0ebe8185876))
* allow sign in with email ([#100](https://github.com/stonith404/pocket-id/issues/100)) ([06b90ed](https://github.com/stonith404/pocket-id/commit/06b90eddd645cce57813f2536e4a6a8836548f2b))
* automatically authorize client if signed in ([d5dd118](https://github.com/stonith404/pocket-id/commit/d5dd118a3f4ad6eed9ca496c458201bb10f148a0))
### Bug Fixes
* always set secure on cookie ([#130](https://github.com/stonith404/pocket-id/issues/130)) ([fda08ac](https://github.com/stonith404/pocket-id/commit/fda08ac1cd88842e25dc47395ed1288a5cfac4f8))
* don't panic if LDAP sync fails on startup ([e284e35](https://github.com/stonith404/pocket-id/commit/e284e352e2b95fac1d098de3d404e8531de4b869))
* improve spacing of checkboxes on application configuration page ([090eca2](https://github.com/stonith404/pocket-id/commit/090eca202d198852e6fbf4e6bebaf3b5ada13944))
* search input not displayed if response hasn't any items ([05a98eb](https://github.com/stonith404/pocket-id/commit/05a98ebe87d7a88e8b96b144c53250a40d724ec3))
* session duration ignored in cookie expiration ([bc8f454](https://github.com/stonith404/pocket-id/commit/bc8f454ea173ecc60e06450a1d22e24207f76714))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.0...v) (2025-01-13)
### Bug Fixes
* audit log table overflow if row data is long ([4d337a2](https://github.com/stonith404/pocket-id/commit/4d337a20c5cb92ef80bb7402f9b99b08e3ad0b6b))
* optional arguments not working with `create-one-time-access-token.sh` ([8885571](https://github.com/stonith404/pocket-id/commit/888557171d61589211b10f70dce405126216ad61))
* remove restrictive validation for group names ([be6e25a](https://github.com/stonith404/pocket-id/commit/be6e25a167de8bf07075b46f09d9fc1fa6c74426))
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)
### Features
* add sorting for tables ([fd69830](https://github.com/stonith404/pocket-id/commit/fd69830c2681985e4fd3c5336a2b75c9fb7bc5d4))
### Bug Fixes
* pkce state not correctly reflected in oidc client info ([61d18a9](https://github.com/stonith404/pocket-id/commit/61d18a9d1b167ff59a59523ff00d00ca8f23258d))
* send test email to the user that has requested it ([a649c4b](https://github.com/stonith404/pocket-id/commit/a649c4b4a543286123f4d1f3c411fe1a7e2c6d71))
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)
### Features
* add PKCE for non public clients ([adcf3dd](https://github.com/stonith404/pocket-id/commit/adcf3ddc6682794e136a454ef9e69ddd130626a8))
* use same table component for OIDC client list as all other lists ([2d31fc2](https://github.com/stonith404/pocket-id/commit/2d31fc2cc9201bb93d296faae622f52c6dcdfebc))
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
### Features
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
### Bug Fixes
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
### Features
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
### Bug Fixes
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
### Bug Fixes
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
### Features
* add support for Postgres database provider ([#79](https://github.com/stonith404/pocket-id/issues/79)) ([9d20a98](https://github.com/stonith404/pocket-id/commit/9d20a98dbbc322fa6f0644e8b31e6b97769887ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.18.0...v) (2024-11-29)
### Features
* **geolite:** add Tailscale IP detection with CGNAT range check ([#77](https://github.com/stonith404/pocket-id/issues/77)) ([edce3d3](https://github.com/stonith404/pocket-id/commit/edce3d337129c9c6e8a60df2122745984ba0f3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.17.0...v) (2024-11-28)
### Features
* add option to disable TLS for email sending ([f9fa2c6](https://github.com/stonith404/pocket-id/commit/f9fa2c6706a8bf949fe5efd6664dec8c80e18659))
* allow empty user and password in SMTP configuration ([a9f4dad](https://github.com/stonith404/pocket-id/commit/a9f4dada321841d3611b15775307228b34e7793f))
### Bug Fixes
* email save toast shows two times ([f2bfc73](https://github.com/stonith404/pocket-id/commit/f2bfc731585ad7424eb8c4c41c18368fc0f75ffc))
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
### ⚠ BREAKING CHANGES
* add option to specify the Max Mind license key for the Geolite2 db
### Features
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
### Bug Fixes
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
### Features
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
### Features
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
### Bug Fixes
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
### Features
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
### Bug Fixes
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
### Features
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
### Bug Fixes
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
### Features
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
### Features
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
### Features
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
### Bug Fixes
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
### Features
* add script for creating one time access token ([a1985ce](https://github.com/stonith404/pocket-id/commit/a1985ce1b200550e91c5cb42a8d19899dcec831e))
* add version information to footer and update link if new update is available ([70ad0b4](https://github.com/stonith404/pocket-id/commit/70ad0b4f39699fd81ffdfd5c8d6839f49348be78))
### Bug Fixes
* cache version information for 3 hours ([29d632c](https://github.com/stonith404/pocket-id/commit/29d632c1514d6edacdfebe6deae4c95fc5a0f621))
* improve text for initial admin account setup ([0a07344](https://github.com/stonith404/pocket-id/commit/0a0734413943b1fff27d8f4ccf07587e207e2189))
* increase callback url count ([f3f0e1d](https://github.com/stonith404/pocket-id/commit/f3f0e1d56d7656bdabbd745a4eaf967f63193b6c))
* no DTO was returned from exchange one time access token endpoint ([824c5cb](https://github.com/stonith404/pocket-id/commit/824c5cb4f3d6be7f940c1758112fbe9322df5768))
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
### Features
* add environment variable to change the caddy port in Docker ([ff06bf0](https://github.com/stonith404/pocket-id/commit/ff06bf0b34496ce472ba6d3ebd4ea249f21c0ec3))
* use improve table for users and audit logs ([11ed661](https://github.com/stonith404/pocket-id/commit/11ed661f86a512f78f66d604a10c1d47d39f2c39))
### Bug Fixes
* allow copy to clipboard for client secret ([29748cc](https://github.com/stonith404/pocket-id/commit/29748cc6c7b7e5a6b54bfe837e0b1a98fa1ad594))
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)
### Bug Fixes
* add key id to JWK ([282ff82](https://github.com/stonith404/pocket-id/commit/282ff82b0c7e2414b3528c8ca325758245b8ae61))
## [](https://github.com/stonith404/pocket-id/compare/v0.7.1...v) (2024-10-04)
### Features
* add location based on ip to the audit log ([025378d](https://github.com/stonith404/pocket-id/commit/025378d14edd2d72da76e90799a0ccdd42cf672c))
## [](https://github.com/stonith404/pocket-id/compare/v0.7.0...v) (2024-10-03)
### Bug Fixes
* initials don't get displayed if Gravatar avatar doesn't exist ([e095628](https://github.com/stonith404/pocket-id/commit/e09562824a794bc7d240e9d229709d4b389db7d5))
## [](https://github.com/stonith404/pocket-id/compare/v0.6.0...v) (2024-10-03)
### ⚠ BREAKING CHANGES
* add ability to set light and dark mode logo
### Features
* add ability to set light and dark mode logo ([be45eed](https://github.com/stonith404/pocket-id/commit/be45eed125e33e9930572660a034d5f12dc310ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.3...v) (2024-10-02)
### Features
* add copy to clipboard option for OIDC client information ([f82020c](https://github.com/stonith404/pocket-id/commit/f82020ccfb0d4fbaa1dd98182188149d8085252a))
* add gravatar profile picture integration ([365734e](https://github.com/stonith404/pocket-id/commit/365734ec5d8966c2ab877c60cfb176b9cdc36880))
* add user groups ([24c948e](https://github.com/stonith404/pocket-id/commit/24c948e6a66f283866f6c8369c16fa6cbcfa626c))
### Bug Fixes
* only return user groups if it is explicitly requested ([a4a90a1](https://github.com/stonith404/pocket-id/commit/a4a90a16a9726569a22e42560184319b25fd7ca6))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.2...v) (2024-09-26)
### Bug Fixes
* add space to "Firstname" and "Lastname" label ([#31](https://github.com/stonith404/pocket-id/issues/31)) ([d6a9bb4](https://github.com/stonith404/pocket-id/commit/d6a9bb4c09efb8102da172e49c36c070b341f0fc))
* port environment variables get ignored in caddyfile ([3c67765](https://github.com/stonith404/pocket-id/commit/3c67765992d7369a79812bc8cd216c9ba12fd96e))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.1...v) (2024-09-19)
### Bug Fixes
* updated application name doesn't apply to webauthn credential ([924bb14](https://github.com/stonith404/pocket-id/commit/924bb1468bbd8e42fa6a530ef740be73ce3b3914))
## [](https://github.com/stonith404/pocket-id/compare/v0.5.0...v) (2024-09-16)
### Features
* **email:** improve email templating ([#27](https://github.com/stonith404/pocket-id/issues/27)) ([64cf562](https://github.com/stonith404/pocket-id/commit/64cf56276a07169bc601a11be905c1eea67c4750))
### Bug Fixes
* debounce oidc client and user search ([9c2848d](https://github.com/stonith404/pocket-id/commit/9c2848db1d93c230afc6c5f64e498e9f6df8c8a7))
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
### Features
* add audit log with email notification ([#26](https://github.com/stonith404/pocket-id/issues/26)) ([9121239](https://github.com/stonith404/pocket-id/commit/9121239dd7c14a2107a984f9f94f54227489a63a))
## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06)
### Features
* add name claim to userinfo endpoint and id token ([4e7574a](https://github.com/stonith404/pocket-id/commit/4e7574a297307395603267c7a3285d538d4111d8))
### Bug Fixes
* limit width of content on large screens ([c6f83a5](https://github.com/stonith404/pocket-id/commit/c6f83a581ad385391d77fec7eeb385060742f097))
* show error message if error occurs while authorizing new client ([8038a11](https://github.com/stonith404/pocket-id/commit/8038a111dd7fa8f5d421b29c3bc0c11d865dc71b))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.1...v) (2024-09-03)
### Features
* add setup details to oidc client details ([fd21ce5](https://github.com/stonith404/pocket-id/commit/fd21ce5aac1daeba04e4e7399a0720338ea710c2))
* add support for more username formats ([903b0b3](https://github.com/stonith404/pocket-id/commit/903b0b39181c208e9411ee61849d2671e7c56dc5))
### Bug Fixes
* non pointer passed to create user ([e7861df](https://github.com/stonith404/pocket-id/commit/e7861df95a6beecab359d1c56f4383373f74bb73))
* oidc client logo not displayed on authorize page ([28ed064](https://github.com/stonith404/pocket-id/commit/28ed064668afeec8f80adda59ba94f1fc2fbce17))
* typo in hasLogo property of oidc dto ([2b9413c](https://github.com/stonith404/pocket-id/commit/2b9413c7575e1322f8547490a9b02a1836bad549))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.0...v) (2024-08-24)
### Bug Fixes
* empty lists don't get returned correctly from the api ([97f7fc4](https://github.com/stonith404/pocket-id/commit/97f7fc4e288c2bb49210072a7a151b58ef44f5b5))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.1...v) (2024-08-23)
### Features
* add support for multiple callback urls ([8166e2e](https://github.com/stonith404/pocket-id/commit/8166e2ead7fc71a0b7a45950b05c5c65a60833b6))
### Bug Fixes
* db migration for multiple callback urls ([552d7cc](https://github.com/stonith404/pocket-id/commit/552d7ccfa58d7922ecb94bdfe6a86651b4cf2745))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19)
### Bug Fixes
* session duration can't be updated ([4780548](https://github.com/stonith404/pocket-id/commit/478054884389ed8a08d707fd82da7b31177a67e5))
## [](https://github.com/stonith404/pocket-id/compare/v0.1.3...v) (2024-08-19)
### Features
* add `INTERNAL_BACKEND_URL` env variable ([0595d73](https://github.com/stonith404/pocket-id/commit/0595d73ea5afbd7937b8f292ffe624139f818f41))
* add user info endpoint to support more oidc clients ([fdc1921](https://github.com/stonith404/pocket-id/commit/fdc1921f5dcb5ac6beef8d1c9b1b7c53f514cce5))
* change default logo ([9eec7a3](https://github.com/stonith404/pocket-id/commit/9eec7a3e9eb7f690099f38a5d4cf7c2516ea9ef9))
## [](https://github.com/stonith404/pocket-id/compare/v0.1.2...v) (2024-08-13)
### Bug Fixes
* add missing passkey flags to make icloud passkeys work ([cc407e1](https://github.com/stonith404/pocket-id/commit/cc407e17d409041ed88b959ce13bd581663d55c3))
* logo not white in dark mode ([5749d05](https://github.com/stonith404/pocket-id/commit/5749d0532fc38bf2fc66571878b7c71643895c9e))
## [](https://github.com/stonith404/pocket-id/compare/v0.1.1...v) (2024-08-13)
### Features
* add option to change session duration ([475b932](https://github.com/stonith404/pocket-id/commit/475b932f9d0ec029ada844072e9d89bebd4e902c))
### Bug Fixes
* a non admin user was able to make himself an admin ([df0cd38](https://github.com/stonith404/pocket-id/commit/df0cd38deeea516c47b26a080eed522f19f7290f))
* background image not loading ([7b44189](https://github.com/stonith404/pocket-id/commit/7b4418958ebfffffd216ef5ba7313cfaad9bc9fa))
* background image on mobile ([4a808c8](https://github.com/stonith404/pocket-id/commit/4a808c86ac204f9b58cfa02f5ceb064162a87076))
* disable search engine indexing ([8395492](https://github.com/stonith404/pocket-id/commit/83954926f5ee328ebf75a75bb47b380ec0680378))
## [](https://github.com/stonith404/pocket-id/compare/v0.1.0...v) (2024-08-12)

View File

@@ -55,13 +55,13 @@ The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in Ty
3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev`
You're all set!
### Reverse Proxy
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
#### Setup
Run `caddy run --config Caddyfile` in the root folder.
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
You're all set!
### Testing
@@ -69,5 +69,5 @@ We are using [Playwright](https://playwright.dev) for end-to-end testing.
The tests can be run like this:
1. Start the backend normally
2. Start the frontend in production mode with `npm run build && node build/index.js`
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js`
3. Run the tests with `npm run test`

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

@@ -1,5 +1,5 @@
# Stage 1: Build Frontend
FROM node:20-alpine AS frontend-builder
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY ./frontend/package*.json ./
RUN npm ci
@@ -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
@@ -20,9 +20,12 @@ WORKDIR /app/backend/cmd
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
# Stage 3: Production Image
FROM node:20-alpine
RUN apk add --no-cache caddy
COPY ./Caddyfile /etc/caddy/Caddyfile
FROM node:22-alpine
# Delete default node user
RUN deluser --remove-home node
RUN apk add --no-cache caddy curl su-exec
COPY ./reverse-proxy /etc/caddy/
WORKDIR /app
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"]

136
README.md
View File

@@ -1,138 +1,20 @@
# <div align="center"><img src="https://github.com/user-attachments/assets/5b5e0d42-e2b4-4523-add5-ac87042a72f1" width="100"/> </br>Pocket ID</div>
# <div align="center"><img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"/> </br>Pocket ID</div>
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
<img src="https://github.com/user-attachments/assets/953c534c-b667-44e5-b976-a59142f1efb8" width="1200"/>
→ Try out the [Demo](https://demo.pocket-id.org)
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases. Additionally, Pocket ID only support passkey authentication which is a passwordless authentication method.
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you 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.
## Setup
> [!WARNING]
> Pocket ID is in its early stages and may contain bugs.
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.
### Installation with Docker (recommended)
1. Download the `docker-compose.yml` and `.env` file:
```bash
curl -O https://raw.githubusercontent.com/stonith404/pocket-id/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/stonith404/pocket-id/main/.env.example
```
2. Edit the `.env` file so that it fits your needs. See the [environment variables](#environment-variables) section for more information.
3. Run `docker compose up -d`
You can now sign in with the admin account on `http://localhost/login/setup`.
### Stand-alone Installation
Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 20
- [Go](https://golang.org/doc/install) >= 1.22
- [Git](https://git-scm.com/downloads)
- [PM2](https://pm2.keymetrics.io/)
- [Caddy](https://caddyserver.com/docs/install) (optional)
1. Copy the `.env.example` file in the `frontend` and `backend` folder to `.env` and change it so that it fits your needs.
```bash
cp frontend/.env.example frontend/.env
cp backend/.env.example backend/.env
```
2. Run the following commands:
```bash
git clone https://github.com/stonith404/pocket-id
cd pocket-id
# Checkout the latest version
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Start the backend
cd backend/cmd
go build -o ../pocket-id-backend
cd ..
pm2 start pocket-id-backend --name pocket-id-backend
# Start the frontend
cd ../frontend
npm install
npm run build
pm2 start --name pocket-id-frontend --node-args="--env-file .env" build/index.js
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
```
You can now sign in with the admin account on `http://localhost/login/setup`.
### Add Pocket ID as an OIDC provider
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
After you have added the client, you can obtain the client ID and client secret.
You may need the following information:
- **Authorization URL**: `https://<your-domain>/authorize`
- **Token URL**: `https://<your-domain>/api/oidc/token`
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
- **PKCE**: `false` as this is not supported yet.
### Update
#### Docker
```bash
docker compose pull
docker compose up -d
```
#### Stand-alone
1. Stop the running services:
```bash
pm2 delete pocket-id-backend pocket-id-frontend pocket-id-caddy
```
2. Run the following commands:
```bash
cd pocket-id
# Checkout the latest version
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Start the backend
cd backend/cmd
go build -o ../pocket-id-backend
cd ..
pm2 start pocket-id-backend --name pocket-id-backend
# Start the frontend
cd ../frontend
npm install
npm run build
pm2 start build/index.js --name pocket-id-frontend
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
```
### Environment variables
| Variable | Default Value | Recommended to change | Description |
| ---------------- | ------------------- | --------------------- | --------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
Visit the [documentation](https://docs.pocket-id.org) for the setup guide and more information.
## Contribute

View File

@@ -1,6 +1,10 @@
APP_ENV=production
PUBLIC_APP_URL=http://localhost
DB_PATH=data/pocket-id.db
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
DB_PROVIDER=sqlite
# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log
SQLITE_DB_PATH=data/pocket-id.db
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
UPLOAD_PATH=data/uploads
PORT=8080
HOST=localhost
HOST=0.0.0.0

3
backend/.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
package main
import (
"golang-rest-api-template/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
)
func main() {

View File

@@ -1,63 +1,74 @@
module golang-rest-api-template
module github.com/pocket-id/pocket-id/backend
go 1.22
go 1.23.1
require (
github.com/caarlos0/env/v11 v11.2.0
github.com/caarlos0/env/v11 v11.3.1
github.com/disintegration/imaging v1.6.2
github.com/fxamacker/cbor/v2 v2.7.0
github.com/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.15.0
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.24.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.25.0
golang.org/x/time v0.6.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
golang.org/x/crypto v0.32.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.12.1 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/go-webauthn/x v0.1.12 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-tpm v0.9.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
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
google.golang.org/protobuf v1.34.2 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,73 +1,127 @@
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 h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/caarlos0/env/v11 v11.2.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs=
github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.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.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.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/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.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.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -79,73 +133,172 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/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.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.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.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.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/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,17 +0,0 @@
<svg id="a"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1015 1015">
<path d="M838.28,380.27c-13.38-135.36-124.37-245.08-263.77-257.28h-299.08c-26.25,0-47.57,21.21-47.7,47.46-1.2,233.2-2.39,466.4-3.59,699.61-.14,26.44,21.26,47.95,47.7,47.95h102.86c24.66,0,45.25-18.79,47.5-43.35,11.45-124.75,22.89-249.51,34.34-374.26-43.84-29.69-66.46-88.34-40.37-148.47,10.44-24.06,29.57-43.41,53.58-53.98,86.02-37.84,169.22,24.14,169.22,105.56,0,40.38-20.47,75.98-51.6,96.99,6.71,56.71,13.42,113.43,20.14,170.14,1.2,10.14,11.21,16.74,21.03,13.93,134.16-38.42,223.25-167.79,209.75-304.31Z"/>
<style>
@media (prefers-color-scheme: dark) {
#a path {
fill: #ffffff;
}
}
@media (prefers-color-scheme: light) {
#a path {
fill: #000000;
}
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 864 B

View File

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

View File

@@ -1,78 +1,15 @@
package bootstrap
import (
"github.com/gin-gonic/gin"
_ "github.com/golang-migrate/migrate/v4/source/file"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/common/middleware"
"golang-rest-api-template/internal/handler"
"golang-rest-api-template/internal/job"
"golang-rest-api-template/internal/utils"
"golang.org/x/time/rate"
"log"
"os"
"time"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func Bootstrap() {
common.InitDatabase()
common.InitDbConfig()
initApplicationImages()
job.RegisterJobs()
initRouter()
}
func initRouter() {
switch common.EnvConfig.AppEnv {
case "production":
gin.SetMode(gin.ReleaseMode)
case "development":
gin.SetMode(gin.DebugMode)
case "test":
gin.SetMode(gin.TestMode)
}
r := gin.Default()
r.Use(gin.Logger())
r.Use(middleware.Cors())
r.Use(middleware.RateLimiter(rate.Every(time.Second), 60))
apiGroup := r.Group("/api")
handler.RegisterRoutes(apiGroup)
handler.RegisterOIDCRoutes(apiGroup)
handler.RegisterUserRoutes(apiGroup)
handler.RegisterConfigurationRoutes(apiGroup)
if common.EnvConfig.AppEnv != "production" {
handler.RegisterTestRoutes(apiGroup)
}
baseGroup := r.Group("/")
handler.RegisterWellKnownRoutes(baseGroup)
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
log.Fatal(err)
}
}
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
files, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
// Skip if files already exist
if len(files) > 1 {
return
}
// Copy files from source to destination
err = utils.CopyDirectory("./images", dirPath)
if err != nil {
log.Fatalf("Error copying directory: %v", err)
}
db := newDatabase()
appConfigService := service.NewAppConfigService(db)
initRouter(db, appConfigService)
}

View File

@@ -0,0 +1,124 @@
package bootstrap
import (
"errors"
"fmt"
"log"
"os"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newDatabase() (db *gorm.DB) {
db, err := connectDatabase()
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
sqlDb, err := db.DB()
if err != nil {
log.Fatalf("failed to get sql.DB: %v", err)
}
// Choose the correct driver for the database provider
var driver database.Driver
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
log.Fatalf("failed to create migration driver: %v", err)
}
// Run migrations
if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
return db
}
func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil {
return fmt.Errorf("failed to create embedded migration source: %v", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %v", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %v", err)
}
return nil
}
func connectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector
// Choose the correct database provider
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
case common.DbProviderPostgres:
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
for i := 1; i <= 3; i++ {
db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true,
Logger: getLogger(),
})
if err == nil {
break
} else {
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
}
}
return db, err
}
func getLogger() logger.Interface {
isProduction := common.EnvConfig.AppEnv == "production"
var logLevel logger.LogLevel
if isProduction {
logLevel = logger.Error
} else {
logLevel = logger.Info
}
return logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel,
IgnoreRecordNotFoundError: isProduction,
ParameterizedQueries: isProduction,
Colorful: !isProduction,
},
)
}

View File

@@ -0,0 +1,86 @@
package bootstrap
import (
"log"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
"gorm.io/gorm"
)
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case "production":
gin.SetMode(gin.ReleaseMode)
case "development":
gin.SetMode(gin.DebugMode)
case "test":
gin.SetMode(gin.TestMode)
}
r := gin.Default()
r.Use(gin.Logger())
// Initialize services
emailService, err := service.NewEmailService(appConfigService, db)
if err != nil {
log.Fatalf("Unable to create email service: %s", err)
}
geoLiteService := service.NewGeoLiteService()
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes
apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
controller.NewTestController(apiGroup, testService)
}
// Set up base routes
baseGroup := r.Group("/")
controller.NewWellKnownController(baseGroup, jwtService)
// Run the server
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,133 +0,0 @@
package common
import (
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
"golang-rest-api-template/internal/model"
"log"
"reflect"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DBPath string `env:"DB_PATH"`
UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DBPath: "data/pocket-id.db",
UploadPath: "data/uploads",
AppURL: "http://localhost",
Port: "8080",
Host: "localhost",
}
var DbConfig = NewDefaultDbConfig()
func NewDefaultDbConfig() model.ApplicationConfiguration {
return model.ApplicationConfiguration{
AppName: model.ApplicationConfigurationVariable{
Key: "appName",
Type: "string",
IsPublic: true,
Value: "Pocket ID",
},
BackgroundImageType: model.ApplicationConfigurationVariable{
Key: "backgroundImageType",
Type: "string",
IsInternal: true,
Value: "jpg",
},
LogoImageType: model.ApplicationConfigurationVariable{
Key: "logoImageType",
Type: "string",
IsInternal: true,
Value: "svg",
},
}
}
// LoadDbConfigFromDb refreshes the database configuration by loading the current values
// from the database and updating the DbConfig struct.
func LoadDbConfigFromDb() error {
dbConfigReflectValue := reflect.ValueOf(&DbConfig).Elem()
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
dbConfigField := dbConfigReflectValue.Field(i)
currentConfigVar := dbConfigField.Interface().(model.ApplicationConfigurationVariable)
var storedConfigVar model.ApplicationConfigurationVariable
if err := DB.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
return err
}
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
}
return nil
}
// InitDbConfig creates the default configuration values in the database if they do not exist,
// updates existing configurations if they differ from the default, and deletes any configurations
// that are not in the default configuration.
func InitDbConfig() {
// Reflect to get the underlying value of DbConfig and its default configuration
dbConfigReflectValue := reflect.ValueOf(&DbConfig).Elem()
defaultDbConfig := NewDefaultDbConfig()
defaultConfigReflectValue := reflect.ValueOf(&defaultDbConfig).Elem()
defaultKeys := make(map[string]struct{})
// Iterate over the fields of DbConfig
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
dbConfigField := dbConfigReflectValue.Field(i)
currentConfigVar := dbConfigField.Interface().(model.ApplicationConfigurationVariable)
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.ApplicationConfigurationVariable)
defaultKeys[currentConfigVar.Key] = struct{}{}
var storedConfigVar model.ApplicationConfigurationVariable
if err := DB.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
// If the configuration does not exist, create it
if err := DB.Create(&defaultConfigVar).Error; err != nil {
log.Fatalf("Failed to create default configuration: %v", err)
}
dbConfigField.Set(reflect.ValueOf(defaultConfigVar))
continue
}
// Update existing configuration if it differs from the default
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
storedConfigVar.Type = defaultConfigVar.Type
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
if err := DB.Save(&storedConfigVar).Error; err != nil {
log.Fatalf("Failed to update configuration: %v", err)
}
}
// Set the value in DbConfig
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
}
// Delete any configurations not in the default keys
var allConfigVars []model.ApplicationConfigurationVariable
if err := DB.Find(&allConfigVars).Error; err != nil {
log.Fatalf("Failed to retrieve existing configurations: %v", err)
}
for _, config := range allConfigVars {
if _, exists := defaultKeys[config.Key]; !exists {
if err := DB.Delete(&config).Error; err != nil {
log.Fatalf("Failed to delete outdated configuration: %v", err)
}
}
}
}
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,84 +0,0 @@
package common
import (
"errors"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"gorm.io/gorm/logger"
"log"
"os"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDatabase() {
connectDatabase()
sqlDb, err := DB.DB()
if err != nil {
log.Fatal("failed to get sql db", err)
}
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres", driver)
if err != nil {
log.Fatal("failed to create migration instance", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
log.Fatal("failed to run migrations", err)
}
}
func connectDatabase() {
var database *gorm.DB
var err error
dbPath := EnvConfig.DBPath
if EnvConfig.AppEnv == "test" {
dbPath = "file::memory:?cache=shared"
}
for i := 1; i <= 3; i++ {
database, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
TranslateError: true,
Logger: getLogger(),
})
if err == nil {
break
} else {
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
}
}
DB = database
}
func getLogger() logger.Interface {
isProduction := EnvConfig.AppEnv == "production"
var logLevel logger.LogLevel
if isProduction {
logLevel = logger.Error
} else {
logLevel = logger.Info
}
// Create the GORM logger
return logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel,
IgnoreRecordNotFoundError: isProduction,
ParameterizedQueries: isProduction,
Colorful: !isProduction,
},
)
}

View File

@@ -0,0 +1,73 @@
package common
import (
"log"
"net/url"
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
)
type DbProvider string
const (
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
SqliteDBPath string `env:"SQLITE_DB_PATH"`
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
SqliteDBPath: "data/pocket-id.db",
PostgresConnectionString: "",
UploadPath: "data/uploads",
AppURL: "http://localhost",
Port: "8080",
Host: "0.0.0.0",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false,
}
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
}
// Validate the environment variables
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
}
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
}
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil {
log.Fatal("PUBLIC_APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
log.Fatal("PUBLIC_APP_URL must not contain a path")
}
}

View File

@@ -0,0 +1,221 @@
package common
import (
"fmt"
"net/http"
)
type AppError interface {
error
HttpStatusCode() int
}
// Custom error types for various conditions
type AlreadyInUseError struct {
Property string
}
func (e *AlreadyInUseError) Error() string {
return fmt.Sprintf("%s is already in use", e.Property)
}
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type InvalidCredentialsError struct{}
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct {
MaxSize string
}
func (e *FileTooLargeError) Error() string {
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
}
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string {
return "You don't have permission to perform this action"
}
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id or secret not provided"
}
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type WrongFileTypeError struct {
ExpectedFileType string
}
func (e *WrongFileTypeError) Error() string {
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
}
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
type MissingSessionIdError struct{}
func (e *MissingSessionIdError) Error() string {
return "Missing session id"
}
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
Key string
}
func (e *ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
}
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
Key string
}
func (e *DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
return "Invalid code verifier"
}
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type OidcAccessDeniedError struct{}
func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service"
}
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e *OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e *OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{}
func (e *UiConfigDisabledError) Error() string {
return "The configuration can't be changed since the UI configuration is disabled"
}
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidUUIDError struct{}
func (e *InvalidUUIDError) Error() string {
return "Invalid UUID"
}
type InvalidEmailError struct{}

View File

@@ -1,207 +0,0 @@
package common
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"github.com/golang-jwt/jwt/v5"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"log"
"math/big"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
var (
PrivateKey *rsa.PrivateKey
PublicKey *rsa.PublicKey
)
const (
privateKeyPath = "data/keys/jwt_private_key.pem"
publicKeyPath = "data/keys/jwt_public_key.pem"
)
type accessTokenJWTClaims struct {
jwt.RegisteredClaims
IsAdmin bool `json:"isAdmin,omitempty"`
}
// GenerateIDToken generates an ID token for the given user, clientID, scope and nonce.
func GenerateIDToken(user model.User, clientID string, scope string, nonce string) (tokenString string, err error) {
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"email": user.Email,
"preferred_username": user.Username,
}
claims := jwt.MapClaims{
"sub": user.ID,
"aud": clientID,
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(time.Now()),
}
if nonce != "" {
claims["nonce"] = nonce
}
if strings.Contains(scope, "profile") {
for k, v := range profileClaims {
claims[k] = v
}
}
if strings.Contains(scope, "email") {
claims["email"] = user.Email
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(PrivateKey)
if err != nil {
return "", err
}
return signedToken, nil
}
// GenerateAccessToken generates an access token for the given user.
func GenerateAccessToken(user model.User) (tokenString string, err error) {
claim := accessTokenJWTClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{utils.GetHostFromURL(EnvConfig.AppURL)},
},
IsAdmin: user.IsAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
tokenString, err = token.SignedString(PrivateKey)
return tokenString, err
}
// VerifyAccessToken verifies the given access token and returns the claims if the token is valid.
func VerifyAccessToken(tokenString string) (*accessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &accessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return PublicKey, nil
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*accessTokenJWTClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, utils.GetHostFromURL(EnvConfig.AppURL)) {
return nil, errors.New("audience doesn't match")
}
return claims, nil
}
type JWK struct {
Kty string `json:"kty"`
Use string `json:"use"`
Kid string `json:"kid"`
Alg string `json:"alg"`
N string `json:"n"`
E string `json:"e"`
}
// GetJWK returns the JSON Web Key (JWK) for the public key.
func GetJWK() (JWK, error) {
if PublicKey == nil {
return JWK{}, errors.New("public key is not initialized")
}
// Create JWK from RSA public key
jwk := JWK{
Kty: "RSA",
Use: "sig",
Kid: "1", // Key ID can be set to any identifier. Here it's statically set to "1"
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(PublicKey.E)).Bytes()),
}
return jwk, nil
}
// generateKeys generates a new RSA key pair and saves the private and public keys to the data folder.
func generateKeys() {
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
log.Fatal("Failed to create directories for keys", err)
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatal("Failed to generate private key", err)
}
privateKeyFile, err := os.Create(privateKeyPath)
if err != nil {
log.Fatal("Failed to create private key file", err)
}
defer privateKeyFile.Close()
privateKeyPEM := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
},
)
_, err = privateKeyFile.Write(privateKeyPEM)
if err != nil {
log.Fatal("Failed to write private key file", err)
}
publicKey := &privateKey.PublicKey
publicKeyFile, err := os.Create(publicKeyPath)
if err != nil {
log.Fatal("Failed to create public key file", err)
}
defer publicKeyFile.Close()
publicKeyPEM := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(publicKey),
},
)
_, err = publicKeyFile.Write(publicKeyPEM)
if err != nil {
log.Fatal("Failed to write public key file", err)
}
}
func init() {
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
generateKeys()
}
privateKeyBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
log.Fatal("Can't read jwt private key", err)
}
PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
log.Fatal("Can't parse jwt private key", err)
}
publicKeyBytes, err := os.ReadFile(publicKeyPath)
if err != nil {
log.Fatal("Can't read jwt public key", err)
}
PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil {
log.Fatal("Can't parse jwt public key", err)
}
}

View File

@@ -1,18 +0,0 @@
package middleware
import (
"golang-rest-api-template/internal/common"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func Cors() gin.HandlerFunc {
return cors.New(cors.Config{
AllowOrigins: []string{common.EnvConfig.AppURL},
AllowMethods: []string{"*"},
AllowHeaders: []string{"*"},
MaxAge: 12 * time.Hour,
})
}

View File

@@ -1,47 +0,0 @@
package middleware
import (
"github.com/gin-gonic/gin"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/utils"
"net/http"
"strings"
)
func JWTAuth(adminOnly bool) gin.HandlerFunc {
return func(c *gin.Context) {
// Extract the token from the cookie or the Authorization header
token, err := c.Cookie("access_token")
if err != nil {
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplitted) == 2 {
token = authorizationHeaderSplitted[1]
} else {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort()
return
}
}
// Verify the token
claims, err := common.VerifyAccessToken(token)
if err != nil {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort()
return
}
// Check if the user is an admin
if adminOnly && !claims.IsAdmin {
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
c.Abort()
return
}
c.Set("userID", claims.Subject)
c.Set("userIsAdmin", claims.IsAdmin)
c.Next()
}
}

View File

@@ -1,37 +0,0 @@
package common
import (
"github.com/go-webauthn/webauthn/webauthn"
"golang-rest-api-template/internal/utils"
"log"
"time"
)
var (
WebAuthn *webauthn.WebAuthn
err error
)
func init() {
config := &webauthn.Config{
RPDisplayName: DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(EnvConfig.AppURL),
RPOrigins: []string{EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: time.Second * 60,
TimeoutUVD: time.Second * 60,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: time.Second * 60,
TimeoutUVD: time.Second * 60,
},
},
}
if WebAuthn, err = webauthn.New(config); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,209 @@
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func NewAppConfigController(
group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
acc := &AppConfigController{
appConfigService: appConfigService,
emailService: emailService,
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
}
type AppConfigController struct {
appConfigService *service.AppConfigService
emailService *service.EmailService
ldapService *service.LdapService
}
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, configVariablesDto)
}
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
var imageName string
var imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
}
acc.getImage(c, imageName, imageType)
}
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
}
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.getImage(c, "background", imageType)
}
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
var imageName string
var imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
}
acc.updateImage(c, imageName, imageType)
}
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
acc.updateImage(c, "favicon", "ico")
}
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.updateImage(c, "background", imageType)
}
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

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

View File

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

View File

@@ -0,0 +1,344 @@
package controller
import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"log"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
group.GET("/oidc/clients/:id", oc.getClientHandler)
group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", jwtAuthMiddleware.Add(true), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
}
type OidcController struct {
oidcService *service.OidcService
jwtService *service.JwtService
}
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
}
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
}
func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
return
}
clientID := input.ClientID
clientSecret := input.ClientSecret
// Client id and secret can also be passed over the Authorization header
if clientID == "" && clientSecret == "" {
clientID, clientSecret, _ = c.Request.BasicAuth()
}
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
}
func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil {
c.Error(err)
return
}
userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
if c.Request.Method == http.MethodGet {
if err := c.ShouldBindQuery(&input); err != nil {
c.Error(err)
return
}
} else if c.Request.Method == http.MethodPost {
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
return
}
}
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
q.Set("state", input.State)
logoutCallbackURL.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
if err != nil {
c.Error(err)
return
}
// Return a different DTO based on the user's role
if c.GetBool("userIsAdmin") {
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
} else {
clientDto := dto.PublicOidcClientDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
}
c.Error(err)
}
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": clientsDto,
"pagination": pagination,
})
}
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil {
c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, clientDto)
}
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil {
c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret})
}
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
if err != nil {
c.Error(err)
return
}
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, oidcClientDto)
}

View File

@@ -0,0 +1,44 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
}
type TestController struct {
TestService *service.TestService
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil {
c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
c.Error(err)
return
}
if err := tc.TestService.ResetAppConfig(); err != nil {
c.Error(err)
return
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}

View File

@@ -0,0 +1,317 @@
package controller
import (
"net/http"
"strconv"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
)
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
appConfigService: appConfigService,
}
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler)
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateUserProfilePictureHandler)
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
}
type UserController struct {
userService *service.UserService
appConfigService *service.AppConfigService
}
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": usersDto,
"pagination": pagination,
})
}
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.Param("id"))
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
user, err := uc.userService.CreateUser(input)
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
func (uc *UserController) updateUserHandler(c *gin.Context) {
uc.updateUser(c, false)
}
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true)
}
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
picture, size, err := uc.userService.GetProfilePicture(userID)
if err != nil {
c.Error(err)
return
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
picture, size, err := uc.userService.GetProfilePicture(userID)
if err != nil {
c.Error(err)
return
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, gin.H{"token": token})
}
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin()
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
var userID string
if updateOwnUser {
userID = c.GetString("userID")
} else {
userID = c.Param("id")
}
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}

View File

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

View File

@@ -0,0 +1,175 @@
package controller
import (
"net/http"
"strconv"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
group.GET("/webauthn/login/start", wc.beginLoginHandler)
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
group.POST("/webauthn/logout", jwtAuthMiddleware.Add(false), wc.logoutHandler)
group.GET("/webauthn/credentials", jwtAuthMiddleware.Add(false), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler)
}
type WebauthnController struct {
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID)
if err != nil {
c.Error(err)
return
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
c.Error(&common.MissingSessionIdError{})
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
if err != nil {
c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin()
if err != nil {
c.Error(err)
return
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
c.Error(err)
return
}
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil {
c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDtos)
}
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil {
c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
}

View File

@@ -0,0 +1,47 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: jwtService}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
}
type WellKnownController struct {
jwtService *service.JwtService
}
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK()
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
}
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
appUrl := common.EnvConfig.AppURL
config := map[string]interface{}{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
}
c.JSON(http.StatusOK, config)
}

View File

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

View File

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

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

View File

@@ -0,0 +1,70 @@
package dto
type PublicOidcClientDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
}
type OidcClientWithAllowedUserGroupsDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
}
type AuthorizationRequiredDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
}
type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
}
type OidcLogoutDto struct {
IdTokenHint string `form:"id_token_hint"`
ClientId string `form:"client_id"`
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
State string `form:"state"`
}

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

View File

@@ -0,0 +1,37 @@
package dto
import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log"
"regexp"
)
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
}

View File

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

View File

@@ -1,190 +0,0 @@
package handler
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/common/middleware"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"gorm.io/gorm"
"net/http"
"os"
"reflect"
)
func RegisterConfigurationRoutes(group *gin.RouterGroup) {
group.GET("/application-configuration", listApplicationConfigurationHandler)
group.PUT("/application-configuration", updateApplicationConfigurationHandler)
group.GET("/application-configuration/logo", getLogoHandler)
group.GET("/application-configuration/background-image", getBackgroundImageHandler)
group.GET("/application-configuration/favicon", getFaviconHandler)
group.PUT("/application-configuration/logo", middleware.JWTAuth(true), updateLogoHandler)
group.PUT("/application-configuration/favicon", middleware.JWTAuth(true), updateFaviconHandler)
group.PUT("/application-configuration/background-image", middleware.JWTAuth(true), updateBackgroundImageHandler)
}
func listApplicationConfigurationHandler(c *gin.Context) {
// Return also the private configuration variables if the user is admin and showAll is true
showAll := c.GetBool("userIsAdmin") && c.DefaultQuery("showAll", "false") == "true"
var configuration []model.ApplicationConfigurationVariable
var err error
if showAll {
err = common.DB.Find(&configuration).Error
} else {
err = common.DB.Find(&configuration, "is_public = true").Error
}
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(200, configuration)
}
func updateApplicationConfigurationHandler(c *gin.Context) {
var input model.ApplicationConfigurationUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
savedConfigVariables := make([]model.ApplicationConfigurationVariable, 10)
tx := common.DB.Begin()
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
// Loop over the input struct fields and update the related configuration variables
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
// Get the existing configuration variable from the db
var applicationConfigurationVariable model.ApplicationConfigurationVariable
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusNotFound, fmt.Sprintf("Invalid configuration variable '%s'", value))
} else {
utils.UnknownHandlerError(c, err)
}
return
}
// Update the value of the existing configuration variable and save it
applicationConfigurationVariable.Value = value
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
tx.Rollback()
utils.UnknownHandlerError(c, err)
return
}
savedConfigVariables[i] = applicationConfigurationVariable
}
tx.Commit()
if err := common.LoadDbConfigFromDb(); err != nil {
utils.UnknownHandlerError(c, err)
}
c.JSON(http.StatusOK, savedConfigVariables)
}
func getLogoHandler(c *gin.Context) {
imagType := common.DbConfig.LogoImageType.Value
getImage(c, "logo", imagType)
}
func getFaviconHandler(c *gin.Context) {
getImage(c, "favicon", "ico")
}
func getBackgroundImageHandler(c *gin.Context) {
imageType := common.DbConfig.BackgroundImageType.Value
getImage(c, "background", imageType)
}
func updateLogoHandler(c *gin.Context) {
imageType := common.DbConfig.LogoImageType.Value
updateImage(c, "logo", imageType)
}
func updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico")
return
}
updateImage(c, "favicon", "ico")
}
func updateBackgroundImageHandler(c *gin.Context) {
imagType := common.DbConfig.BackgroundImageType.Value
updateImage(c, "background", imagType)
}
func getImage(c *gin.Context, name string, imageType string) {
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
func updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
fileType := utils.GetFileExtension(file.Filename)
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
utils.HandlerError(c, http.StatusBadRequest, "File type not supported")
return
}
// Delete the old image if it has a different file type
if fileType != oldImageType {
oldImagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType)
if err := os.Remove(oldImagePath); err != nil {
utils.UnknownHandlerError(c, err)
return
}
}
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType)
err = c.SaveUploadedFile(file, imagePath)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
// Update the file type in the database
key := fmt.Sprintf("%sImageType", imageName)
err = common.DB.Model(&model.ApplicationConfigurationVariable{}).Where("key = ?", key).Update("value", fileType).Error
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
if err := common.LoadDbConfigFromDb(); err != nil {
utils.UnknownHandlerError(c, err)
}
c.Status(http.StatusNoContent)
}

View File

@@ -1,415 +0,0 @@
package handler
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/common/middleware"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"net/http"
"os"
"time"
)
func RegisterOIDCRoutes(group *gin.RouterGroup) {
group.POST("/oidc/authorize", middleware.JWTAuth(false), authorizeHandler)
group.POST("/oidc/authorize/new-client", middleware.JWTAuth(false), authorizeNewClientHandler)
group.POST("/oidc/token", createIDTokenHandler)
group.GET("/oidc/clients", middleware.JWTAuth(true), listClientsHandler)
group.POST("/oidc/clients", middleware.JWTAuth(true), createClientHandler)
group.GET("/oidc/clients/:id", getClientHandler)
group.PUT("/oidc/clients/:id", middleware.JWTAuth(true), updateClientHandler)
group.DELETE("/oidc/clients/:id", middleware.JWTAuth(true), deleteClientHandler)
group.POST("/oidc/clients/:id/secret", middleware.JWTAuth(true), createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", middleware.JWTAuth(true), middleware.LimitFileSize(2<<20), updateClientLogoHandler)
}
type AuthorizeRequest struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
Nonce string `json:"nonce"`
}
func authorizeHandler(c *gin.Context) {
var parsedBody AuthorizeRequest
if err := c.ShouldBindJSON(&parsedBody); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
common.DB.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", parsedBody.ClientID, c.GetString("userID"))
// If the record isn't found or the scope is different return an error
// The client will have to call the authorizeNewClientHandler
if userAuthorizedOIDCClient.Scope != parsedBody.Scope {
utils.HandlerError(c, http.StatusForbidden, "missing authorization")
return
}
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
}
// authorizeNewClientHandler authorizes a new client for the user
// a new client is a new client when the user has not authorized the client before
func authorizeNewClientHandler(c *gin.Context) {
var parsedBody model.AuthorizeNewClientDto
if err := c.ShouldBindJSON(&parsedBody); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: c.GetString("userID"),
ClientID: parsedBody.ClientID,
Scope: parsedBody.Scope,
}
err := common.DB.Create(&userAuthorizedClient).Error
if err != nil && errors.Is(err, gorm.ErrDuplicatedKey) {
err = common.DB.Model(&userAuthorizedClient).Update("scope", parsedBody.Scope).Error
}
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
}
func createIDTokenHandler(c *gin.Context) {
var body model.OidcIdTokenDto
if err := c.ShouldBind(&body); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
// Currently only authorization_code grant type is supported
if body.GrantType != "authorization_code" {
utils.HandlerError(c, http.StatusBadRequest, "grant type not supported")
return
}
clientID := body.ClientID
clientSecret := body.ClientSecret
// Client id and secret can also be passed over the Authorization header
if clientID == "" || clientSecret == "" {
var ok bool
clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok {
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
return
}
}
// Get the client
var client model.OidcClient
err := common.DB.First(&client, "id = ?", clientID, clientSecret).Error
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "OIDC OIDC client not found")
return
}
// Check if client secret is correct
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid client secret")
return
}
var authorizationCodeMetaData model.OidcAuthorizationCode
err = common.DB.Preload("User").First(&authorizationCodeMetaData, "code = ?", body.Code).Error
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
return
}
// Check if the client id matches the client id in the authorization code and if the code has expired
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
return
}
idToken, e := common.GenerateIDToken(authorizationCodeMetaData.User, clientID, authorizationCodeMetaData.Scope, authorizationCodeMetaData.Nonce)
if e != nil {
utils.UnknownHandlerError(c, err)
return
}
// Delete the authorization code after it has been used
common.DB.Delete(&authorizationCodeMetaData)
c.JSON(http.StatusOK, gin.H{"id_token": idToken})
}
func getClientHandler(c *gin.Context) {
clientId := c.Param("id")
var client model.OidcClient
err := common.DB.First(&client, "id = ?", clientId).Error
if err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
return
}
c.JSON(http.StatusOK, client)
}
func listClientsHandler(c *gin.Context) {
var clients []model.OidcClient
searchTerm := c.Query("search")
query := common.DB.Model(&model.OidcClient{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("name LIKE ?", searchPattern)
}
pagination, err := utils.Paginate(c, query, &clients)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": clients,
"pagination": pagination,
})
}
func createClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
client := model.OidcClient{
Name: input.Name,
CallbackURL: input.CallbackURL,
CreatedByID: c.GetString("userID"),
}
if err := common.DB.Create(&client).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusCreated, client)
}
func deleteClientHandler(c *gin.Context) {
var client model.OidcClient
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC OIDC client not found")
return
}
if err := common.DB.Delete(&client).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func updateClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
var client model.OidcClient
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
return
}
utils.UnknownHandlerError(c, err)
return
}
client.Name = input.Name
client.CallbackURL = input.CallbackURL
if err := common.DB.Save(&client).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusNoContent, client)
}
// createClientSecretHandler creates a new secret for the client and revokes the old one
func createClientSecretHandler(c *gin.Context) {
var client model.OidcClient
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
return
}
utils.UnknownHandlerError(c, err)
return
}
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
client.Secret = string(hashedSecret)
if err := common.DB.Save(&client).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"secret": clientSecret})
}
func getClientLogoHandler(c *gin.Context) {
var client model.OidcClient
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
return
}
if client.ImageType == nil {
utils.HandlerError(c, http.StatusNotFound, "image not found")
return
}
imageType := *client.ImageType
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType)
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
func updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
fileType := utils.GetFileExtension(file.Filename)
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
utils.HandlerError(c, http.StatusBadRequest, "file type not supported")
return
}
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, c.Param("id"), fileType)
err = c.SaveUploadedFile(file, imagePath)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
var client model.OidcClient
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
return
}
// Delete the old image if it has a different file type
if client.ImageType != nil && fileType != *client.ImageType {
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
if err := os.Remove(oldImagePath); err != nil {
utils.UnknownHandlerError(c, err)
return
}
}
client.ImageType = &fileType
if err := common.DB.Save(&client).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func deleteClientLogoHandler(c *gin.Context) {
var client model.OidcClient
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
return
}
if client.ImageType == nil {
utils.HandlerError(c, http.StatusNotFound, "image not found")
return
}
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
if err := os.Remove(imagePath); err != nil {
utils.UnknownHandlerError(c, err)
return
}
client.ImageType = nil
if err := common.DB.Save(&client).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: time.Now().Add(15 * time.Minute),
Code: randomString,
ClientID: clientID,
UserID: userID,
Scope: scope,
Nonce: nonce,
}
if err := common.DB.Create(&oidcAuthorizationCode).Error; err != nil {
return "", err
}
return randomString, nil
}

View File

@@ -1,237 +0,0 @@
package handler
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"log"
"os"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"gorm.io/gorm"
)
func RegisterTestRoutes(group *gin.RouterGroup) {
group.POST("/test/reset", resetAndSeedHandler)
}
func resetAndSeedHandler(c *gin.Context) {
if err := resetDatabase(); err != nil {
utils.UnknownHandlerError(c, err)
return
}
if err := resetApplicationImages(); err != nil {
utils.UnknownHandlerError(c, err)
return
}
if err := seedDatabase(); err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(200, gin.H{"message": "Database reset and seeded"})
}
// seedDatabase seeds the database with initial data and uses a transaction to ensure atomicity.
func seedDatabase() error {
return common.DB.Transaction(func(tx *gorm.DB) error {
users := []model.User{
{
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
IsAdmin: false,
},
}
for _, user := range users {
if err := tx.Create(&user).Error; err != nil {
return err
}
}
oidcClients := []model.OidcClient{
{
Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURL: "http://nextcloud/auth/callback",
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
},
{
Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURL: "http://immich/auth/callback",
CreatedByID: users[0].ID,
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
return err
}
}
authCode := model.OidcAuthorizationCode{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: time.Now().Add(1 * time.Hour),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
if err := tx.Create(&authCode).Error; err != nil {
return err
}
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: time.Now().Add(1 * time.Hour),
UserID: users[0].ID,
}
if err := tx.Create(&accessToken).Error; err != nil {
return err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
}
webauthnCredentials := []model.WebauthnCredential{
{
Name: "Passkey 1",
CredentialID: "test-credential-1",
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg=="),
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
UserID: users[0].ID,
},
{
Name: "Passkey 2",
CredentialID: "test-credential-2",
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA=="),
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
UserID: users[0].ID,
},
}
for _, credential := range webauthnCredentials {
if err := tx.Create(&credential).Error; err != nil {
return err
}
}
webauthnSession := model.WebauthnSession{
Challenge: "challenge",
ExpiresAt: time.Now().Add(1 * time.Hour),
UserVerification: "preferred",
}
if err := tx.Create(&webauthnSession).Error; err != nil {
return err
}
return nil
})
}
// resetDatabase resets the database by deleting all rows from each table.
func resetDatabase() error {
err := common.DB.Transaction(func(tx *gorm.DB) error {
var tables []string
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
return err
}
for _, table := range tables {
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
common.InitDbConfig()
return nil
}
// resetApplicationImages resets the application images by removing existing images and replacing them with the default ones
func resetApplicationImages() error {
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
log.Printf("Error removing directory: %v", err)
return err
}
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil {
log.Printf("Error copying directory: %v", err)
return err
}
return nil
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
func getCborPublicKey(base64PublicKey string) []byte {
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
if err != nil {
log.Fatalf("Failed to decode base64 key: %v", err)
}
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
if err != nil {
log.Fatalf("Failed to parse public key: %v", err)
}
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
log.Fatalf("Not an ECDSA public key")
}
coseKey := map[int]interface{}{
1: 2, // Key type: EC2
3: -7, // Algorithm: ECDSA with SHA-256
-1: 1, // Curve: P-256
-2: ecdsaPubKey.X.Bytes(), // X coordinate
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
}
cborPublicKey, err := cbor.Marshal(coseKey)
if err != nil {
log.Fatalf("Failed to encode CBOR: %v", err)
}
return cborPublicKey
}

View File

@@ -1,269 +0,0 @@
package handler
import (
"errors"
"github.com/gin-gonic/gin"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/common/middleware"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"golang.org/x/time/rate"
"gorm.io/gorm"
"log"
"net/http"
"time"
)
func RegisterUserRoutes(group *gin.RouterGroup) {
group.GET("/users", middleware.JWTAuth(true), listUsersHandler)
group.GET("/users/me", middleware.JWTAuth(false), getCurrentUserHandler)
group.GET("/users/:id", middleware.JWTAuth(true), getUserHandler)
group.POST("/users", middleware.JWTAuth(true), createUserHandler)
group.PUT("/users/:id", middleware.JWTAuth(true), updateUserHandler)
group.PUT("/users/me", middleware.JWTAuth(false), updateCurrentUserHandler)
group.DELETE("/users/:id", middleware.JWTAuth(true), deleteUserHandler)
group.POST("/users/:id/one-time-access-token", middleware.JWTAuth(true), createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", middleware.RateLimiter(rate.Every(10*time.Second), 5), exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", getSetupAccessTokenHandler)
}
func listUsersHandler(c *gin.Context) {
var users []model.User
searchTerm := c.Query("search")
query := common.DB.Model(&model.User{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.Paginate(c, query, &users)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": users,
"pagination": pagination,
})
}
func getUserHandler(c *gin.Context) {
var user model.User
if err := common.DB.Where("id = ?", c.Param("id")).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusNotFound, "User not found")
return
}
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, user)
}
func getCurrentUserHandler(c *gin.Context) {
var user model.User
if err := common.DB.Where("id = ?", c.GetString("userID")).First(&user).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, user)
}
func deleteUserHandler(c *gin.Context) {
var user model.User
if err := common.DB.Where("id = ?", c.Param("id")).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusNotFound, "User not found")
return
}
utils.UnknownHandlerError(c, err)
return
}
if err := common.DB.Delete(&user).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func createUserHandler(c *gin.Context) {
var user model.User
if err := c.ShouldBindJSON(&user); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
if err := common.DB.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
if err := checkDuplicatedFields(user); err != nil {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
return
}
} else {
utils.UnknownHandlerError(c, err)
return
}
}
c.JSON(http.StatusCreated, user)
}
func updateUserHandler(c *gin.Context) {
updateUser(c, c.Param("id"))
}
func updateCurrentUserHandler(c *gin.Context) {
updateUser(c, c.GetString("userID"))
}
func createOneTimeAccessTokenHandler(c *gin.Context) {
var input model.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
oneTimeAccessToken := model.OneTimeAccessToken{
UserID: input.UserID,
ExpiresAt: input.ExpiresAt,
Token: randomString,
}
if err := common.DB.Create(&oneTimeAccessToken).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"token": oneTimeAccessToken.Token})
}
func exchangeOneTimeAccessTokenHandler(c *gin.Context) {
var oneTimeAccessToken model.OneTimeAccessToken
if err := common.DB.Where("token = ? AND expires_at > ?", c.Param("token"), utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusForbidden, "Token is invalid or expired")
return
}
utils.UnknownHandlerError(c, err)
return
}
token, err := common.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
utils.UnknownHandlerError(c, err)
log.Println(err)
return
}
if err := common.DB.Delete(&oneTimeAccessToken).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, oneTimeAccessToken.User)
}
// getSetupAccessTokenHandler creates the initial admin user and returns an access token for the user
// This handler is only available if there are no users in the database
func getSetupAccessTokenHandler(c *gin.Context) {
var userCount int64
if err := common.DB.Model(&model.User{}).Count(&userCount).Error; err != nil {
log.Fatal("failed to count users", err)
}
// If there are more than one user, we don't need to create the admin user
if userCount > 1 {
utils.HandlerError(c, http.StatusForbidden, "Setup already completed")
return
}
var user = model.User{
FirstName: "Admin",
LastName: "Admin",
Username: "admin",
Email: "admin@admin.com",
IsAdmin: true,
}
// Create the initial admin user if it doesn't exist
if err := common.DB.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
log.Fatal("failed to create admin user", err)
}
// If the user already has credentials, the setup is already completed
if len(user.Credentials) > 0 {
utils.HandlerError(c, http.StatusForbidden, "Setup already completed")
return
}
token, err := common.GenerateAccessToken(user)
if err != nil {
utils.UnknownHandlerError(c, err)
log.Println(err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
}
func updateUser(c *gin.Context, userID string) {
var user model.User
if err := common.DB.Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.HandlerError(c, http.StatusNotFound, "User not found")
return
}
utils.UnknownHandlerError(c, err)
return
}
var updatedUser model.User
if err := c.ShouldBindJSON(&updatedUser); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
if err := common.DB.Model(&user).Updates(&updatedUser).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
if err := checkDuplicatedFields(user); err != nil {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
return
}
} else {
utils.UnknownHandlerError(c, err)
return
}
}
c.JSON(http.StatusOK, updatedUser)
}
func checkDuplicatedFields(user model.User) error {
var existingUser model.User
if common.DB.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
return errors.New("email is already taken")
}
if common.DB.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
return errors.New("username is already taken")
}
return nil
}

View File

@@ -1,255 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/common/middleware"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"golang.org/x/time/rate"
"gorm.io/gorm"
"log"
"net/http"
"strings"
"time"
)
func RegisterRoutes(group *gin.RouterGroup) {
group.GET("/webauthn/register/start", middleware.JWTAuth(false), beginRegistrationHandler)
group.POST("/webauthn/register/finish", middleware.JWTAuth(false), verifyRegistrationHandler)
group.GET("/webauthn/login/start", beginLoginHandler)
group.POST("/webauthn/login/finish", middleware.RateLimiter(rate.Every(10*time.Second), 5), verifyLoginHandler)
group.POST("/webauthn/logout", middleware.JWTAuth(false), logoutHandler)
group.GET("/webauthn/credentials", middleware.JWTAuth(false), listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", middleware.JWTAuth(false), updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", middleware.JWTAuth(false), deleteCredentialHandler)
}
func beginRegistrationHandler(c *gin.Context) {
var user model.User
err := common.DB.Preload("Credentials").Find(&user, "id = ?", c.GetString("userID")).Error
if err != nil {
utils.UnknownHandlerError(c, err)
log.Println(err)
return
}
options, session, err := common.WebAuthn.BeginRegistration(&user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()))
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
// Save the webauthn session so we can retrieve it in the verifyRegistrationHandler
sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires,
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
if err = common.DB.Create(&sessionToStore).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.SetCookie("session_id", sessionToStore.ID, int(common.WebAuthn.Config.Timeouts.Registration.Timeout.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, options.Response)
}
func verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
return
}
// Retrieve the session that was previously created by the beginRegistrationHandler
var storedSession model.WebauthnSession
err = common.DB.First(&storedSession, "id = ?", sessionID).Error
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt,
UserID: []byte(c.GetString("userID")),
}
var user model.User
err = common.DB.Find(&user, "id = ?", c.GetString("userID")).Error
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
credential, err := common.WebAuthn.FinishRegistration(&user, session, c.Request)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
CredentialID: string(credential.ID),
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
Transport: credential.Transport,
UserID: user.ID,
}
if err := common.DB.Create(&credentialToStore).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, credentialToStore)
}
func beginLoginHandler(c *gin.Context) {
options, session, err := common.WebAuthn.BeginDiscoverableLogin()
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
// Save the webauthn session so we can retrieve it in the verifyLoginHandler
sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires,
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
if err = common.DB.Create(&sessionToStore).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.SetCookie("session_id", sessionToStore.ID, int(common.WebAuthn.Config.Timeouts.Registration.Timeout.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, options.Response)
}
func verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Invalid body")
return
}
// Retrieve the session that was previously created by the beginLoginHandler
var storedSession model.WebauthnSession
if err := common.DB.First(&storedSession, "id = ?", sessionID).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt,
}
var user *model.User
_, err = common.WebAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
if err := common.DB.Preload("Credentials").First(&user, "id = ?", string(userHandle)).Error; err != nil {
return nil, err
}
return user, nil
}, session, credentialAssertionData)
if err != nil {
if strings.Contains(err.Error(), gorm.ErrRecordNotFound.Error()) {
utils.HandlerError(c, http.StatusBadRequest, "no user with this passkey exists")
} else {
utils.UnknownHandlerError(c, err)
}
return
}
err = common.DB.Find(&user, "id = ?", c.GetString("userID")).Error
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
token, err := common.GenerateAccessToken(*user)
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
}
func listCredentialsHandler(c *gin.Context) {
var credentials []model.WebauthnCredential
if err := common.DB.Find(&credentials, "user_id = ?", c.GetString("userID")).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, credentials)
}
func deleteCredentialHandler(c *gin.Context) {
var passkeyCount int64
if err := common.DB.Model(&model.WebauthnCredential{}).Where("user_id = ?", c.GetString("userID")).Count(&passkeyCount).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
if passkeyCount == 1 {
utils.HandlerError(c, http.StatusBadRequest, "You must have at least one passkey")
return
}
var credential model.WebauthnCredential
if err := common.DB.First(&credential, "id = ? AND user_id = ?", c.Param("id"), c.GetString("userID")).Error; err != nil {
utils.HandlerError(c, http.StatusNotFound, "Credential not found")
return
}
if err := common.DB.Delete(&credential).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func updateCredentialHandler(c *gin.Context) {
var credential model.WebauthnCredential
if err := common.DB.Where("id = ? AND user_id = ?", c.Param("id"), c.GetString("userID")).First(&credential).Error; err != nil {
utils.HandlerError(c, http.StatusNotFound, "Credential not found")
return
}
var input struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
return
}
credential.Name = input.Name
if err := common.DB.Save(&credential).Error; err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func logoutHandler(c *gin.Context) {
c.SetCookie("access_token", "", 0, "/", "", false, true)
c.Status(http.StatusNoContent)
}

View File

@@ -1,39 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/utils"
"net/http"
)
func RegisterWellKnownRoutes(group *gin.RouterGroup) {
group.GET("/.well-known/jwks.json", jwks)
group.GET("/.well-known/openid-configuration", openIDConfiguration)
}
func jwks(c *gin.Context) {
jwk, err := common.GetJWK()
if err != nil {
utils.UnknownHandlerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
}
func openIDConfiguration(c *gin.Context) {
appUrl := common.EnvConfig.AppURL
config := map[string]interface{}{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
}
c.JSON(http.StatusOK, config)
}

View File

@@ -1,30 +1,55 @@
package job
import (
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/model"
"golang-rest-api-template/internal/utils"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
func RegisterJobs() {
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", clearOidcAuthorizationCodes)
jobs := &Jobs{db: db}
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
scheduler.Start()
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
type Jobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
_, err := scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
@@ -42,16 +67,3 @@ func registerJob(scheduler gocron.Scheduler, name string, interval string, job f
log.Fatalf("Failed to register job %q: %v", name, err)
}
}
func clearWebauthnSessions() error {
return common.DB.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
}
func clearOneTimeAccessTokens() error {
return common.DB.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
}
func clearOidcAuthorizationCodes() error {
return common.DB.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
}

View File

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

View File

@@ -0,0 +1,33 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type CorsMiddleware struct{}
func NewCorsMiddleware() *CorsMiddleware {
return &CorsMiddleware{}
}
func (m *CorsMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Allow all origins for the token endpoint
if c.FullPath() == "/api/oidc/token" {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
} else {
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

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

View File

@@ -2,16 +2,24 @@ package middleware
import (
"fmt"
"github.com/gin-gonic/gin"
"golang-rest-api-template/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func LimitFileSize(maxSize int64) gin.HandlerFunc {
type FileSizeLimitMiddleware struct{}
func NewFileSizeLimitMiddleware() *FileSizeLimitMiddleware {
return &FileSizeLimitMiddleware{}
}
func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
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

@@ -0,0 +1,60 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
type JwtAuthMiddleware struct {
jwtService *service.JwtService
ignoreUnauthenticated bool
}
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
}
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
return func(c *gin.Context) {
// Extract the token from the cookie or the Authorization header
token, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplitted) == 2 {
token = authorizationHeaderSplitted[1]
} else if m.ignoreUnauthenticated {
c.Next()
return
} else {
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
}
claims, err := m.jwtService.VerifyAccessToken(token)
if err != nil && m.ignoreUnauthenticated {
c.Next()
return
} else if err != nil {
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
// Check if the user is an admin
if adminOnly && !claims.IsAdmin {
c.Error(&common.MissingPermissionError{})
c.Abort()
return
}
c.Set("userID", claims.Subject)
c.Set("userIsAdmin", claims.IsAdmin)
c.Next()
}
}

View File

@@ -1,20 +1,28 @@
package middleware
import (
"golang-rest-api-template/internal/common"
"golang-rest-api-template/internal/utils"
"net/http"
"sync"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// RateLimiter is a Gin middleware for rate limiting based on client IP
func RateLimiter(limit rate.Limit, burst int) gin.HandlerFunc {
type RateLimitMiddleware struct{}
func NewRateLimitMiddleware() *RateLimitMiddleware {
return &RateLimitMiddleware{}
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine
go cleanupClients()
go cleanupClients(&mu, clients)
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -26,9 +34,9 @@ func RateLimiter(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
}
@@ -42,12 +50,8 @@ type client struct {
lastSeen time.Time
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() {
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for {
time.Sleep(time.Minute)
mu.Lock()
@@ -61,7 +65,7 @@ func cleanupClients() {
}
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock()
defer mu.Unlock()

View File

@@ -0,0 +1,51 @@
package model
type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null"`
Type string
IsPublic bool
IsInternal bool
Value string
DefaultValue string
}
type AppConfig struct {
// General
AppName AppConfigVariable
SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable
// Internal
BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
// Email
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable
LdapBindDn AppConfigVariable
LdapBindPassword AppConfigVariable
LdapBase AppConfigVariable
LdapUserSearchFilter AppConfigVariable
LdapUserGroupSearchFilter AppConfigVariable
LdapSkipCertVerify AppConfigVariable
LdapAttributeUserUniqueIdentifier AppConfigVariable
LdapAttributeUserUsername AppConfigVariable
LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable
LdapAttributeUserProfilePicture AppConfigVariable
LdapAttributeGroupMember AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable
}

View File

@@ -1,19 +0,0 @@
package model
type ApplicationConfigurationVariable struct {
Key string `gorm:"primaryKey;not null" json:"key"`
Type string `json:"type"`
IsPublic bool `json:"-"`
IsInternal bool `json:"-"`
Value string `json:"value"`
}
type ApplicationConfiguration struct {
AppName ApplicationConfigurationVariable
BackgroundImageType ApplicationConfigurationVariable
LogoImageType ApplicationConfigurationVariable
}
type ApplicationConfigurationUpdateDto struct {
AppName string `json:"appName" binding:"required"`
}

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

@@ -1,20 +1,23 @@
package model
import (
"github.com/google/uuid"
"gorm.io/gorm"
"time"
"github.com/google/uuid"
model "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
// Base contains common columns for all tables.
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(db *gorm.DB) (err error) {
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,29 +1,54 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"time"
)
type UserAuthorizedOidcClient struct {
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 UrlList
LogoutCallbackURLs UrlList
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
CreatedByID string
CreatedBy User
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string
CreatedBy User
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
@@ -32,34 +57,16 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil
}
type OidcAuthorizationCode struct {
Base
type UrlList []string
Code string
Scope string
Nonce string
ExpiresAt time.Time
UserID string
User User
ClientID string
func (cu *UrlList) 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"`
func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu)
}

View File

@@ -0,0 +1,53 @@
package datatype
import (
"database/sql/driver"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time
func (date *DateTime) Scan(value interface{}) (err error) {
*date = DateTime(value.(time.Time))
return
}
func (date DateTime) Value() (driver.Value, error) {
if common.EnvConfig.DbProvider == common.DbProviderSqlite {
return time.Time(date).Unix(), nil
} else {
return time.Time(date), nil
}
}
func (date DateTime) UTC() time.Time {
return time.Time(date).UTC()
}
func (date DateTime) ToTime() time.Time {
return time.Time(date)
}
// GormDataType gorm common data type
func (date DateTime) GormDataType() string {
return "date"
}
func (date DateTime) GobEncode() ([]byte, error) {
return time.Time(date).GobEncode()
}
func (date *DateTime) GobDecode(b []byte) error {
return (*time.Time)(date).GobDecode(b)
}
func (date DateTime) MarshalJSON() ([]byte, error) {
return time.Time(date).MarshalJSON()
}
func (date *DateTime) UnmarshalJSON(b []byte) error {
return (*time.Time)(date).UnmarshalJSON(b)
}

View File

@@ -3,19 +3,22 @@ package model
import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type User struct {
Base
Username string `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,10 +34,14 @@ 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,
Flags: webauthn.CredentialFlags{
BackupState: credential.BackupState,
BackupEligible: credential.BackupEligible,
},
}
}
@@ -53,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

@@ -4,30 +4,47 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
"github.com/go-webauthn/webauthn/protocol"
"time"
"github.com/go-webauthn/webauthn/protocol"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type WebauthnSession struct {
Base
Challenge string
ExpiresAt time.Time
ExpiresAt datatype.DateTime
UserVerification string
}
type WebauthnCredential struct {
Base
Name string `json:"name"`
CredentialID string `json:"credentialID"`
PublicKey []byte `json:"publicKey"`
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"`
UserID string
}
type PublicKeyCredentialCreationOptions struct {
Response protocol.PublicKeyCredentialCreationOptions
SessionID string
Timeout time.Duration
}
type PublicKeyCredentialRequestOptions struct {
Response protocol.PublicKeyCredentialRequestOptions
SessionID string
Timeout time.Duration
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport
// Scan and Value methods for GORM to handle the custom type

View File

@@ -0,0 +1,395 @@
package service
import (
"fmt"
"log"
"mime/multipart"
"os"
"reflect"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type AppConfigService struct {
DbConfig *model.AppConfig
db *gorm.DB
}
func NewAppConfigService(db *gorm.DB) *AppConfigService {
service := &AppConfigService{
DbConfig: &defaultDbConfig,
db: db,
}
if err := service.InitDbConfig(); err != nil {
log.Fatalf("Failed to initialize app config service: %v", err)
}
return service
}
var defaultDbConfig = model.AppConfig{
// General
AppName: model.AppConfigVariable{
Key: "appName",
Type: "string",
IsPublic: true,
DefaultValue: "Pocket ID",
},
SessionDuration: model.AppConfigVariable{
Key: "sessionDuration",
Type: "number",
DefaultValue: "60",
},
EmailsVerified: model.AppConfigVariable{
Key: "emailsVerified",
Type: "bool",
DefaultValue: "false",
},
AllowOwnAccountEdit: model.AppConfigVariable{
Key: "allowOwnAccountEdit",
Type: "bool",
IsPublic: true,
DefaultValue: "true",
},
// Internal
BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType",
Type: "string",
IsInternal: true,
DefaultValue: "jpg",
},
LogoLightImageType: model.AppConfigVariable{
Key: "logoLightImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
LogoDarkImageType: model.AppConfigVariable{
Key: "logoDarkImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
SmtpTls: model.AppConfigVariable{
Key: "smtpTls",
Type: "bool",
DefaultValue: "true",
},
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapUserSearchFilter: model.AppConfigVariable{
Key: "ldapUserSearchFilter",
Type: "string",
DefaultValue: "(objectClass=person)",
},
LdapUserGroupSearchFilter: model.AppConfigVariable{
Key: "ldapUserGroupSearchFilter",
Type: "string",
DefaultValue: "(objectClass=groupOfNames)",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeUserProfilePicture: model.AppConfigVariable{
Key: "ldapAttributeUserProfilePicture",
Type: "string",
},
LdapAttributeGroupMember: model.AppConfigVariable{
Key: "ldapAttributeGroupMember",
Type: "string",
DefaultValue: "member",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
}
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
}
tx := s.db.Begin()
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
var savedConfigVariables []model.AppConfigVariable
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
appConfigVariable.Value = value
if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
}
tx.Commit()
if err := s.LoadDbConfigFromDb(); err != nil {
return nil, err
}
return savedConfigVariables, nil
}
func (s *AppConfigService) UpdateImageType(imageName string, fileType string) error {
key := fmt.Sprintf("%sImageType", imageName)
err := s.db.Model(&model.AppConfigVariable{}).Where("key = ?", key).Update("value", fileType).Error
if err != nil {
return err
}
return s.LoadDbConfigFromDb()
}
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable
var err error
if showAll {
err = s.db.Find(&configuration).Error
} else {
err = s.db.Find(&configuration, "is_public = true").Error
}
if err != nil {
return nil, err
}
for i := range configuration {
if common.EnvConfig.UiConfigDisabled {
// Set the value to the environment variable if the UI config is disabled
configuration[i].Value = s.getConfigVariableFromEnvironmentVariable(configuration[i].Key, configuration[i].DefaultValue)
} else if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
// Set the value to the default value if it is empty
configuration[i].Value = configuration[i].DefaultValue
}
}
return configuration, nil
}
func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, imageName string, oldImageType string) error {
fileType := utils.GetFileExtension(uploadedFile.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return &common.FileTypeNotSupportedError{}
}
// Delete the old image if it has a different file type
if fileType != oldImageType {
oldImagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType)
if err := os.Remove(oldImagePath); err != nil {
return err
}
}
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType)
if err := utils.SaveFile(uploadedFile, imagePath); err != nil {
return err
}
// Update the file type in the database
if err := s.UpdateImageType(imageName, fileType); err != nil {
return err
}
return nil
}
// InitDbConfig creates the default configuration values in the database if they do not exist,
// updates existing configurations if they differ from the default, and deletes any configurations
// that are not in the default configuration.
func (s *AppConfigService) InitDbConfig() error {
// Reflect to get the underlying value of DbConfig and its default configuration
defaultConfigReflectValue := reflect.ValueOf(defaultDbConfig)
defaultKeys := make(map[string]struct{})
// Iterate over the fields of DbConfig
for i := 0; i < defaultConfigReflectValue.NumField(); i++ {
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.AppConfigVariable)
defaultKeys[defaultConfigVar.Key] = struct{}{}
var storedConfigVar model.AppConfigVariable
if err := s.db.First(&storedConfigVar, "key = ?", defaultConfigVar.Key).Error; err != nil {
// If the configuration does not exist, create it
if err := s.db.Create(&defaultConfigVar).Error; err != nil {
return err
}
continue
}
// Update existing configuration if it differs from the default
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
storedConfigVar.Type = defaultConfigVar.Type
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
if err := s.db.Save(&storedConfigVar).Error; err != nil {
return err
}
}
}
// Delete any configurations not in the default keys
var allConfigVars []model.AppConfigVariable
if err := s.db.Find(&allConfigVars).Error; err != nil {
return err
}
for _, config := range allConfigVars {
if _, exists := defaultKeys[config.Key]; !exists {
if err := s.db.Delete(&config).Error; err != nil {
return err
}
}
}
return s.LoadDbConfigFromDb()
}
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfigFromDb() error {
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
dbConfigField := dbConfigReflectValue.Field(i)
currentConfigVar := dbConfigField.Interface().(model.AppConfigVariable)
var storedConfigVar model.AppConfigVariable
if err := s.db.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
return err
}
if common.EnvConfig.UiConfigDisabled {
storedConfigVar.Value = s.getConfigVariableFromEnvironmentVariable(currentConfigVar.Key, storedConfigVar.DefaultValue)
} else if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
storedConfigVar.Value = storedConfigVar.DefaultValue
}
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
}
return nil
}
func (s *AppConfigService) getConfigVariableFromEnvironmentVariable(key, fallbackValue string) string {
environmentVariableName := utils.CamelCaseToScreamingSnakeCase(key)
if value, exists := os.LookupEnv(environmentVariableName); exists {
return value
}
return fallbackValue
}

View File

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

View File

@@ -0,0 +1,197 @@
package service
import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
// Reserved claims
var reservedClaims = map[string]struct{}{
"given_name": {},
"family_name": {},
"name": {},
"email": {},
"preferred_username": {},
"groups": {},
"sub": {},
"iss": {},
"aud": {},
"exp": {},
"iat": {},
"auth_time": {},
"nonce": {},
"acr": {},
"amr": {},
"azp": {},
"nbf": {},
"jti": {},
}
type CustomClaimService struct {
db *gorm.DB
}
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
return &CustomClaimService{db: db}
}
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
func isReservedClaim(key string) bool {
_, ok := reservedClaims[key]
return ok
}
// idType is the type of the id used to identify the user or user group
type idType string
const (
UserID idType = "user_id"
UserGroupID idType = "user_group_id"
)
// UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(UserID, userID, claims)
}
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
}
// updateCustomClaims updates the custom claims for a user or user group
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
seenKeys := make(map[string]bool)
for _, claim := range claims {
if seenKeys[claim.Key] {
return nil, &common.DuplicateClaimError{Key: claim.Key}
}
seenKeys[claim.Key] = true
}
var existingClaims []model.CustomClaim
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
if err != nil {
return nil, err
}
// Delete claims that are not in the new list
for _, existingClaim := range existingClaims {
found := false
for _, claim := range claims {
if claim.Key == existingClaim.Key {
found = true
break
}
}
if !found {
err = s.db.Delete(&existingClaim).Error
if err != nil {
return nil, err
}
}
}
// Add or update claims
for _, claim := range claims {
if isReservedClaim(claim.Key) {
return nil, &common.ReservedClaimError{Key: claim.Key}
}
customClaim := model.CustomClaim{
Key: claim.Key,
Value: claim.Value,
}
if idType == UserID {
customClaim.UserID = &value
} else if idType == UserGroupID {
customClaim.UserGroupID = &value
}
// Update the claim if it already exists or create a new one
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
if err != nil {
return nil, err
}
}
// Get the updated claims
var updatedClaims []model.CustomClaim
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
return customClaims, err
}
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
return customClaims, err
}
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
// prioritizing the user's claims over user group claims with the same key.
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
// Get the custom claims of the user
customClaims, err := s.GetCustomClaimsForUser(userID)
if err != nil {
return nil, err
}
// Store user's claims in a map to prioritize and prevent duplicates
claimsMap := make(map[string]model.CustomClaim)
for _, claim := range customClaims {
claimsMap[claim.Key] = claim
}
// Get all user groups of the user
var userGroupsOfUser []model.UserGroup
err = s.db.Preload("CustomClaims").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error
if err != nil {
return nil, err
}
// Add only non-duplicate custom claims from user groups
for _, userGroup := range userGroupsOfUser {
for _, groupClaim := range userGroup.CustomClaims {
// Only add claim if it does not exist in the user's claims
if _, exists := claimsMap[groupClaim.Key]; !exists {
claimsMap[groupClaim.Key] = groupClaim
}
}
}
// Convert the claimsMap back to a slice
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
for _, claim := range claimsMap {
finalClaims = append(finalClaims, claim)
}
return finalClaims, nil
}
// GetSuggestions returns a list of custom claim keys that have been used before
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
var customClaimsKeys []string
err := s.db.Model(&model.CustomClaim{}).
Group("key").
Order("COUNT(*) DESC").
Pluck("key", &customClaimsKeys).Error
return customClaimsKeys, err
}

View File

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

View File

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

View File

@@ -0,0 +1,222 @@
package service
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"sync"
"time"
"github.com/oschwald/maxminddb-golang/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type GeoLiteService struct {
disableUpdater bool
mutex sync.Mutex
}
var localhostIPNets = []*net.IPNet{
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
{IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128)}, // ::1/128
}
var privateLanIPNets = []*net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
}
var tailscaleIPNets = []*net.IPNet{
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10
}
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
func NewGeoLiteService() *GeoLiteService {
service := &GeoLiteService{}
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
// Warn the user, and disable the updater.
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
service.disableUpdater = true
}
go func() {
if err := service.updateDatabase(); err != nil {
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
}
}()
return service
}
// GetLocationByIP returns the country and city of the given IP address.
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
// Check the IP address against known private IP ranges
if ip := net.ParseIP(ipAddress); ip != nil {
for _, ipNet := range tailscaleIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "Tailscale", nil
}
}
for _, ipNet := range privateLanIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "LAN/Docker/k8s", nil
}
}
for _, ipNet := range localhostIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "localhost", nil
}
}
}
// Race condition between reading and writing the database.
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
if err != nil {
return "", "", err
}
defer db.Close()
addr := netip.MustParseAddr(ipAddress)
var record struct {
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Country struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
err = db.Lookup(addr).Decode(&record)
if err != nil {
return "", "", err
}
return record.Country.Names["en"], record.City.Names["en"], nil
}
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) updateDatabase() error {
if s.disableUpdater {
// Avoid updating the GeoLite2 City database.
return nil
}
if s.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date.")
return nil
}
log.Println("Updating GeoLite2 City database...")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
// Download the database tar.gz file
resp, err := http.Get(downloadUrl)
if err != nil {
return fmt.Errorf("failed to download database: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download database, received HTTP %d", resp.StatusCode)
}
// Extract the database file directly to the target path
if err := s.extractDatabase(resp.Body); err != nil {
return fmt.Errorf("failed to extract database: %w", err)
}
log.Println("GeoLite2 City database successfully updated.")
return nil
}
// isDatabaseUpToDate checks if the database file is older than 14 days.
func (s *GeoLiteService) isDatabaseUpToDate() bool {
info, err := os.Stat(common.EnvConfig.GeoLiteDBPath)
if err != nil {
// If the file doesn't exist, treat it as not up-to-date
return false
}
return time.Since(info.ModTime()) < 14*24*time.Hour
}
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
gzr, err := gzip.NewReader(reader)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzr.Close()
tarReader := tar.NewReader(gzr)
// Iterate over the files in the tar archive
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar archive: %w", err)
}
// Check if the file is the GeoLite2-City.mmdb file
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
// extract to a temporary file to avoid having a corrupted db in case of write failure.
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary database file: %w", err)
}
tempName := tmpFile.Name()
// Write the file contents directly to the target location
if _, err := io.Copy(tmpFile, tarReader); err != nil {
// if fails to write, then cleanup and throw an error
tmpFile.Close()
os.Remove(tempName)
return fmt.Errorf("failed to write database file: %w", err)
}
tmpFile.Close()
// ensure the database is not corrupted
db, err := maxminddb.Open(tempName)
if err != nil {
// if fails to write, then cleanup and throw an error
os.Remove(tempName)
return fmt.Errorf("failed to open downloaded database file: %w", err)
}
db.Close()
// ensure we lock the structure before we overwrite the database
// to prevent race conditions between reading and writing the mmdb.
s.mutex.Lock()
// replace the old file with the new file
err = os.Rename(tempName, common.EnvConfig.GeoLiteDBPath)
s.mutex.Unlock()
if err != nil {
// if cannot overwrite via rename, then cleanup and throw an error
os.Remove(tempName)
return fmt.Errorf("failed to replace database file: %w", err)
}
return nil
}
}
return errors.New("GeoLite2-City.mmdb not found in archive")
}

View File

@@ -0,0 +1,299 @@
package service
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"log"
"math/big"
"os"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
const (
privateKeyPath = "data/keys/jwt_private_key.pem"
publicKeyPath = "data/keys/jwt_public_key.pem"
)
type JwtService struct {
PublicKey *rsa.PublicKey
PrivateKey *rsa.PrivateKey
appConfigService *AppConfigService
}
func NewJwtService(appConfigService *AppConfigService) *JwtService {
service := &JwtService{
appConfigService: appConfigService,
}
// Ensure keys are generated or loaded
if err := service.loadOrGenerateKeys(); err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err)
}
return service
}
type AccessTokenJWTClaims struct {
jwt.RegisteredClaims
IsAdmin bool `json:"isAdmin,omitempty"`
}
type JWK struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
N string `json:"n"`
E string `json:"e"`
}
// loadOrGenerateKeys loads RSA keys from the given paths or generates them if they do not exist.
func (s *JwtService) loadOrGenerateKeys() error {
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
if err := s.generateKeys(); err != nil {
return err
}
}
privateKeyBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
return errors.New("can't read jwt private key: " + err.Error())
}
s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
return errors.New("can't parse jwt private key: " + err.Error())
}
publicKeyBytes, err := os.ReadFile(publicKeyPath)
if err != nil {
return errors.New("can't read jwt public key: " + err.Error())
}
s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil {
return errors.New("can't parse jwt public key: " + err.Error())
}
return nil
}
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
sessionDurationInMinutes, _ := strconv.Atoi(s.appConfigService.DbConfig.SessionDuration.Value)
claim := AccessTokenJWTClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
},
IsAdmin: user.IsAdmin,
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid
return token.SignedString(s.PrivateKey)
}
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*AccessTokenJWTClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
return nil, errors.New("audience doesn't match")
}
return claims, nil
}
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) {
claims := jwt.MapClaims{
"aud": clientID,
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(time.Now()),
"iss": common.EnvConfig.AppURL,
}
for k, v := range userClaims {
claims[k] = v
}
if nonce != "" {
claims["nonce"] = nonce
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid
return token.SignedString(s.PrivateKey)
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{clientID},
Issuer: common.EnvConfig.AppURL,
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error())
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid
return token.SignedString(s.PrivateKey)
}
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
}
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
}
// GetJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetJWK() (JWK, error) {
if s.PublicKey == nil {
return JWK{}, errors.New("public key is not initialized")
}
kid, err := s.generateKeyID(s.PublicKey)
if err != nil {
return JWK{}, err
}
jwk := JWK{
Kid: kid,
Kty: "RSA",
Use: "sig",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
}
return jwk, nil
}
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", errors.New("failed to marshal public key: " + err.Error())
}
// Compute SHA-256 hash of the public key
hash := sha256.New()
hash.Write(pubASN1)
hashed := hash.Sum(nil)
// Truncate the hash to the first 8 bytes for a shorter Key ID
shortHash := hashed[:8]
// Return Base64 encoded truncated hash as Key ID
return base64.RawURLEncoding.EncodeToString(shortHash), nil
}
// generateKeys generates a new RSA key pair and saves them to the specified paths.
func (s *JwtService) generateKeys() error {
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
return errors.New("failed to create directories for keys: " + err.Error())
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return errors.New("failed to generate private key: " + err.Error())
}
s.PrivateKey = privateKey
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
return err
}
publicKey := &privateKey.PublicKey
s.PublicKey = publicKey
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
return err
}
return nil
}
// savePEMKey saves a PEM encoded key to a file.
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
keyFile, err := os.Create(path)
if err != nil {
return errors.New("failed to create key file: " + err.Error())
}
defer keyFile.Close()
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: keyType,
Bytes: keyBytes,
})
if _, err := keyFile.Write(keyPEM); err != nil {
return errors.New("failed to write key file: " + err.Error())
}
return nil
}

View File

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

View File

@@ -0,0 +1,569 @@
package service
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"os"
"regexp"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
}
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
return &OidcService{
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
}
}
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var client model.OidcClient
if err := s.db.Preload("AllowedUserGroups").First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
if err != nil {
return "", "", err
}
// Check if the user group is allowed to authorize the client
var user model.User
if err := s.db.Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
return "", "", err
}
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return "", "", &common.OidcAccessDeniedError{}
}
// Check if the user has already authorized the client with the given scope
hasAuthorizedClient, err := s.HasAuthorizedClient(input.ClientID, userID, input.Scope)
if err != nil {
return "", "", err
}
// If the user has not authorized the client, create a new authorization in the database
if !hasAuthorizedClient {
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: input.ClientID,
Scope: input.Scope,
}
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
// The client has already been authorized but with a different scope so we need to update the scope
if err := s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error; err != nil {
return "", "", err
}
} else {
return "", "", err
}
}
}
// Create the authorization code
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil {
return "", "", err
}
// Log the authorization event
if hasAuthorizedClient {
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
} else {
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
}
return code, callbackURL, nil
}
// HasAuthorizedClient checks if the user has already authorized the client with the given scope
func (s *OidcService) HasAuthorizedClient(clientID, userID, scope string) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
if userAuthorizedOidcClient.Scope != scope {
return false, nil
}
return true, nil
}
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if len(client.AllowedUserGroups) == 0 {
return true
}
isAllowedToAuthorize := false
for _, userGroup := range client.AllowedUserGroups {
for _, userGroupUser := range user.UserGroups {
if userGroup.ID == userGroupUser.ID {
isAllowedToAuthorize = true
break
}
}
}
return isAllowedToAuthorize
}
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
if grantType != "authorization_code" {
return "", "", &common.OidcGrantTypeNotSupportedError{}
}
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return "", "", err
}
// Verify the client secret if the client is not public
if !client.IsPublic {
if clientID == "" || clientSecret == "" {
return "", "", &common.OidcMissingClientCredentialsError{}
}
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return "", "", &common.OidcClientSecretInvalidError{}
}
}
var authorizationCodeMetaData model.OidcAuthorizationCode
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
if err != nil {
return "", "", &common.OidcInvalidAuthorizationCodeError{}
}
// If the client is public or PKCE is enabled, the code verifier must match the code challenge
if client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", &common.OidcInvalidCodeVerifierError{}
}
}
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", &common.OidcInvalidAuthorizationCodeError{}
}
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
if err != nil {
return "", "", err
}
idToken, err := s.jwtService.GenerateIDToken(userClaims, clientID, authorizationCodeMetaData.Nonce)
if err != nil {
return "", "", err
}
accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID)
s.db.Delete(&authorizationCodeMetaData)
return idToken, accessToken, nil
}
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
var client model.OidcClient
if err := s.db.Preload("CreatedBy").Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("name LIKE ?", searchPattern)
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
return clients, pagination, nil
}
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{
Name: input.Name,
CallbackURLs: input.CallbackURLs,
LogoutCallbackURLs: input.LogoutCallbackURLs,
CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
}
if err := s.db.Create(&client).Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
var client model.OidcClient
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
func (s *OidcService) DeleteClient(clientID string) error {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return err
}
if err := s.db.Delete(&client).Error; err != nil {
return err
}
return nil
}
func (s *OidcService) CreateClientSecret(clientID string) (string, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return "", err
}
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
if err != nil {
return "", err
}
client.Secret = string(hashedSecret)
if err := s.db.Save(&client).Error; err != nil {
return "", err
}
return clientSecret, nil
}
func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return "", "", err
}
if client.ImageType == nil {
return "", "", errors.New("image not found")
}
imageType := *client.ImageType
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType)
mimeType := utils.GetImageMimeType(imageType)
return imagePath, mimeType, nil
}
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
fileType := utils.GetFileExtension(file.Filename)
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return &common.FileTypeNotSupportedError{}
}
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
if err := utils.SaveFile(file, imagePath); err != nil {
return err
}
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return err
}
if client.ImageType != nil && fileType != *client.ImageType {
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
if err := os.Remove(oldImagePath); err != nil {
return err
}
}
client.ImageType = &fileType
if err := s.db.Save(&client).Error; err != nil {
return err
}
return nil
}
func (s *OidcService) DeleteClientLogo(clientID string) error {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return err
}
if client.ImageType == nil {
return errors.New("image not found")
}
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
if err := os.Remove(imagePath); err != nil {
return err
}
client.ImageType = nil
if err := s.db.Save(&client).Error; err != nil {
return err
}
return nil
}
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
return nil, err
}
user := authorizedOidcClient.User
scope := authorizedOidcClient.Scope
claims := map[string]interface{}{
"sub": user.ID,
}
if strings.Contains(scope, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
}
if strings.Contains(scope, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"name": user.FullName(),
"preferred_username": user.Username,
"picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID),
}
if strings.Contains(scope, "profile") {
// Add profile claims
for k, v := range profileClaims {
claims[k] = v
}
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue interface{}
json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if jsonValue != nil {
// It's JSON so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshalling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
}
}
}
if strings.Contains(scope, "email") {
claims["email"] = user.Email
}
return claims, nil
}
func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
client, err = s.GetClient(id)
if err != nil {
return model.OidcClient{}, err
}
// Fetch the user groups based on UserGroupIDs in input
var groups []model.UserGroup
if len(input.UserGroupIDs) > 0 {
if err := s.db.Where("id IN (?)", input.UserGroupIDs).Find(&groups).Error; err != nil {
return model.OidcClient{}, err
}
}
// Replace the current user groups with the new set of user groups
if err := s.db.Model(&client).Association("AllowedUserGroups").Replace(groups); err != nil {
return model.OidcClient{}, err
}
// Save the updated client
if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
// ValidateEndSession returns the logout callback URL for the client if all the validations pass
func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) (string, error) {
// If no ID token hint is provided, return an error
if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{}
}
// If the ID token hint is provided, verify the ID token
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
if err != nil {
return "", &common.TokenInvalidError{}
}
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request
if input.ClientId != "" && claims.Audience[0] != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{}
}
clientId := claims.Audience[0]
// Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
return "", &common.OidcMissingAuthorizationError{}
}
// If the client has no logout callback URLs, return an error
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
return "", &common.OidcNoCallbackURLError{}
}
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri)
if err != nil {
return "", err
}
return callbackURL, nil
}
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
Code: randomString,
ClientID: clientID,
UserID: userID,
Scope: scope,
Nonce: nonce,
CodeChallenge: &codeChallenge,
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
}
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
return "", err
}
return randomString, nil
}
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
}
if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge
}
// Compute SHA-256 hash of the codeVerifier
h := sha256.New()
h.Write([]byte(codeVerifier))
codeVerifierHash := h.Sum(nil)
// Base64 URL encode the verifier hash
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
return encodedVerifierHash == codeChallenge
}
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" {
return urls[0], nil
}
for _, callbackPattern := range urls {
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil {
return "", err
}
if matched {
return inputCallbackURL, nil
}
}
return "", &common.OidcInvalidCallbackURLError{}
}

View File

@@ -0,0 +1,361 @@
package service
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fxamacker/cbor/v2"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/resources"
"github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type TestService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
}
func (s *TestService) SeedDatabase() error {
return s.db.Transaction(func(tx *gorm.DB) error {
users := []model.User{
{
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
IsAdmin: false,
},
}
for _, user := range users {
if err := tx.Create(&user).Error; err != nil {
return err
}
}
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{
ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
},
Name: "developers",
FriendlyName: "Developers",
Users: []model.User{users[0], users[1]},
},
{
Base: model.Base{
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
},
Name: "designers",
FriendlyName: "Designers",
Users: []model.User{users[0]},
},
}
for _, group := range userGroups {
if err := tx.Create(&group).Error; err != nil {
return err
}
}
oidcClients := []model.OidcClient{
{
Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
},
{
Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: users[1].ID,
AllowedUserGroups: []model.UserGroup{
userGroups[1],
},
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
return err
}
}
authCode := model.OidcAuthorizationCode{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
if err := tx.Create(&authCode).Error; err != nil {
return err
}
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
}
if err := tx.Create(&accessToken).Error; err != nil {
return err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
}
// To generate a new key pair, run the following command:
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
publicKeyPasskey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKeyPasskey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
if err != nil {
return err
}
webauthnCredentials := []model.WebauthnCredential{
{
Name: "Passkey 1",
CredentialID: []byte("test-credential-tim"),
PublicKey: publicKeyPasskey1,
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
UserID: users[0].ID,
},
{
Name: "Passkey 2",
CredentialID: []byte("test-credential-craig"),
PublicKey: publicKeyPasskey2,
AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal},
UserID: users[1].ID,
},
}
for _, credential := range webauthnCredentials {
if err := tx.Create(&credential).Error; err != nil {
return err
}
}
webauthnSession := model.WebauthnSession{
Challenge: "challenge",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserVerification: "preferred",
}
if err := tx.Create(&webauthnSession).Error; err != nil {
return err
}
return nil
})
}
func (s *TestService) ResetDatabase() error {
err := s.db.Transaction(func(tx *gorm.DB) error {
var tables []string
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Query to get all tables for SQLite
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
return err
}
case common.DbProviderPostgres:
// Query to get all tables for PostgreSQL
if err := tx.Raw(`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND tablename != 'schema_migrations';
`).Scan(&tables).Error; err != nil {
return err
}
default:
return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
// Delete all rows from all tables
for _, table := range tables {
if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil {
return err
}
}
return nil
})
return err
}
func (s *TestService) ResetApplicationImages() error {
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
log.Printf("Error removing directory: %v", err)
return err
}
files, err := resources.FS.ReadDir("images")
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join("images", file.Name())
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}
func (s *TestService) ResetAppConfig() error {
// Reseed the config variables
if err := s.appConfigService.InitDbConfig(); err != nil {
return err
}
// Reset all app config variables to their default values
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
return err
}
// Reload the app config from the database after resetting the values
return s.appConfigService.LoadDbConfigFromDb()
}
func (s *TestService) SetJWTKeys() {
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
-----END RSA PRIVATE KEY-----
`
block, _ := pem.Decode([]byte(privateKeyString))
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
s.jwtService.PrivateKey = privateKey
s.jwtService.PublicKey = &privateKey.PublicKey
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
}
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA public key")
}
coseKey := map[int]interface{}{
1: 2, // Key type: EC2
3: -7, // Algorithm: ECDSA with SHA-256
-1: 1, // Curve: P-256
-2: ecdsaPubKey.X.Bytes(), // X coordinate
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
}
cborPublicKey, err := cbor.Marshal(coseKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal COSE key: %w", err)
}
return cborPublicKey, nil
}

View File

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

View File

@@ -0,0 +1,316 @@
package service
import (
"errors"
"fmt"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils/image"
"io"
"log"
"net/url"
"os"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
}
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.Model(&model.User{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err
}
func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err
}
func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return nil, 0, &common.InvalidUUIDError{}
}
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
file, err := os.Open(profilePicturePath)
if err == nil {
// Get the file size
fileInfo, err := file.Stat()
if err != nil {
return nil, 0, err
}
return file, fileInfo.Size(), nil
}
// If the file does not exist, return the default profile picture
user, err := s.GetUser(userID)
if err != nil {
return nil, 0, err
}
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName)
if err != nil {
return nil, 0, err
}
return defaultPicture, int64(defaultPicture.Len()), nil
}
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
// Convert the image to a smaller square image
profilePicture, err := profilepicture.CreateProfilePicture(file)
if err != nil {
return err
}
// Ensure the directory exists
profilePictureDir := fmt.Sprintf("%s/profile-pictures", common.EnvConfig.UploadPath)
if err := os.MkdirAll(profilePictureDir, os.ModePerm); err != nil {
return err
}
// Create the profile picture file
createdProfilePicture, err := os.Create(fmt.Sprintf("%s/%s.png", profilePictureDir, userID))
if err != nil {
return err
}
defer createdProfilePicture.Close()
// Copy the image to the file
_, err = io.Copy(createdProfilePicture, profilePicture)
if err != nil {
return err
}
return nil
}
func (s *UserService) DeleteUser(userID string) error {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return err
}
// Disallow deleting the user if it is an LDAP user and LDAP is enabled
if user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
return &common.LdapUserUpdateError{}
}
// Delete the profile picture
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
if err := os.Remove(profilePicturePath); err != nil && !os.IsNotExist(err) {
return err
}
return s.db.Delete(&user).Error
}
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
}
if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user)
}
return model.User{}, err
}
return user, nil
}
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err
}
// Disallow updating the user if it is an LDAP group and LDAP is enabled
if !allowLdapUpdate && user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
return model.User{}, &common.LdapUserUpdateError{}
}
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
}
if err := s.db.Save(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return user, s.checkDuplicatedFields(user)
}
return user, err
}
return user, nil
}
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if err != nil {
return err
}
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
go func() {
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link,
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
return nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", err
}
oneTimeAccessToken := model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
if err := s.db.Create(&oneTimeAccessToken).Error; err != nil {
return "", err
}
return oneTimeAccessToken.Token, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
if err := s.db.Delete(&oneTimeAccessToken).Error; err != nil {
return model.User{}, "", err
}
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
}
return oneTimeAccessToken.User, accessToken, nil
}
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount > 1 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
user := model.User{
FirstName: "Admin",
LastName: "Admin",
Username: "admin",
Email: "admin@admin.com",
IsAdmin: true,
}
if err := s.db.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
return model.User{}, "", err
}
if len(user.Credentials) > 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
return user, token, nil
}
func (s *UserService) checkDuplicatedFields(user model.User) error {
var existingUser model.User
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
return &common.AlreadyInUseError{Property: "email"}
}
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
return &common.AlreadyInUseError{Property: "username"}
}
return nil
}

View File

@@ -0,0 +1,210 @@
package service
import (
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type WebAuthnService struct {
db *gorm.DB
webAuthn *webauthn.WebAuthn
jwtService *JwtService
auditLogService *AuditLogService
appConfigService *AppConfigService
}
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: time.Second * 60,
TimeoutUVD: time.Second * 60,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: time.Second * 60,
TimeoutUVD: time.Second * 60,
},
},
}
wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService, appConfigService: appConfigService}
}
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
s.updateWebAuthnConfig()
var user model.User
if err := s.db.Preload("Credentials").Find(&user, "id = ?", userID).Error; err != nil {
return nil, err
}
options, session, err := s.webAuthn.BeginRegistration(&user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()))
if err != nil {
return nil, err
}
sessionToStore := &model.WebauthnSession{
ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
if err := s.db.Create(&sessionToStore).Error; err != nil {
return nil, err
}
return &model.PublicKeyCredentialCreationOptions{
Response: options.Response,
SessionID: sessionToStore.ID,
Timeout: s.webAuthn.Config.Timeouts.Registration.Timeout,
}, nil
}
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.WebauthnCredential{}, err
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
UserID: []byte(userID),
}
var user model.User
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return model.WebauthnCredential{}, err
}
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil {
return model.WebauthnCredential{}, err
}
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
CredentialID: credential.ID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
Transport: credential.Transport,
UserID: user.ID,
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
}
if err := s.db.Create(&credentialToStore).Error; err != nil {
return model.WebauthnCredential{}, err
}
return credentialToStore, nil
}
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
options, session, err := s.webAuthn.BeginDiscoverableLogin()
if err != nil {
return nil, err
}
sessionToStore := &model.WebauthnSession{
ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge,
UserVerification: string(session.UserVerification),
}
if err := s.db.Create(&sessionToStore).Error; err != nil {
return nil, err
}
return &model.PublicKeyCredentialRequestOptions{
Response: options.Response,
SessionID: sessionToStore.ID,
Timeout: s.webAuthn.Config.Timeouts.Registration.Timeout,
}, nil
}
func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.User{}, "", err
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
}
var user *model.User
_, err := s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
if err := s.db.Preload("Credentials").First(&user, "id = ?", string(userHandle)).Error; err != nil {
return nil, err
}
return user, nil
}, session, credentialAssertionData)
if err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err
}
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
return *user, token, nil
}
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {
var credentials []model.WebauthnCredential
if err := s.db.Find(&credentials, "user_id = ?", userID).Error; err != nil {
return nil, err
}
return credentials, nil
}
func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error {
var credential model.WebauthnCredential
if err := s.db.First(&credential, "id = ? AND user_id = ?", credentialID, userID).Error; err != nil {
return err
}
if err := s.db.Delete(&credential).Error; err != nil {
return err
}
return nil
}
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) {
var credential model.WebauthnCredential
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil {
return credential, err
}
credential.Name = name
if err := s.db.Save(&credential).Error; err != nil {
return credential, err
}
return credential, nil
}
// updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime
func (s *WebAuthnService) updateWebAuthnConfig() {
s.webAuthn.Config.RPDisplayName = s.appConfigService.DbConfig.AppName.Value
}

View File

@@ -0,0 +1,13 @@
package cookie
import (
"github.com/gin-gonic/gin"
)
func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
c.SetCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/", "", true, true)
}
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
}

View File

@@ -0,0 +1,17 @@
package cookie
import (
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
AccessTokenCookieName = "access_token"
SessionIdCookieName = "session"
}
}

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

View File

@@ -2,9 +2,12 @@ package utils
import (
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/pocket-id/pocket-id/backend/resources"
)
func GetFileExtension(filename string) string {
@@ -27,27 +30,8 @@ func GetImageMimeType(ext string) string {
}
}
func CopyDirectory(srcDir, destDir string) error {
files, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join(srcDir, file.Name())
destFilePath := filepath.Join(destDir, file.Name())
err := copyFile(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}
func copyFile(srcFilePath, destFilePath string) error {
srcFile, err := os.Open(srcFilePath)
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return err
}
@@ -71,3 +55,24 @@ func copyFile(srcFilePath, destFilePath string) error {
return nil
}
func SaveFile(file *multipart.FileHeader, dst string) error {
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}

View File

@@ -1,19 +0,0 @@
package utils
import (
"github.com/gin-gonic/gin"
"log"
"net/http"
"strings"
)
func UnknownHandlerError(c *gin.Context, err error) {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
func HandlerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -0,0 +1,96 @@
package profilepicture
import (
"bytes"
"fmt"
"github.com/disintegration/imaging"
"github.com/pocket-id/pocket-id/backend/resources"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"image"
"image/color"
"io"
"strings"
)
const profilePictureSize = 300
// CreateProfilePicture resizes the profile picture to a square
func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) {
img, err := imaging.Decode(file)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos)
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil {
return nil, fmt.Errorf("failed to encode image: %v", err)
}
return &buf, nil
}
// CreateDefaultProfilePicture creates a profile picture with the initials
func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) {
// Get the initials
initials := ""
if len(firstName) > 0 {
initials += string(firstName[0])
}
if len(lastName) > 0 {
initials += string(lastName[0])
}
initials = strings.ToUpper(initials)
// Create a blank image with a white background
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Load the font
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
// Parse the font
fontFace, err := opentype.Parse(fontBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse font: %w", err)
}
// Create a font.Face with a specific size
fontSize := 160.0
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
})
if err != nil {
return nil, fmt.Errorf("failed to create font face: %w", err)
}
// Create a drawer for the image
drawer := &font.Drawer{
Dst: img,
Src: image.NewUniform(color.RGBA{R: 0, G: 0, B: 0, A: 255}), // Black text color
Face: face,
}
// Center the initials
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
drawer.Dot = fixed.P(x, y)
// Draw the initials
drawer.DrawString(initials)
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil {
return nil, fmt.Errorf("failed to encode image: %v", err)
}
return &buf, nil
}

View File

@@ -1,21 +1,47 @@
package utils
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"strconv"
"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(c *gin.Context, db *gorm.DB, result interface{}) (PaginationResponse, error) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
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
}
@@ -29,17 +55,23 @@ func Paginate(c *gin.Context, db *gorm.DB, result interface{}) (PaginationRespon
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,9 @@ import (
"fmt"
"math/big"
"net/url"
"regexp"
"strings"
"unicode"
)
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
@@ -29,15 +32,44 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
return string(result), nil
}
func GetHostFromURL(rawURL string) string {
func GetHostnameFromURL(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsedURL.Host
return parsedURL.Hostname()
}
// StringPointer creates a string pointer from a string value
func StringPointer(s string) *string {
return &s
}
func CapitalizeFirstLetter(s string) string {
if s == "" {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func CamelCaseToSnakeCase(s string) string {
var result []rune
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
result = append(result, '_')
}
result = append(result, unicode.ToLower(r))
}
return string(result)
}
func CamelCaseToScreamingSnakeCase(s string) string {
// Insert underscores before uppercase letters (except the first one)
re := regexp.MustCompile(`([a-z0-9])([A-Z])`)
snake := re.ReplaceAllString(s, `${1}_${2}`)
// Convert to uppercase
return strings.ToUpper(snake)
}

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

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