mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 15:22:59 +03:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62cdab2b59 | ||
|
|
f2bfc73158 | ||
|
|
a9f4dada32 | ||
|
|
f9fa2c6706 | ||
|
|
7d6b1d19e9 | ||
|
|
31a6b57ec1 | ||
|
|
f11ed44733 | ||
|
|
541481721f | ||
|
|
0e95e9c56f | ||
|
|
fcf08a4d89 | ||
|
|
0b4101ccce | ||
|
|
27ea1fc2d3 | ||
|
|
f637a89f57 | ||
|
|
058084ed64 | ||
|
|
9370292fe5 | ||
|
|
46eef1fcb7 | ||
|
|
e784093342 | ||
|
|
653d948f73 | ||
|
|
a1302ef7bf | ||
|
|
5f44fef85f | ||
|
|
3613ac261c | ||
|
|
760c8e83bb | ||
|
|
3f29325f45 | ||
|
|
aca2240a50 | ||
|
|
de45398903 | ||
|
|
3d3fb4d855 | ||
|
|
725388fcc7 | ||
|
|
ad1d3560f9 | ||
|
|
becfc0004a | ||
|
|
376d747616 | ||
|
|
5b9f4d7326 | ||
|
|
0de4b55dc4 | ||
|
|
78c88f5339 | ||
|
|
60e7dafa01 | ||
|
|
2ccabf835c | ||
|
|
590cb02f6c | ||
|
|
8c96ab9574 | ||
|
|
3484daf870 | ||
|
|
cfbc0d6d35 | ||
|
|
939601b6a4 | ||
|
|
b9daa5d757 | ||
|
|
8304065652 | ||
|
|
7bfc3f43a5 | ||
|
|
c056089c60 | ||
|
|
3350398abc | ||
|
|
0b0a6781ff | ||
|
|
735dc70d5f | ||
|
|
47e164b4b5 | ||
|
|
18c5103c20 | ||
|
|
5565f60d6d |
@@ -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
|
||||
TRUST_PROXY=false
|
||||
TRUST_PROXY=false
|
||||
MAXMIND_LICENSE_KEY=
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
@@ -11,20 +11,35 @@ jobs:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Download GeoLite2 City database
|
||||
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||
- name: 'Login to GitHub Container Registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.repository_owner}}
|
||||
password: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
@@ -32,6 +47,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
13
.github/workflows/e2e-tests.yml
vendored
13
.github/workflows/e2e-tests.yml
vendored
@@ -15,12 +15,10 @@ jobs:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Create dummy GeoLite2 City database
|
||||
run: touch ./backend/GeoLite2-City.mmdb
|
||||
|
||||
|
||||
- name: Build Docker Image
|
||||
run: docker build -t stonith404/pocket-id .
|
||||
|
||||
- name: Run Docker Container
|
||||
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
|
||||
|
||||
@@ -36,13 +34,10 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
|
||||
- name: Get container logs
|
||||
if: always()
|
||||
run: docker logs pocket-id
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/tests/.output
|
||||
path: frontend/tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,5 +34,6 @@ vite.config.ts.timestamp-*
|
||||
# Application specific
|
||||
data
|
||||
/frontend/tests/.auth
|
||||
/frontend/tests/.report
|
||||
pocket-id-backend
|
||||
/backend/GeoLite2-City.mmdb
|
||||
104
CHANGELOG.md
104
CHANGELOG.md
@@ -1,3 +1,107 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.17.0...v) (2024-11-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add option to disable TLS for email sending ([f9fa2c6](https://github.com/stonith404/pocket-id/commit/f9fa2c6706a8bf949fe5efd6664dec8c80e18659))
|
||||
* allow empty user and password in SMTP configuration ([a9f4dad](https://github.com/stonith404/pocket-id/commit/a9f4dada321841d3611b15775307228b34e7793f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* email save toast shows two times ([f2bfc73](https://github.com/stonith404/pocket-id/commit/f2bfc731585ad7424eb8c4c41c18368fc0f75ffc))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* add option to specify the Max Mind license key for the Geolite2 db
|
||||
|
||||
### Features
|
||||
|
||||
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
|
||||
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
|
||||
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
|
||||
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
|
||||
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
|
||||
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
|
||||
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
|
||||
|
||||
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -21,7 +21,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
||||
|
||||
# Stage 3: Production Image
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache caddy
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk add --no-cache caddy curl su-exec
|
||||
COPY ./reverse-proxy /etc/caddy/
|
||||
|
||||
WORKDIR /app
|
||||
@@ -31,15 +34,14 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
||||
|
||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
||||
COPY --from=backend-builder /app/backend/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
|
||||
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||
|
||||
COPY ./scripts ./scripts
|
||||
RUN chmod +x ./scripts/*.sh
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
ENV APP_ENV=production
|
||||
|
||||
# Use a shell form to run both the frontend and backend
|
||||
CMD ["sh", "./scripts/docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
59
README.md
59
README.md
@@ -11,7 +11,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
## Setup
|
||||
|
||||
> [!WARNING]
|
||||
> Pocket ID is in its early stages and may contain bugs.
|
||||
> Pocket ID 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
|
||||
|
||||
@@ -68,10 +68,6 @@ Required tools:
|
||||
cd ..
|
||||
pm2 start pocket-id-backend --name pocket-id-backend
|
||||
|
||||
# Optional: Download the GeoLite2 city database.
|
||||
# If not downloaded the ip location in the audit log will be empty.
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
@@ -85,28 +81,23 @@ Required tools:
|
||||
|
||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||
|
||||
### Add Pocket ID as an OIDC provider
|
||||
### Nginx Reverse Proxy
|
||||
|
||||
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
|
||||
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
|
||||
|
||||
After you have added the client, you can obtain the client ID and client secret.
|
||||
```nginx
|
||||
proxy_busy_buffers_size 512k;
|
||||
proxy_buffers 4 512k;
|
||||
proxy_buffer_size 256k;
|
||||
```
|
||||
|
||||
You may need the following information:
|
||||
## Proxy Services with Pocket ID
|
||||
|
||||
- **Authorization URL**: `https://<your-domain>/authorize`
|
||||
- **Token URL**: `https://<your-domain>/api/oidc/token`
|
||||
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
||||
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
|
||||
|
||||
### Proxy Services with Pocket ID
|
||||
|
||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
|
||||
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/oauth2-proxy) to add authentication to your services that don't support OIDC.
|
||||
|
||||
See the [guide](docs/proxy-services.md) for more information.
|
||||
|
||||
### Update
|
||||
## Update
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -135,9 +126,6 @@ docker compose up -d
|
||||
cd ..
|
||||
pm2 start pocket-id-backend --name pocket-id-backend
|
||||
|
||||
# Optional: Update the GeoLite2 city database
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
@@ -149,18 +137,21 @@ docker compose up -d
|
||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||
| `MAXMIND_LICENSE_KEY` | `-` | yes | License Key for the GeoLite2 Database. The license key is required to retrieve the geographical location of IP addresses in the audit log. If the key is not provided, IP locations will be marked as "unknown." You can obtain a license key for free [here](https://www.maxmind.com/en/geolite2/signup). |
|
||||
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `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
|
||||
|
||||
|
||||
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 -}}
|
||||
@@ -19,6 +19,7 @@ func newDatabase() (db *gorm.DB) {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
sqlDb, err := db.DB()
|
||||
sqlDb.SetMaxOpenConns(1)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get sql.DB: %v", err)
|
||||
}
|
||||
|
||||
@@ -30,20 +30,23 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
|
||||
// Initialize services
|
||||
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
||||
emailService, err := service.NewEmailService(appConfigService, db, templateDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create email service: %s", err)
|
||||
}
|
||||
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
||||
geoLiteService := service.NewGeoLiteService()
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||
userService := service.NewUserService(db, jwtService)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService)
|
||||
customClaimService := service.NewCustomClaimService(db)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
userGroupService := service.NewUserGroupService(db)
|
||||
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||
|
||||
@@ -55,10 +58,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
apiGroup := r.Group("/api")
|
||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
|
||||
@@ -14,6 +14,8 @@ type EnvConfigSchema struct {
|
||||
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{
|
||||
@@ -24,6 +26,8 @@ var EnvConfig = &EnvConfigSchema{
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
EmailTemplatesPath: "./email-templates",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1,19 +1,162 @@
|
||||
package common
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUsernameTaken = errors.New("username is already taken")
|
||||
ErrEmailTaken = errors.New("email is already taken")
|
||||
ErrSetupAlreadyCompleted = errors.New("setup already completed")
|
||||
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
|
||||
ErrOidcMissingAuthorization = errors.New("missing authorization")
|
||||
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
|
||||
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
|
||||
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
|
||||
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
|
||||
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
|
||||
ErrFileTypeNotSupported = errors.New("file type not supported")
|
||||
ErrInvalidCredentials = errors.New("no user found with provided credentials")
|
||||
ErrNameAlreadyInUse = errors.New("name is already in use")
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AppError interface {
|
||||
error
|
||||
HttpStatusCode() int
|
||||
}
|
||||
|
||||
// Custom error types for various conditions
|
||||
|
||||
type AlreadyInUseError struct {
|
||||
Property string
|
||||
}
|
||||
|
||||
func (e *AlreadyInUseError) Error() string {
|
||||
return fmt.Sprintf("%s is already in use", e.Property)
|
||||
}
|
||||
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type SetupAlreadyCompletedError struct{}
|
||||
|
||||
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type TokenInvalidOrExpiredError struct{}
|
||||
|
||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcMissingAuthorizationError struct{}
|
||||
|
||||
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcGrantTypeNotSupportedError struct{}
|
||||
|
||||
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcMissingClientCredentialsError struct{}
|
||||
|
||||
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcClientSecretInvalidError struct{}
|
||||
|
||||
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcInvalidAuthorizationCodeError struct{}
|
||||
|
||||
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcInvalidCallbackURLError struct{}
|
||||
|
||||
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" }
|
||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type FileTypeNotSupportedError struct{}
|
||||
|
||||
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type InvalidCredentialsError struct{}
|
||||
|
||||
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
|
||||
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type FileTooLargeError struct {
|
||||
MaxSize string
|
||||
}
|
||||
|
||||
func (e *FileTooLargeError) Error() string {
|
||||
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
|
||||
}
|
||||
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||
|
||||
type NotSignedInError struct{}
|
||||
|
||||
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
||||
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type MissingPermissionError struct{}
|
||||
|
||||
func (e *MissingPermissionError) Error() string {
|
||||
return "You don't have permission to perform this action"
|
||||
}
|
||||
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type TooManyRequestsError struct{}
|
||||
|
||||
func (e *TooManyRequestsError) Error() string {
|
||||
return "Too many requests. Please wait a while before trying again."
|
||||
}
|
||||
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||
|
||||
type ClientIdOrSecretNotProvidedError struct{}
|
||||
|
||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||
return "Client id or secret not provided"
|
||||
}
|
||||
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type WrongFileTypeError struct {
|
||||
ExpectedFileType string
|
||||
}
|
||||
|
||||
func (e *WrongFileTypeError) Error() string {
|
||||
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
|
||||
}
|
||||
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type MissingSessionIdError struct{}
|
||||
|
||||
func (e *MissingSessionIdError) Error() string {
|
||||
return "Missing session id"
|
||||
}
|
||||
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ReservedClaimError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *ReservedClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||
}
|
||||
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DuplicateClaimError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *DuplicateClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||
}
|
||||
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type AccountEditNotAllowedError struct{}
|
||||
|
||||
func (e *AccountEditNotAllowedError) Error() string {
|
||||
return "You are not allowed to edit your account"
|
||||
}
|
||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidCodeVerifierError struct{}
|
||||
|
||||
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||
return "Invalid code verifier"
|
||||
}
|
||||
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcMissingCodeChallengeError struct{}
|
||||
|
||||
func (e *OidcMissingCodeChallengeError) Error() string {
|
||||
return "Missing code challenge"
|
||||
}
|
||||
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
@@ -15,10 +14,13 @@ import (
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||
appConfigService *service.AppConfigService) {
|
||||
appConfigService *service.AppConfigService,
|
||||
emailService *service.EmailService,
|
||||
) {
|
||||
|
||||
acc := &AppConfigController{
|
||||
appConfigService: appConfigService,
|
||||
emailService: emailService,
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
||||
@@ -30,22 +32,25 @@ func NewAppConfigController(
|
||||
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
||||
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
||||
|
||||
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
emailService *service.EmailService
|
||||
}
|
||||
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
configuration, err := acc.appConfigService.ListAppConfig(false)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,13 +60,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
configuration, err := acc.appConfigService.ListAppConfig(true)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,19 +76,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,13 +141,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if fileType != "ico" {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
|
||||
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||
return
|
||||
}
|
||||
acc.updateImage(c, "favicon", "ico")
|
||||
@@ -164,17 +169,23 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
err := acc.emailService.SendTestEmail()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
||||
@@ -31,7 +30,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
// Fetch audit logs for the user
|
||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +38,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
78
backend/internal/controller/custom_claim_controller.go
Normal file
78
backend/internal/controller/custom_claim_controller.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
||||
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
||||
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||
}
|
||||
|
||||
type CustomClaimController struct {
|
||||
customClaimService *service.CustomClaimService
|
||||
}
|
||||
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, claims)
|
||||
}
|
||||
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
}
|
||||
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var customClaimsDto []dto.CustomClaimDto
|
||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, customClaimsDto)
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -42,19 +39,13 @@ type OidcController struct {
|
||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcMissingAuthorization) {
|
||||
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
|
||||
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,17 +60,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,10 +79,13 @@ func (oc *OidcController) authorizeNewClientHandler(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 {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,25 +93,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
clientSecret := input.ClientSecret
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if clientID == "" || clientSecret == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||
if !ok {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
|
||||
return
|
||||
}
|
||||
if clientID == "" && clientSecret == "" {
|
||||
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||
}
|
||||
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
|
||||
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
|
||||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
|
||||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,14 +110,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
||||
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
||||
if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
userID := jwtClaims.Subject
|
||||
clientId := jwtClaims.Audience[0]
|
||||
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -150,7 +128,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(clientId)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -171,7 +149,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
@@ -181,13 +159,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientsDto []dto.OidcClientDto
|
||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,19 +178,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +200,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -232,19 +210,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -254,7 +232,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,7 +242,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -275,17 +253,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrFileTypeNotSupported) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -295,7 +269,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -19,17 +18,22 @@ type TestController struct {
|
||||
|
||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetAppConfig(); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService) {
|
||||
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||
uc := UserController{
|
||||
UserService: userService,
|
||||
UserService: userService,
|
||||
AppConfigService: appConfigService,
|
||||
}
|
||||
|
||||
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
||||
@@ -33,7 +32,8 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
UserService *service.UserService
|
||||
UserService *service.UserService
|
||||
AppConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
@@ -43,13 +43,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
|
||||
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var usersDto []dto.UserDto
|
||||
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,13 +62,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||
user, err := uc.UserService.GetUser(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,13 +78,13 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
user, err := uc.UserService.GetUser(c.GetString("userID"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
|
||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,23 +103,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := uc.UserService.CreateUser(input)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,19 +127,23 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
||||
c.Error(&common.AccountEditNotAllowedError{})
|
||||
return
|
||||
}
|
||||
uc.updateUser(c, true)
|
||||
}
|
||||
|
||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
var input dto.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
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 {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,17 +153,13 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrTokenInvalidOrExpired) {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -174,17 +170,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.UserService.SetupInitialAdmin()
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,7 +187,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,17 +200,13 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
|
||||
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
||||
@@ -37,7 +34,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
|
||||
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,12 +42,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
for i, group := range groups {
|
||||
var groupDto dto.UserGroupDtoWithUserCount
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
groupsDto[i] = groupDto
|
||||
@@ -65,13 +62,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,23 +78,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Create(input)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,23 +100,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
||||
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +121,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -142,19 +131,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
var input dto.UserGroupUpdateUsersDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var groupDto dto.UserGroupDtoWithUsers
|
||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
@@ -38,7 +36,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
options, err := wc.webAuthnService.BeginRegistration(userID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,20 +47,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,7 +70,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||
options, err := wc.webAuthnService.BeginLogin()
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,13 +81,13 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
|
||||
c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,17 +95,13 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||
|
||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrInvalidCredentials) {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
|
||||
} else {
|
||||
utils.ControllerError(c, err)
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,13 +113,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDtos []dto.WebauthnCredentialDto
|
||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +132,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
||||
|
||||
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,19 +145,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
||||
|
||||
var input dto.WebauthnCredentialUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDto dto.WebauthnCredentialDto
|
||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -21,7 +20,7 @@ type WellKnownController struct {
|
||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||
jwk, err := wkc.jwtService.GetJWK()
|
||||
if err != nil {
|
||||
utils.ControllerError(c, err)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,7 +36,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "preferred_username"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
|
||||
@@ -12,12 +12,16 @@ type AppConfigVariableDto struct {
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
||||
SmtHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
EmailEnabled string `json:"emailEnabled" binding:"required"`
|
||||
SmtHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
SmtpTls string `json:"smtpTls"`
|
||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package dto
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"time"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type AuditLogDto struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
|
||||
Event model.AuditLogEvent `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
|
||||
11
backend/internal/dto/custom_claim_dto.go
Normal file
11
backend/internal/dto/custom_claim_dto.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package dto
|
||||
|
||||
type CustomClaimDto struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type CustomClaimCreateDto struct {
|
||||
Key string `json:"key" binding:"required,claimKey"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
}
|
||||
@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
|
||||
type OidcClientDto struct {
|
||||
PublicOidcClientDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
CreatedBy UserDto `json:"createdBy"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
type OidcCreateTokensDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ package dto
|
||||
import "time"
|
||||
|
||||
type UserDto struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type UserGroupDtoWithUsers struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
Users []UserDto `json:"users"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
Users []UserDto `json:"users"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserGroupDtoWithUserCount struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
UserCount int64 `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserCount int64 `json:"userCount"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserGroupCreateDto struct {
|
||||
|
||||
@@ -29,8 +29,15 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
}
|
||||
|
||||
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
||||
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores
|
||||
regex := "^[a-z0-9_]+$"
|
||||
// The string can only contain lowercase letters, numbers, and underscores
|
||||
regex := "^[a-z0-9_]*$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
|
||||
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
|
||||
// The string can only contain letters and numbers
|
||||
regex := "^[A-Za-z0-9]*$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
@@ -52,4 +59,10 @@ func init() {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"time"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type WebauthnCredentialDto struct {
|
||||
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type CorsMiddleware struct{}
|
||||
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
|
||||
}
|
||||
|
||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||
return cors.New(cors.Config{
|
||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
||||
AllowMethods: []string{"*"},
|
||||
AllowHeaders: []string{"*"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
})
|
||||
return func(c *gin.Context) {
|
||||
// Allow all origins for the token endpoint
|
||||
if c.FullPath() == "/api/oidc/token" {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
package utils
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
type ErrorHandlerMiddleware struct{}
|
||||
|
||||
func ControllerError(c *gin.Context, err error) {
|
||||
// Check for record not found errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
CustomControllerError(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
|
||||
return &ErrorHandlerMiddleware{}
|
||||
}
|
||||
|
||||
func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
for _, err := range c.Errors {
|
||||
|
||||
// Check for record not found errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
errorResponse(c, http.StatusNotFound, "Record not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for slice validation errors
|
||||
var sliceValidationErrors binding.SliceValidationError
|
||||
if errors.As(err, &sliceValidationErrors) {
|
||||
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var appErr common.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for validation errors
|
||||
var validationErrors validator.ValidationErrors
|
||||
if errors.As(err, &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
CustomControllerError(c, http.StatusBadRequest, message)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
log.Println(err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||
func errorResponse(c *gin.Context, statusCode int, message string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, gin.H{"error": message})
|
||||
}
|
||||
|
||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
@@ -67,9 +97,3 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
|
||||
return combinedErrors
|
||||
}
|
||||
|
||||
func CustomControllerError(c *gin.Context, statusCode int, message string) {
|
||||
// Capitalize the first letter of the message
|
||||
message = strings.ToUpper(message[:1]) + message[1:]
|
||||
c.JSON(statusCode, gin.H{"error": message})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package middleware
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,8 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
|
||||
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
|
||||
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
|
||||
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
|
||||
c.Error(err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -29,7 +28,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Error(&common.NotSignedInError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -40,14 +39,14 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
} else if err != nil {
|
||||
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
|
||||
c.Error(&common.NotSignedInError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
|
||||
c.Error(&common.MissingPermissionError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package middleware
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +31,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
|
||||
limiter := getLimiter(ip, limit, burst)
|
||||
if !limiter.Allow() {
|
||||
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
|
||||
c.Error(&common.TooManyRequestsError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
package model
|
||||
|
||||
type AppConfigVariable struct {
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Type string
|
||||
IsPublic bool
|
||||
IsInternal bool
|
||||
Value string
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Type string
|
||||
IsPublic bool
|
||||
IsInternal bool
|
||||
Value string
|
||||
DefaultValue string
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
AppName AppConfigVariable
|
||||
SessionDuration AppConfigVariable
|
||||
EmailsVerified AppConfigVariable
|
||||
AllowOwnAccountEdit AppConfigVariable
|
||||
|
||||
BackgroundImageType AppConfigVariable
|
||||
LogoLightImageType AppConfigVariable
|
||||
LogoDarkImageType AppConfigVariable
|
||||
SessionDuration AppConfigVariable
|
||||
|
||||
EmailEnabled AppConfigVariable
|
||||
SmtpHost AppConfigVariable
|
||||
SmtpPort AppConfigVariable
|
||||
SmtpFrom AppConfigVariable
|
||||
SmtpUser AppConfigVariable
|
||||
SmtpPassword AppConfigVariable
|
||||
EmailEnabled AppConfigVariable
|
||||
SmtpHost AppConfigVariable
|
||||
SmtpPort AppConfigVariable
|
||||
SmtpFrom AppConfigVariable
|
||||
SmtpUser AppConfigVariable
|
||||
SmtpPassword AppConfigVariable
|
||||
SmtpTls AppConfigVariable
|
||||
SmtpSkipCertVerify AppConfigVariable
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ type AuditLogData map[string]string
|
||||
type AuditLogEvent string
|
||||
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||
)
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
|
||||
11
backend/internal/model/custom_claim.go
Normal file
11
backend/internal/model/custom_claim.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type CustomClaim struct {
|
||||
Base
|
||||
|
||||
Key string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt datatype.DateTime
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
CodeChallenge *string
|
||||
CodeChallengeMethodSha256 *bool
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
@@ -39,6 +41,7 @@ type OidcClient struct {
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
|
||||
@@ -15,8 +15,9 @@ type User struct {
|
||||
LastName string
|
||||
IsAdmin bool
|
||||
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
@@ -58,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
return descriptors
|
||||
}
|
||||
|
||||
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string
|
||||
|
||||
@@ -5,4 +5,5 @@ type UserGroup struct {
|
||||
FriendlyName string
|
||||
Name string `gorm:"unique"`
|
||||
Users []User `gorm:"many2many:user_groups_users;"`
|
||||
CustomClaims []CustomClaim
|
||||
}
|
||||
|
||||
@@ -31,38 +31,49 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
||||
|
||||
var defaultDbConfig = model.AppConfig{
|
||||
AppName: model.AppConfigVariable{
|
||||
Key: "appName",
|
||||
Type: "string",
|
||||
IsPublic: true,
|
||||
Value: "Pocket ID",
|
||||
Key: "appName",
|
||||
Type: "string",
|
||||
IsPublic: true,
|
||||
DefaultValue: "Pocket ID",
|
||||
},
|
||||
SessionDuration: model.AppConfigVariable{
|
||||
Key: "sessionDuration",
|
||||
Type: "number",
|
||||
Value: "60",
|
||||
Key: "sessionDuration",
|
||||
Type: "number",
|
||||
DefaultValue: "60",
|
||||
},
|
||||
EmailsVerified: model.AppConfigVariable{
|
||||
Key: "emailsVerified",
|
||||
Type: "bool",
|
||||
DefaultValue: "false",
|
||||
},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{
|
||||
Key: "allowOwnAccountEdit",
|
||||
Type: "bool",
|
||||
IsPublic: true,
|
||||
DefaultValue: "true",
|
||||
},
|
||||
BackgroundImageType: model.AppConfigVariable{
|
||||
Key: "backgroundImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "jpg",
|
||||
Key: "backgroundImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
DefaultValue: "jpg",
|
||||
},
|
||||
LogoLightImageType: model.AppConfigVariable{
|
||||
Key: "logoLightImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "svg",
|
||||
Key: "logoLightImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
DefaultValue: "svg",
|
||||
},
|
||||
LogoDarkImageType: model.AppConfigVariable{
|
||||
Key: "logoDarkImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
Value: "svg",
|
||||
Key: "logoDarkImageType",
|
||||
Type: "string",
|
||||
IsInternal: true,
|
||||
DefaultValue: "svg",
|
||||
},
|
||||
EmailEnabled: model.AppConfigVariable{
|
||||
Key: "emailEnabled",
|
||||
Type: "bool",
|
||||
Value: "false",
|
||||
Key: "emailEnabled",
|
||||
Type: "bool",
|
||||
DefaultValue: "false",
|
||||
},
|
||||
SmtpHost: model.AppConfigVariable{
|
||||
Key: "smtpHost",
|
||||
@@ -84,6 +95,16 @@ var defaultDbConfig = model.AppConfig{
|
||||
Key: "smtpPassword",
|
||||
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) {
|
||||
@@ -115,7 +136,7 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
|
||||
|
||||
tx.Commit()
|
||||
|
||||
if err := s.loadDbConfigFromDb(); err != nil {
|
||||
if err := s.LoadDbConfigFromDb(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -129,7 +150,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
|
||||
return err
|
||||
}
|
||||
|
||||
return s.loadDbConfigFromDb()
|
||||
return s.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
|
||||
@@ -146,6 +167,13 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the value to the default value if it is empty
|
||||
for i := range configuration {
|
||||
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||
configuration[i].Value = configuration[i].DefaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return configuration, nil
|
||||
}
|
||||
|
||||
@@ -153,7 +181,7 @@ func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, image
|
||||
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
||||
mimeType := utils.GetImageMimeType(fileType)
|
||||
if mimeType == "" {
|
||||
return common.ErrFileTypeNotSupported
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
// Delete the old image if it has a different file type
|
||||
@@ -201,10 +229,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
||||
}
|
||||
|
||||
// Update existing configuration if it differs from the default
|
||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal {
|
||||
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
|
||||
storedConfigVar.Type = defaultConfigVar.Type
|
||||
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
|
||||
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
|
||||
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
|
||||
if err := s.db.Save(&storedConfigVar).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -224,10 +253,11 @@ func (s *AppConfigService) InitDbConfig() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.loadDbConfigFromDb()
|
||||
return s.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromDb() error {
|
||||
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
|
||||
func (s *AppConfigService) LoadDbConfigFromDb() error {
|
||||
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
|
||||
|
||||
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
|
||||
@@ -238,6 +268,10 @@ func (s *AppConfigService) loadDbConfigFromDb() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||
}
|
||||
|
||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,27 @@ package service
|
||||
|
||||
import (
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
type AuditLogService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
emailService *EmailService
|
||||
geoliteService *GeoLiteService
|
||||
}
|
||||
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
|
||||
}
|
||||
|
||||
// Create creates a new audit log entry in the database
|
||||
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||
country, city, err := s.GetIpLocation(ipAddress)
|
||||
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get IP location: %v\n", err)
|
||||
}
|
||||
@@ -48,8 +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
|
||||
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
|
||||
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
|
||||
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
|
||||
|
||||
// Count the number of times the user has logged in from the same device
|
||||
var count int64
|
||||
@@ -97,29 +96,3 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
||||
ua := userAgentParser.Parse(userAgent)
|
||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||
}
|
||||
|
||||
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
|
||||
db, err := maxminddb.Open("GeoLite2-City.mmdb")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
addr := netip.MustParseAddr(ipAddress)
|
||||
|
||||
var record struct {
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
Country struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
err = db.Lookup(addr).Decode(&record)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||
}
|
||||
|
||||
197
backend/internal/service/custom_claim_service.go
Normal file
197
backend/internal/service/custom_claim_service.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Reserved claims
|
||||
var reservedClaims = map[string]struct{}{
|
||||
"given_name": {},
|
||||
"family_name": {},
|
||||
"name": {},
|
||||
"email": {},
|
||||
"preferred_username": {},
|
||||
"groups": {},
|
||||
"sub": {},
|
||||
"iss": {},
|
||||
"aud": {},
|
||||
"exp": {},
|
||||
"iat": {},
|
||||
"auth_time": {},
|
||||
"nonce": {},
|
||||
"acr": {},
|
||||
"amr": {},
|
||||
"azp": {},
|
||||
"nbf": {},
|
||||
"jti": {},
|
||||
}
|
||||
|
||||
type CustomClaimService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
|
||||
return &CustomClaimService{db: db}
|
||||
}
|
||||
|
||||
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
|
||||
func isReservedClaim(key string) bool {
|
||||
_, ok := reservedClaims[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// idType is the type of the id used to identify the user or user group
|
||||
type idType string
|
||||
|
||||
const (
|
||||
UserID idType = "user_id"
|
||||
UserGroupID idType = "user_group_id"
|
||||
)
|
||||
|
||||
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
return s.updateCustomClaims(UserID, userID, claims)
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
return s.updateCustomClaims(UserGroupID, userGroupID, claims)
|
||||
}
|
||||
|
||||
// updateCustomClaims updates the custom claims for a user or user group
|
||||
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
// Check for duplicate keys in the claims slice
|
||||
seenKeys := make(map[string]bool)
|
||||
for _, claim := range claims {
|
||||
if seenKeys[claim.Key] {
|
||||
return nil, &common.DuplicateClaimError{Key: claim.Key}
|
||||
}
|
||||
seenKeys[claim.Key] = true
|
||||
}
|
||||
|
||||
var existingClaims []model.CustomClaim
|
||||
err := s.db.Where(string(idType), value).Find(&existingClaims).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete claims that are not in the new list
|
||||
for _, existingClaim := range existingClaims {
|
||||
found := false
|
||||
for _, claim := range claims {
|
||||
if claim.Key == existingClaim.Key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = s.db.Delete(&existingClaim).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update claims
|
||||
for _, claim := range claims {
|
||||
if isReservedClaim(claim.Key) {
|
||||
return nil, &common.ReservedClaimError{Key: claim.Key}
|
||||
}
|
||||
customClaim := model.CustomClaim{
|
||||
Key: claim.Key,
|
||||
Value: claim.Value,
|
||||
}
|
||||
|
||||
if idType == UserID {
|
||||
customClaim.UserID = &value
|
||||
} else if idType == UserGroupID {
|
||||
customClaim.UserGroupID = &value
|
||||
}
|
||||
|
||||
// Update the claim if it already exists or create a new one
|
||||
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the updated claims
|
||||
var updatedClaims []model.CustomClaim
|
||||
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) {
|
||||
var customClaims []model.CustomClaim
|
||||
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error
|
||||
return customClaims, err
|
||||
}
|
||||
|
||||
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) {
|
||||
var customClaims []model.CustomClaim
|
||||
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error
|
||||
return customClaims, err
|
||||
}
|
||||
|
||||
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
|
||||
// prioritizing the user's claims over user group claims with the same key.
|
||||
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) {
|
||||
// Get the custom claims of the user
|
||||
customClaims, err := s.GetCustomClaimsForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store user's claims in a map to prioritize and prevent duplicates
|
||||
claimsMap := make(map[string]model.CustomClaim)
|
||||
for _, claim := range customClaims {
|
||||
claimsMap[claim.Key] = claim
|
||||
}
|
||||
|
||||
// Get all user groups of the user
|
||||
var userGroupsOfUser []model.UserGroup
|
||||
err = s.db.Preload("CustomClaims").
|
||||
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
|
||||
Where("user_groups_users.user_id = ?", userID).
|
||||
Find(&userGroupsOfUser).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add only non-duplicate custom claims from user groups
|
||||
for _, userGroup := range userGroupsOfUser {
|
||||
for _, groupClaim := range userGroup.CustomClaims {
|
||||
// Only add claim if it does not exist in the user's claims
|
||||
if _, exists := claimsMap[groupClaim.Key]; !exists {
|
||||
claimsMap[groupClaim.Key] = groupClaim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the claimsMap back to a slice
|
||||
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
|
||||
for _, claim := range claimsMap {
|
||||
finalClaims = append(finalClaims, claim)
|
||||
}
|
||||
|
||||
return finalClaims, nil
|
||||
}
|
||||
|
||||
// GetSuggestions returns a list of custom claim keys that have been used before
|
||||
func (s *CustomClaimService) GetSuggestions() ([]string, error) {
|
||||
var customClaimsKeys []string
|
||||
|
||||
err := s.db.Model(&model.CustomClaim{}).
|
||||
Group("key").
|
||||
Order("COUNT(*) DESC").
|
||||
Pluck("key", &customClaimsKeys).Error
|
||||
|
||||
return customClaimsKeys, err
|
||||
}
|
||||
@@ -2,14 +2,18 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
ttemplate "text/template"
|
||||
@@ -17,11 +21,12 @@ import (
|
||||
|
||||
type EmailService struct {
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
}
|
||||
|
||||
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
||||
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
|
||||
|
||||
return &EmailService{
|
||||
appConfigService: appConfigService,
|
||||
db: db,
|
||||
htmlTemplates: htmlTemplates,
|
||||
textTemplates: textTemplates,
|
||||
}, 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 {
|
||||
// Check if SMTP settings are set
|
||||
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)
|
||||
|
||||
// Set up the authentication information.
|
||||
auth := smtp.PlainAuth("",
|
||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
)
|
||||
|
||||
// Send the email
|
||||
err = smtp.SendMail(
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
|
||||
auth,
|
||||
srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||
[]string{toEmail.Email},
|
||||
[]byte(c.String()),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
// Set up the TLS configuration
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
|
||||
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
}
|
||||
|
||||
// Connect to the SMTP server
|
||||
port := srv.appConfigService.DbConfig.SmtpPort.Value
|
||||
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
|
||||
var client *smtp.Client
|
||||
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
|
||||
client, err = smtp.Dial(smtpAddress)
|
||||
} else if port == "465" {
|
||||
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
} else {
|
||||
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
IPAddress string
|
||||
Country string
|
||||
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}
|
||||
|
||||
142
backend/internal/service/geolite_service.go
Normal file
142
backend/internal/service/geolite_service.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
@@ -18,18 +20,20 @@ import (
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
customClaimService *CustomClaimService
|
||||
}
|
||||
|
||||
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
|
||||
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
|
||||
return &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
customClaimService: customClaimService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", common.ErrOidcMissingAuthorization
|
||||
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", &common.OidcMissingAuthorizationError{}
|
||||
}
|
||||
|
||||
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -62,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
||||
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 {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -81,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 {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -91,13 +103,9 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
||||
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" {
|
||||
return "", "", common.ErrOidcGrantTypeNotSupported
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", "", common.ErrOidcMissingClientCredentials
|
||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
@@ -105,19 +113,33 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
return "", "", common.ErrOidcClientSecretInvalid
|
||||
// Verify the client secret if the client is not public
|
||||
if !client.IsPublic {
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
return "", "", &common.OidcClientSecretInvalidError{}
|
||||
}
|
||||
}
|
||||
|
||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||
if err != nil {
|
||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
|
||||
// If 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()) {
|
||||
return "", "", common.ErrOidcInvalidAuthorizationCode
|
||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
|
||||
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
|
||||
@@ -184,6 +206,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
||||
|
||||
client.Name = input.Name
|
||||
client.CallbackURLs = input.CallbackURLs
|
||||
client.IsPublic = input.IsPublic
|
||||
|
||||
if err := s.db.Save(&client).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
@@ -249,7 +272,7 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
|
||||
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||
return common.ErrFileTypeNotSupported
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
|
||||
@@ -315,6 +338,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
|
||||
if strings.Contains(scope, "email") {
|
||||
claims["email"] = user.Email
|
||||
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
|
||||
}
|
||||
|
||||
if strings.Contains(scope, "groups") {
|
||||
@@ -328,14 +352,25 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
profileClaims := map[string]interface{}{
|
||||
"given_name": user.FirstName,
|
||||
"family_name": user.LastName,
|
||||
"name": user.FirstName + " " + user.LastName,
|
||||
"name": user.FullName(),
|
||||
"preferred_username": user.Username,
|
||||
}
|
||||
|
||||
if strings.Contains(scope, "profile") {
|
||||
// Add profile claims
|
||||
for k, v := range profileClaims {
|
||||
claims[k] = v
|
||||
}
|
||||
|
||||
// Add custom claims
|
||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customClaim := range customClaims {
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
}
|
||||
}
|
||||
if strings.Contains(scope, "email") {
|
||||
claims["email"] = user.Email
|
||||
@@ -344,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||
|
||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
CodeChallenge: &codeChallenge,
|
||||
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||
@@ -366,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
||||
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 == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
}
|
||||
@@ -374,5 +429,5 @@ func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackU
|
||||
return inputCallbackURL, nil
|
||||
}
|
||||
|
||||
return "", common.ErrOidcInvalidCallbackURL
|
||||
return "", &common.OidcInvalidCallbackURLError{}
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ func (s *TestService) SeedDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey1, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKey2, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,17 +187,16 @@ func (s *TestService) ResetDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete all rows from all tables
|
||||
for _, table := range tables {
|
||||
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.appConfigService.InitDbConfig()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -215,8 +214,23 @@ func (s *TestService) ResetApplicationImages() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TestService) ResetAppConfig() error {
|
||||
// Reseed the config variables
|
||||
if err := s.appConfigService.InitDbConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset all app config variables to their default values
|
||||
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the app config from the database after resetting the values
|
||||
return s.appConfigService.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||
func getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
query := s.db.Model(&model.UserGroup{})
|
||||
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
|
||||
|
||||
if name != "" {
|
||||
query = query.Where("name LIKE ?", "%"+name+"%")
|
||||
@@ -29,7 +29,7 @@ func (s *UserGroupService) List(name string, page int, pageSize int) (groups []m
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
||||
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
|
||||
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
|
||||
return group, err
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
|
||||
|
||||
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
}
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (grou
|
||||
|
||||
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
}
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService}
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
|
||||
@@ -35,7 +36,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
|
||||
|
||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||
var user model.User
|
||||
err := s.db.Where("id = ?", userID).First(&user).Error
|
||||
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
|
||||
return user, err
|
||||
}
|
||||
|
||||
@@ -88,7 +89,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -104,6 +105,8 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
|
||||
|
||||
return oneTimeAccessToken.Token, nil
|
||||
}
|
||||
|
||||
@@ -111,7 +114,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", common.ErrTokenInvalidOrExpired
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -133,7 +136,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
if userCount > 1 {
|
||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
@@ -149,7 +152,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||
}
|
||||
|
||||
if len(user.Credentials) > 0 {
|
||||
return model.User{}, "", common.ErrSetupAlreadyCompleted
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
}
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(user)
|
||||
@@ -163,11 +166,11 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||
func (s *UserService) checkDuplicatedFields(user model.User) error {
|
||||
var existingUser model.User
|
||||
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
|
||||
return common.ErrEmailTaken
|
||||
return &common.AlreadyInUseError{Property: "email"}
|
||||
}
|
||||
|
||||
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
|
||||
return common.ErrUsernameTaken
|
||||
return &common.AlreadyInUseError{Property: "username"}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -165,7 +165,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
|
||||
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
|
||||
|
||||
return *user, token, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_config_variables DROP COLUMN default_value;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_config_variables ADD COLUMN default_value TEXT;
|
||||
1
backend/migrations/20241028064959_custom_claims.down.sql
Normal file
1
backend/migrations/20241028064959_custom_claims.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE custom_claims;
|
||||
15
backend/migrations/20241028064959_custom_claims.up.sql
Normal file
15
backend/migrations/20241028064959_custom_claims.up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE custom_claims
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
|
||||
user_id TEXT,
|
||||
user_group_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) 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)
|
||||
);
|
||||
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
3
backend/migrations/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/20241115131129_pkce.up.sql
Normal file
3
backend/migrations/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;
|
||||
@@ -6,4 +6,11 @@ services:
|
||||
ports:
|
||||
- 3000:80
|
||||
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
|
||||
BIN
docs/imgs/jelly_fin_img.png
Normal file
BIN
docs/imgs/jelly_fin_img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/imgs/jelly_fin_img2.png
Normal file
BIN
docs/imgs/jelly_fin_img2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/imgs/jelly_fin_img3.png
Normal file
BIN
docs/imgs/jelly_fin_img3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
55
docs/jellyfin.md
Normal file
55
docs/jellyfin.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Jellyfin SSO Integration Guide
|
||||
|
||||
> Due to the current limitations of the Jellyfin SSO plugin, this integration will only work in a browser. When tested, the Jellyfin app did not work and displayed an error, even when custom menu buttons were created.
|
||||
|
||||
> To view the original references and a full list of capabilities, please visit the [Jellyfin SSO OpenID Section](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#openid).
|
||||
|
||||
### Requirements
|
||||
- [Jellyfin Server](https://jellyfin.org/downloads/server)
|
||||
- [Jellyfin SSO Plugin](https://github.com/9p4/jellyfin-plugin-sso)
|
||||
- HTTPS connection to your Jellyfin server
|
||||
|
||||
### OIDC - Pocket ID Setup
|
||||
To start, we need to create a new SSO resource in our Jellyfin application.
|
||||
|
||||
> Replace the `JELLYFINDOMAIN` and `PROVIDER` elements in the URL.
|
||||
|
||||
1. Log into the admin panel, and go to OIDC Clients -> Add OIDC Client.
|
||||
2. **Name**: Jellyfin (or any name you prefer)
|
||||
3. **Callback URL**: `https://JELLYFINDOMAIN.com/sso/OID/redirect/PROVIDER`
|
||||
4. For this example, we’ll be using the provider named "test_resource."
|
||||
5. Click **Save**. Keep the page open, as we will need the OID client ID and OID secret.
|
||||
|
||||
### OIDC Client - Jellyfin SSO Resource
|
||||
|
||||
1. Visit the plugin page (<i>Administration Dashboard -> My Plugins -> SSO-Auth</i>).
|
||||
2. Enter the <i>OID Provider Name (we used "test_resource" as our name in the callback URL), Open ID, OID Secret, and mark it as enabled.</i>
|
||||
3. The following steps are optional based on your needs. In this guide, we’ll be managing only regular users, not admins.
|
||||
|
||||

|
||||
|
||||
> To manage user access through groups, follow steps **4, 5, and 6**. Otherwise, leave it blank and skip to step 7.
|
||||
|
||||

|
||||
|
||||
4. Under <i>Roles</i>, type the name of the group you want to use. **Note:** This must be the group name, not the label. Double-check in Pocket ID, as an incorrect name will lock users out.
|
||||
5. Skip every field until you reach the **Role Claim** field, and type `groups`.
|
||||
> This step is crucial if you want to manage users through groups.
|
||||
6. Repeat the above step under **Request Additional Scopes**. This will pull the group scope during the sign-in process; otherwise, the previous steps won’t work.
|
||||
|
||||

|
||||
|
||||
7. Skip the remaining fields until you reach **Scheme Override**. Enter `https` here. If omitted, it will attempt to use HTTP first, which will break as WebAuthn requires an HTTPS connection.
|
||||
8. Click **Save** and restart Jellyfin.
|
||||
|
||||
### Optional Step - Custom Home Button
|
||||
Follow the [guide to create a login button on the login page](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#creating-a-login-button-on-the-main-page) to add a custom button on your sign-in page. This step is optional, as you could also provide the sign-in URL via a bookmark or other means.
|
||||
|
||||
### Signing into Your Jellyfin Instance
|
||||
Done! You have successfully set up SSO for your Jellyfin instance using Pocket ID.
|
||||
|
||||
> **Note:** Sometimes there may be a brief delay when using the custom menu option. This is related to the Jellyfin plugin and not Pocket ID.
|
||||
|
||||
If your users already have accounts, as long as their Pocket ID username matches their Jellyfin ID, they will be logged in automatically. Otherwise, a new user will be created with access to all of your folders. Of course, you can modify this in your configuration as desired.
|
||||
|
||||
This setup will only work if sign-in is performed using the `https://jellyfin.example.com/sso/OID/start/PROVIDER` URL. This URL initiates the SSO plugin and applies all the configurations we completed above.
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.18.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
|
||||
@@ -12,8 +12,8 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI
|
||||
? [['html'], ['github']]
|
||||
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.output' }]],
|
||||
? [['html', { outputFolder: 'tests/.report' }], ['github']]
|
||||
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.report' }]],
|
||||
use: {
|
||||
baseURL: 'http://localhost',
|
||||
video: 'retain-on-failure',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
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 { debounced } from '$lib/utils/debounce-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -66,93 +67,104 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 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>
|
||||
{#if items.data.length === 0}
|
||||
<div class="my-5 flex flex-col items-center">
|
||||
<Empty class="text-muted-foreground h-20" />
|
||||
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
|
||||
</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}
|
||||
|
||||
116
frontend/src/lib/components/auto-complete-input.svelte
Normal file
116
frontend/src/lib/components/auto-complete-input.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder,
|
||||
suggestionLimit = 5,
|
||||
suggestions
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
suggestionLimit?: number;
|
||||
suggestions: string[];
|
||||
} = $props();
|
||||
|
||||
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
|
||||
let selectedIndex = $state(-1);
|
||||
let keyError: string | undefined = $state();
|
||||
|
||||
let isInputFocused = $state(false);
|
||||
|
||||
function handleSuggestionClick(suggestion: (typeof suggestions)[0]) {
|
||||
value = suggestion;
|
||||
filteredSuggestions = [];
|
||||
}
|
||||
|
||||
function handleOnInput() {
|
||||
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
|
||||
keyError = 'Only alphanumeric characters are allowed';
|
||||
return;
|
||||
} else {
|
||||
keyError = undefined;
|
||||
}
|
||||
|
||||
filteredSuggestions = suggestions
|
||||
.filter((s) => s.includes(value.toLowerCase()))
|
||||
.slice(0, suggestionLimit);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (selectedIndex >= 0) {
|
||||
handleSuggestionClick(filteredSuggestions[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
isInputFocused = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let isOpen = $derived(filteredSuggestions.length > 0 && isInputFocused);
|
||||
|
||||
$effect(() => {
|
||||
// Reset selection when suggestions change
|
||||
if (filteredSuggestions) {
|
||||
selectedIndex = -1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid w-full"
|
||||
role="combobox"
|
||||
onkeydown={handleKeydown}
|
||||
aria-controls="suggestion-list"
|
||||
aria-expanded={isOpen}
|
||||
tabindex="-1"
|
||||
>
|
||||
<Input
|
||||
{placeholder}
|
||||
bind:value
|
||||
oninput={handleOnInput}
|
||||
onfocus={() => (isInputFocused = true)}
|
||||
onblur={() => (isInputFocused = false)}
|
||||
/>
|
||||
{#if keyError}
|
||||
<p class="mt-1 text-sm text-red-500">{keyError}</p>
|
||||
{/if}
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
disableFocusTrap
|
||||
openFocus={() => {}}
|
||||
closeOnOutsideClick={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
|
||||
<Popover.Content class="p-0" sideOffset={5} sameWidth>
|
||||
{#each filteredSuggestions as suggestion, index}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onmousedown={() => handleSuggestionClick(suggestion)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleSuggestionClick(suggestion);
|
||||
}}
|
||||
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
|
||||
index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
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>
|
||||
75
frontend/src/lib/components/custom-claims-input.svelte
Normal file
75
frontend/src/lib/components/custom-claims-input.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||
import { LucideMinus, LucidePlus } from 'lucide-svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import AutoCompleteInput from './auto-complete-input.svelte';
|
||||
|
||||
let {
|
||||
customClaims = $bindable(),
|
||||
error = $bindable(null),
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
customClaims: CustomClaim[];
|
||||
error?: string | null;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const limit = 20;
|
||||
|
||||
const customClaimService = new CustomClaimService();
|
||||
|
||||
let suggestions: string[] = $state([]);
|
||||
let filteredSuggestions: string[] = $derived(
|
||||
suggestions.filter(
|
||||
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
|
||||
)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
customClaimService.getSuggestions().then((data) => (suggestions = data));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each customClaims as _, i}
|
||||
<div class="flex gap-x-2">
|
||||
<AutoCompleteInput
|
||||
placeholder="Key"
|
||||
suggestions={filteredSuggestions}
|
||||
bind:value={customClaims[i].key}
|
||||
/>
|
||||
<Input placeholder="Value" bind:value={customClaims[i].value} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Remove custom claim"
|
||||
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
|
||||
>
|
||||
<LucideMinus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</FormInput>
|
||||
{#if error}
|
||||
<p class="mt-1 text-sm text-red-500">{error}</p>
|
||||
{/if}
|
||||
{#if customClaims.length < limit}
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
|
||||
>
|
||||
<LucidePlus class="mr-1 h-4 w-4" />
|
||||
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
input?: FormInput<string | boolean | number>;
|
||||
label: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
||||
@@ -24,15 +24,17 @@
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const id = label.toLowerCase().replace(/ /g, '-');
|
||||
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<Label class="mb-0" for={id}>{label}</Label>
|
||||
{#if label}
|
||||
<Label class="mb-0" for={id}>{label}</Label>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
{/if}
|
||||
<div class="mt-2">
|
||||
<div class={label || description ? 'mt-2' : ''}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else if input}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type VariantProps, tv } from "tailwind-variants";
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
aria-hidden="true"
|
||||
class={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
|
||||
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Trigger = PopoverPrimitive.Trigger;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import { cn, flyAndScale } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = PopoverPrimitive.ContentProps;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let transition: $$Props["transition"] = flyAndScale;
|
||||
export let transitionConfig: $$Props["transitionConfig"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Content
|
||||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</PopoverPrimitive.Content>
|
||||
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>
|
||||
@@ -14,14 +14,19 @@ export default class AppConfigService extends APIService {
|
||||
|
||||
const appConfig: Partial<AllAppConfig> = {};
|
||||
data.forEach(({ key, value }) => {
|
||||
(appConfig as any)[key] = value;
|
||||
(appConfig as any)[key] = this.parseValue(value);
|
||||
});
|
||||
|
||||
return appConfig as AllAppConfig;
|
||||
}
|
||||
|
||||
async update(appConfig: AllAppConfig) {
|
||||
const res = await this.api.put('/application-configuration', appConfig);
|
||||
// Convert all values to string
|
||||
const appConfigConvertedToString = {};
|
||||
for (const key in appConfig) {
|
||||
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
|
||||
}
|
||||
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
|
||||
return res.data as AllAppConfig;
|
||||
}
|
||||
|
||||
@@ -48,6 +53,10 @@ export default class AppConfigService extends APIService {
|
||||
await this.api.put(`/application-configuration/background-image`, formData);
|
||||
}
|
||||
|
||||
async sendTestEmail() {
|
||||
await this.api.post('/application-configuration/test-email');
|
||||
}
|
||||
|
||||
async getVersionInformation() {
|
||||
const response = (
|
||||
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
||||
@@ -62,4 +71,16 @@ export default class AppConfigService extends APIService {
|
||||
currentVersion
|
||||
};
|
||||
}
|
||||
|
||||
private parseValue(value: string) {
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
} else if (value === 'false') {
|
||||
return false;
|
||||
} else if (!isNaN(parseFloat(value))) {
|
||||
return parseFloat(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
frontend/src/lib/services/custom-claim-service.ts
Normal file
19
frontend/src/lib/services/custom-claim-service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CustomClaim } from '$lib/types/custom-claim.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class CustomClaimService extends APIService {
|
||||
async getSuggestions() {
|
||||
const res = await this.api.get('/custom-claims/suggestions');
|
||||
return res.data as string[];
|
||||
}
|
||||
|
||||
async updateUserCustomClaims(userId: string, claims: CustomClaim[]) {
|
||||
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
|
||||
return res.data as CustomClaim[];
|
||||
}
|
||||
|
||||
async updateUserGroupCustomClaims(userGroupId: string, claims: CustomClaim[]) {
|
||||
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
|
||||
return res.data as CustomClaim[];
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
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', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
});
|
||||
|
||||
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', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
|
||||
@@ -42,10 +42,10 @@ export default class UserService extends APIService {
|
||||
await this.api.delete(`/users/${id}`);
|
||||
}
|
||||
|
||||
async createOneTimeAccessToken(userId: string) {
|
||||
async createOneTimeAccessToken(userId: string, expiresAt: Date) {
|
||||
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 5).toISOString()
|
||||
expiresAt
|
||||
});
|
||||
return res.data.token;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
export type AllAppConfig = {
|
||||
export type AppConfig = {
|
||||
appName: string;
|
||||
sessionDuration: string;
|
||||
emailEnabled: string;
|
||||
allowOwnAccountEdit: boolean;
|
||||
};
|
||||
|
||||
export type AllAppConfig = AppConfig & {
|
||||
sessionDuration: number;
|
||||
emailsVerified: boolean;
|
||||
emailEnabled: boolean;
|
||||
smtpHost: string;
|
||||
smtpPort: string;
|
||||
smtpPort: number;
|
||||
smtpFrom: string;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
smtpTls: boolean;
|
||||
smtpSkipCertVerify: boolean;
|
||||
};
|
||||
|
||||
export type AppConfig = AllAppConfig;
|
||||
|
||||
export type AppConfigRawResponse = {
|
||||
key: string;
|
||||
type: string;
|
||||
@@ -21,4 +26,4 @@ export type AppVersionInformation = {
|
||||
isUpToDate: boolean;
|
||||
newestVersion: string;
|
||||
currentVersion: string;
|
||||
};
|
||||
};
|
||||
|
||||
4
frontend/src/lib/types/custom-claim.type.ts
Normal file
4
frontend/src/lib/types/custom-claim.type.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type CustomClaim = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export type OidcClient = {
|
||||
logoURL: string;
|
||||
callbackURLs: [string, ...string[]];
|
||||
hasLogo: boolean;
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CustomClaim } from './custom-claim.type';
|
||||
import type { User } from './user.type';
|
||||
|
||||
export type UserGroup = {
|
||||
@@ -5,6 +6,7 @@ export type UserGroup = {
|
||||
friendlyName: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
customClaims: CustomClaim[];
|
||||
};
|
||||
|
||||
export type UserGroupWithUsers = UserGroup & {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CustomClaim } from './custom-claim.type';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -5,6 +7,7 @@ export type User = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
customClaims: CustomClaim[];
|
||||
};
|
||||
|
||||
export type UserCreate = Omit<User, 'id'>;
|
||||
export type UserCreate = Omit<User, 'id' | 'customClaims'>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
|
||||
let debounceTimeout: number | undefined;
|
||||
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (debounceTimeout !== undefined) {
|
||||
@@ -10,4 +10,4 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
|
||||
func(...args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
nonce: url.searchParams.get('nonce') || undefined,
|
||||
state: url.searchParams.get('state')!,
|
||||
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;
|
||||
|
||||
export let data: PageData;
|
||||
let { scope, nonce, client, state, callbackURL } = data;
|
||||
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||
|
||||
async function authorize() {
|
||||
isLoading = true;
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
await oidService
|
||||
.authorize(client!.id, scope, callbackURL, nonce)
|
||||
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
@@ -55,7 +55,7 @@
|
||||
isLoading = true;
|
||||
try {
|
||||
await oidService
|
||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
if ($userStore?.isAdmin) {
|
||||
links = [
|
||||
// svelte-ignore state_referenced_locally
|
||||
...links,
|
||||
{ href: '/settings/admin/users', label: 'Users' },
|
||||
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
||||
@@ -68,7 +69,7 @@
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-muted-foreground py-3 text-xs">
|
||||
Powered by <a
|
||||
class="text-white"
|
||||
class="text-foreground"
|
||||
href="https://github.com/stonith404/pocket-id"
|
||||
target="_blank">Pocket ID</a
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { Passkey } from '$lib/types/passkey.type';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
@@ -51,14 +52,16 @@
|
||||
<title>Account Settings</title>
|
||||
</svelte:head>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Account Details</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<AccountForm {account} callback={updateAccount} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{#if $appConfigStore.allowOwnAccountEdit}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Account Details</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<AccountForm {account} callback={updateAccount} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AppConfigService from '$lib/services/app-config-service';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -14,43 +16,49 @@
|
||||
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let emailEnabled = $state(appConfig.emailEnabled == 'true');
|
||||
const appConfigService = new AppConfigService();
|
||||
|
||||
let isSendingTestEmail = $state(false);
|
||||
let emailEnabled = $state(appConfig.emailEnabled);
|
||||
|
||||
const updatedAppConfig = {
|
||||
emailEnabled: emailEnabled.toString(),
|
||||
emailEnabled: appConfig.emailEnabled,
|
||||
smtpHost: appConfig.smtpHost,
|
||||
smtpPort: appConfig.smtpPort,
|
||||
smtpUser: appConfig.smtpUser,
|
||||
smtpPassword: appConfig.smtpPassword,
|
||||
smtpFrom: appConfig.smtpFrom
|
||||
smtpFrom: appConfig.smtpFrom,
|
||||
smtpTls: appConfig.smtpTls,
|
||||
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
smtpHost: z.string().min(1),
|
||||
smtpPort: z.string().min(1),
|
||||
smtpUser: z.string().min(1),
|
||||
smtpPassword: z.string().min(1),
|
||||
smtpFrom: z.string().email()
|
||||
smtpPort: z.number().min(1),
|
||||
smtpUser: z.string(),
|
||||
smtpPassword: z.string(),
|
||||
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() {
|
||||
console.log('submit');
|
||||
const data = form.validate();
|
||||
if (!data) return false;
|
||||
isLoading = true;
|
||||
await callback({
|
||||
...data,
|
||||
emailEnabled: 'true'
|
||||
}).finally(() => (isLoading = false));
|
||||
emailEnabled: true
|
||||
});
|
||||
toast.success('Email configuration updated successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onDisable() {
|
||||
await callback({ emailEnabled: 'false' });
|
||||
emailEnabled = false;
|
||||
await callback({ emailEnabled });
|
||||
toast.success('Email disabled successfully');
|
||||
}
|
||||
|
||||
@@ -59,22 +67,48 @@
|
||||
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>
|
||||
|
||||
<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 Port" 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 Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||
<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 class="mt-5 flex justify-end gap-3">
|
||||
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
||||
{#if emailEnabled}
|
||||
<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}
|
||||
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
|
||||
<Button onclick={onEnable}>Enable</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -18,20 +21,16 @@
|
||||
|
||||
const updatedAppConfig = {
|
||||
appName: appConfig.appName,
|
||||
sessionDuration: appConfig.sessionDuration
|
||||
sessionDuration: appConfig.sessionDuration,
|
||||
emailsVerified: appConfig.emailsVerified,
|
||||
allowOwnAccountEdit: appConfig.allowOwnAccountEdit
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string().min(2).max(30),
|
||||
sessionDuration: z.string().refine(
|
||||
(val) => {
|
||||
const num = Number(val);
|
||||
return Number.isInteger(num) && num >= 1 && num <= 43200;
|
||||
},
|
||||
{
|
||||
message: 'Session duration must be between 1 and 43200 minutes'
|
||||
}
|
||||
)
|
||||
sessionDuration: z.number().min(1).max(43200),
|
||||
emailsVerified: z.boolean(),
|
||||
allowOwnAccountEdit: z.boolean()
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||
@@ -49,9 +48,22 @@
|
||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||
<FormInput
|
||||
label="Session Duration"
|
||||
type="number"
|
||||
description="The duration of a session in minutes before the user has to sign in again."
|
||||
bind:input={$inputs.sessionDuration}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="self-account-editing"
|
||||
label="Enable Self-Account Editing"
|
||||
description="Whether the users should be able to edit their own account details."
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="emails-verified"
|
||||
label="Emails Verified"
|
||||
description="Whether the user's email should be marked as verified for the OIDC clients."
|
||||
bind:checked={$inputs.emailsVerified.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||
'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) {
|
||||
@@ -34,6 +35,8 @@
|
||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
||||
|
||||
client.isPublic = updatedClient.isPublic;
|
||||
|
||||
await Promise.all([dataPromise, imagePromise])
|
||||
.then(() => {
|
||||
toast.success('OIDC client updated successfully');
|
||||
@@ -93,27 +96,29 @@
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div class="mb-2 mt-1 flex items-center">
|
||||
<Label class="w-44">Client secret</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
{$clientSecretStore}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<Button
|
||||
class="ml-2"
|
||||
onclick={createClientSecret}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !client.isPublic}
|
||||
<div class="mb-2 mt-1 flex items-center">
|
||||
<Label class="w-44">Client secret</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
{$clientSecretStore}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<Button
|
||||
class="ml-2"
|
||||
onclick={createClientSecret}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showAllDetails}
|
||||
<div transition:slide>
|
||||
{#each Object.entries(setupDetails) as [key, value]}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FileInput from '$lib/components/file-input.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -28,12 +29,14 @@
|
||||
|
||||
const client: OidcClientCreate = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURLs: existingClient?.callbackURLs || [""]
|
||||
callbackURLs: existingClient?.callbackURLs || [''],
|
||||
isPublic: existingClient?.isPublic || false
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
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;
|
||||
@@ -71,15 +74,21 @@
|
||||
</script>
|
||||
|
||||
<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} />
|
||||
<OidcCallbackUrlInput
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||
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 class="mt-3">
|
||||
<div class="mt-8">
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||
import UserGroupService from '$lib/services/user-group-service';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||
@@ -18,6 +20,7 @@
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
const userService = new UserService();
|
||||
const customClaimService = new CustomClaimService();
|
||||
|
||||
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||
let success = true;
|
||||
@@ -40,6 +43,15 @@
|
||||
axiosErrorToast(e);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCustomClaims() {
|
||||
await customClaimService
|
||||
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
|
||||
.then(() => toast.success('Custom claims updated successfully'))
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -53,7 +65,7 @@
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Meta data</Card.Title>
|
||||
<Card.Title>General</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
@@ -76,3 +88,20 @@
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Custom Claims</Card.Title>
|
||||
<Card.Description>
|
||||
Custom claims are key-value pairs that can be used to store additional information about a
|
||||
user. These claims will be included in the ID token if the scope "profile" is requested.
|
||||
Custom claims defined on the user will be prioritized if there are conflicts.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import CustomClaimsInput from '../../../../../lib/components/custom-claims-input.svelte';
|
||||
import UserForm from '../user-form.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let user = $state(data);
|
||||
|
||||
const userService = new UserService();
|
||||
const customClaimService = new CustomClaimService();
|
||||
|
||||
async function updateUser(updatedUser: UserCreate) {
|
||||
let success = true;
|
||||
@@ -24,6 +28,15 @@
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
async function updateCustomClaims() {
|
||||
await customClaimService
|
||||
.updateUserCustomClaims(user.id, user.customClaims)
|
||||
.then(() => toast.success('Custom claims updated successfully'))
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -37,10 +50,25 @@
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{user.firstName} {user.lastName}</Card.Title>
|
||||
<Card.Title>General</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
<UserForm existingUser={user} callback={updateUser} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Custom Claims</Card.Title>
|
||||
<Card.Description>
|
||||
Custom claims are key-value pairs that can be used to store additional information about a
|
||||
user. These claims will be included in the ID token if the scope "profile" is requested.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
|
||||
let {
|
||||
oneTimeLink = $bindable()
|
||||
userId = $bindable()
|
||||
}: {
|
||||
oneTimeLink: string | null;
|
||||
userId: string | null;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
let oneTimeLink: string | null = $state(null);
|
||||
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
|
||||
|
||||
let availableExpirations = {
|
||||
'1 hour': 60 * 60,
|
||||
'12 hours': 60 * 60 * 12,
|
||||
'1 day': 60 * 60 * 24,
|
||||
'1 week': 60 * 60 * 24 * 7,
|
||||
'1 month': 60 * 60 * 24 * 30
|
||||
};
|
||||
|
||||
async function createOneTimeAccessToken() {
|
||||
try {
|
||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||
const token = await userService.createOneTimeAccessToken(userId!, expiration);
|
||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
oneTimeLink = null;
|
||||
userId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
|
||||
<Dialog.Root open={!!userId} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>One Time Link</Dialog.Title>
|
||||
@@ -25,9 +54,36 @@
|
||||
have lost it.</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<Label for="one-time-link">One Time Link</Label>
|
||||
{#if oneTimeLink === null}
|
||||
<div>
|
||||
<Label for="expiration">Expiration</Label>
|
||||
<Select.Root
|
||||
selected={{
|
||||
label: Object.keys(availableExpirations)[0],
|
||||
value: Object.keys(availableExpirations)[0]
|
||||
}}
|
||||
onSelectedChange={(v) =>
|
||||
(selectedExpiration = v!.value as keyof typeof availableExpirations)}
|
||||
>
|
||||
<Select.Trigger class="h-9 ">
|
||||
<Select.Value>{selectedExpiration}</Select.Value>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each Object.keys(availableExpirations) as key}
|
||||
<Select.Item value={key}>{key}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => createOneTimeAccessToken()}
|
||||
disabled={!selectedExpiration}
|
||||
>
|
||||
Generate Link
|
||||
</Button>
|
||||
{:else}
|
||||
<Label for="one-time-link" class="sr-only">One Time Link</Label>
|
||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
@@ -70,15 +69,12 @@
|
||||
<FormInput label="Username" bind:input={$inputs.username} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||
Admin Privileges
|
||||
</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxWithLabel
|
||||
id="admin-privileges"
|
||||
label="Admin Privileges"
|
||||
description="Admins have full access to the admin panel."
|
||||
bind:checked={$inputs.isAdmin.value}
|
||||
/>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user