mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-15 17:23:23 +03:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee885fbff5 | ||
|
|
333a1a18d5 | ||
|
|
1ff20caa3c | ||
|
|
f6f2736bba | ||
|
|
993330d932 | ||
|
|
204313aacf | ||
|
|
0729ce9e1a | ||
|
|
2d0bd8dcbf | ||
|
|
ff75322e7d | ||
|
|
daced661c4 | ||
|
|
0716c38fb8 | ||
|
|
789d9394a5 | ||
|
|
aeda512cb7 | ||
|
|
5480ab0f18 | ||
|
|
bad901ea2b | ||
|
|
34e35193f9 | ||
|
|
232c13b5ca | ||
|
|
9d20a98dbb | ||
|
|
e9d83dd6c3 | ||
|
|
3006bc9ef7 | ||
|
|
ae1e2f5e77 | ||
|
|
edce3d3371 | ||
|
|
9a8ec15678 | ||
|
|
62cdab2b59 | ||
|
|
f2bfc73158 | ||
|
|
a9f4dada32 | ||
|
|
f9fa2c6706 | ||
|
|
7d6b1d19e9 | ||
|
|
31a6b57ec1 | ||
|
|
f11ed44733 | ||
|
|
541481721f | ||
|
|
0e95e9c56f | ||
|
|
fcf08a4d89 | ||
|
|
0b4101ccce | ||
|
|
27ea1fc2d3 | ||
|
|
f637a89f57 | ||
|
|
058084ed64 | ||
|
|
9370292fe5 | ||
|
|
46eef1fcb7 | ||
|
|
e784093342 | ||
|
|
653d948f73 | ||
|
|
a1302ef7bf | ||
|
|
5f44fef85f | ||
|
|
3613ac261c | ||
|
|
760c8e83bb | ||
|
|
3f29325f45 | ||
|
|
aca2240a50 | ||
|
|
de45398903 | ||
|
|
3d3fb4d855 | ||
|
|
725388fcc7 | ||
|
|
ad1d3560f9 | ||
|
|
becfc0004a | ||
|
|
376d747616 | ||
|
|
5b9f4d7326 |
@@ -1,2 +1,6 @@
|
|||||||
|
# See the README for more information: https://github.com/stonith404/pocket-id?tab=readme-ov-file#environment-variables
|
||||||
PUBLIC_APP_URL=http://localhost
|
PUBLIC_APP_URL=http://localhost
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
|
MAXMIND_LICENSE_KEY=
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|||||||
@@ -11,20 +11,35 @@ jobs:
|
|||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},prefix=v
|
||||||
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker registry
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Download GeoLite2 City database
|
- name: 'Login to GitHub Container Registry'
|
||||||
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{github.repository_owner}}
|
||||||
|
password: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
@@ -32,6 +47,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
120
.github/workflows/e2e-tests.yml
vendored
120
.github/workflows/e2e-tests.yml
vendored
@@ -5,25 +5,43 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and export
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
tags: stonith404/pocket-id:test
|
||||||
|
outputs: type=docker,dest=/tmp/docker-image.tar
|
||||||
|
|
||||||
|
- name: Upload Docker image artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-image
|
||||||
|
path: /tmp/docker-image.tar
|
||||||
|
|
||||||
|
test-sqlite:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Create dummy GeoLite2 City database
|
- name: Download Docker image artifact
|
||||||
run: touch ./backend/GeoLite2-City.mmdb
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
- name: Build Docker Image
|
name: docker-image
|
||||||
run: docker build -t stonith404/pocket-id .
|
path: /tmp
|
||||||
|
- name: Load Docker Image
|
||||||
- name: Run Docker Container
|
run: docker load -i /tmp/docker-image.tar
|
||||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@@ -33,6 +51,13 @@ jobs:
|
|||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run Docker Container with Sqlite DB
|
||||||
|
run: |
|
||||||
|
docker run -d --name pocket-id-sqlite \
|
||||||
|
-p 80:80 \
|
||||||
|
-e APP_ENV=test \
|
||||||
|
stonith404/pocket-id:test
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
@@ -40,7 +65,80 @@ jobs:
|
|||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report-sqlite
|
||||||
|
path: frontend/tests/.report
|
||||||
|
include-hidden-files: true
|
||||||
|
retention-days: 15
|
||||||
|
|
||||||
|
test-postgres:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Download Docker image artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-image
|
||||||
|
path: /tmp
|
||||||
|
- name: Load Docker Image
|
||||||
|
run: docker load -i /tmp/docker-image.tar
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Create Docker network
|
||||||
|
run: docker network create pocket-id-network
|
||||||
|
|
||||||
|
- name: Start Postgres DB
|
||||||
|
run: |
|
||||||
|
docker run -d --name pocket-id-db \
|
||||||
|
--network pocket-id-network \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-e POSTGRES_DB=pocket-id \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:17
|
||||||
|
|
||||||
|
- name: Wait for Postgres to start
|
||||||
|
run: |
|
||||||
|
for i in {1..10}; do
|
||||||
|
if docker exec pocket-id-db pg_isready -U postgres; then
|
||||||
|
echo "Postgres is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for Postgres..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run Docker Container with Postgres DB
|
||||||
|
run: |
|
||||||
|
docker run -d --name pocket-id-postgres \
|
||||||
|
--network pocket-id-network \
|
||||||
|
-p 80:80 \
|
||||||
|
-e APP_ENV=test \
|
||||||
|
-e DB_PROVIDER=postgres \
|
||||||
|
-e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
|
||||||
|
stonith404/pocket-id:test
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npx playwright test
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report-postgres
|
||||||
path: frontend/tests/.report
|
path: frontend/tests/.report
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
|
|||||||
125
CHANGELOG.md
125
CHANGELOG.md
@@ -1,3 +1,128 @@
|
|||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
|
||||||
|
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
|
||||||
|
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
|
||||||
|
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
|
||||||
|
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add support for Postgres database provider ([#79](https://github.com/stonith404/pocket-id/issues/79)) ([9d20a98](https://github.com/stonith404/pocket-id/commit/9d20a98dbbc322fa6f0644e8b31e6b97769887ce))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.18.0...v) (2024-11-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **geolite:** add Tailscale IP detection with CGNAT range check ([#77](https://github.com/stonith404/pocket-id/issues/77)) ([edce3d3](https://github.com/stonith404/pocket-id/commit/edce3d337129c9c6e8a60df2122745984ba0f3e0))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.17.0...v) (2024-11-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to disable TLS for email sending ([f9fa2c6](https://github.com/stonith404/pocket-id/commit/f9fa2c6706a8bf949fe5efd6664dec8c80e18659))
|
||||||
|
* allow empty user and password in SMTP configuration ([a9f4dad](https://github.com/stonith404/pocket-id/commit/a9f4dada321841d3611b15775307228b34e7793f))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* email save toast shows two times ([f2bfc73](https://github.com/stonith404/pocket-id/commit/f2bfc731585ad7424eb8c4c41c18368fc0f75ffc))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* add option to specify the Max Mind license key for the Geolite2 db
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
|
||||||
|
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
|
||||||
|
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
|
||||||
|
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
|
||||||
|
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
|
||||||
|
|
||||||
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -21,7 +21,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
|||||||
|
|
||||||
# Stage 3: Production Image
|
# Stage 3: Production Image
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
RUN apk add --no-cache caddy
|
# Delete default node user
|
||||||
|
RUN deluser --remove-home node
|
||||||
|
|
||||||
|
RUN apk add --no-cache caddy curl su-exec
|
||||||
COPY ./reverse-proxy /etc/caddy/
|
COPY ./reverse-proxy /etc/caddy/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -31,7 +34,6 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
|||||||
|
|
||||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||||
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
COPY --from=backend-builder /app/backend/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/email-templates ./backend/email-templates
|
||||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||||
|
|
||||||
@@ -41,5 +43,5 @@ RUN chmod +x ./scripts/*.sh
|
|||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
# Use a shell form to run both the frontend and backend
|
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||||
CMD ["sh", "./scripts/docker-entrypoint.sh"]
|
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||||
46
README.md
46
README.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||||
|
|
||||||
|
→ Try out the [Demo](https://pocket-id.eliasschneider.com)
|
||||||
|
|
||||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" 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.
|
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.
|
||||||
@@ -11,11 +13,11 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
|||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Pocket ID is in its early stages and may contain bugs.
|
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
|
||||||
|
|
||||||
### Before you start
|
### Before you start
|
||||||
|
|
||||||
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
|
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
|
||||||
|
|
||||||
### Installation with Docker (recommended)
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
@@ -68,10 +70,6 @@ Required tools:
|
|||||||
cd ..
|
cd ..
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
pm2 start pocket-id-backend --name pocket-id-backend
|
||||||
|
|
||||||
# Optional: Download the GeoLite2 city database.
|
|
||||||
# If not downloaded the ip location in the audit log will be empty.
|
|
||||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
|
||||||
|
|
||||||
# Start the frontend
|
# Start the frontend
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm install
|
npm install
|
||||||
@@ -80,14 +78,14 @@ Required tools:
|
|||||||
|
|
||||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||||
cd ..
|
cd ..
|
||||||
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
|
pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||||
|
|
||||||
### Nginx Reverse Proxy
|
### Nginx Reverse Proxy
|
||||||
|
|
||||||
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
|
To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
proxy_busy_buffers_size 512k;
|
proxy_busy_buffers_size 512k;
|
||||||
@@ -97,7 +95,7 @@ proxy_buffer_size 256k;
|
|||||||
|
|
||||||
## Proxy Services with Pocket ID
|
## Proxy Services with Pocket ID
|
||||||
|
|
||||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
|
||||||
|
|
||||||
See the [guide](docs/proxy-services.md) for more information.
|
See the [guide](docs/proxy-services.md) for more information.
|
||||||
|
|
||||||
@@ -130,9 +128,6 @@ docker compose up -d
|
|||||||
cd ..
|
cd ..
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
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
|
# Start the frontend
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm install
|
npm install
|
||||||
@@ -141,21 +136,26 @@ docker compose up -d
|
|||||||
|
|
||||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||||
cd ..
|
cd ..
|
||||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default Value | Recommended to change | Description |
|
| Variable | Default Value | Recommended to change | Description |
|
||||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
| `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. |
|
| `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. |
|
| `MAXMIND_LICENSE_KEY` | `-` | yes | License Key for the GeoLite2 Database. The license key is required to retrieve the geographical location of IP addresses in the audit log. If the key is not provided, IP locations will be marked as "unknown." You can obtain a license key for free [here](https://www.maxmind.com/en/geolite2/signup). |
|
||||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
|
||||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
| `DB_PROVIDER` | `sqlite` | no | The database provider you want to use. Currently `sqlite` and `postgres` are supported. |
|
||||||
| `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. |
|
| `SQLITE_DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. This gets ignored if you didn't set `DB_PROVIDER` to `sqlite`. |
|
||||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
| `POSTGRES_CONNECTION_STRING` | `-` | no | The connection string to your Postgres database. This gets ignored if you didn't set `DB_PROVIDER` to `postgres`. A connection string can look like this: `postgresql://user:password@host:5432/pocket-id`. |
|
||||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||||
|
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||||
|
| `GEOLITE_DB_PATH` | `data/GeoLite2-City.mmdb` | no | The path where the GeoLite2 database should be stored. |
|
||||||
|
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||||
|
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||||
|
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||||
|
|
||||||
## Contribute
|
## Contribute
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
PUBLIC_APP_URL=http://localhost
|
PUBLIC_APP_URL=http://localhost
|
||||||
DB_PATH=data/pocket-id.db
|
DB_PROVIDER=sqlite
|
||||||
|
SQLITE_DB_PATH=data/pocket-id.db
|
||||||
|
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
||||||
UPLOAD_PATH=data/uploads
|
UPLOAD_PATH=data/uploads
|
||||||
PORT=8080
|
PORT=8080
|
||||||
HOST=localhost
|
HOST=localhost
|
||||||
11
backend/email-templates/test_html.tmpl
Normal file
11
backend/email-templates/test_html.tmpl
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{ define "base" -}}
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
|
||||||
|
<h1>{{ .AppName }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>This is a test email.</p>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
3
backend/email-templates/test_text.tmpl
Normal file
3
backend/email-templates/test_text.tmpl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{ define "base" -}}
|
||||||
|
This is a test email.
|
||||||
|
{{ end -}}
|
||||||
@@ -5,7 +5,6 @@ go 1.23.1
|
|||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.2.2
|
github.com/caarlos0/env/v11 v11.2.2
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
github.com/fxamacker/cbor/v2 v2.7.0
|
||||||
github.com/gin-contrib/cors v1.7.2
|
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/go-co-op/gocron/v2 v2.12.1
|
github.com/go-co-op/gocron/v2 v2.12.1
|
||||||
github.com/go-playground/validator/v10 v10.22.1
|
github.com/go-playground/validator/v10 v10.22.1
|
||||||
@@ -18,6 +17,7 @@ require (
|
|||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
|
||||||
golang.org/x/crypto v0.27.0
|
golang.org/x/crypto v0.27.0
|
||||||
golang.org/x/time v0.6.0
|
golang.org/x/time v0.6.0
|
||||||
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/driver/sqlite v1.5.6
|
gorm.io/driver/sqlite v1.5.6
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
@@ -36,6 +36,10 @@ require (
|
|||||||
github.com/google/go-tpm v0.9.1 // indirect
|
github.com/google/go-tpm v0.9.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
github.com/jonboulle/clockwork v0.4.0 // indirect
|
||||||
@@ -43,6 +47,7 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
@@ -57,6 +62,7 @@ require (
|
|||||||
golang.org/x/arch v0.10.0 // indirect
|
golang.org/x/arch v0.10.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||||
golang.org/x/net v0.29.0 // indirect
|
golang.org/x/net v0.29.0 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/text v0.18.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
|
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 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.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
@@ -13,18 +17,32 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
|
||||||
|
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||||
|
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
|
||||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
|
||||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
|
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-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -39,6 +57,8 @@ 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/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 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||||
@@ -55,6 +75,14 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
|||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -85,16 +113,28 @@ github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNG
|
|||||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
|
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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
@@ -118,6 +158,14 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
|||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
|
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
@@ -130,6 +178,8 @@ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWB
|
|||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
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 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
@@ -146,6 +196,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
"github.com/golang-migrate/migrate/v4/database"
|
||||||
|
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -19,15 +23,29 @@ func newDatabase() (db *gorm.DB) {
|
|||||||
log.Fatalf("failed to connect to database: %v", err)
|
log.Fatalf("failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
sqlDb, err := db.DB()
|
sqlDb, err := db.DB()
|
||||||
sqlDb.SetMaxOpenConns(1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get sql.DB: %v", err)
|
log.Fatalf("failed to get sql.DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{})
|
// Choose the correct driver for the database provider
|
||||||
|
var driver database.Driver
|
||||||
|
switch common.EnvConfig.DbProvider {
|
||||||
|
case common.DbProviderSqlite:
|
||||||
|
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
|
||||||
|
case common.DbProviderPostgres:
|
||||||
|
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||||
|
default:
|
||||||
|
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create migration driver: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
m, err := migrate.NewWithDatabaseInstance(
|
m, err := migrate.NewWithDatabaseInstance(
|
||||||
"file://migrations",
|
"file://migrations/"+string(common.EnvConfig.DbProvider),
|
||||||
"postgres", driver)
|
"pocket-id", driver,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create migration instance: %v", err)
|
log.Fatalf("failed to create migration instance: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,15 +59,20 @@ func newDatabase() (db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func connectDatabase() (db *gorm.DB, err error) {
|
func connectDatabase() (db *gorm.DB, err error) {
|
||||||
dbPath := common.EnvConfig.DBPath
|
var dialector gorm.Dialector
|
||||||
|
|
||||||
// Use in-memory database for testing
|
// Choose the correct database provider
|
||||||
if common.EnvConfig.AppEnv == "test" {
|
switch common.EnvConfig.DbProvider {
|
||||||
dbPath = "file::memory:?cache=shared"
|
case common.DbProviderSqlite:
|
||||||
|
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
|
||||||
|
case common.DbProviderPostgres:
|
||||||
|
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
db, err = gorm.Open(dialector, &gorm.Config{
|
||||||
TranslateError: true,
|
TranslateError: true,
|
||||||
Logger: getLogger(),
|
Logger: getLogger(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,15 +30,16 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||||
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
emailService, err := service.NewEmailService(appConfigService, db, templateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to create email service: %s", err)
|
log.Fatalf("Unable to create email service: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
geoLiteService := service.NewGeoLiteService()
|
||||||
|
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||||
jwtService := service.NewJwtService(appConfigService)
|
jwtService := service.NewJwtService(appConfigService)
|
||||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||||
userService := service.NewUserService(db, jwtService)
|
userService := service.NewUserService(db, jwtService, auditLogService)
|
||||||
customClaimService := service.NewCustomClaimService(db)
|
customClaimService := service.NewCustomClaimService(db)
|
||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
testService := service.NewTestService(db, appConfigService)
|
testService := service.NewTestService(db, appConfigService)
|
||||||
@@ -58,7 +59,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||||
|
|||||||
@@ -6,28 +6,55 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type DbProvider string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DbProviderSqlite DbProvider = "sqlite"
|
||||||
|
DbProviderPostgres DbProvider = "postgres"
|
||||||
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
AppEnv string `env:"APP_ENV"`
|
AppEnv string `env:"APP_ENV"`
|
||||||
AppURL string `env:"PUBLIC_APP_URL"`
|
AppURL string `env:"PUBLIC_APP_URL"`
|
||||||
DBPath string `env:"DB_PATH"`
|
DbProvider DbProvider `env:"DB_PROVIDER"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
SqliteDBPath string `env:"SQLITE_DB_PATH"`
|
||||||
Port string `env:"BACKEND_PORT"`
|
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
|
||||||
Host string `env:"HOST"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
|
Port string `env:"BACKEND_PORT"`
|
||||||
|
Host string `env:"HOST"`
|
||||||
|
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
|
||||||
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||||
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = &EnvConfigSchema{
|
||||||
AppEnv: "production",
|
AppEnv: "production",
|
||||||
DBPath: "data/pocket-id.db",
|
DbProvider: "sqlite",
|
||||||
UploadPath: "data/uploads",
|
SqliteDBPath: "data/pocket-id.db",
|
||||||
AppURL: "http://localhost",
|
PostgresConnectionString: "",
|
||||||
Port: "8080",
|
UploadPath: "data/uploads",
|
||||||
Host: "localhost",
|
AppURL: "http://localhost",
|
||||||
EmailTemplatesPath: "./email-templates",
|
Port: "8080",
|
||||||
|
Host: "localhost",
|
||||||
|
EmailTemplatesPath: "./email-templates",
|
||||||
|
MaxMindLicenseKey: "",
|
||||||
|
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Validate the environment variables
|
||||||
|
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
|
||||||
|
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
|
||||||
|
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
|
||||||
|
log.Fatal("Missing SQLITE_DB_PATH environment variable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
|||||||
|
|
||||||
type OidcInvalidCallbackURLError struct{}
|
type OidcInvalidCallbackURLError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" }
|
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" }
|
||||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type FileTypeNotSupportedError struct{}
|
type FileTypeNotSupportedError struct{}
|
||||||
@@ -102,7 +102,7 @@ func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyR
|
|||||||
type ClientIdOrSecretNotProvidedError struct{}
|
type ClientIdOrSecretNotProvidedError struct{}
|
||||||
|
|
||||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||||
return "Client id and secret not provided"
|
return "Client id or secret not provided"
|
||||||
}
|
}
|
||||||
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
@@ -146,3 +146,17 @@ func (e *AccountEditNotAllowedError) Error() string {
|
|||||||
return "You are not allowed to edit your account"
|
return "You are not allowed to edit your account"
|
||||||
}
|
}
|
||||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
|
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||||
|
return "Invalid code verifier"
|
||||||
|
}
|
||||||
|
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcMissingCodeChallengeError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingCodeChallengeError) Error() string {
|
||||||
|
return "Missing code challenge"
|
||||||
|
}
|
||||||
|
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import (
|
|||||||
func NewAppConfigController(
|
func NewAppConfigController(
|
||||||
group *gin.RouterGroup,
|
group *gin.RouterGroup,
|
||||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||||
appConfigService *service.AppConfigService) {
|
appConfigService *service.AppConfigService,
|
||||||
|
emailService *service.EmailService,
|
||||||
|
) {
|
||||||
|
|
||||||
acc := &AppConfigController{
|
acc := &AppConfigController{
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
|
emailService: emailService,
|
||||||
}
|
}
|
||||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
||||||
@@ -29,10 +32,13 @@ func NewAppConfigController(
|
|||||||
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
||||||
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
||||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
||||||
|
|
||||||
|
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfigController struct {
|
type AppConfigController struct {
|
||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
|
emailService *service.EmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||||
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
|||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||||
|
err := acc.emailService.SendTestEmail()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"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/dto"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
@@ -80,7 +79,10 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
var input dto.OidcIdTokenDto
|
// Disable cors for this endpoint
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
var input dto.OidcCreateTokensDto
|
||||||
|
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -91,16 +93,11 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
clientSecret := input.ClientSecret
|
clientSecret := input.ClientSecret
|
||||||
|
|
||||||
// Client id and secret can also be passed over the Authorization header
|
// Client id and secret can also be passed over the Authorization header
|
||||||
if clientID == "" || clientSecret == "" {
|
if clientID == "" && clientSecret == "" {
|
||||||
var ok bool
|
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||||
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)
|
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -91,9 +91,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||||
|
|
||||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ type AppConfigUpdateDto struct {
|
|||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
SmtpUser string `json:"smtpUser"`
|
SmtpUser string `json:"smtpUser"`
|
||||||
SmtpPassword string `json:"smtpPassword"`
|
SmtpPassword string `json:"smtpPassword"`
|
||||||
|
SmtpTls string `json:"smtpTls"`
|
||||||
|
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package dto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"time"
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditLogDto struct {
|
type AuditLogDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event model.AuditLogEvent `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
|
|||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
PublicOidcClientDto
|
PublicOidcClientDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
CreatedBy UserDto `json:"createdBy"`
|
CreatedBy UserDto `json:"createdBy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
|
|||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcIdTokenDto struct {
|
type OidcCreateTokensDto struct {
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
Code string `form:"code" binding:"required"`
|
Code string `form:"code" binding:"required"`
|
||||||
ClientID string `form:"client_id"`
|
ClientID string `form:"client_id"`
|
||||||
ClientSecret string `form:"client_secret"`
|
ClientSecret string `form:"client_secret"`
|
||||||
|
CodeVerifier string `form:"code_verifier"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ type UserDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=3,max=20"`
|
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||||
LastName string `json:"lastName" binding:"required,min=3,max=30"`
|
LastName string `json:"lastName" binding:"required,min=1,max=50"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
type UserGroupDtoWithUsers struct {
|
type UserGroupDtoWithUsers struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
Users []UserDto `json:"users"`
|
Users []UserDto `json:"users"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupDtoWithUserCount struct {
|
type UserGroupDtoWithUserCount struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
UserCount int64 `json:"userCount"`
|
UserCount int64 `json:"userCount"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupCreateDto struct {
|
type UserGroupCreateDto struct {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package dto
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"time"
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebauthnCredentialDto struct {
|
type WebauthnCredentialDto struct {
|
||||||
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
|
|||||||
BackupEligible bool `json:"backupEligible"`
|
BackupEligible bool `json:"backupEligible"`
|
||||||
BackupState bool `json:"backupState"`
|
BackupState bool `json:"backupState"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebauthnCredentialUpdateDto struct {
|
type WebauthnCredentialUpdateDto struct {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,22 +30,22 @@ type Jobs struct {
|
|||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
func (j *Jobs) clearWebauthnSessions() error {
|
func (j *Jobs) clearWebauthnSessions() error {
|
||||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error
|
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
func (j *Jobs) clearOneTimeAccessTokens() error {
|
||||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
|
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
||||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
|
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
func (j *Jobs) clearAuditLogs() error {
|
func (j *Jobs) clearAuditLogs() error {
|
||||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
|
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CorsMiddleware struct{}
|
type CorsMiddleware struct{}
|
||||||
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||||
return cors.New(cors.Config{
|
return func(c *gin.Context) {
|
||||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
// Allow all origins for the token endpoint
|
||||||
AllowMethods: []string{"*"},
|
if c.FullPath() == "/api/oidc/token" {
|
||||||
AllowHeaders: []string{"*"},
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
MaxAge: 12 * time.Hour,
|
} else {
|
||||||
})
|
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
|||||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||||
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
c.Error(&common.NotSignedInError{})
|
c.Error(&common.NotSignedInError{})
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
|||||||
limiter := getLimiter(ip, limit, burst)
|
limiter := getLimiter(ip, limit, burst)
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
c.Error(&common.TooManyRequestsError{})
|
c.Error(&common.TooManyRequestsError{})
|
||||||
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ type AppConfig struct {
|
|||||||
LogoLightImageType AppConfigVariable
|
LogoLightImageType AppConfigVariable
|
||||||
LogoDarkImageType AppConfigVariable
|
LogoDarkImageType AppConfigVariable
|
||||||
|
|
||||||
EmailEnabled AppConfigVariable
|
EmailEnabled AppConfigVariable
|
||||||
SmtpHost AppConfigVariable
|
SmtpHost AppConfigVariable
|
||||||
SmtpPort AppConfigVariable
|
SmtpPort AppConfigVariable
|
||||||
SmtpFrom AppConfigVariable
|
SmtpFrom AppConfigVariable
|
||||||
SmtpUser AppConfigVariable
|
SmtpUser AppConfigVariable
|
||||||
SmtpPassword AppConfigVariable
|
SmtpPassword AppConfigVariable
|
||||||
|
SmtpTls AppConfigVariable
|
||||||
|
SmtpSkipCertVerify AppConfigVariable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ type AuditLogData map[string]string
|
|||||||
type AuditLogEvent string
|
type AuditLogEvent string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scan and Value methods for GORM to handle the custom type
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
|
|||||||
type OidcAuthorizationCode struct {
|
type OidcAuthorizationCode struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Code string
|
Code string
|
||||||
Scope string
|
Scope string
|
||||||
Nonce string
|
Nonce string
|
||||||
ExpiresAt datatype.DateTime
|
CodeChallenge *string
|
||||||
|
CodeChallengeMethodSha256 *bool
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
@@ -39,6 +41,7 @@ type OidcClient struct {
|
|||||||
CallbackURLs CallbackURLs
|
CallbackURLs CallbackURLs
|
||||||
ImageType *string
|
ImageType *string
|
||||||
HasLogo bool `gorm:"-"`
|
HasLogo bool `gorm:"-"`
|
||||||
|
IsPublic bool
|
||||||
|
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
CreatedBy User
|
CreatedBy User
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package datatype
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DateTime custom type for time.Time to store date as unix timestamp in the database
|
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
|
||||||
type DateTime time.Time
|
type DateTime time.Time
|
||||||
|
|
||||||
func (date *DateTime) Scan(value interface{}) (err error) {
|
func (date *DateTime) Scan(value interface{}) (err error) {
|
||||||
@@ -14,7 +15,11 @@ func (date *DateTime) Scan(value interface{}) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (date DateTime) Value() (driver.Value, error) {
|
func (date DateTime) Value() (driver.Value, error) {
|
||||||
return time.Time(date).Unix(), nil
|
if common.EnvConfig.DbProvider == common.DbProviderSqlite {
|
||||||
|
return time.Time(date).Unix(), nil
|
||||||
|
} else {
|
||||||
|
return time.Time(date), nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (date DateTime) UTC() time.Time {
|
func (date DateTime) UTC() time.Time {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (u User) WebAuthnCredentials() []webauthn.Credential {
|
|||||||
|
|
||||||
for i, credential := range u.Credentials {
|
for i, credential := range u.Credentials {
|
||||||
credentials[i] = webauthn.Credential{
|
credentials[i] = webauthn.Credential{
|
||||||
ID: []byte(credential.CredentialID),
|
ID: credential.CredentialID,
|
||||||
AttestationType: credential.AttestationType,
|
AttestationType: credential.AttestationType,
|
||||||
PublicKey: credential.PublicKey,
|
PublicKey: credential.PublicKey,
|
||||||
Transport: credential.Transport,
|
Transport: credential.Transport,
|
||||||
@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
return descriptors
|
return descriptors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||||
|
|
||||||
type OneTimeAccessToken struct {
|
type OneTimeAccessToken struct {
|
||||||
Base
|
Base
|
||||||
Token string
|
Token string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ type WebauthnSession struct {
|
|||||||
Base
|
Base
|
||||||
|
|
||||||
Challenge string
|
Challenge string
|
||||||
ExpiresAt time.Time
|
ExpiresAt datatype.DateTime
|
||||||
UserVerification string
|
UserVerification string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ type WebauthnCredential struct {
|
|||||||
Base
|
Base
|
||||||
|
|
||||||
Name string
|
Name string
|
||||||
CredentialID string
|
CredentialID []byte
|
||||||
PublicKey []byte
|
PublicKey []byte
|
||||||
AttestationType string
|
AttestationType string
|
||||||
Transport AuthenticatorTransportList
|
Transport AuthenticatorTransportList
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
Key: "smtpPassword",
|
Key: "smtpPassword",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
},
|
},
|
||||||
|
SmtpTls: model.AppConfigVariable{
|
||||||
|
Key: "smtpTls",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "true",
|
||||||
|
},
|
||||||
|
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||||
|
Key: "smtpSkipCertVerify",
|
||||||
|
Type: "bool",
|
||||||
|
DefaultValue: "false",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||||
|
|||||||
@@ -2,28 +2,27 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
userAgentParser "github.com/mileusna/useragent"
|
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/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
"net/netip"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditLogService struct {
|
type AuditLogService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
|
geoliteService *GeoLiteService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
|
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
|
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create creates a new audit log entry in the database
|
// 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 {
|
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||||
country, city, err := s.GetIpLocation(ipAddress)
|
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get IP location: %v\n", err)
|
log.Printf("Failed to get IP location: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -48,8 +47,8 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
// 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 {
|
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
|
||||||
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
|
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
|
||||||
|
|
||||||
// Count the number of times the user has logged in from the same device
|
// Count the number of times the user has logged in from the same device
|
||||||
var count int64
|
var count int64
|
||||||
@@ -97,29 +96,3 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
|||||||
ua := userAgentParser.Parse(userAgent)
|
ua := userAgentParser.Parse(userAgent)
|
||||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
|
"gorm.io/gorm"
|
||||||
htemplate "html/template"
|
htemplate "html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"mime/quotedprintable"
|
"mime/quotedprintable"
|
||||||
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
ttemplate "text/template"
|
ttemplate "text/template"
|
||||||
@@ -17,11 +21,12 @@ import (
|
|||||||
|
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
|
db *gorm.DB
|
||||||
htmlTemplates map[string]*htemplate.Template
|
htmlTemplates map[string]*htemplate.Template
|
||||||
textTemplates map[string]*ttemplate.Template
|
textTemplates map[string]*ttemplate.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
|
||||||
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||||
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
|
|||||||
|
|
||||||
return &EmailService{
|
return &EmailService{
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
|
db: db,
|
||||||
htmlTemplates: htmlTemplates,
|
htmlTemplates: htmlTemplates,
|
||||||
textTemplates: textTemplates,
|
textTemplates: textTemplates,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) SendTestEmail() error {
|
||||||
|
var user model.User
|
||||||
|
if err := srv.db.First(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendEmail(srv,
|
||||||
|
email.Address{
|
||||||
|
Email: user.Email,
|
||||||
|
Name: user.FullName(),
|
||||||
|
}, TestTemplate, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||||
// Check if SMTP settings are set
|
// Check if SMTP settings are set
|
||||||
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
||||||
@@ -71,26 +90,108 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
|||||||
)
|
)
|
||||||
c.Body(body)
|
c.Body(body)
|
||||||
|
|
||||||
// Set up the authentication information.
|
// Set up the TLS configuration
|
||||||
auth := smtp.PlainAuth("",
|
tlsConfig := &tls.Config{
|
||||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
|
||||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
ServerName: srv.appConfigService.DbConfig.SmtpHost.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect to the SMTP server
|
||||||
|
port := srv.appConfigService.DbConfig.SmtpPort.Value
|
||||||
|
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
|
||||||
|
var client *smtp.Client
|
||||||
|
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
|
||||||
|
client, err = smtp.Dial(smtpAddress)
|
||||||
|
} else if port == "465" {
|
||||||
|
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||||
|
smtpAddress,
|
||||||
|
tlsConfig,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||||
|
smtpAddress,
|
||||||
|
tlsConfig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer client.Quit()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
|
||||||
|
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
|
||||||
|
|
||||||
|
// Set up the authentication if user or password are set
|
||||||
|
if smtpUser != "" || smtpPassword != "" {
|
||||||
|
auth := smtp.PlainAuth("",
|
||||||
|
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||||
|
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||||
|
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
|
)
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("failed to authenticate SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
||||||
|
return fmt.Errorf("send email content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||||
|
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||||
|
conn, err := net.Dial("tcp", serverAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.StartTLS(tlsConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
|
||||||
|
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
|
||||||
|
return fmt.Errorf("failed to set sender: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(toEmail.Email); err != nil {
|
||||||
|
return fmt.Errorf("failed to set recipient: %w", err)
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start data: %w", err)
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(c.String()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write email data: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close data writer: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var TestTemplate = email.Template[struct{}]{
|
||||||
|
Path: "test",
|
||||||
|
Title: func(data *email.TemplateData[struct{}]) string {
|
||||||
|
return "Test email"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
type NewLoginTemplateData struct {
|
type NewLoginTemplateData struct {
|
||||||
IPAddress string
|
IPAddress string
|
||||||
Country string
|
Country string
|
||||||
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is list of all template paths used for preloading templates
|
// this is list of all template paths used for preloading templates
|
||||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}
|
||||||
|
|||||||
152
backend/internal/service/geolite_service.go
Normal file
152
backend/internal/service/geolite_service.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oschwald/maxminddb-golang/v2"
|
||||||
|
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeoLiteService struct{}
|
||||||
|
|
||||||
|
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
|
||||||
|
func NewGeoLiteService() *GeoLiteService {
|
||||||
|
service := &GeoLiteService{}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := service.updateDatabase(); err != nil {
|
||||||
|
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocationByIP returns the country and city of the given IP address.
|
||||||
|
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
||||||
|
// Check if IP is in Tailscale's CGNAT range (100.64.0.0/10)
|
||||||
|
if ip := net.ParseIP(ipAddress); ip != nil {
|
||||||
|
if ip.To4() != nil && ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127 {
|
||||||
|
return "Internal Network", "Tailscale", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(ipAddress)
|
||||||
|
|
||||||
|
var record struct {
|
||||||
|
City struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"city"`
|
||||||
|
Country struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Lookup(addr).Decode(&record)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
||||||
|
func (s *GeoLiteService) updateDatabase() error {
|
||||||
|
if s.isDatabaseUpToDate() {
|
||||||
|
log.Println("GeoLite2 City database is up-to-date.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Updating GeoLite2 City database...")
|
||||||
|
|
||||||
|
// Download and extract the database
|
||||||
|
downloadUrl := fmt.Sprintf(
|
||||||
|
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
|
||||||
|
common.EnvConfig.MaxMindLicenseKey,
|
||||||
|
)
|
||||||
|
// Download the database tar.gz file
|
||||||
|
resp, err := http.Get(downloadUrl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download database: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to download database, received HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the database file directly to the target path
|
||||||
|
if err := s.extractDatabase(resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("GeoLite2 City database successfully updated.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDatabaseUpToDate checks if the database file is older than 14 days.
|
||||||
|
func (s *GeoLiteService) isDatabaseUpToDate() bool {
|
||||||
|
info, err := os.Stat(common.EnvConfig.GeoLiteDBPath)
|
||||||
|
if err != nil {
|
||||||
|
// If the file doesn't exist, treat it as not up-to-date
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(info.ModTime()) < 14*24*time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
|
||||||
|
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
|
||||||
|
gzr, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
|
||||||
|
tarReader := tar.NewReader(gzr)
|
||||||
|
|
||||||
|
// Iterate over the files in the tar archive
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read tar archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file is the GeoLite2-City.mmdb file
|
||||||
|
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
|
||||||
|
outFile, err := os.Create(common.EnvConfig.GeoLiteDBPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create target database file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
// Write the file contents directly to the target location
|
||||||
|
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||||
|
return fmt.Errorf("failed to write database file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("GeoLite2-City.mmdb not found in archive")
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
@@ -96,7 +95,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
Subject: user.ID,
|
Subject: user.ID,
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
|
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
|
||||||
},
|
},
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
}
|
}
|
||||||
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
|
|||||||
return nil, errors.New("can't parse claims")
|
return nil, errors.New("can't parse claims")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(claims.Audience, utils.GetHostFromURL(common.EnvConfig.AppURL)) {
|
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
|
||||||
return nil, errors.New("audience doesn't match")
|
return nil, errors.New("audience doesn't match")
|
||||||
}
|
}
|
||||||
return claims, nil
|
return claims, nil
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
@@ -39,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||||
|
|
||||||
|
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||||
return "", "", &common.OidcMissingAuthorizationError{}
|
return "", "", &common.OidcMissingAuthorizationError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
if client.IsPublic && input.CodeChallenge == "" {
|
||||||
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -93,31 +103,41 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
|||||||
return code, callbackURL, nil
|
return code, callbackURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
|
||||||
if grantType != "authorization_code" {
|
if grantType != "authorization_code" {
|
||||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clientID == "" || clientSecret == "" {
|
|
||||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var client model.OidcClient
|
var client model.OidcClient
|
||||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
// Verify the client secret if the client is not public
|
||||||
if err != nil {
|
if !client.IsPublic {
|
||||||
return "", "", &common.OidcClientSecretInvalidError{}
|
if clientID == "" || clientSecret == "" {
|
||||||
|
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", &common.OidcClientSecretInvalidError{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||||
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the client is public, the code verifier must match the code challenge
|
||||||
|
if client.IsPublic {
|
||||||
|
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
|
||||||
|
return "", "", &common.OidcInvalidCodeVerifierError{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||||
}
|
}
|
||||||
@@ -186,6 +206,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
|||||||
|
|
||||||
client.Name = input.Name
|
client.Name = input.Name
|
||||||
client.CallbackURLs = input.CallbackURLs
|
client.CallbackURLs = input.CallbackURLs
|
||||||
|
client.IsPublic = input.IsPublic
|
||||||
|
|
||||||
if err := s.db.Save(&client).Error; err != nil {
|
if err := s.db.Save(&client).Error; err != nil {
|
||||||
return model.OidcClient{}, err
|
return model.OidcClient{}, err
|
||||||
@@ -331,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
profileClaims := map[string]interface{}{
|
profileClaims := map[string]interface{}{
|
||||||
"given_name": user.FirstName,
|
"given_name": user.FirstName,
|
||||||
"family_name": user.LastName,
|
"family_name": user.LastName,
|
||||||
"name": user.FirstName + " " + user.LastName,
|
"name": user.FullName(),
|
||||||
"preferred_username": user.Username,
|
"preferred_username": user.Username,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||||
|
|
||||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||||
Code: randomString,
|
Code: randomString,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
|
CodeChallenge: &codeChallenge,
|
||||||
|
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||||
@@ -380,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
|||||||
return randomString, nil
|
return randomString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||||
|
if !codeChallengeMethodSha256 {
|
||||||
|
return codeVerifier == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the codeVerifier
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(codeVerifier))
|
||||||
|
codeVerifierHash := h.Sum(nil)
|
||||||
|
|
||||||
|
// Base64 URL encode the verifier hash
|
||||||
|
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
|
||||||
|
|
||||||
|
return encodedVerifierHash == codeChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
if inputCallbackURL == "" {
|
if inputCallbackURL == "" {
|
||||||
return client.CallbackURLs[0], nil
|
return client.CallbackURLs[0], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,33 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oneTimeAccessTokens := []model.OneTimeAccessToken{{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
|
||||||
|
},
|
||||||
|
Token: "HPe6k6uiDRRVuAQV",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
|
UserID: users[0].ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
|
||||||
|
},
|
||||||
|
Token: "YCGDtftvsvYWiXd0",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
|
||||||
|
UserID: users[0].ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, token := range oneTimeAccessTokens {
|
||||||
|
if err := tx.Create(&token).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userGroups := []model.UserGroup{
|
userGroups := []model.UserGroup{
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e",
|
ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
|
||||||
},
|
},
|
||||||
Name: "developers",
|
Name: "developers",
|
||||||
FriendlyName: "Developers",
|
FriendlyName: "Developers",
|
||||||
@@ -146,7 +169,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
webauthnCredentials := []model.WebauthnCredential{
|
webauthnCredentials := []model.WebauthnCredential{
|
||||||
{
|
{
|
||||||
Name: "Passkey 1",
|
Name: "Passkey 1",
|
||||||
CredentialID: "test-credential-1",
|
CredentialID: []byte("test-credential-1"),
|
||||||
PublicKey: publicKey1,
|
PublicKey: publicKey1,
|
||||||
AttestationType: "none",
|
AttestationType: "none",
|
||||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||||
@@ -154,7 +177,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Passkey 2",
|
Name: "Passkey 2",
|
||||||
CredentialID: "test-credential-2",
|
CredentialID: []byte("test-credential-2"),
|
||||||
PublicKey: publicKey2,
|
PublicKey: publicKey2,
|
||||||
AttestationType: "none",
|
AttestationType: "none",
|
||||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||||
@@ -169,7 +192,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
|
|
||||||
webauthnSession := model.WebauthnSession{
|
webauthnSession := model.WebauthnSession{
|
||||||
Challenge: "challenge",
|
Challenge: "challenge",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
UserVerification: "preferred",
|
UserVerification: "preferred",
|
||||||
}
|
}
|
||||||
if err := tx.Create(&webauthnSession).Error; err != nil {
|
if err := tx.Create(&webauthnSession).Error; err != nil {
|
||||||
@@ -183,13 +206,29 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
func (s *TestService) ResetDatabase() error {
|
func (s *TestService) ResetDatabase() error {
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var tables []string
|
var tables []string
|
||||||
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
|
|
||||||
return err
|
switch common.EnvConfig.DbProvider {
|
||||||
|
case common.DbProviderSqlite:
|
||||||
|
// Query to get all tables for SQLite
|
||||||
|
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case common.DbProviderPostgres:
|
||||||
|
// Query to get all tables for PostgreSQL
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename != 'schema_migrations';
|
||||||
|
`).Scan(&tables).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all rows from all tables
|
// Delete all rows from all tables
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
|
auditLogService *AuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
|
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
|
||||||
return &UserService{db: db, jwtService: jwtService}
|
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
||||||
@@ -88,7 +89,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
|
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -104,12 +105,14 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
|
||||||
|
|
||||||
return oneTimeAccessToken.Token, nil
|
return oneTimeAccessToken.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
||||||
var oneTimeAccessToken model.OneTimeAccessToken
|
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 err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"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"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,7 +23,7 @@ type WebAuthnService struct {
|
|||||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||||
webauthnConfig := &webauthn.Config{
|
webauthnConfig := &webauthn.Config{
|
||||||
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
||||||
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
|
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
|
||||||
RPOrigins: []string{common.EnvConfig.AppURL},
|
RPOrigins: []string{common.EnvConfig.AppURL},
|
||||||
Timeouts: webauthn.TimeoutsConfig{
|
Timeouts: webauthn.TimeoutsConfig{
|
||||||
Login: webauthn.TimeoutConfig{
|
Login: webauthn.TimeoutConfig{
|
||||||
@@ -55,7 +56,7 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionToStore := &model.WebauthnSession{
|
sessionToStore := &model.WebauthnSession{
|
||||||
ExpiresAt: session.Expires,
|
ExpiresAt: datatype.DateTime(session.Expires),
|
||||||
Challenge: session.Challenge,
|
Challenge: session.Challenge,
|
||||||
UserVerification: string(session.UserVerification),
|
UserVerification: string(session.UserVerification),
|
||||||
}
|
}
|
||||||
@@ -79,7 +80,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
|||||||
|
|
||||||
session := webauthn.SessionData{
|
session := webauthn.SessionData{
|
||||||
Challenge: storedSession.Challenge,
|
Challenge: storedSession.Challenge,
|
||||||
Expires: storedSession.ExpiresAt,
|
Expires: storedSession.ExpiresAt.ToTime(),
|
||||||
UserID: []byte(userID),
|
UserID: []byte(userID),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
|
|||||||
|
|
||||||
credentialToStore := model.WebauthnCredential{
|
credentialToStore := model.WebauthnCredential{
|
||||||
Name: "New Passkey",
|
Name: "New Passkey",
|
||||||
CredentialID: string(credential.ID),
|
CredentialID: credential.ID,
|
||||||
AttestationType: credential.AttestationType,
|
AttestationType: credential.AttestationType,
|
||||||
PublicKey: credential.PublicKey,
|
PublicKey: credential.PublicKey,
|
||||||
Transport: credential.Transport,
|
Transport: credential.Transport,
|
||||||
@@ -117,7 +118,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionToStore := &model.WebauthnSession{
|
sessionToStore := &model.WebauthnSession{
|
||||||
ExpiresAt: session.Expires,
|
ExpiresAt: datatype.DateTime(session.Expires),
|
||||||
Challenge: session.Challenge,
|
Challenge: session.Challenge,
|
||||||
UserVerification: string(session.UserVerification),
|
UserVerification: string(session.UserVerification),
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
||||||
var storedSession model.WebauthnSession
|
var storedSession model.WebauthnSession
|
||||||
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
@@ -141,7 +142,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
|||||||
|
|
||||||
session := webauthn.SessionData{
|
session := webauthn.SessionData{
|
||||||
Challenge: storedSession.Challenge,
|
Challenge: storedSession.Challenge,
|
||||||
Expires: storedSession.ExpiresAt,
|
Expires: storedSession.ExpiresAt.ToTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
var user *model.User
|
var user *model.User
|
||||||
@@ -156,16 +157,12 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
|||||||
return model.User{}, "", err
|
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)
|
token, err := s.jwtService.GenerateAccessToken(*user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
|
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
|
||||||
|
|
||||||
return *user, token, nil
|
return *user, token, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
|
|||||||
return string(result), nil
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostFromURL(rawURL string) string {
|
func GetHostnameFromURL(rawURL string) string {
|
||||||
parsedURL, err := url.Parse(rawURL)
|
parsedURL, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return parsedURL.Host
|
return parsedURL.Hostname()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StringPointer creates a string pointer from a string value
|
// StringPointer creates a string pointer from a string value
|
||||||
|
|||||||
126
backend/migrations/postgres/20241211111554_init.up.sql
Normal file
126
backend/migrations/postgres/20241211111554_init.up.sql
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
CREATE TABLE app_config_variables
|
||||||
|
(
|
||||||
|
key VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
is_public BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
is_internal BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
default_value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_groups
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
friendly_name VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit_logs
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
event VARCHAR(100) NOT NULL,
|
||||||
|
ip_address INET NOT NULL,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
user_id UUID REFERENCES users ON DELETE SET NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
country VARCHAR(100),
|
||||||
|
city VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE custom_claims
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
key VARCHAR(255) NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
user_id UUID REFERENCES users ON DELETE CASCADE,
|
||||||
|
user_group_id UUID REFERENCES user_groups ON DELETE CASCADE,
|
||||||
|
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
|
||||||
|
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE oidc_authorization_codes
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
code VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
nonce VARCHAR(255),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
client_id UUID NOT NULL,
|
||||||
|
code_challenge VARCHAR(255),
|
||||||
|
code_challenge_method_sha256 BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE oidc_clients
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
name VARCHAR(255),
|
||||||
|
secret TEXT,
|
||||||
|
callback_urls JSONB,
|
||||||
|
image_type VARCHAR(10),
|
||||||
|
created_by_id UUID REFERENCES users ON DELETE SET NULL,
|
||||||
|
is_public BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE one_time_access_tokens
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_authorized_oidc_clients
|
||||||
|
(
|
||||||
|
scope VARCHAR(255),
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, client_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_groups_users
|
||||||
|
(
|
||||||
|
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
user_group_id UUID NOT NULL REFERENCES user_groups ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, user_group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE webauthn_credentials
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
credential_id BYTEA NOT NULL UNIQUE,
|
||||||
|
public_key BYTEA NOT NULL,
|
||||||
|
attestation_type VARCHAR(20) NOT NULL,
|
||||||
|
transport JSONB NOT NULL,
|
||||||
|
user_id UUID REFERENCES users ON DELETE CASCADE,
|
||||||
|
backup_eligible BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
backup_state BOOLEAN DEFAULT FALSE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE webauthn_sessions
|
||||||
|
(
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
challenge VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
user_verification VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
3
backend/migrations/sqlite/20241115131129_pkce.down.sql
Normal file
3
backend/migrations/sqlite/20241115131129_pkce.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
|
||||||
|
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
|
||||||
|
ALTER TABLE oidc_clients DROP COLUMN is_public;
|
||||||
3
backend/migrations/sqlite/20241115131129_pkce.up.sql
Normal file
3
backend/migrations/sqlite/20241115131129_pkce.up.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
|
||||||
|
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
|
||||||
|
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
pocket-id:
|
pocket-id:
|
||||||
image: stonith404/pocket-id:latest
|
image: stonith404/pocket-id # or ghcr.io/stonith404/pocket-id
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- 3000:80
|
- 3000:80
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/app/backend/data"
|
- "./data:/app/backend/data"
|
||||||
|
# Optional healthcheck
|
||||||
|
healthcheck:
|
||||||
|
test: "curl -f http://localhost/health"
|
||||||
|
interval: 1m30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 2
|
||||||
|
start_period: 10s
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# Proxy Services through Pocket ID
|
# Proxy Services through Pocket ID
|
||||||
|
|
||||||
The goal of Pocket ID is to stay simple. Because of that 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. This guide will show you how to set up OAuth2 Proxy with Pocket ID.
|
The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID.
|
||||||
|
|
||||||
## Docker Setup
|
## Docker Setup
|
||||||
|
|
||||||
#### 1. Add OAuth2 proxy to the service that should be proxied.
|
#### 1. Add OAuth2 proxy to the service that should be proxied.
|
||||||
|
|
||||||
To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma:
|
To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., if [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Example with Uptime Kuma
|
# Example with Uptime Kuma
|
||||||
@@ -23,7 +23,7 @@ oauth2-proxy:
|
|||||||
|
|
||||||
#### 2. Create a new OIDC client in Pocket ID.
|
#### 2. Create a new OIDC client in Pocket ID.
|
||||||
|
|
||||||
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. After adding the client, you will obtain the client ID and client secret.
|
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. Now enter `https://<domain-of-proxied-service>/oauth2/callback` as the callback URL. After adding the client, you will obtain the client ID and client secret, which you will need in the next step.
|
||||||
|
|
||||||
#### 3. Create a configuration file for OAuth2 Proxy.
|
#### 3. Create a configuration file for OAuth2 Proxy.
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ upstreams="http://<service-to-be-proxied>:<port>"
|
|||||||
|
|
||||||
# Additional Configuration
|
# Additional Configuration
|
||||||
provider="oidc"
|
provider="oidc"
|
||||||
scope = "openid email profile"
|
scope = "openid email profile groups"
|
||||||
|
|
||||||
# If you are using a reverse proxy in front of OAuth2 Proxy
|
# If you are using a reverse proxy in front of OAuth2 Proxy
|
||||||
reverse_proxy = true
|
reverse_proxy = true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.13.0",
|
"version": "0.22.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import * as Pagination from '$lib/components/ui/pagination';
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import Empty from '$lib/icons/empty.svelte';
|
||||||
import type { Paginated } from '$lib/types/pagination.type';
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
import { debounced } from '$lib/utils/debounce-util';
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
@@ -66,93 +67,104 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
{#if items.data.length === 0}
|
||||||
{#if !withoutSearch}
|
<div class="my-5 flex flex-col items-center">
|
||||||
<Input
|
<Empty class="text-muted-foreground h-20" />
|
||||||
class="mb-4 max-w-sm"
|
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
|
||||||
placeholder={'Search...'}
|
|
||||||
type="text"
|
|
||||||
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
{#if selectedIds}
|
|
||||||
<Table.Head>
|
|
||||||
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
|
|
||||||
</Table.Head>
|
|
||||||
{/if}
|
|
||||||
{#each columns as column}
|
|
||||||
{#if typeof column === 'string'}
|
|
||||||
<Table.Head>{column}</Table.Head>
|
|
||||||
{:else}
|
|
||||||
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each items.data as item}
|
|
||||||
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
|
||||||
{#if selectedIds}
|
|
||||||
<Table.Cell>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.includes(item.id)}
|
|
||||||
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
{@render rows({ item })}
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
<div class="mt-5 flex items-center justify-between space-x-2">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<p class="text-sm font-medium">Items per page</p>
|
|
||||||
<Select.Root
|
|
||||||
selected={{
|
|
||||||
label: items.pagination.itemsPerPage.toString(),
|
|
||||||
value: items.pagination.itemsPerPage
|
|
||||||
}}
|
|
||||||
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="h-9 w-[80px]">
|
|
||||||
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each availablePageSizes as size}
|
|
||||||
<Select.Item value={size}>{size}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<Pagination.Root
|
|
||||||
class="mx-0 w-auto"
|
|
||||||
count={items.pagination.totalItems}
|
|
||||||
perPage={items.pagination.itemsPerPage}
|
|
||||||
{onPageChange}
|
|
||||||
page={items.pagination.currentPage}
|
|
||||||
let:pages
|
|
||||||
>
|
|
||||||
<Pagination.Content class="flex justify-end">
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.PrevButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
{#each pages as page (page.key)}
|
|
||||||
{#if page.type !== 'ellipsis'}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
|
||||||
{page.value}
|
|
||||||
</Pagination.Link>
|
|
||||||
</Pagination.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.NextButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
</Pagination.Content>
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<div class="w-full">
|
||||||
|
{#if !withoutSearch}
|
||||||
|
<Input
|
||||||
|
class="mb-4 max-w-sm"
|
||||||
|
placeholder={'Search...'}
|
||||||
|
type="text"
|
||||||
|
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Head>
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
|
||||||
|
</Table.Head>
|
||||||
|
{/if}
|
||||||
|
{#each columns as column}
|
||||||
|
{#if typeof column === 'string'}
|
||||||
|
<Table.Head>{column}</Table.Head>
|
||||||
|
{:else}
|
||||||
|
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items.data as item}
|
||||||
|
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Cell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
|
{@render rows({ item })}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-5 flex flex-col-reverse items-center justify-between gap-3 space-x-2 sm:flex-row"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<p class="text-sm font-medium">Items per page</p>
|
||||||
|
<Select.Root
|
||||||
|
selected={{
|
||||||
|
label: items.pagination.itemsPerPage.toString(),
|
||||||
|
value: items.pagination.itemsPerPage
|
||||||
|
}}
|
||||||
|
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-9 w-[80px]">
|
||||||
|
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each availablePageSizes as size}
|
||||||
|
<Select.Item value={size}>{size}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Pagination.Root
|
||||||
|
class="mx-0 w-auto"
|
||||||
|
count={items.pagination.totalItems}
|
||||||
|
perPage={items.pagination.itemsPerPage}
|
||||||
|
{onPageChange}
|
||||||
|
page={items.pagination.currentPage}
|
||||||
|
let:pages
|
||||||
|
>
|
||||||
|
<Pagination.Content class="flex justify-end">
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type !== 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
25
frontend/src/lib/components/checkbox-with-label.svelte
Normal file
25
frontend/src/lib/components/checkbox-with-label.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
checked = $bindable(),
|
||||||
|
label,
|
||||||
|
description
|
||||||
|
}: { id: string; checked: boolean; label: string; description?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="items-top mt-5 flex space-x-2">
|
||||||
|
<Checkbox {id} bind:checked />
|
||||||
|
<div class="grid gap-1.5 leading-none">
|
||||||
|
<Label for={id} class="mb-0 text-sm font-medium leading-none">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
21
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
21
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { HeadingLevel } from "./index.js";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
|
||||||
|
level?: HeadingLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let level: $$Props["level"] = "h5";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={level}
|
||||||
|
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</svelte:element>
|
||||||
17
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
17
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { type Variant, alertVariants } from "./index.js";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
variant?: Variant;
|
||||||
|
};
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let variant: $$Props["variant"] = "default";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
35
frontend/src/lib/components/ui/alert/index.ts
Normal file
35
frontend/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
import Description from './alert-description.svelte';
|
||||||
|
import Title from './alert-title.svelte';
|
||||||
|
import Root from './alert.svelte';
|
||||||
|
|
||||||
|
export const alertVariants = tv({
|
||||||
|
base: '[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
|
||||||
|
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
warning:
|
||||||
|
'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 [&>svg]:text-amber-900 dark:[&>svg]:text-amber-100'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Variant = VariantProps<typeof alertVariants>['variant'];
|
||||||
|
export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||||
|
|
||||||
|
export {
|
||||||
|
//
|
||||||
|
Root as Alert,
|
||||||
|
Description as AlertDescription,
|
||||||
|
Title as AlertTitle,
|
||||||
|
Description,
|
||||||
|
Root,
|
||||||
|
Title
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@ import { type VariantProps, tv } from "tailwind-variants";
|
|||||||
export { default as Badge } from "./badge.svelte";
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
export const badgeVariants = tv({
|
||||||
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden="true"
|
||||||
class={cn("flex h-9 w-9 items-center justify-center", className)}
|
class={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
|
|||||||
24
frontend/src/lib/icons/empty.svelte
Normal file
24
frontend/src/lib/icons/empty.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 336.19673868301203 129.38671875"
|
||||||
|
class={className}
|
||||||
|
>
|
||||||
|
<g stroke-linecap="round" transform="translate(10 10) rotate(0 158.09836934150601 54.693359375)">
|
||||||
|
<path
|
||||||
|
d="M27.35 0 C121.36 -0.62, 208.79 0.52, 288.85 0 M288.85 0 C305.5 3.32, 316.8 6.14, 316.2 27.35 M316.2 27.35 C315.58 42.15, 314.92 54.54, 316.2 82.04 M316.2 82.04 C313.79 100.68, 304.9 110.1, 288.85 109.39 M288.85 109.39 C192.86 108.68, 93.17 110.07, 27.35 109.39 M27.35 109.39 C13.09 109.46, -1.61 102.22, 0 82.04 M0 82.04 C-0.35 60.8, -1.11 41.01, 0 27.35 M0 27.35 C1.94 9.62, 8.6 1.41, 27.35 0"
|
||||||
|
stroke="#A1A1AA"
|
||||||
|
stroke-width="4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke-dasharray="8 12"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
@@ -53,6 +53,10 @@ export default class AppConfigService extends APIService {
|
|||||||
await this.api.put(`/application-configuration/background-image`, formData);
|
await this.api.put(`/application-configuration/background-image`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendTestEmail() {
|
||||||
|
await this.api.post('/application-configuration/test-email');
|
||||||
|
}
|
||||||
|
|
||||||
async getVersionInformation() {
|
async getVersionInformation() {
|
||||||
const response = (
|
const response = (
|
||||||
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
||||||
|
|||||||
@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
class OidcService extends APIService {
|
class OidcService extends APIService {
|
||||||
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
clientId
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize/new-client', {
|
const res = await this.api.post('/oidc/authorize/new-client', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
clientId
|
clientId,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type AllAppConfig = AppConfig & {
|
|||||||
smtpFrom: string;
|
smtpFrom: string;
|
||||||
smtpUser: string;
|
smtpUser: string;
|
||||||
smtpPassword: string;
|
smtpPassword: string;
|
||||||
|
smtpTls: boolean;
|
||||||
|
smtpSkipCertVerify: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfigRawResponse = {
|
export type AppConfigRawResponse = {
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ export type OidcClient = {
|
|||||||
logoURL: string;
|
logoURL: string;
|
||||||
callbackURLs: [string, ...string[]];
|
callbackURLs: [string, ...string[]];
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
|
isPublic: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||||
|
|
||||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||||
logo: File | null;
|
logo: File | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthorizeResponse = {
|
export type AuthorizeResponse = {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
nonce: url.searchParams.get('nonce') || undefined,
|
nonce: url.searchParams.get('nonce') || undefined,
|
||||||
state: url.searchParams.get('state')!,
|
state: url.searchParams.get('state')!,
|
||||||
callbackURL: url.searchParams.get('redirect_uri')!,
|
callbackURL: url.searchParams.get('redirect_uri')!,
|
||||||
client
|
client,
|
||||||
|
codeChallenge: url.searchParams.get('code_challenge')!,
|
||||||
|
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let authorizationRequired = false;
|
let authorizationRequired = false;
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
let { scope, nonce, client, state, callbackURL } = data;
|
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||||
|
|
||||||
async function authorize() {
|
async function authorize() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await oidService
|
await oidService
|
||||||
.authorize(client!.id, scope, callbackURL, nonce)
|
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
@@ -55,7 +55,14 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
await oidService
|
await oidService
|
||||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
.authorizeNewClient(
|
||||||
|
client!.id,
|
||||||
|
scope,
|
||||||
|
callbackURL,
|
||||||
|
nonce,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
|
)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
@@ -68,7 +75,11 @@
|
|||||||
function onSuccess(code: string, callbackURL: string) {
|
function onSuccess(code: string, callbackURL: string) {
|
||||||
success = true;
|
success = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
|
const redirectURL = new URL(callbackURL);
|
||||||
|
redirectURL.searchParams.append('code', code);
|
||||||
|
redirectURL.searchParams.append('state', state);
|
||||||
|
|
||||||
|
window.location.href = redirectURL.toString();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
20
frontend/src/routes/health/+server.ts
Normal file
20
frontend/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import AppConfigService from '$lib/services/app-config-service';
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const appConfigService = new AppConfigService();
|
||||||
|
let backendOk = true;
|
||||||
|
await appConfigService.list().catch(() => (backendOk = false));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
status: backendOk ? 'HEALTHY' : 'UNHEALTHY'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: backendOk ? 200 : 500,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import Logo from '$lib/components/logo.svelte';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import WebAuthnService from '$lib/services/webauthn-service';
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { toast } from 'svelte-sonner';
|
import { fade } from 'svelte/transition';
|
||||||
|
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte';
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let error: string | undefined = $state(undefined);
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
|
error = undefined;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const loginOptions = await webauthnService.getLoginOptions();
|
const loginOptions = await webauthnService.getLoginOptions();
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
userStore.setUser(user);
|
userStore.setUser(user);
|
||||||
goto('/settings');
|
goto('/settings');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(getWebauthnErrorMessage(e));
|
error = getWebauthnErrorMessage(e);
|
||||||
}
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -35,15 +37,21 @@
|
|||||||
|
|
||||||
<SignInWrapper>
|
<SignInWrapper>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="bg-muted rounded-2xl p-3">
|
<LoginLogoErrorIndicator error={!!error} />
|
||||||
<Logo class="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||||
Sign in to {$appConfigStore.appName}
|
Sign in to {$appConfigStore.appName}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted-foreground mt-2">
|
{#if error}
|
||||||
Authenticate yourself with your passkey to access the admin panel
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
</p>
|
{error}. Please try to sign in again.
|
||||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button>
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
|
Authenticate yourself with your passkey to access the admin panel.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Button class="mt-10" {isLoading} on:click={authenticate}
|
||||||
|
>{error ? 'Try again' : 'Authenticate'}</Button
|
||||||
|
>
|
||||||
</SignInWrapper>
|
</SignInWrapper>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Logo from '$lib/components/logo.svelte';
|
||||||
|
import CrossAnimated from '$lib/icons/cross-animated.svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
const {
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
error: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-2xl p-3 transition-[background-color] duration-300
|
||||||
|
{error ? 'bg-red-200' : 'bg-muted'}"
|
||||||
|
>
|
||||||
|
{#if error}
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center">
|
||||||
|
<CrossAnimated class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div in:fade={{ duration: 300 }}>
|
||||||
|
<Logo class="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
if ($userStore?.isAdmin) {
|
if ($userStore?.isAdmin) {
|
||||||
links = [
|
links = [
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
...links,
|
...links,
|
||||||
{ href: '/settings/admin/users', label: 'Users' },
|
{ href: '/settings/admin/users', label: 'Users' },
|
||||||
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { LucideAlertTriangle } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import AccountForm from './account-form.svelte';
|
import AccountForm from './account-form.svelte';
|
||||||
import PasskeyList from './passkey-list.svelte';
|
import PasskeyList from './passkey-list.svelte';
|
||||||
@@ -52,6 +54,16 @@
|
|||||||
<title>Account Settings</title>
|
<title>Account Settings</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if passkeys.length == 0}
|
||||||
|
<Alert.Root variant="warning">
|
||||||
|
<LucideAlertTriangle class="size-4" />
|
||||||
|
<Alert.Title>Passkey missing</Alert.Title>
|
||||||
|
<Alert.Description
|
||||||
|
>Please add a passkey to prevent losing access to your account.</Alert.Description
|
||||||
|
>
|
||||||
|
</Alert.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if $appConfigStore.allowOwnAccountEdit}
|
{#if $appConfigStore.allowOwnAccountEdit}
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
@@ -77,7 +89,7 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
{#if passkeys.length != 0}
|
{#if passkeys.length != 0}
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<PasskeyList {passkeys} />
|
<PasskeyList bind:passkeys />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -16,9 +16,16 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(2).max(50),
|
firstName: z.string().min(1).max(50),
|
||||||
lastName: z.string().min(2).max(50),
|
lastName: z.string().min(1).max(50),
|
||||||
username: z.string().min(2).max(50),
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(30)
|
||||||
|
.regex(
|
||||||
|
/^[a-z0-9_@.-]+$/,
|
||||||
|
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
|
||||||
|
),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
isAdmin: z.boolean()
|
isAdmin: z.boolean()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,15 +9,10 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
||||||
|
|
||||||
let { passkeys: initialsPasskeys }: { passkeys: Passkey[] } = $props();
|
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
|
||||||
let passkeys = $state<Passkey[]>(initialsPasskeys);
|
|
||||||
|
|
||||||
const webauthnService = new WebauthnService();
|
const webauthnService = new WebauthnService();
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
passkeys = initialsPasskeys;
|
|
||||||
});
|
|
||||||
|
|
||||||
let passkeyToRename: Passkey | null = $state(null);
|
let passkeyToRename: Passkey | null = $state(null);
|
||||||
|
|
||||||
async function deletePasskey(passkey: Passkey) {
|
async function deletePasskey(passkey: Passkey) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import AppConfigService from '$lib/services/app-config-service';
|
||||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -14,7 +16,9 @@
|
|||||||
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
const appConfigService = new AppConfigService();
|
||||||
|
|
||||||
|
let isSendingTestEmail = $state(false);
|
||||||
let emailEnabled = $state(appConfig.emailEnabled);
|
let emailEnabled = $state(appConfig.emailEnabled);
|
||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
@@ -23,27 +27,31 @@
|
|||||||
smtpPort: appConfig.smtpPort,
|
smtpPort: appConfig.smtpPort,
|
||||||
smtpUser: appConfig.smtpUser,
|
smtpUser: appConfig.smtpUser,
|
||||||
smtpPassword: appConfig.smtpPassword,
|
smtpPassword: appConfig.smtpPassword,
|
||||||
smtpFrom: appConfig.smtpFrom
|
smtpFrom: appConfig.smtpFrom,
|
||||||
|
smtpTls: appConfig.smtpTls,
|
||||||
|
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
smtpHost: z.string().min(1),
|
smtpHost: z.string().min(1),
|
||||||
smtpPort: z.number().min(1),
|
smtpPort: z.number().min(1),
|
||||||
smtpUser: z.string().min(1),
|
smtpUser: z.string(),
|
||||||
smtpPassword: z.string().min(1),
|
smtpPassword: z.string(),
|
||||||
smtpFrom: z.string().email()
|
smtpFrom: z.string().email(),
|
||||||
|
smtpTls: z.boolean(),
|
||||||
|
smtpSkipCertVerify: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
|
console.log('submit');
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return false;
|
if (!data) return false;
|
||||||
isLoading = true;
|
|
||||||
await callback({
|
await callback({
|
||||||
...data,
|
...data,
|
||||||
emailEnabled: true
|
emailEnabled: true
|
||||||
}).finally(() => (isLoading = false));
|
});
|
||||||
toast.success('Email configuration updated successfully');
|
toast.success('Email configuration updated successfully');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -59,22 +67,48 @@
|
|||||||
emailEnabled = true;
|
emailEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onTestEmail() {
|
||||||
|
isSendingTestEmail = true;
|
||||||
|
await appConfigService
|
||||||
|
.sendTestEmail()
|
||||||
|
.then(() => toast.success('Test email sent successfully to your Email address.'))
|
||||||
|
.catch(() =>
|
||||||
|
toast.error('Failed to send test email. Check the server logs for more information.')
|
||||||
|
)
|
||||||
|
.finally(() => (isSendingTestEmail = false));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="mt-5 grid grid-cols-2 gap-5">
|
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||||
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
||||||
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
||||||
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||||
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="tls"
|
||||||
|
label="TLS"
|
||||||
|
description="Enable TLS for the SMTP connection."
|
||||||
|
bind:checked={$inputs.smtpTls.value}
|
||||||
|
/>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="skip-cert-verify"
|
||||||
|
label="Skip Certificate Verification"
|
||||||
|
description="This can be useful for self-signed certificates."
|
||||||
|
bind:checked={$inputs.smtpSkipCertVerify.value}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end gap-3">
|
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
||||||
{#if emailEnabled}
|
{#if emailEnabled}
|
||||||
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
||||||
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button>
|
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
||||||
|
>Send Test Email</Button
|
||||||
|
>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
|
<Button onclick={onEnable}>Enable</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
@@ -51,28 +52,18 @@
|
|||||||
description="The duration of a session in minutes before the user has to sign in again."
|
description="The duration of a session in minutes before the user has to sign in again."
|
||||||
bind:input={$inputs.sessionDuration}
|
bind:input={$inputs.sessionDuration}
|
||||||
/>
|
/>
|
||||||
<div class="items-top mt-5 flex space-x-2">
|
<CheckboxWithLabel
|
||||||
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} />
|
id="self-account-editing"
|
||||||
<div class="grid gap-1.5 leading-none">
|
label="Enable Self-Account Editing"
|
||||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
description="Whether the users should be able to edit their own account details."
|
||||||
Enable Self-Account Editing
|
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||||
</Label>
|
/>
|
||||||
<p class="text-muted-foreground text-[0.8rem]">
|
<CheckboxWithLabel
|
||||||
Whether the user should be able to edit their own account details.
|
id="emails-verified"
|
||||||
</p>
|
label="Emails Verified"
|
||||||
</div>
|
description="Whether the user's email should be marked as verified for the OIDC clients."
|
||||||
</div>
|
bind:checked={$inputs.emailsVerified.value}
|
||||||
<div class="items-top mt-5 flex space-x-2">
|
/>
|
||||||
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
|
|
||||||
<div class="grid gap-1.5 leading-none">
|
|
||||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
|
||||||
Emails Verified
|
|
||||||
</Label>
|
|
||||||
<p class="text-muted-foreground text-[0.8rem]">
|
|
||||||
Whether the user's email should be marked as verified for the OIDC clients.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">Save</Button>
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
|||||||
@@ -26,13 +26,19 @@
|
|||||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
|
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||||
|
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
let success = true;
|
let success = true;
|
||||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
const imagePromise =
|
||||||
|
updatedClient.logo !== undefined
|
||||||
|
? oidcService.updateClientLogo(client, updatedClient.logo)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
client.isPublic = updatedClient.isPublic;
|
||||||
|
|
||||||
await Promise.all([dataPromise, imagePromise])
|
await Promise.all([dataPromise, imagePromise])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -93,27 +99,29 @@
|
|||||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 mt-1 flex items-center">
|
{#if !client.isPublic}
|
||||||
<Label class="w-44">Client secret</Label>
|
<div class="mb-2 mt-1 flex items-center">
|
||||||
{#if $clientSecretStore}
|
<Label class="w-44">Client secret</Label>
|
||||||
<CopyToClipboard value={$clientSecretStore}>
|
{#if $clientSecretStore}
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
<CopyToClipboard value={$clientSecretStore}>
|
||||||
{$clientSecretStore}
|
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||||
</span>
|
{$clientSecretStore}
|
||||||
</CopyToClipboard>
|
</span>
|
||||||
{:else}
|
</CopyToClipboard>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
{:else}
|
||||||
>••••••••••••••••••••••••••••••••</span
|
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||||
>
|
>••••••••••••••••••••••••••••••••</span
|
||||||
<Button
|
>
|
||||||
class="ml-2"
|
<Button
|
||||||
onclick={createClientSecret}
|
class="ml-2"
|
||||||
size="sm"
|
onclick={createClientSecret}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
variant="ghost"
|
||||||
>
|
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||||
{/if}
|
>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if showAllDetails}
|
{#if showAllDetails}
|
||||||
<div transition:slide>
|
<div transition:slide>
|
||||||
{#each Object.entries(setupDetails) as [key, value]}
|
{#each Object.entries(setupDetails) as [key, value]}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import FileInput from '$lib/components/file-input.svelte';
|
import FileInput from '$lib/components/file-input.svelte';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -21,19 +22,21 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let logo = $state<File | null>(null);
|
let logo = $state<File | null | undefined>();
|
||||||
let logoDataURL: string | null = $state(
|
let logoDataURL: string | null = $state(
|
||||||
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const client: OidcClientCreate = {
|
const client: OidcClientCreate = {
|
||||||
name: existingClient?.name || '',
|
name: existingClient?.name || '',
|
||||||
callbackURLs: existingClient?.callbackURLs || [""]
|
callbackURLs: existingClient?.callbackURLs || [''],
|
||||||
|
isPublic: existingClient?.isPublic || false
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
callbackURLs: z.array(z.string().url()).nonempty()
|
callbackURLs: z.array(z.string().url()).nonempty(),
|
||||||
|
isPublic: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = typeof formSchema;
|
type FormSchema = typeof formSchema;
|
||||||
@@ -71,15 +74,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div class="grid grid-cols-2 gap-3 sm:flex-row">
|
||||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||||
<OidcCallbackUrlInput
|
<OidcCallbackUrlInput
|
||||||
class="w-full"
|
class="w-full"
|
||||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||||
bind:error={$inputs.callbackURLs.error}
|
bind:error={$inputs.callbackURLs.error}
|
||||||
/>
|
/>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="public-client"
|
||||||
|
label="Public Client"
|
||||||
|
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
|
||||||
|
bind:checked={$inputs.isPublic.value}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-8">
|
||||||
<Label for="logo">Logo</Label>
|
<Label for="logo">Logo</Label>
|
||||||
<div class="mt-2 flex items-end gap-3">
|
<div class="mt-2 flex items-end gap-3">
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
@@ -99,7 +108,7 @@
|
|||||||
onchange={onLogoChange}
|
onchange={onLogoChange}
|
||||||
>
|
>
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
|
||||||
</Button>
|
</Button>
|
||||||
</FileInput>
|
</FileInput>
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user