Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1d3560f9 | ||
|
|
becfc0004a | ||
|
|
376d747616 | ||
|
|
5b9f4d7326 | ||
|
|
0de4b55dc4 | ||
|
|
78c88f5339 | ||
|
|
60e7dafa01 | ||
|
|
2ccabf835c | ||
|
|
590cb02f6c | ||
|
|
8c96ab9574 | ||
|
|
3484daf870 | ||
|
|
cfbc0d6d35 | ||
|
|
939601b6a4 | ||
|
|
b9daa5d757 | ||
|
|
8304065652 | ||
|
|
7bfc3f43a5 | ||
|
|
c056089c60 | ||
|
|
3350398abc | ||
|
|
0b0a6781ff | ||
|
|
735dc70d5f | ||
|
|
47e164b4b5 | ||
|
|
18c5103c20 | ||
|
|
5565f60d6d | ||
|
|
bd4f87b2d2 | ||
|
|
6560fd9279 | ||
|
|
29d632c151 | ||
|
|
2092007752 | ||
|
|
0aff6181c9 | ||
|
|
824c5cb4f3 | ||
|
|
3a300a2b51 | ||
|
|
a1985ce1b2 | ||
|
|
b39bc4f79a | ||
|
|
0a07344139 | ||
|
|
f3f0e1d56d | ||
|
|
70ad0b4f39 | ||
|
|
2587058ded | ||
|
|
ff06bf0b34 | ||
|
|
11ed661f86 | ||
|
|
29748cc6c7 | ||
|
|
edfb99d221 | ||
|
|
282ff82b0c | ||
|
|
9d5f83da78 | ||
|
|
896da812a3 | ||
|
|
d2b3b7647d | ||
|
|
025378d14e | ||
|
|
e033ba6d45 | ||
|
|
e09562824a | ||
|
|
08f7fd16a9 | ||
|
|
be45eed125 | ||
|
|
9e94a436cc | ||
|
|
f82020ccfb | ||
|
|
a4a90a16a9 | ||
|
|
365734ec5d | ||
|
|
d02d8931a0 | ||
|
|
24c948e6a6 | ||
|
|
7a54d3ae20 | ||
|
|
5e1d19e0a4 | ||
|
|
d6a9bb4c09 | ||
|
|
3c67765992 | ||
|
|
6bb613e0e7 | ||
|
|
7be115f7da | ||
|
|
924bb1468b | ||
|
|
4553458939 | ||
|
|
9c2848db1d | ||
|
|
64cf56276a | ||
|
|
1f0ec08290 | ||
|
|
9121239dd7 | ||
|
|
4010ee27d6 | ||
|
|
4e7574a297 | ||
|
|
8038a111dd | ||
|
|
c6f83a581a | ||
|
|
8ad632e6c1 | ||
|
|
903b0b3918 | ||
|
|
fd21ce5aac | ||
|
|
e7861df95a | ||
|
|
8e27320649 | ||
|
|
2b9413c757 | ||
|
|
fd5a881cfb | ||
|
|
28ed064668 | ||
|
|
5446b46b65 | ||
|
|
0ce6045657 | ||
|
|
3fe24a04de | ||
|
|
6769cc8c10 | ||
|
|
97f7fc4e28 | ||
|
|
fc47c2a2a4 | ||
|
|
f1a6c8db85 | ||
|
|
552d7ccfa5 | ||
|
|
e45b0b3ed0 | ||
|
|
8166e2ead7 | ||
|
|
ae7aeb0945 | ||
|
|
16f273ffce | ||
|
|
9f49e5577e | ||
|
|
d92b80b80f | ||
|
|
aaed71e1c8 | ||
|
|
4780548843 | ||
|
|
a5dfdd2178 | ||
|
|
9eec7a3e9e | ||
|
|
fdc1921f5d | ||
|
|
601f6c488a | ||
|
|
0595d73ea5 | ||
|
|
74f4c22800 | ||
|
|
b49063d692 | ||
|
|
5bbb92ae19 | ||
|
|
cc407e17d4 | ||
|
|
5749d0532f | ||
|
|
089005d1af | ||
|
|
4a808c86ac | ||
|
|
83954926f5 | ||
|
|
475b932f9d | ||
|
|
df0cd38dee | ||
|
|
7b4418958e |
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/frontend/.svelte-kit
|
||||
/frontend/build
|
||||
/backend/bin
|
||||
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
|
||||
# Application specific
|
||||
data
|
||||
/scripts/development
|
||||
@@ -1 +1,2 @@
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
TRUST_PROXY=false
|
||||
@@ -23,6 +23,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Download GeoLite2 City database
|
||||
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
|
||||
13
.github/workflows/e2e-tests.yml
vendored
@@ -15,9 +15,13 @@ jobs:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
|
||||
- name: Create dummy GeoLite2 City database
|
||||
run: touch ./backend/GeoLite2-City.mmdb
|
||||
|
||||
- name: Build Docker Image
|
||||
run: docker build -t stonith404/pocket-id .
|
||||
|
||||
- name: Run Docker Container
|
||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
||||
|
||||
@@ -33,13 +37,10 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
|
||||
- name: Get container logs
|
||||
if: always()
|
||||
run: docker logs pocket-id
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/tests/.output
|
||||
path: frontend/tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
4
.gitignore
vendored
@@ -34,4 +34,6 @@ vite.config.ts.timestamp-*
|
||||
# Application specific
|
||||
data
|
||||
/frontend/tests/.auth
|
||||
pocket-id-backend
|
||||
/frontend/tests/.report
|
||||
pocket-id-backend
|
||||
/backend/GeoLite2-City.mmdb
|
||||
236
CHANGELOG.md
@@ -1,3 +1,239 @@
|
||||
## [](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)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ You're all set!
|
||||
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
|
||||
|
||||
#### Setup
|
||||
Run `caddy run --config Caddyfile` in the root folder.
|
||||
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
10
Caddyfile
@@ -1,10 +0,0 @@
|
||||
:80 {
|
||||
reverse_proxy /api/* http://localhost:8080
|
||||
reverse_proxy /.well-known/* http://localhost:8080
|
||||
reverse_proxy /* http://localhost:3000
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/access.log
|
||||
level WARN
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -22,7 +22,7 @@ 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
|
||||
COPY ./reverse-proxy /etc/caddy/
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||
@@ -31,11 +31,14 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
||||
|
||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
||||
COPY --from=backend-builder /app/backend/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
|
||||
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||
|
||||
COPY ./scripts ./scripts
|
||||
RUN chmod +x ./scripts/*.sh
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
ENV APP_ENV=production
|
||||
|
||||
# Use a shell form to run both the frontend and backend
|
||||
|
||||
107
README.md
@@ -1,16 +1,22 @@
|
||||
# <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"/>
|
||||
<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, Pocket ID only support passkey authentication which is a passwordless authentication method.
|
||||
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
|
||||
|
||||
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you don’t need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, you’ll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
|
||||
|
||||
## Setup
|
||||
|
||||
> [!WARNING]
|
||||
> Pocket ID is in its early stages and may contain bugs.
|
||||
|
||||
### Before you start
|
||||
|
||||
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
1. Download the `docker-compose.yml` and `.env` file:
|
||||
@@ -26,65 +32,76 @@ The goal of Pocket ID is to be a simple and easy-to-use. There are other self-ho
|
||||
|
||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||
|
||||
### Unraid
|
||||
|
||||
Pocket ID is available as a template on the Community Apps store.
|
||||
|
||||
### Stand-alone Installation
|
||||
|
||||
Required tools:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 20
|
||||
- [Go](https://golang.org/doc/install) >= 1.22
|
||||
- [Go](https://golang.org/doc/install) >= 1.23
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [PM2](https://pm2.keymetrics.io/)
|
||||
- [Caddy](https://caddyserver.com/docs/install) (optional)
|
||||
|
||||
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
|
||||
```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`)
|
||||
# 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 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: Download the GeoLite2 city database.
|
||||
# If not downloaded the ip location in the audit log will be empty.
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||
cd ..
|
||||
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
|
||||
```
|
||||
# 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
|
||||
### Nginx Reverse Proxy
|
||||
|
||||
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
|
||||
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
|
||||
|
||||
After you have added the client, you can obtain the client ID and client secret.
|
||||
```nginx
|
||||
proxy_busy_buffers_size 512k;
|
||||
proxy_buffers 4 512k;
|
||||
proxy_buffer_size 256k;
|
||||
```
|
||||
|
||||
You may need the following information:
|
||||
## Proxy Services with Pocket ID
|
||||
|
||||
- **Authorization URL**: `https://<your-domain>/authorize`
|
||||
- **Token URL**: `https://<your-domain>/api/oidc/token`
|
||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
||||
- **PKCE**: `false` as this is not supported yet.
|
||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
||||
|
||||
### Update
|
||||
See the [guide](docs/proxy-services.md) for more information.
|
||||
|
||||
## Update
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -113,6 +130,9 @@ docker compose up -d
|
||||
cd ..
|
||||
pm2 start pocket-id-backend --name pocket-id-backend
|
||||
|
||||
# Optional: Update the GeoLite2 city database
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
@@ -124,15 +144,18 @@ docker compose up -d
|
||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------- | ------------------- | --------------------- | --------------------------------------------- |
|
||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"golang-rest-api-template/internal/bootstrap"
|
||||
"github.com/stonith404/pocket-id/backend/internal/bootstrap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
14
backend/email-templates/components/email_html.tmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
{{ define "root" }}
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{ template "style" . }}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{{ template "base" . }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
7
backend/email-templates/components/email_text.tmpl
Normal file
@@ -0,0 +1,7 @@
|
||||
{{- define "root" -}}
|
||||
{{- template "base" . -}}
|
||||
{{- end }}
|
||||
|
||||
|
||||
--
|
||||
This is automatically sent email from {{.AppName}}.
|
||||
80
backend/email-templates/components/style_html.tmpl
Normal file
@@ -0,0 +1,80 @@
|
||||
{{ define "style" }}
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 32px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid p {
|
||||
margin: 0;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
36
backend/email-templates/login-with-new-device_html.tmpl
Normal file
@@ -0,0 +1,36 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<div class="grid">
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<div>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="message">
|
||||
This sign-in was detected from a new device or location. If you recognize this activity, you can
|
||||
safely ignore this message. If not, please review your account and security settings.
|
||||
</p>
|
||||
</div>
|
||||
{{ end -}}
|
||||
15
backend/email-templates/login-with-new-device_text.tmpl
Normal file
@@ -0,0 +1,15 @@
|
||||
{{ define "base" -}}
|
||||
New Sign-In Detected
|
||||
====================
|
||||
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
|
||||
{{ end }}
|
||||
IP Address: {{ .Data.IPAddress }}
|
||||
Device: {{ .Data.Device }}
|
||||
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
|
||||
|
||||
This sign-in was detected from a new device or location. If you recognize
|
||||
this activity, you can safely ignore this message. If not, please review
|
||||
your account and security settings.
|
||||
{{ end -}}
|
||||
@@ -1,26 +1,29 @@
|
||||
module golang-rest-api-template
|
||||
module github.com/stonith404/pocket-id/backend
|
||||
|
||||
go 1.22
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.2.0
|
||||
github.com/caarlos0/env/v11 v11.2.2
|
||||
github.com/fxamacker/cbor/v2 v2.7.0
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.11.0
|
||||
github.com/go-webauthn/webauthn v0.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.12.1
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-migrate/migrate/v4 v4.17.1
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.25.0
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/time v0.6.0
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/gorm v1.25.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.12.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
@@ -28,8 +31,7 @@ require (
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.12 // indirect
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -42,22 +44,21 @@ require (
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/arch v0.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
|
||||
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/caarlos0/env/v11 v11.2.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs=
|
||||
github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo=
|
||||
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
|
||||
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
@@ -23,26 +23,26 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
|
||||
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-webauthn/webauthn v0.11.0 h1:2U0jWuGeoiI+XSZkHPFRtwaYtqmMUsqABtlfSq1rODo=
|
||||
github.com/go-webauthn/webauthn v0.11.0/go.mod h1:57ZrqsZzD/eboQDVtBkvTdfqFYAh/7IwzdPT+sPWqB0=
|
||||
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
|
||||
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
|
||||
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
@@ -79,8 +79,10 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -88,26 +90,26 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
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/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -120,20 +122,20 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
|
||||
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
|
||||
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
@@ -146,6 +148,6 @@ 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/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=
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,17 +1 @@
|
||||
<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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>
|
||||
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 539 B |
3
backend/images/logoDark.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
|
||||
<path fill="white" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
3
backend/images/logoLight.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
|
||||
<path fill="black" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
57
backend/internal/bootstrap/application_images_bootstrap.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||
func initApplicationImages() {
|
||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||
|
||||
sourceFiles, err := os.ReadDir("./images")
|
||||
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 := "./images/" + sourceFile.Name()
|
||||
destFilePath := dirPath + "/" + sourceFile.Name()
|
||||
|
||||
err := utils.CopyFile(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error copying file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
|
||||
for _, destinationFile := range destinationFiles {
|
||||
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
|
||||
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
|
||||
|
||||
if sourceFileWithoutExtension == destinationFileWithoutExtension {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getImageNameWithoutExtension(fileName string) string {
|
||||
splitted := strings.Split(fileName, ".")
|
||||
return strings.Join(splitted[:len(splitted)-1], ".")
|
||||
}
|
||||
@@ -1,78 +1,16 @@
|
||||
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/stonith404/pocket-id/backend/internal/job"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func Bootstrap() {
|
||||
common.InitDatabase()
|
||||
common.InitDbConfig()
|
||||
db := newDatabase()
|
||||
appConfigService := service.NewAppConfigService(db)
|
||||
|
||||
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)
|
||||
}
|
||||
job.RegisterJobs(db)
|
||||
initRouter(db, appConfigService)
|
||||
}
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
package common
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDatabase() {
|
||||
connectDatabase()
|
||||
sqlDb, err := DB.DB()
|
||||
func newDatabase() (db *gorm.DB) {
|
||||
db, err := connectDatabase()
|
||||
if err != nil {
|
||||
log.Fatal("failed to get sql db", err)
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
sqlDb, err := db.DB()
|
||||
sqlDb.SetMaxOpenConns(1)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get sql.DB: %v", err)
|
||||
}
|
||||
|
||||
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
"file://migrations",
|
||||
"postgres", driver)
|
||||
if err != nil {
|
||||
log.Fatal("failed to create migration instance", err)
|
||||
log.Fatalf("failed to create migration instance: %v", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
log.Fatal("failed to run migrations", err)
|
||||
log.Fatalf("failed to apply migrations: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func connectDatabase() {
|
||||
var database *gorm.DB
|
||||
var err error
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
dbPath := common.EnvConfig.DBPath
|
||||
|
||||
dbPath := EnvConfig.DBPath
|
||||
if EnvConfig.AppEnv == "test" {
|
||||
// Use in-memory database for testing
|
||||
if common.EnvConfig.AppEnv == "test" {
|
||||
dbPath = "file::memory:?cache=shared"
|
||||
}
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
database, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: getLogger(),
|
||||
})
|
||||
@@ -57,11 +61,11 @@ func connectDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
DB = database
|
||||
return db, err
|
||||
}
|
||||
|
||||
func getLogger() logger.Interface {
|
||||
isProduction := EnvConfig.AppEnv == "production"
|
||||
isProduction := common.EnvConfig.AppEnv == "production"
|
||||
|
||||
var logLevel logger.LogLevel
|
||||
if isProduction {
|
||||
@@ -70,7 +74,6 @@ func getLogger() logger.Interface {
|
||||
logLevel = logger.Info
|
||||
}
|
||||
|
||||
// Create the GORM logger
|
||||
return logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
79
backend/internal/bootstrap/router_bootstrap.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/controller"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/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
|
||||
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create email service: %s", err)
|
||||
}
|
||||
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||
userService := service.NewUserService(db, jwtService)
|
||||
customClaimService := service.NewCustomClaimService(db)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
userGroupService := service.NewUserGroupService(db)
|
||||
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||
|
||||
// Initialize middleware
|
||||
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
|
||||
// Set up API routes
|
||||
apiGroup := r.Group("/api")
|
||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
controller.NewTestController(apiGroup, testService)
|
||||
}
|
||||
|
||||
// Set up base routes
|
||||
baseGroup := r.Group("/")
|
||||
controller.NewWellKnownController(baseGroup, jwtService)
|
||||
|
||||
// Run the server
|
||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
33
backend/internal/common/env_config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"log"
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
AppURL string `env:"PUBLIC_APP_URL"`
|
||||
DBPath string `env:"DB_PATH"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
|
||||
}
|
||||
|
||||
var EnvConfig = &EnvConfigSchema{
|
||||
AppEnv: "production",
|
||||
DBPath: "data/pocket-id.db",
|
||||
UploadPath: "data/uploads",
|
||||
AppURL: "http://localhost",
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
EmailTemplatesPath: "./email-templates",
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
148
backend/internal/common/errors.go
Normal file
@@ -0,0 +1,148 @@
|
||||
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 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" }
|
||||
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. Please wait a while before trying again."
|
||||
}
|
||||
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||
|
||||
type ClientIdOrSecretNotProvidedError struct{}
|
||||
|
||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||
return "Client id and 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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func JWTAuth(adminOnly bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// Extract the token from the cookie or the Authorization header
|
||||
token, err := c.Cookie("access_token")
|
||||
if err != nil {
|
||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authorizationHeaderSplitted) == 2 {
|
||||
token = authorizationHeaderSplitted[1]
|
||||
} else {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
claims, err := common.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", claims.Subject)
|
||||
c.Set("userIsAdmin", claims.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
WebAuthn *webauthn.WebAuthn
|
||||
err error
|
||||
)
|
||||
|
||||
func init() {
|
||||
config := &webauthn.Config{
|
||||
RPDisplayName: DbConfig.AppName.Value,
|
||||
RPID: utils.GetHostFromURL(EnvConfig.AppURL),
|
||||
RPOrigins: []string{EnvConfig.AppURL},
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: time.Second * 60,
|
||||
TimeoutUVD: time.Second * 60,
|
||||
},
|
||||
Registration: webauthn.TimeoutConfig{
|
||||
Enforce: true,
|
||||
Timeout: time.Second * 60,
|
||||
TimeoutUVD: time.Second * 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if WebAuthn, err = webauthn.New(config); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
177
backend/internal/controller/app_config_controller.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||
appConfigService *service.AppConfigService) {
|
||||
|
||||
acc := &AppConfigController{
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
55
backend/internal/controller/audit_log_controller.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
||||
alc := AuditLogController{
|
||||
auditLogService: auditLogService,
|
||||
}
|
||||
|
||||
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
|
||||
}
|
||||
|
||||
type AuditLogController struct {
|
||||
auditLogService *service.AuditLogService
|
||||
}
|
||||
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
// Fetch audit logs for the user
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
||||
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,
|
||||
})
|
||||
}
|
||||
78
backend/internal/controller/custom_claim_controller.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
||||
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
||||
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||
}
|
||||
|
||||
type CustomClaimController struct {
|
||||
customClaimService *service.CustomClaimService
|
||||
}
|
||||
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
}
|
||||
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
}
|
||||
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
}
|
||||
280
backend/internal/controller/oidc_controller.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||
|
||||
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.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) authorizeNewClientHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(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) createTokensHandler(c *gin.Context) {
|
||||
var input dto.OidcIdTokenDto
|
||||
|
||||
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 == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||
if !ok {
|
||||
c.Error(&common.ClientIdOrSecretNotProvidedError{})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
||||
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) 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.OidcClientDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
clientDto := dto.PublicOidcClientDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
||||
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.OidcClientDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, clientDto)
|
||||
}
|
||||
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||
if err != nil {
|
||||
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.OidcClientDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
}
|
||||
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
41
backend/internal/controller/test_controller.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
214
backend/internal/controller/user_controller.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"golang.org/x/time/rate"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||
uc := UserController{
|
||||
UserService: userService,
|
||||
AppConfigService: appConfigService,
|
||||
}
|
||||
|
||||
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.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)
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
UserService *service.UserService
|
||||
AppConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
||||
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) 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) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, 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
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
151
backend/internal/controller/user_group_controller.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
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) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
170
backend/internal/controller/webauthn_controller.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
|
||||
wc := &WebauthnController{webAuthnService: webauthnService}
|
||||
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
|
||||
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
options, err := wc.webAuthnService.BeginRegistration(userID)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, options.Response)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
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
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
|
||||
c.JSON(http.StatusOK, options.Response)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, 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
|
||||
}
|
||||
|
||||
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
|
||||
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) {
|
||||
c.SetCookie("access_token", "", 0, "/", "", false, true)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,36 +1,42 @@
|
||||
package handler
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RegisterWellKnownRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/.well-known/jwks.json", jwks)
|
||||
group.GET("/.well-known/openid-configuration", openIDConfiguration)
|
||||
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)
|
||||
}
|
||||
|
||||
func jwks(c *gin.Context) {
|
||||
jwk, err := common.GetJWK()
|
||||
type WellKnownController struct {
|
||||
jwtService *service.JwtService
|
||||
}
|
||||
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
jwk, err := wkc.jwtService.GetJWK()
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
||||
}
|
||||
|
||||
func openIDConfiguration(c *gin.Context) {
|
||||
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",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
25
backend/internal/dto/app_config_dto.go
Normal file
@@ -0,0 +1,25 @@
|
||||
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"`
|
||||
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
||||
SmtHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
}
|
||||
19
backend/internal/dto/audit_log_dto.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditLogDto struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
Event model.AuditLogEvent `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
UserID string `json:"userID"`
|
||||
Data model.AuditLogData `json:"data"`
|
||||
}
|
||||
11
backend/internal/dto/custom_claim_dto.go
Normal file
@@ -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"`
|
||||
}
|
||||
116
backend/internal/dto/dto_mapper.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MapStructList maps a list of source structs to a list of destination structs
|
||||
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
||||
*destination = make([]D, 0, len(source))
|
||||
|
||||
for _, item := range source {
|
||||
var destItem D
|
||||
if err := MapStruct(item, &destItem); err != nil {
|
||||
return err
|
||||
}
|
||||
*destination = append(*destination, destItem)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MapStruct maps a source struct to a destination struct
|
||||
func MapStruct[S any, D any](source S, destination *D) error {
|
||||
// Ensure destination is a non-nil pointer
|
||||
destValue := reflect.ValueOf(destination)
|
||||
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
|
||||
return errors.New("destination must be a non-nil pointer to a struct")
|
||||
}
|
||||
|
||||
// Ensure source is a struct
|
||||
sourceValue := reflect.ValueOf(source)
|
||||
if sourceValue.Kind() != reflect.Struct {
|
||||
return errors.New("source must be a struct")
|
||||
}
|
||||
|
||||
return mapStructInternal(sourceValue, destValue.Elem())
|
||||
}
|
||||
|
||||
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
||||
// Loop through the fields of the destination struct
|
||||
for i := 0; i < destVal.NumField(); i++ {
|
||||
destField := destVal.Field(i)
|
||||
destFieldType := destVal.Type().Field(i)
|
||||
|
||||
if destFieldType.Anonymous {
|
||||
// Recursively handle embedded structs
|
||||
if err := mapStructInternal(sourceVal, destField); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
sourceField := sourceVal.FieldByName(destFieldType.Name)
|
||||
|
||||
// If the source field is valid and can be assigned to the destination field
|
||||
if sourceField.IsValid() && destField.CanSet() {
|
||||
// Handle direct assignment for simple types
|
||||
if sourceField.Type() == destField.Type() {
|
||||
destField.Set(sourceField)
|
||||
|
||||
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
|
||||
// Handle slices
|
||||
if sourceField.Type().Elem() == destField.Type().Elem() {
|
||||
// Direct assignment for slices of primitive types or non-struct elements
|
||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||
|
||||
for j := 0; j < sourceField.Len(); j++ {
|
||||
newSlice.Index(j).Set(sourceField.Index(j))
|
||||
}
|
||||
|
||||
destField.Set(newSlice)
|
||||
|
||||
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
|
||||
// Recursively map slices of structs
|
||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||
|
||||
for j := 0; j < sourceField.Len(); j++ {
|
||||
// Get the element from both source and destination slice
|
||||
sourceElem := sourceField.Index(j)
|
||||
destElem := reflect.New(destField.Type().Elem()).Elem()
|
||||
|
||||
// Recursively map the struct elements
|
||||
if err := mapStructInternal(sourceElem, destElem); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the mapped element in the new slice
|
||||
newSlice.Index(j).Set(destElem)
|
||||
}
|
||||
|
||||
destField.Set(newSlice)
|
||||
}
|
||||
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
|
||||
// Recursively map nested structs
|
||||
if err := mapStructInternal(sourceField, destField); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Type switch for specific type conversions
|
||||
switch sourceField.Interface().(type) {
|
||||
case datatype.DateTime:
|
||||
// Convert datatype.DateTime to time.Time
|
||||
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
|
||||
dateValue := sourceField.Interface().(datatype.DateTime)
|
||||
destField.Set(reflect.ValueOf(dateValue.ToTime()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
37
backend/internal/dto/oidc_dto.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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"`
|
||||
CreatedBy UserDto `json:"createdBy"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
Code string `json:"code"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
26
backend/internal/dto/user_dto.go
Normal file
@@ -0,0 +1,26 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=3,max=20"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
|
||||
LastName string `json:"lastName" binding:"required,min=3,max=30"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
34
backend/internal/dto/user_group_dto.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type UserGroupDtoWithUsers struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
Users []UserDto `json:"users"`
|
||||
CreatedAt time.Time `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"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserGroupCreateDto struct {
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"`
|
||||
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"`
|
||||
}
|
||||
|
||||
type UserGroupUpdateUsersDto struct {
|
||||
UserIDs []string `json:"userIds" binding:"required"`
|
||||
}
|
||||
|
||||
type AssignUserToGroupDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
68
backend/internal/dto/validations.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
|
||||
urls := fl.Field().Interface().([]string)
|
||||
for _, u := range urls {
|
||||
_, err := url.ParseRequestURI(u)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
|
||||
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
||||
// The string can only contain lowercase letters, numbers, and underscores
|
||||
regex := "^[a-z0-9_]*$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
|
||||
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
|
||||
// The string can only contain letters and numbers
|
||||
regex := "^[A-Za-z0-9]*$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
|
||||
func init() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("userGroupName", validateUserGroupName); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
backend/internal/dto/webauthn_dto.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=30"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/common/middleware"
|
||||
"golang-rest-api-template/internal/model"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RegisterOIDCRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/oidc/authorize", middleware.JWTAuth(false), authorizeHandler)
|
||||
group.POST("/oidc/authorize/new-client", middleware.JWTAuth(false), authorizeNewClientHandler)
|
||||
group.POST("/oidc/token", createIDTokenHandler)
|
||||
|
||||
group.GET("/oidc/clients", middleware.JWTAuth(true), listClientsHandler)
|
||||
group.POST("/oidc/clients", middleware.JWTAuth(true), createClientHandler)
|
||||
group.GET("/oidc/clients/:id", getClientHandler)
|
||||
group.PUT("/oidc/clients/:id", middleware.JWTAuth(true), updateClientHandler)
|
||||
group.DELETE("/oidc/clients/:id", middleware.JWTAuth(true), deleteClientHandler)
|
||||
|
||||
group.POST("/oidc/clients/:id/secret", middleware.JWTAuth(true), createClientSecretHandler)
|
||||
|
||||
group.GET("/oidc/clients/:id/logo", getClientLogoHandler)
|
||||
group.DELETE("/oidc/clients/:id/logo", deleteClientLogoHandler)
|
||||
group.POST("/oidc/clients/:id/logo", middleware.JWTAuth(true), middleware.LimitFileSize(2<<20), updateClientLogoHandler)
|
||||
}
|
||||
|
||||
type AuthorizeRequest struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
func authorizeHandler(c *gin.Context) {
|
||||
var parsedBody AuthorizeRequest
|
||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
common.DB.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", parsedBody.ClientID, c.GetString("userID"))
|
||||
|
||||
// If the record isn't found or the scope is different return an error
|
||||
// The client will have to call the authorizeNewClientHandler
|
||||
if userAuthorizedOIDCClient.Scope != parsedBody.Scope {
|
||||
utils.HandlerError(c, http.StatusForbidden, "missing authorization")
|
||||
return
|
||||
}
|
||||
|
||||
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
|
||||
}
|
||||
|
||||
// authorizeNewClientHandler authorizes a new client for the user
|
||||
// a new client is a new client when the user has not authorized the client before
|
||||
func authorizeNewClientHandler(c *gin.Context) {
|
||||
var parsedBody model.AuthorizeNewClientDto
|
||||
if err := c.ShouldBindJSON(&parsedBody); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: c.GetString("userID"),
|
||||
ClientID: parsedBody.ClientID,
|
||||
Scope: parsedBody.Scope,
|
||||
}
|
||||
err := common.DB.Create(&userAuthorizedClient).Error
|
||||
|
||||
if err != nil && errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
err = common.DB.Model(&userAuthorizedClient).Update("scope", parsedBody.Scope).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
authorizationCode, err := createAuthorizationCode(parsedBody.ClientID, c.GetString("userID"), parsedBody.Scope, parsedBody.Nonce)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": authorizationCode})
|
||||
|
||||
}
|
||||
|
||||
func createIDTokenHandler(c *gin.Context) {
|
||||
var body model.OidcIdTokenDto
|
||||
|
||||
if err := c.ShouldBind(&body); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Currently only authorization_code grant type is supported
|
||||
if body.GrantType != "authorization_code" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "grant type not supported")
|
||||
return
|
||||
}
|
||||
|
||||
clientID := body.ClientID
|
||||
clientSecret := body.ClientSecret
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if clientID == "" || clientSecret == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||
if !ok {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client model.OidcClient
|
||||
err := common.DB.First(&client, "id = ?", clientID, clientSecret).Error
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "OIDC OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if client secret is correct
|
||||
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid client secret")
|
||||
return
|
||||
}
|
||||
|
||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||
err = common.DB.Preload("User").First(&authorizationCodeMetaData, "code = ?", body.Code).Error
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the client id matches the client id in the authorization code and if the code has expired
|
||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
idToken, e := common.GenerateIDToken(authorizationCodeMetaData.User, clientID, authorizationCodeMetaData.Scope, authorizationCodeMetaData.Nonce)
|
||||
if e != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the authorization code after it has been used
|
||||
common.DB.Delete(&authorizationCodeMetaData)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id_token": idToken})
|
||||
}
|
||||
|
||||
func getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
|
||||
var client model.OidcClient
|
||||
err := common.DB.First(&client, "id = ?", clientId).Error
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, client)
|
||||
}
|
||||
|
||||
func listClientsHandler(c *gin.Context) {
|
||||
var clients []model.OidcClient
|
||||
searchTerm := c.Query("search")
|
||||
|
||||
query := common.DB.Model(&model.OidcClient{})
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
query = query.Where("name LIKE ?", searchPattern)
|
||||
}
|
||||
|
||||
pagination, err := utils.Paginate(c, query, &clients)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": clients,
|
||||
"pagination": pagination,
|
||||
})
|
||||
}
|
||||
|
||||
func createClientHandler(c *gin.Context) {
|
||||
var input model.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
client := model.OidcClient{
|
||||
Name: input.Name,
|
||||
CallbackURL: input.CallbackURL,
|
||||
CreatedByID: c.GetString("userID"),
|
||||
}
|
||||
|
||||
if err := common.DB.Create(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, client)
|
||||
}
|
||||
|
||||
func deleteClientHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.DB.Delete(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func updateClientHandler(c *gin.Context) {
|
||||
var input model.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client.Name = input.Name
|
||||
client.CallbackURL = input.CallbackURL
|
||||
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, client)
|
||||
}
|
||||
|
||||
// createClientSecretHandler creates a new secret for the client and revokes the old one
|
||||
func createClientSecretHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client.Secret = string(hashedSecret)
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"secret": clientSecret})
|
||||
}
|
||||
|
||||
func getClientLogoHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
if client.ImageType == nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
imageType := *client.ImageType
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType)
|
||||
mimeType := utils.GetImageMimeType(imageType)
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
func updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||
utils.HandlerError(c, http.StatusBadRequest, "file type not supported")
|
||||
return
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, c.Param("id"), fileType)
|
||||
err = c.SaveUploadedFile(file, imagePath)
|
||||
if err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the old image if it has a different file type
|
||||
if client.ImageType != nil && fileType != *client.ImageType {
|
||||
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||
if err := os.Remove(oldImagePath); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client.ImageType = &fileType
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteClientLogoHandler(c *gin.Context) {
|
||||
var client model.OidcClient
|
||||
if err := common.DB.First(&client, "id = ?", c.Param("id")).Error; err != nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
|
||||
return
|
||||
}
|
||||
|
||||
if client.ImageType == nil {
|
||||
utils.HandlerError(c, http.StatusNotFound, "image not found")
|
||||
return
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||
if err := os.Remove(imagePath); err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
client.ImageType = nil
|
||||
if err := common.DB.Save(&client).Error; err != nil {
|
||||
utils.UnknownHandlerError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
}
|
||||
|
||||
if err := common.DB.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return randomString, nil
|
||||
}
|
||||
@@ -1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -3,28 +3,51 @@ 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"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RegisterJobs() {
|
||||
func RegisterJobs(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 < ?", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
// ClearAuditLogs deletes audit logs older than 90 days
|
||||
func (j *Jobs) clearAuditLogs() error {
|
||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
|
||||
}
|
||||
|
||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||
_, err := scheduler.NewJob(
|
||||
gocron.CronJob(interval, false),
|
||||
gocron.NewTask(job),
|
||||
@@ -42,16 +65,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
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"golang-rest-api-template/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Cors() gin.HandlerFunc {
|
||||
type CorsMiddleware struct{}
|
||||
|
||||
func NewCorsMiddleware() *CorsMiddleware {
|
||||
return &CorsMiddleware{}
|
||||
}
|
||||
|
||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||
return cors.New(cors.Config{
|
||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
||||
AllowMethods: []string{"*"},
|
||||
99
backend/internal/middleware/error_handler.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrorHandlerMiddleware struct{}
|
||||
|
||||
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||
return &ErrorHandlerMiddleware{}
|
||||
}
|
||||
|
||||
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
for _, err := range c.Errors {
|
||||
|
||||
// Check for record not found errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for slice validation errors
|
||||
var sliceValidationErrors binding.SliceValidationError
|
||||
if errors.As(err, &sliceValidationErrors) {
|
||||
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var appErr common.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, gin.H{"error": message})
|
||||
}
|
||||
|
||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
var errorMessages []string
|
||||
|
||||
for _, ve := range validationErrors {
|
||||
fieldName := ve.Field()
|
||||
var errorMessage string
|
||||
switch ve.Tag() {
|
||||
case "required":
|
||||
errorMessage = fmt.Sprintf("%s is required", fieldName)
|
||||
case "email":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||
case "username":
|
||||
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||
case "min":
|
||||
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
|
||||
case "max":
|
||||
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
|
||||
case "urlList":
|
||||
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
|
||||
}
|
||||
|
||||
errorMessages = append(errorMessages, errorMessage)
|
||||
}
|
||||
|
||||
// Join all the error messages into a single string
|
||||
combinedErrors := strings.Join(errorMessages, ", ")
|
||||
|
||||
return combinedErrors
|
||||
}
|
||||
@@ -3,15 +3,22 @@ package middleware
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
58
backend/internal/middleware/jwt_auth.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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("access_token")
|
||||
if err != nil {
|
||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
if len(authorizationHeaderSplitted) == 2 {
|
||||
token = authorizationHeaderSplitted[1]
|
||||
} else if m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
c.Error(&common.NotSignedInError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||
if err != nil && m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else if err != nil {
|
||||
c.Error(&common.NotSignedInError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
c.Error(&common.MissingPermissionError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", claims.Subject)
|
||||
c.Set("userIsAdmin", claims.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"golang-rest-api-template/internal/common"
|
||||
"golang-rest-api-template/internal/utils"
|
||||
"net/http"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -11,8 +9,13 @@ import (
|
||||
"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 {
|
||||
// Start the cleanup routine
|
||||
go cleanupClients()
|
||||
|
||||
@@ -28,7 +31,7 @@ func RateLimiter(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
|
||||
limiter := getLimiter(ip, limit, burst)
|
||||
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
|
||||
}
|
||||
28
backend/internal/model/app_config.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
type AppConfigVariable struct {
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Type string
|
||||
IsPublic bool
|
||||
IsInternal bool
|
||||
Value string
|
||||
DefaultValue string
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
AppName AppConfigVariable
|
||||
SessionDuration AppConfigVariable
|
||||
EmailsVerified AppConfigVariable
|
||||
AllowOwnAccountEdit AppConfigVariable
|
||||
|
||||
BackgroundImageType AppConfigVariable
|
||||
LogoLightImageType AppConfigVariable
|
||||
LogoDarkImageType AppConfigVariable
|
||||
|
||||
EmailEnabled AppConfigVariable
|
||||
SmtpHost AppConfigVariable
|
||||
SmtpPort AppConfigVariable
|
||||
SmtpFrom AppConfigVariable
|
||||
SmtpUser AppConfigVariable
|
||||
SmtpPassword AppConfigVariable
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
52
backend/internal/model/audit_log.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type AuditLog struct {
|
||||
Base
|
||||
|
||||
Event AuditLogEvent
|
||||
IpAddress string
|
||||
Country string
|
||||
City string
|
||||
UserAgent string
|
||||
UserID string
|
||||
Data AuditLogData
|
||||
}
|
||||
|
||||
type AuditLogData map[string]string
|
||||
|
||||
type AuditLogEvent string
|
||||
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "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)
|
||||
}
|
||||
@@ -2,19 +2,21 @@ package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
model "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Base contains common columns for all tables.
|
||||
type Base struct {
|
||||
ID string `gorm:"primaryKey;not null" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `gorm:"primaryKey;not null"`
|
||||
CreatedAt model.DateTime
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
11
backend/internal/model/custom_claim.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type CustomClaim struct {
|
||||
Base
|
||||
|
||||
Key string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
@@ -1,26 +1,44 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserAuthorizedOidcClient struct {
|
||||
Scope string
|
||||
UserID string `json:"userId" gorm:"primary_key;"`
|
||||
UserID string `gorm:"primary_key;"`
|
||||
User User
|
||||
|
||||
ClientID string `json:"clientId" gorm:"primary_key;"`
|
||||
ClientID string `gorm:"primary_key;"`
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
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
|
||||
Secret string
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
@@ -32,34 +50,16 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
type CallbackURLs []string
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt time.Time
|
||||
|
||||
UserID string
|
||||
User User
|
||||
|
||||
ClientID string
|
||||
func (cu *CallbackURLs) Scan(value interface{}) error {
|
||||
if v, ok := value.([]byte); ok {
|
||||
return json.Unmarshal(v, cu)
|
||||
} else {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL" binding:"required"`
|
||||
}
|
||||
|
||||
type AuthorizeNewClientDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
func (cu CallbackURLs) Value() (driver.Value, error) {
|
||||
return json.Marshal(cu)
|
||||
}
|
||||
|
||||
47
backend/internal/model/types/date_time.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package datatype
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateTime custom type for time.Time to store date as unix timestamp in the database
|
||||
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) {
|
||||
return time.Time(date).Unix(), nil
|
||||
}
|
||||
|
||||
func (date DateTime) UTC() time.Time {
|
||||
return time.Time(date).UTC()
|
||||
}
|
||||
|
||||
func (date DateTime) ToTime() time.Time {
|
||||
return time.Time(date)
|
||||
}
|
||||
|
||||
// GormDataType gorm common data type
|
||||
func (date DateTime) GormDataType() string {
|
||||
return "date"
|
||||
}
|
||||
|
||||
func (date DateTime) GobEncode() ([]byte, error) {
|
||||
return time.Time(date).GobEncode()
|
||||
}
|
||||
|
||||
func (date *DateTime) GobDecode(b []byte) error {
|
||||
return (*time.Time)(date).GobDecode(b)
|
||||
}
|
||||
|
||||
func (date DateTime) MarshalJSON() ([]byte, error) {
|
||||
return time.Time(date).MarshalJSON()
|
||||
}
|
||||
|
||||
func (date *DateTime) UnmarshalJSON(b []byte) error {
|
||||
return (*time.Time)(date).UnmarshalJSON(b)
|
||||
}
|
||||
@@ -3,19 +3,21 @@ package model
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"time"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Username string
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
IsAdmin bool
|
||||
|
||||
Credentials []WebauthnCredential `json:"-"`
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
@@ -35,6 +37,10 @@ func (u User) WebAuthnCredentials() []webauthn.Credential {
|
||||
AttestationType: credential.AttestationType,
|
||||
PublicKey: credential.PublicKey,
|
||||
Transport: credential.Transport,
|
||||
Flags: webauthn.CredentialFlags{
|
||||
BackupState: credential.BackupState,
|
||||
BackupEligible: credential.BackupEligible,
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
@@ -55,19 +61,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
9
backend/internal/model/user_group.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type UserGroup struct {
|
||||
Base
|
||||
FriendlyName string
|
||||
Name string `gorm:"unique"`
|
||||
Users []User `gorm:"many2many:user_groups_users;"`
|
||||
CustomClaims []CustomClaim
|
||||
}
|
||||
@@ -19,15 +19,30 @@ type WebauthnSession struct {
|
||||
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 string
|
||||
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
|
||||
|
||||
269
backend/internal/service/app_config_service.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
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{
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
EmailEnabled: model.AppConfigVariable{
|
||||
Key: "emailEnabled",
|
||||
Type: "bool",
|
||||
DefaultValue: "false",
|
||||
},
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
var savedConfigVariables []model.AppConfigVariable
|
||||
|
||||
tx := s.db.Begin()
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
key := field.Tag.Get("json")
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Set the value to the default value if it is empty
|
||||
for i := range configuration {
|
||||
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||
configuration[i].Value = configuration[i].DefaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return configuration, nil
|
||||
}
|
||||
|
||||
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 storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||
}
|
||||
|
||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
125
backend/internal/service/audit_log_service.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
type AuditLogService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
|
||||
}
|
||||
|
||||
// Create creates a new audit log entry in the database
|
||||
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||
country, city, err := s.GetIpLocation(ipAddress)
|
||||
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, data model.AuditLogData) model.AuditLog {
|
||||
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
|
||||
|
||||
// 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, send an email
|
||||
if 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, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) {
|
||||
var logs []model.AuditLog
|
||||
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
|
||||
|
||||
pagination, err := utils.Paginate(page, pageSize, 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
|
||||
}
|
||||
|
||||
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
|
||||
db, err := maxminddb.Open("GeoLite2-City.mmdb")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
addr := netip.MustParseAddr(ipAddress)
|
||||
|
||||
var record struct {
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
Country struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
err = db.Lookup(addr).Decode(&record)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||
}
|
||||
197
backend/internal/service/custom_claim_service.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Reserved claims
|
||||
var reservedClaims = map[string]struct{}{
|
||||
"given_name": {},
|
||||
"family_name": {},
|
||||
"name": {},
|
||||
"email": {},
|
||||
"preferred_username": {},
|
||||
"groups": {},
|
||||
"sub": {},
|
||||
"iss": {},
|
||||
"aud": {},
|
||||
"exp": {},
|
||||
"iat": {},
|
||||
"auth_time": {},
|
||||
"nonce": {},
|
||||
"acr": {},
|
||||
"amr": {},
|
||||
"azp": {},
|
||||
"nbf": {},
|
||||
"jti": {},
|
||||
}
|
||||
|
||||
type CustomClaimService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
|
||||
return &CustomClaimService{db: db}
|
||||
}
|
||||
|
||||
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
|
||||
func isReservedClaim(key string) bool {
|
||||
_, ok := reservedClaims[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// idType is the type of the id used to identify the user or user group
|
||||
type idType string
|
||||
|
||||
const (
|
||||
UserID idType = "user_id"
|
||||
UserGroupID idType = "user_group_id"
|
||||
)
|
||||
|
||||
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
return s.updateCustomClaims(UserID, userID, claims)
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
|
||||
}
|
||||
|
||||
// updateCustomClaims updates the custom claims for a user or user group
|
||||
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
// Check for duplicate keys in the claims slice
|
||||
seenKeys := make(map[string]bool)
|
||||
for _, claim := range claims {
|
||||
if seenKeys[claim.Key] {
|
||||
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||
}
|
||||
seenKeys[claim.Key] = true
|
||||
}
|
||||
|
||||
var existingClaims []model.CustomClaim
|
||||
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete claims that are not in the new list
|
||||
for _, existingClaim := range existingClaims {
|
||||
found := false
|
||||
for _, claim := range claims {
|
||||
if claim.Key == existingClaim.Key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = s.db.Delete(&existingClaim).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update claims
|
||||
for _, claim := range claims {
|
||||
if isReservedClaim(claim.Key) {
|
||||
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||
}
|
||||
customClaim := model.CustomClaim{
|
||||
Key: claim.Key,
|
||||
Value: claim.Value,
|
||||
}
|
||||
|
||||
if idType == UserID {
|
||||
customClaim.UserID = &value
|
||||
} else if idType == UserGroupID {
|
||||
customClaim.UserGroupID = &value
|
||||
}
|
||||
|
||||
// Update the claim if it already exists or create a new one
|
||||
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the updated claims
|
||||
var updatedClaims []model.CustomClaim
|
||||
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
|
||||
var customClaims []model.CustomClaim
|
||||
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
|
||||
return customClaims, err
|
||||
}
|
||||
|
||||
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
|
||||
var customClaims []model.CustomClaim
|
||||
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
|
||||
return customClaims, err
|
||||
}
|
||||
|
||||
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
|
||||
// prioritizing the user's claims over user group claims with the same key.
|
||||
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
|
||||
// Get the custom claims of the user
|
||||
customClaims, err := s.GetCustomClaimsForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store user's claims in a map to prioritize and prevent duplicates
|
||||
claimsMap := make(map[string]model.CustomClaim)
|
||||
for _, claim := range customClaims {
|
||||
claimsMap[claim.Key] = claim
|
||||
}
|
||||
|
||||
// Get all user groups of the user
|
||||
var userGroupsOfUser []model.UserGroup
|
||||
err = s.db.Preload("CustomClaims").
|
||||
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||
Where("user_groups_users.user_id = ?", userID).
|
||||
Find(&userGroupsOfUser).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add only non-duplicate custom claims from user groups
|
||||
for _, userGroup := range userGroupsOfUser {
|
||||
for _, groupClaim := range userGroup.CustomClaims {
|
||||
// Only add claim if it does not exist in the user's claims
|
||||
if _, exists := claimsMap[groupClaim.Key]; !exists {
|
||||
claimsMap[groupClaim.Key] = groupClaim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the claimsMap back to a slice
|
||||
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
|
||||
for _, claim := range claimsMap {
|
||||
finalClaims = append(finalClaims, claim)
|
||||
}
|
||||
|
||||
return finalClaims, nil
|
||||
}
|
||||
|
||||
// GetSuggestions returns a list of custom claim keys that have been used before
|
||||
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
|
||||
var customClaimsKeys []string
|
||||
|
||||
err := s.db.Model(&model.CustomClaim{}).
|
||||
Group("key").
|
||||
Order("COUNT(*) DESC").
|
||||
Pluck("key", &customClaimsKeys).Error
|
||||
|
||||
return customClaimsKeys, err
|
||||
}
|
||||
137
backend/internal/service/email_service.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
ttemplate "text/template"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
appConfigService *AppConfigService
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
}
|
||||
|
||||
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
}
|
||||
|
||||
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
}
|
||||
|
||||
return &EmailService{
|
||||
appConfigService: appConfigService,
|
||||
htmlTemplates: htmlTemplates,
|
||||
textTemplates: textTemplates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||
// Check if SMTP settings are set
|
||||
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
||||
return errors.New("email not enabled")
|
||||
}
|
||||
|
||||
data := &email.TemplateData[V]{
|
||||
AppName: srv.appConfigService.DbConfig.AppName.Value,
|
||||
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
|
||||
Data: tData,
|
||||
}
|
||||
|
||||
body, boundary, err := prepareBody(srv, template, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare email body for '%s': %w", template.Path, err)
|
||||
}
|
||||
|
||||
// Construct the email message
|
||||
c := email.NewComposer()
|
||||
c.AddHeader("Subject", template.Title(data))
|
||||
c.AddAddressHeader("From", []email.Address{
|
||||
{
|
||||
Email: srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||
Name: srv.appConfigService.DbConfig.AppName.Value,
|
||||
},
|
||||
})
|
||||
c.AddAddressHeader("To", []email.Address{toEmail})
|
||||
c.AddHeaderRaw("Content-Type",
|
||||
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
||||
)
|
||||
c.Body(body)
|
||||
|
||||
// Set up the authentication information.
|
||||
auth := smtp.PlainAuth("",
|
||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
)
|
||||
|
||||
// Send the email
|
||||
err = smtp.SendMail(
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
|
||||
auth,
|
||||
srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||
[]string{toEmail.Email},
|
||||
[]byte(c.String()),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %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
|
||||
}
|
||||
39
backend/internal/service/email_service_templates.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"time"
|
||||
)
|
||||
|
||||
/**
|
||||
How to add new template:
|
||||
- pick unique and descriptive template ${name} (for example "login-with-new-device")
|
||||
- in backend/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)
|
||||
},
|
||||
}
|
||||
|
||||
type NewLoginTemplateData struct {
|
||||
IPAddress string
|
||||
Country string
|
||||
City string
|
||||
Device string
|
||||
DateTime time.Time
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
||||
312
backend/internal/service/jwt_service.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
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{utils.GetHostFromURL(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, utils.GetHostFromURL(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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// loadKeys loads RSA keys from the given paths.
|
||||
func (s *JwtService) loadKeys() error {
|
||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
||||
if err := s.generateKeys(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't read jwt private key: %w", err)
|
||||
}
|
||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't parse jwt private key: %w", err)
|
||||
}
|
||||
|
||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't read jwt public key: %w", err)
|
||||
}
|
||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't parse jwt public key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
392
backend/internal/service/oidc_service.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
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 userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", &common.OidcMissingAuthorizationError{}
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
|
||||
|
||||
return code, callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
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) {
|
||||
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
|
||||
} else {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||
|
||||
return code, callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||
if grantType != "authorization_code" {
|
||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
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 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").First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
|
||||
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.Paginate(page, pageSize, 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,
|
||||
CreatedByID: userID,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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.FirstName + " " + user.LastName,
|
||||
"preferred_username": user.Username,
|
||||
}
|
||||
|
||||
if strings.Contains(scope, "profile") {
|
||||
// Add profile claims
|
||||
for k, v := range profileClaims {
|
||||
claims[k] = v
|
||||
}
|
||||
|
||||
// Add custom claims
|
||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customClaim := range customClaims {
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
}
|
||||
}
|
||||
if strings.Contains(scope, "email") {
|
||||
claims["email"] = user.Email
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) 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: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return randomString, nil
|
||||
}
|
||||
|
||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||
if inputCallbackURL == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
}
|
||||
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
|
||||
return inputCallbackURL, nil
|
||||
}
|
||||
|
||||
return "", &common.OidcInvalidCallbackURLError{}
|
||||
}
|
||||
@@ -1,48 +1,34 @@
|
||||
package handler
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"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"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterTestRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/test/reset", resetAndSeedHandler)
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
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"})
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService {
|
||||
return &TestService{db: db, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (s *TestService) SeedDatabase() error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
users := []model.User{
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -71,25 +57,49 @@ func seedDatabase() error {
|
||||
}
|
||||
}
|
||||
|
||||
userGroups := []model.UserGroup{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e",
|
||||
},
|
||||
Name: "developers",
|
||||
FriendlyName: "Developers",
|
||||
Users: []model.User{users[0], users[1]},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
|
||||
},
|
||||
Name: "designers",
|
||||
FriendlyName: "Designers",
|
||||
Users: []model.User{users[0]},
|
||||
},
|
||||
}
|
||||
for _, group := range userGroups {
|
||||
if err := tx.Create(&group).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oidcClients := []model.OidcClient{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURL: "http://nextcloud/auth/callback",
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
},
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURL: "http://immich/auth/callback",
|
||||
CreatedByID: users[0].ID,
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
@@ -102,7 +112,7 @@ func seedDatabase() error {
|
||||
Code: "auth-code",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
}
|
||||
@@ -112,7 +122,7 @@ func seedDatabase() error {
|
||||
|
||||
accessToken := model.OneTimeAccessToken{
|
||||
Token: "one-time-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
}
|
||||
if err := tx.Create(&accessToken).Error; err != nil {
|
||||
@@ -128,11 +138,16 @@ func seedDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
webauthnCredentials := []model.WebauthnCredential{
|
||||
{
|
||||
Name: "Passkey 1",
|
||||
CredentialID: "test-credential-1",
|
||||
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg=="),
|
||||
PublicKey: publicKey1,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
@@ -140,7 +155,7 @@ func seedDatabase() error {
|
||||
{
|
||||
Name: "Passkey 2",
|
||||
CredentialID: "test-credential-2",
|
||||
PublicKey: getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA=="),
|
||||
PublicKey: publicKey2,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
@@ -165,31 +180,27 @@ func seedDatabase() error {
|
||||
})
|
||||
}
|
||||
|
||||
// resetDatabase resets the database by deleting all rows from each table.
|
||||
func resetDatabase() error {
|
||||
err := common.DB.Transaction(func(tx *gorm.DB) error {
|
||||
func (s *TestService) ResetDatabase() error {
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var tables []string
|
||||
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete all rows from all tables
|
||||
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
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// resetApplicationImages resets the application images by removing existing images and replacing them with the default ones
|
||||
func resetApplicationImages() error {
|
||||
|
||||
func (s *TestService) ResetApplicationImages() error {
|
||||
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
||||
log.Printf("Error removing directory: %v", err)
|
||||
return err
|
||||
@@ -203,21 +214,35 @@ func resetApplicationImages() error {
|
||||
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)
|
||||
func (s *TestService) ResetAppConfig() error {
|
||||
// Reseed the config variables
|
||||
if err := s.appConfigService.InitDbConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset all app config variables to their default values
|
||||
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the app config from the database after resetting the values
|
||||
return s.appConfigService.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||
func (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 {
|
||||
log.Fatalf("Failed to parse public key: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
log.Fatalf("Not an ECDSA public key")
|
||||
return nil, fmt.Errorf("not an ECDSA public key")
|
||||
}
|
||||
|
||||
coseKey := map[int]interface{}{
|
||||
@@ -230,8 +255,8 @@ func getCborPublicKey(base64PublicKey string) []byte {
|
||||
|
||||
cborPublicKey, err := cbor.Marshal(coseKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to encode CBOR: %v", err)
|
||||
return nil, fmt.Errorf("failed to marshal COSE key: %w", err)
|
||||
}
|
||||
|
||||
return cborPublicKey
|
||||
return cborPublicKey, nil
|
||||
}
|
||||
111
backend/internal/service/user_group_service.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
||||
return &UserGroupService{db: db}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
|
||||
|
||||
if name != "" {
|
||||
query = query.Where("name LIKE ?", "%"+name+"%")
|
||||
}
|
||||
|
||||
response, err = utils.Paginate(page, pageSize, query, &groups)
|
||||
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
|
||||
}
|
||||
|
||||
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 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) (group model.UserGroup, err error) {
|
||||
group, err = s.Get(id)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
174
backend/internal/service/user_service.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService}
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
||||
var users []model.User
|
||||
query := s.db.Model(&model.User{})
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
pagination, err := utils.Paginate(page, pageSize, query, &users)
|
||||
return users, pagination, err
|
||||
}
|
||||
|
||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteUser(userID string) error {
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 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) (model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
user.FirstName = updatedUser.FirstName
|
||||
user.LastName = updatedUser.LastName
|
||||
user.Email = updatedUser.Email
|
||||
user.Username = updatedUser.Username
|
||||
if !updateOwnUser {
|
||||
user.IsAdmin = updatedUser.IsAdmin
|
||||
}
|
||||
|
||||
if err := s.db.Save(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return user, s.checkDuplicatedFields(user)
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oneTimeAccessToken := model.OneTimeAccessToken{
|
||||
UserID: userID,
|
||||
ExpiresAt: 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) (model.User, string, error) {
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
212
backend/internal/service/webauthn_service.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
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.GetHostFromURL(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: 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,
|
||||
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: string(credential.ID),
|
||||
AttestationType: credential.AttestationType,
|
||||
PublicKey: credential.PublicKey,
|
||||
Transport: credential.Transport,
|
||||
UserID: user.ID,
|
||||
BackupEligible: credential.Flags.BackupEligible,
|
||||
BackupState: credential.Flags.BackupState,
|
||||
}
|
||||
if err := 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: 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, userID 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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(*user)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
|
||||
|
||||
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
|
||||
}
|
||||
213
backend/internal/utils/email/composer.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxLineLength = 78
|
||||
const continuePrefix = " "
|
||||
const addressSeparator = ", "
|
||||
|
||||
type Composer struct {
|
||||
isClosed bool
|
||||
content strings.Builder
|
||||
}
|
||||
|
||||
func NewComposer() *Composer {
|
||||
return &Composer{}
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
func (c *Composer) AddAddressHeader(name string, addresses []Address) {
|
||||
c.content.WriteString(genAddressHeader(name, addresses, maxLineLength))
|
||||
c.content.WriteString("\n")
|
||||
}
|
||||
|
||||
func genAddressHeader(name string, addresses []Address, maxLength int) string {
|
||||
hl := &headerLine{
|
||||
maxLineLength: maxLength,
|
||||
continuePrefix: continuePrefix,
|
||||
}
|
||||
|
||||
hl.Write(name)
|
||||
hl.Write(": ")
|
||||
|
||||
for i, addr := range addresses {
|
||||
var email string
|
||||
if i < len(addresses)-1 {
|
||||
email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator)
|
||||
} else {
|
||||
email = fmt.Sprintf("<%s>", addr.Email)
|
||||
}
|
||||
writeHeaderQ(hl, addr.Name)
|
||||
writeHeaderAtom(hl, " ")
|
||||
writeHeaderAtom(hl, email)
|
||||
}
|
||||
hl.EndLine()
|
||||
return hl.String()
|
||||
}
|
||||
|
||||
func (c *Composer) AddHeader(name, value string) {
|
||||
if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength {
|
||||
c.AddHeaderRaw(name, value)
|
||||
return
|
||||
}
|
||||
|
||||
c.content.WriteString(genHeader(name, value, maxLineLength))
|
||||
c.content.WriteString("\n")
|
||||
}
|
||||
|
||||
func genHeader(name, value string, maxLength int) string {
|
||||
// add content as raw header when it is printable ASCII and shorter than maxLineLength
|
||||
hl := &headerLine{
|
||||
maxLineLength: maxLength,
|
||||
continuePrefix: continuePrefix,
|
||||
}
|
||||
|
||||
hl.Write(name)
|
||||
hl.Write(": ")
|
||||
writeHeaderQ(hl, value)
|
||||
hl.EndLine()
|
||||
return hl.String()
|
||||
}
|
||||
|
||||
const qEncStart = "=?utf-8?q?"
|
||||
const qEncEnd = "?="
|
||||
|
||||
type headerLine struct {
|
||||
buffer strings.Builder
|
||||
line strings.Builder
|
||||
maxLineLength int
|
||||
continuePrefix string
|
||||
}
|
||||
|
||||
func (h *headerLine) FitsLine(length int) bool {
|
||||
return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength
|
||||
}
|
||||
|
||||
func (h *headerLine) Write(str string) {
|
||||
h.line.WriteString(str)
|
||||
}
|
||||
|
||||
func (h *headerLine) EndLineWith(str string) {
|
||||
h.line.WriteString(str)
|
||||
h.EndLine()
|
||||
}
|
||||
|
||||
func (h *headerLine) EndLine() {
|
||||
if h.line.Len() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if h.buffer.Len() != 0 {
|
||||
h.buffer.WriteString("\n")
|
||||
h.buffer.WriteString(h.continuePrefix)
|
||||
}
|
||||
h.buffer.WriteString(h.line.String())
|
||||
h.line.Reset()
|
||||
}
|
||||
|
||||
func (h *headerLine) String() string {
|
||||
return h.buffer.String()
|
||||
}
|
||||
|
||||
func writeHeaderQ(header *headerLine, value string) {
|
||||
|
||||
// current line does not fit event the first character - do \n
|
||||
if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) {
|
||||
header.EndLineWith("")
|
||||
}
|
||||
|
||||
header.Write(qEncStart)
|
||||
|
||||
for _, token := range convertRunes(value) {
|
||||
if header.FitsLine(len(token) + len(qEncEnd)) {
|
||||
header.Write(token)
|
||||
} else {
|
||||
header.EndLineWith(qEncEnd)
|
||||
header.Write(qEncStart)
|
||||
header.Write(token)
|
||||
}
|
||||
}
|
||||
|
||||
header.Write(qEncEnd)
|
||||
}
|
||||
|
||||
func writeHeaderAtom(header *headerLine, value string) {
|
||||
if !header.FitsLine(len(value)) {
|
||||
header.EndLine()
|
||||
}
|
||||
header.Write(value)
|
||||
}
|
||||
|
||||
func (c *Composer) AddHeaderRaw(name, value string) {
|
||||
if c.isClosed {
|
||||
panic("composer had already written body!")
|
||||
}
|
||||
header := fmt.Sprintf("%s: %s\n", name, value)
|
||||
c.content.WriteString(header)
|
||||
}
|
||||
|
||||
func (c *Composer) Body(body string) {
|
||||
c.content.WriteString("\n")
|
||||
c.content.WriteString(body)
|
||||
c.isClosed = true
|
||||
}
|
||||
|
||||
func (c *Composer) String() string {
|
||||
return c.content.String()
|
||||
}
|
||||
|
||||
func convertRunes(str string) []string {
|
||||
var enc = make([]string, 0, len(str))
|
||||
for _, r := range []rune(str) {
|
||||
if r == ' ' {
|
||||
enc = append(enc, "_")
|
||||
} else if isPrintableASCIIRune(r) &&
|
||||
r != '=' &&
|
||||
r != '?' &&
|
||||
r != '_' {
|
||||
enc = append(enc, string(r))
|
||||
} else {
|
||||
enc = append(enc, string(toHex([]byte(string(r)))))
|
||||
}
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
func toHex(in []byte) []byte {
|
||||
enc := make([]byte, 0, len(in)*2)
|
||||
for _, b := range in {
|
||||
enc = append(enc, '=')
|
||||
enc = append(enc, hex(b/16))
|
||||
enc = append(enc, hex(b%16))
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
func hex(n byte) byte {
|
||||
if n > 9 {
|
||||
return n + (65 - 10)
|
||||
} else {
|
||||
return n + 48
|
||||
}
|
||||
}
|
||||
|
||||
func isPrintableASCII(str string) bool {
|
||||
for _, r := range []rune(str) {
|
||||
if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isPrintableASCIIRune(r rune) bool {
|
||||
return r > 31 && r < 127
|
||||
}
|
||||
92
backend/internal/utils/email/composer_test.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
97
backend/internal/utils/email/email_service_templates.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"path"
|
||||
ttemplate "text/template"
|
||||
)
|
||||
|
||||
const templateComponentsDir = "components"
|
||||
|
||||
type Template[V any] struct {
|
||||
Path string
|
||||
Title func(data *TemplateData[V]) string
|
||||
}
|
||||
|
||||
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]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) {
|
||||
tmpl, err := rootTemplate.Clone()
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("clone root html template: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s%s", template, suffix)
|
||||
_, err = tmpl.ParseFS(templateDir, filename)
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err)
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) {
|
||||
components := path.Join(templateComponentsDir, "*_text.tmpl")
|
||||
rootTmpl, err := ttemplate.ParseFS(templateDir, components)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||
}
|
||||
|
||||
var textTemplates = make(map[string]*ttemplate.Template, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
rootTmplClone, err := rootTmpl.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone root template: %w", err)
|
||||
}
|
||||
|
||||
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||
}
|
||||
}
|
||||
|
||||
return textTemplates, nil
|
||||
}
|
||||
|
||||
func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) {
|
||||
components := path.Join(templateComponentsDir, "*_html.tmpl")
|
||||
rootTmpl, err := htemplate.ParseFS(templateDir, components)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||
}
|
||||
|
||||
var htmlTemplates = make(map[string]*htemplate.Template, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
rootTmplClone, err := rootTmpl.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone root template: %w", err)
|
||||
}
|
||||
|
||||
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||
}
|
||||
}
|
||||
|
||||
return htmlTemplates, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -37,7 +38,7 @@ func CopyDirectory(srcDir, destDir string) error {
|
||||
srcFilePath := filepath.Join(srcDir, file.Name())
|
||||
destFilePath := filepath.Join(destDir, file.Name())
|
||||
|
||||
err := copyFile(srcFilePath, destFilePath)
|
||||
err := CopyFile(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,7 +47,7 @@ func CopyDirectory(srcDir, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(srcFilePath, destFilePath string) error {
|
||||
func CopyFile(srcFilePath, destFilePath string) error {
|
||||
srcFile, err := os.Open(srcFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,3 +72,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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
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"))
|
||||
|
||||
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -38,8 +34,9 @@ func Paginate(c *gin.Context, db *gorm.DB, result interface{}) (PaginationRespon
|
||||
}
|
||||
|
||||
return PaginationResponse{
|
||||
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
||||
TotalItems: totalItems,
|
||||
CurrentPage: page,
|
||||
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
||||
TotalItems: totalItems,
|
||||
CurrentPage: page,
|
||||
ItemsPerPage: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
func FormatDateForDb(time time.Time) string {
|
||||
const layout = "2006-01-02 15:04:05.000-07:00"
|
||||
return time.Format(layout)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ CREATE TABLE webauthn_credentials
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type TEXT NOT NULL,
|
||||
transport TEXT NOT NULL,
|
||||
transport BLOB NOT NULL,
|
||||
user_id TEXT REFERENCES users
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE webauthn_credentials ADD COLUMN backup_eligible BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE webauthn_credentials ADD COLUMN backup_state BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE webauthn_credentials DROP COLUMN backup_eligible;
|
||||
ALTER TABLE webauthn_credentials DROP COLUMN backup_state;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE app_config_variables
|
||||
RENAME TO application_configuration_variables;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE application_configuration_variables
|
||||
RENAME TO app_config_variables;
|
||||
@@ -0,0 +1,23 @@
|
||||
create table oidc_clients
|
||||
(
|
||||
id TEXT not null primary key,
|
||||
created_at DATETIME,
|
||||
name TEXT,
|
||||
secret TEXT,
|
||||
callback_url TEXT,
|
||||
image_type TEXT,
|
||||
created_by_id TEXT
|
||||
references users
|
||||
);
|
||||
|
||||
insert into oidc_clients(id, created_at, name, secret, callback_url, image_type, created_by_id)
|
||||
select id,
|
||||
created_at,
|
||||
name,
|
||||
secret,
|
||||
json_extract(callback_urls, '$[0]'),
|
||||
image_type,
|
||||
created_by_id
|
||||
from oidc_clients_dg_tmp;
|
||||
|
||||
drop table oidc_clients_dg_tmp;
|
||||
@@ -0,0 +1,26 @@
|
||||
create table oidc_clients_dg_tmp
|
||||
(
|
||||
id TEXT not null primary key,
|
||||
created_at DATETIME,
|
||||
name TEXT,
|
||||
secret TEXT,
|
||||
callback_urls BLOB,
|
||||
image_type TEXT,
|
||||
created_by_id TEXT
|
||||
references users
|
||||
);
|
||||
|
||||
insert into oidc_clients_dg_tmp(id, created_at, name, secret, callback_urls, image_type, created_by_id)
|
||||
select id,
|
||||
created_at,
|
||||
name,
|
||||
secret,
|
||||
CAST('["' || callback_url || '"]' AS BLOB),
|
||||
image_type,
|
||||
created_by_id
|
||||
from oidc_clients;
|
||||
|
||||
drop table oidc_clients;
|
||||
|
||||
alter table oidc_clients_dg_tmp
|
||||
rename to oidc_clients;
|
||||
1
backend/migrations/20240908123031_audit_log.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE audit_logs;
|
||||
10
backend/migrations/20240908123031_audit_log.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE audit_logs
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
event TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
user_id TEXT REFERENCES users
|
||||
);
|
||||
2
backend/migrations/20240924202721_user_groups.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE user_groups;
|
||||
DROP TABLE user_groups_users;
|
||||