mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-25 06:04:34 +03:00
Compare commits
3 Commits
main
...
invite-ema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70aab2143d | ||
|
|
bcde61b8fb | ||
|
|
fdfaac8a35 |
1
.github/workflows/backend-linter.yml
vendored
1
.github/workflows/backend-linter.yml
vendored
@@ -17,6 +17,7 @@ permissions:
|
||||
pull-requests: read
|
||||
# Optional: allow write access to checks to allow the action to annotate code in the PR.
|
||||
checks: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
|
||||
6
.github/workflows/build-next.yml
vendored
6
.github/workflows/build-next.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.5.0
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -82,11 +82,11 @@ jobs:
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4.2.0
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4.2.0
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.5.0
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.5.0
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -76,12 +76,12 @@ jobs:
|
||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4.2.0
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4.2.0
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
|
||||
3
.github/workflows/svelte-check.yml
vendored
3
.github/workflows/svelte-check.yml
vendored
@@ -25,6 +25,7 @@ permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
@@ -41,7 +42,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.5.0
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
1
.github/workflows/unit-tests.yml
vendored
1
.github/workflows/unit-tests.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
|
||||
1
.github/workflows/update-aaguids.yml
vendored
1
.github/workflows/update-aaguids.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
update-aaguids:
|
||||
|
||||
@@ -4,10 +4,10 @@ go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2
|
||||
github.com/aws/smithy-go v1.27.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
|
||||
github.com/aws/smithy-go v1.27.3
|
||||
github.com/caarlos0/env/v11 v11.4.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
@@ -19,12 +19,12 @@ require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.22.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/italypaleale/francis v0.1.0-beta.11
|
||||
github.com/italypaleale/francis v0.1.0-beta.10
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be
|
||||
github.com/italypaleale/go-sql-utils v0.2.4
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
@@ -33,8 +33,8 @@ require (
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/libtnb/sqlite v1.2.0
|
||||
github.com/lmittmann/tint v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.23
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
|
||||
@@ -60,14 +60,14 @@ require (
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
modernc.org/sqlite v1.54.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/ClickHouse/ch-go v0.65.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/alphadose/haxmap v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
@@ -80,10 +80,10 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
@@ -110,7 +110,7 @@ require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
@@ -136,7 +136,7 @@ require (
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -172,7 +172,7 @@ require (
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
@@ -229,7 +229,7 @@ require (
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
@@ -243,7 +243,7 @@ require (
|
||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.7 // indirect
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/libc v1.73.5 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/ClickHouse/ch-go v0.65.0 h1:vZAXfTQliuNNefqkPDewX3kgRxN6Q4vUENnnY+ynTRY=
|
||||
github.com/ClickHouse/ch-go v0.65.0/go.mod h1:tCM0XEH5oWngoi9Iu/8+tjPBo04I/FxNIffpdjtwx3k=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0 h1:zVWJUmUGdtCApM/vRfQhruGXIm1M643bk68B3IYbR1I=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0/go.mod h1:rGFIgeNbJVggBp2C+0FXOdfjsMlpsKx7FUYnHHyy2KE=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
@@ -22,10 +22,10 @@ github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||
@@ -42,18 +42,18 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
|
||||
github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M=
|
||||
github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
|
||||
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
@@ -159,8 +159,8 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@@ -174,8 +174,8 @@ github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AY
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -257,14 +257,14 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.11 h1:FurXV2vMkRzJRFldQ6Z/bhLSJz8YXHm84uiASGeyWWU=
|
||||
github.com/italypaleale/francis v0.1.0-beta.11/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk=
|
||||
github.com/italypaleale/francis v0.1.0-beta.10 h1:LCYVwkZAkakv7g5ZS6TVIRH7hozr1A0eg24LXWWBJYE=
|
||||
github.com/italypaleale/francis v0.1.0-beta.10/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be h1:jgu+Mdsda++LqPxz8cj8vvgiFINQ8PhFB4Q1VZpyPjs=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
|
||||
@@ -350,12 +350,12 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libtnb/sqlite v1.2.0 h1:XsA7jsXHH2qmFkTWoy5YCKJybzHzWQ0flqDFP5Y9Yto=
|
||||
github.com/libtnb/sqlite v1.2.0/go.mod h1:O6vURH5fa5IgSmXd/qLAL2zSnYFUd7xSIumsmX3BrSI=
|
||||
github.com/lmittmann/tint v1.2.0 h1:AogHRHy8HUJUnNJBHJlYa+fR4YY8mko2cnCp67xn9JY=
|
||||
github.com/lmittmann/tint v1.2.0/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg=
|
||||
@@ -419,8 +419,8 @@ github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7ol
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -655,8 +655,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -771,8 +771,8 @@ k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYK
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
|
||||
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
@@ -781,8 +781,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
|
||||
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -791,8 +791,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
|
||||
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
|
||||
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it
|
||||
|
||||
// AppConfigActorType is the actor type for the AppConfig actor
|
||||
const AppConfigActorType = "AppConfig"
|
||||
|
||||
// appConfigActor is a singleton actor that manages the dynamic app configuration
|
||||
type appConfigActor struct {
|
||||
log *slog.Logger
|
||||
client actor.Client[*AppConfigModel]
|
||||
}
|
||||
|
||||
// appConfigActorBootstrap is the type for the payload of the init method
|
||||
type appConfigActorBootstrap struct {
|
||||
LegacyConfig map[string]string
|
||||
}
|
||||
|
||||
// NewAppConfigActor allocates a new AppConfig actor
|
||||
// It satisfies actor.Factory
|
||||
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
|
||||
log := slog.
|
||||
With(
|
||||
slog.String("scope", "actor"),
|
||||
slog.String("actorType", AppConfigActorType),
|
||||
slog.String("actorID", actorID),
|
||||
)
|
||||
|
||||
log.Info("AppConfig actor created")
|
||||
|
||||
return &appConfigActor{
|
||||
log: log,
|
||||
client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap implements actor.ActorBootstrapper for the singleton actor
|
||||
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// If we already have a state, nothing else to do
|
||||
if state != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the request data contains legacy config to init from
|
||||
if data != nil {
|
||||
payload := appConfigActorBootstrap{}
|
||||
err = data.Decode(&payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method 'init': %w", err)
|
||||
}
|
||||
|
||||
if len(payload.LegacyConfig) > 0 {
|
||||
state, err = fromLegacyConfig(payload.LegacyConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have no state, generate a new default config
|
||||
if state == nil {
|
||||
state = getDefaultConfig()
|
||||
}
|
||||
|
||||
// Save the updated state
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, state, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
// Only supported method is "get"
|
||||
if method != "get" {
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// Return the state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *appConfigActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
// Check the method first
|
||||
switch method {
|
||||
case "get", "update", "replace":
|
||||
// All good
|
||||
// Note: we support "get" also via Invoke and not just Peek
|
||||
default:
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
switch method {
|
||||
case "get":
|
||||
// If the method is "get", just return the actor state, we're done
|
||||
// This switch case is a no-op
|
||||
|
||||
case "replace":
|
||||
// Replace the entire config
|
||||
// The input data must be a dto.AppConfigUpdateDto
|
||||
if data == nil {
|
||||
return nil, errors.New("request body is empty for method 'replace'")
|
||||
}
|
||||
payload := dto.AppConfigUpdateDto{}
|
||||
err = data.Decode(&payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request body is not valid for method 'replace': %w", err)
|
||||
}
|
||||
|
||||
// Update the in-memory data
|
||||
// Work on a clone to avoid touching the cached object in case of errors
|
||||
newState := state.Clone()
|
||||
newState.Replace(payload)
|
||||
|
||||
// Save the updated state, which also updates the cached object
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, newState, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return newState, nil
|
||||
|
||||
case "update":
|
||||
// Update the config
|
||||
// The input data must be a map[string]string
|
||||
if data == nil {
|
||||
return nil, errors.New("request body is empty for method 'update'")
|
||||
}
|
||||
payload := map[string]string{}
|
||||
err = data.Decode(&payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request body is not valid for method 'update': %w", err)
|
||||
}
|
||||
|
||||
// Update the in-memory data
|
||||
// Work on a clone to avoid touching the cached object in case of errors
|
||||
newState := state.Clone()
|
||||
err = newState.Update(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request body is not valid for method 'update': %w", err)
|
||||
}
|
||||
|
||||
// Save the updated state, which also updates the cached object
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, newState, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return newState, nil
|
||||
}
|
||||
|
||||
// Return the state
|
||||
return state, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/go-kit/utils"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
// This file holds the one-time migration of the legacy (pre-actor) app config
|
||||
// The legacy config was stored in the "config_migrated" key of the kv table, and it's loaded here to bootstrap the AppConfig actor on first startup
|
||||
|
||||
// LoadLegacyConfig loads the legacy config from the database
|
||||
// This was migrated to the "config_migrated" key in the kv table
|
||||
func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) {
|
||||
// Retrieve the migrated config from the kv table
|
||||
row := model.KV{
|
||||
Key: "config_migrated",
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
err := db.WithContext(ctx).First(&row).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// There's no migrated config in the database, nothing to do
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("failed to load migrated config from the database: %w", err)
|
||||
case row.Value == nil || len(*row.Value) == 0:
|
||||
// Also no migrated config, nothing to do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The value is a JSON-encoded dictionary
|
||||
res := map[string]string{}
|
||||
err = json.Unmarshal([]byte(*row.Value), &res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing migrated config: %w", err)
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// fromLegacyConfig builds an appConfigModel from a legacy config map
|
||||
// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type
|
||||
// Keys that are missing (or have an empty value) retain the default value
|
||||
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
|
||||
// Start from the default configuration, then override with the values from the legacy config
|
||||
dest := getDefaultConfig()
|
||||
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Look up the value in the legacy config
|
||||
// If the key is missing or the value is empty, we keep the default value
|
||||
value, ok := legacyCfg[key]
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Cast the string value to the field's type
|
||||
fv := rv.Field(i)
|
||||
switch fv.Kind() { //nolint:exhaustive
|
||||
case reflect.String:
|
||||
fv.SetString(value)
|
||||
case reflect.Bool:
|
||||
fv.SetBool(utils.IsTruthy(value))
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err)
|
||||
}
|
||||
fv.SetInt(n)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key)
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/go-kit/utils"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
type AppConfigModel struct {
|
||||
// General
|
||||
AppName AppConfigValue `json:"appName" public:"true"`
|
||||
SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
|
||||
HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
|
||||
EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
|
||||
AccentColor AppConfigValue `json:"accentColor" public:"true"`
|
||||
DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
|
||||
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
|
||||
AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
|
||||
|
||||
SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
|
||||
SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
|
||||
// Email
|
||||
RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
|
||||
SmtpHost AppConfigValue `json:"smtpHost"`
|
||||
SmtpPort AppConfigValue `json:"smtpPort"`
|
||||
SmtpFrom AppConfigValue `json:"smtpFrom"`
|
||||
SmtpUser AppConfigValue `json:"smtpUser"`
|
||||
SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
|
||||
SmtpTls AppConfigValue `json:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
|
||||
EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
|
||||
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
|
||||
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
|
||||
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
|
||||
// LDAP
|
||||
LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
|
||||
LdapUrl AppConfigValue `json:"ldapUrl"`
|
||||
LdapBindDn AppConfigValue `json:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
|
||||
LdapBase AppConfigValue `json:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
|
||||
LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the AppConfigModel.
|
||||
func (m *AppConfigModel) Clone() *AppConfigModel {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// All fields are value types (AppConfigValue is a string), so copying the struct is sufficient for a deep copy.
|
||||
clone := *m
|
||||
return &clone
|
||||
}
|
||||
|
||||
// AppConfigValue holds a value
|
||||
type AppConfigValue string
|
||||
|
||||
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
|
||||
func (a AppConfigValue) IsTrue() bool {
|
||||
return utils.IsTruthy(string(a))
|
||||
}
|
||||
|
||||
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
|
||||
func (a AppConfigValue) AsDurationMinutes() time.Duration {
|
||||
val, err := strconv.Atoi(string(a))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(val) * time.Minute
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer
|
||||
func (a AppConfigValue) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
func getDefaultConfig() *AppConfigModel {
|
||||
// Values are the default ones
|
||||
return &AppConfigModel{
|
||||
// General
|
||||
AppName: "Pocket ID",
|
||||
SessionDuration: "60",
|
||||
HomePageURL: "/settings/account",
|
||||
EmailsVerified: "false",
|
||||
DisableAnimations: "false",
|
||||
AllowOwnAccountEdit: "true",
|
||||
AllowUserSignups: "disabled",
|
||||
SignupDefaultUserGroupIDs: "[]",
|
||||
SignupDefaultCustomClaims: "[]",
|
||||
AccentColor: "default",
|
||||
// Email
|
||||
RequireUserEmail: "true",
|
||||
SmtpHost: "",
|
||||
SmtpPort: "",
|
||||
SmtpFrom: "",
|
||||
SmtpUser: "",
|
||||
SmtpPassword: "",
|
||||
SmtpTls: "none",
|
||||
SmtpSkipCertVerify: "false",
|
||||
EmailLoginNotificationEnabled: "false",
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
||||
EmailOneTimeAccessAsAdminEnabled: "false",
|
||||
EmailApiKeyExpirationEnabled: "false",
|
||||
EmailVerificationEnabled: "false",
|
||||
// LDAP
|
||||
LdapEnabled: "false",
|
||||
LdapUrl: "",
|
||||
LdapBindDn: "",
|
||||
LdapBindPassword: "",
|
||||
LdapBase: "",
|
||||
LdapUserSearchFilter: "(objectClass=person)",
|
||||
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
|
||||
LdapSkipCertVerify: "false",
|
||||
LdapAttributeUserUniqueIdentifier: "",
|
||||
LdapAttributeUserUsername: "",
|
||||
LdapAttributeUserEmail: "",
|
||||
LdapAttributeUserFirstName: "",
|
||||
LdapAttributeUserLastName: "",
|
||||
LdapAttributeUserDisplayName: "cn",
|
||||
LdapAttributeUserProfilePicture: "",
|
||||
LdapAttributeGroupMember: "member",
|
||||
LdapAttributeGroupUniqueIdentifier: "",
|
||||
LdapAttributeGroupName: "",
|
||||
LdapAdminGroupName: "",
|
||||
LdapSoftDeleteUsers: "true",
|
||||
}
|
||||
}
|
||||
|
||||
// Replace updates every configuration property with the values from the input DTO
|
||||
// An empty string value resets the corresponding property to its default value
|
||||
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) {
|
||||
// Collect the values from the input DTO into a map, keyed by the "json" tag
|
||||
inRv := reflect.ValueOf(input)
|
||||
inRt := inRv.Type()
|
||||
values := make(map[string]string, inRt.NumField())
|
||||
for i := range inRt.NumField() {
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",")
|
||||
values[key] = inRv.Field(i).String()
|
||||
}
|
||||
|
||||
// Iterate through all the properties, setting each one from the input
|
||||
// Properties that are missing from the input or have an empty value are reset to their default
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
rv := reflect.ValueOf(m).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
|
||||
value, ok := values[key]
|
||||
if !ok || value == "" {
|
||||
value = defaults.Field(i).String()
|
||||
}
|
||||
|
||||
rv.Field(i).SetString(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Update sets configuration properties from the provided key-value pairs
|
||||
// Keys correspond to the "json" tags on the model
|
||||
// An empty string value resets the property to its default value
|
||||
func (m *AppConfigModel) Update(values map[string]string) error {
|
||||
rv := reflect.ValueOf(m).Elem()
|
||||
rt := rv.Type()
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
|
||||
// Iterate through the key-value pairs
|
||||
for key, value := range values {
|
||||
// Find the field in the struct whose "json" tag matches
|
||||
fieldIdx := -1
|
||||
for j := range rt.NumField() {
|
||||
// Separate the key (before the comma) from any optional attributes after
|
||||
tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",")
|
||||
if tagValue == key {
|
||||
fieldIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if fieldIdx < 0 {
|
||||
return AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
// An empty string means we use the default value for the property
|
||||
if value == "" {
|
||||
value = defaults.Field(fieldIdx).String()
|
||||
}
|
||||
|
||||
rv.Field(fieldIdx).SetString(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppConfigVariable is a single application configuration property, as a key/value pair
|
||||
type AppConfigVariable struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ToAppConfigVariableSlice returns the configuration as a slice of key/value pairs
|
||||
// If showAll is false, only properties marked as public are included
|
||||
// If redactSensitiveValues is true, sensitive values are redacted when the UI config is disabled
|
||||
func (m *AppConfigModel) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
|
||||
// Iterate through all fields
|
||||
cfgValue := reflect.ValueOf(m).Elem()
|
||||
cfgType := cfgValue.Type()
|
||||
|
||||
res := make([]AppConfigVariable, 0, cfgType.NumField())
|
||||
for i := range cfgType.NumField() {
|
||||
field := cfgType.Field(i)
|
||||
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're only showing public variables and this is not public, skip it
|
||||
if !showAll && field.Tag.Get("public") != "true" {
|
||||
continue
|
||||
}
|
||||
|
||||
value := cfgValue.Field(i).String()
|
||||
|
||||
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
|
||||
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && field.Tag.Get("sensitive") == "true" {
|
||||
value = "XXXXXXXXXX"
|
||||
}
|
||||
|
||||
res = append(res, AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
type AppConfigKeyNotFoundError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
|
||||
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
|
||||
return ok
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place.
|
||||
func dtoWithMarkerValues() dto.AppConfigUpdateDto {
|
||||
var input dto.AppConfigUpdateDto
|
||||
rv := reflect.ValueOf(&input).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
rv.Field(i).SetString("marker-" + key)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Replace(t *testing.T) {
|
||||
t.Run("populates every property from the DTO", func(t *testing.T) {
|
||||
input := dtoWithMarkerValues()
|
||||
|
||||
var m AppConfigModel
|
||||
m.Replace(input)
|
||||
|
||||
// Each model property must hold the marker built from its own "json" key.
|
||||
// This also asserts that the model and the DTO share the same set of keys.
|
||||
rv := reflect.ValueOf(&m).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty values fall back to their default", func(t *testing.T) {
|
||||
defaults := getDefaultConfig()
|
||||
|
||||
// Start from all-markers, then blank out a few properties whose default is non-empty
|
||||
input := dtoWithMarkerValues()
|
||||
input.AppName = ""
|
||||
input.SessionDuration = ""
|
||||
input.SmtpTls = ""
|
||||
input.LdapUserSearchFilter = ""
|
||||
|
||||
var m AppConfigModel
|
||||
m.Replace(input)
|
||||
|
||||
// Blanked properties are reset to their default
|
||||
assert.Equal(t, defaults.AppName, m.AppName)
|
||||
assert.Equal(t, defaults.SessionDuration, m.SessionDuration)
|
||||
assert.Equal(t, defaults.SmtpTls, m.SmtpTls)
|
||||
assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter)
|
||||
|
||||
// A property that was provided keeps the provided value
|
||||
assert.Equal(t, AppConfigValue("marker-homePageUrl"), m.HomePageURL)
|
||||
})
|
||||
|
||||
t.Run("an empty DTO resets every property to its default", func(t *testing.T) {
|
||||
// Pre-populate with junk to prove Replace overwrites existing state
|
||||
m := AppConfigModel{
|
||||
AppName: "Custom Name",
|
||||
LdapEnabled: "true",
|
||||
SmtpHost: "smtp.example.com",
|
||||
}
|
||||
|
||||
m.Replace(dto.AppConfigUpdateDto{})
|
||||
|
||||
assert.Equal(t, *getDefaultConfig(), m)
|
||||
})
|
||||
|
||||
t.Run("provided values overwrite existing non-default values", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
m.AppName = "Old Name"
|
||||
m.LdapEnabled = "true"
|
||||
|
||||
input := dto.AppConfigUpdateDto{}
|
||||
input.AppName = "New Name"
|
||||
|
||||
m.Replace(input)
|
||||
|
||||
// Explicitly provided value wins
|
||||
assert.Equal(t, AppConfigValue("New Name"), m.AppName)
|
||||
// Everything else in the DTO was empty, so it is reset to the default
|
||||
assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled)
|
||||
})
|
||||
|
||||
t.Run("stores raw string values without type coercion", func(t *testing.T) {
|
||||
input := dto.AppConfigUpdateDto{}
|
||||
input.SessionDuration = "120" // int-tagged property
|
||||
input.LdapEnabled = "true" // bool-tagged property
|
||||
|
||||
var m AppConfigModel
|
||||
m.Replace(input)
|
||||
|
||||
assert.Equal(t, AppConfigValue("120"), m.SessionDuration)
|
||||
assert.Equal(t, AppConfigValue("true"), m.LdapEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Clone(t *testing.T) {
|
||||
t.Run("clones every property", func(t *testing.T) {
|
||||
// Populate every property with a unique marker so we can assert each one is copied
|
||||
var original AppConfigModel
|
||||
rv := reflect.ValueOf(&original).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
rv.Field(i).SetString("marker-" + key)
|
||||
}
|
||||
|
||||
clone := original.Clone()
|
||||
|
||||
require.NotNil(t, clone)
|
||||
// The clone must be a distinct object with equal contents
|
||||
assert.NotSame(t, &original, clone)
|
||||
assert.Equal(t, original, *clone)
|
||||
})
|
||||
|
||||
t.Run("mutating the clone does not affect the original", func(t *testing.T) {
|
||||
original := getDefaultConfig()
|
||||
|
||||
clone := original.Clone()
|
||||
clone.AppName = "Changed"
|
||||
clone.LdapEnabled = "true"
|
||||
|
||||
// The original keeps its values
|
||||
assert.Equal(t, getDefaultConfig().AppName, original.AppName)
|
||||
assert.Equal(t, getDefaultConfig().LdapEnabled, original.LdapEnabled)
|
||||
|
||||
// The clone holds the new values
|
||||
assert.Equal(t, AppConfigValue("Changed"), clone.AppName)
|
||||
assert.Equal(t, AppConfigValue("true"), clone.LdapEnabled)
|
||||
})
|
||||
|
||||
t.Run("mutating the original does not affect the clone", func(t *testing.T) {
|
||||
original := getDefaultConfig()
|
||||
|
||||
clone := original.Clone()
|
||||
original.AppName = "Changed"
|
||||
|
||||
assert.Equal(t, getDefaultConfig().AppName, clone.AppName)
|
||||
})
|
||||
|
||||
t.Run("cloning a nil receiver returns nil", func(t *testing.T) {
|
||||
var m *AppConfigModel
|
||||
assert.Nil(t, m.Clone())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Update(t *testing.T) {
|
||||
t.Run("updates a single property", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update(map[string]string{"appName": "My App"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, AppConfigValue("My App"), m.AppName)
|
||||
})
|
||||
|
||||
t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update(map[string]string{"appName": "My App", "homePageUrl": "/home", "ldapEnabled": "true"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, AppConfigValue("My App"), m.AppName)
|
||||
assert.Equal(t, AppConfigValue("/home"), m.HomePageURL)
|
||||
assert.Equal(t, AppConfigValue("true"), m.LdapEnabled)
|
||||
// A property that was not part of the update keeps its previous value
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("an empty value resets the property to its default", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
m.SmtpTls = "tls" // default is "none"
|
||||
m.SessionDuration = "120" // default is "60"
|
||||
|
||||
err := m.Update(map[string]string{"smtpTls": "", "sessionDuration": ""})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls)
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("stores raw string values without type coercion", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update(map[string]string{"sessionDuration": "120", "disableAnimations": "true"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, AppConfigValue("120"), m.SessionDuration)
|
||||
assert.Equal(t, AppConfigValue("true"), m.DisableAnimations)
|
||||
})
|
||||
|
||||
t.Run("an empty map is a no-op", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
before := *m
|
||||
|
||||
err := m.Update(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, before, *m)
|
||||
})
|
||||
|
||||
t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update(map[string]string{"thisKeyDoesNotExist": "value"})
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'")
|
||||
|
||||
notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
|
||||
})
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type AppConfigService struct {
|
||||
actSvc *actor.Service
|
||||
envConfig *AppConfigModel
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{}
|
||||
|
||||
// If the UI config is disabled, we do not need to init the config actor
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
service.envConfig, err = service.loadDbConfigFromEnv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app config from the env: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if"
|
||||
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
|
||||
defer tracing.End(span, err)
|
||||
|
||||
// Load the legacy config if any, which we need to send to the actor as bootstrap data
|
||||
legacyCfg, err := LoadLegacyConfig(ctx, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading legacy config: %w", err)
|
||||
}
|
||||
|
||||
// Register the AppConfig actor
|
||||
// This is a singleton actor and it's bootstrapped with the legacy config if present
|
||||
bootstrapData := &appConfigActorBootstrap{
|
||||
LegacyConfig: legacyCfg,
|
||||
}
|
||||
err = actors.RegisterSingletonActor(
|
||||
AppConfigActorType, NewAppConfigActor,
|
||||
local.WithBootstrapData(bootstrapData),
|
||||
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
|
||||
}
|
||||
|
||||
service.actSvc = actors.Service()
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetConfig returns the application configuration
|
||||
// Important: Treat the object as read-only: do not modify its properties directly!
|
||||
func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) {
|
||||
// If the UI config is disabled, only load from the env
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return s.envConfig, nil
|
||||
}
|
||||
|
||||
// Retrieve the config from the actor
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving config from actor: %w", err)
|
||||
}
|
||||
if res == nil {
|
||||
return nil, errors.New("config actor response was empty")
|
||||
}
|
||||
|
||||
var cfg AppConfigModel
|
||||
err = res.Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding config actor response: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpdateAppConfig replaces the entire application configuration with the values from the input DTO.
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) {
|
||||
// If the UI config is disabled, we cannot continue
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Replace the entire config by invoking the actor
|
||||
cfg, err := s.invokeConfigActor(ctx, "replace", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return the updated config
|
||||
return cfg.ToAppConfigVariableSlice(true, false), nil
|
||||
}
|
||||
|
||||
// UpdateAppConfigValues updates the provided application configuration values.
|
||||
// Keys correspond to the "json" tags on the config model.
|
||||
// An empty string value resets the property to its default value.
|
||||
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
// If the UI config is disabled, we cannot continue
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Collect the key-value pairs into a map for the actor
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
values := make(map[string]string, len(keysAndValues)/2)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
values[keysAndValues[i-1]] = keysAndValues[i]
|
||||
}
|
||||
|
||||
// Update the config by invoking the actor
|
||||
_, err := s.invokeConfigActor(ctx, "update", values)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAppConfig returns the application configuration as a slice of key/value pairs.
|
||||
// If showAll is false, only properties marked as public are included.
|
||||
func (s *AppConfigService) ListAppConfig(ctx context.Context, showAll bool) ([]AppConfigVariable, error) {
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg.ToAppConfigVariableSlice(showAll, true), nil
|
||||
}
|
||||
|
||||
// invokeConfigActor invokes a method on the AppConfig actor and decodes the returned state.
|
||||
func (s *AppConfigService) invokeConfigActor(parentCtx context.Context, method string, data any) (*AppConfigModel, error) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
res, err := s.actSvc.Invoke(ctx, AppConfigActorType, actor.SingletonActorID, method, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error invoking config actor method '%s': %w", method, err)
|
||||
}
|
||||
if res == nil {
|
||||
return nil, errors.New("config actor response was empty")
|
||||
}
|
||||
|
||||
var cfg AppConfigModel
|
||||
err = res.Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding config actor response: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
|
||||
// First, start from the default configuration
|
||||
dest := getDefaultConfig()
|
||||
|
||||
// Iterate through each field
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Derive the environment variable name from the configuration's JSON key
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||
|
||||
// Set the value if it's set
|
||||
value, ok := os.LookupEnv(envVarName)
|
||||
if ok {
|
||||
rv.Field(i).SetString(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's sensitive, we also allow reading from file
|
||||
if field.Tag.Get("sensitive") == "true" {
|
||||
fileName := os.Getenv(envVarName + "_FILE")
|
||||
if fileName != "" {
|
||||
// #nosec G703 - Value is provided by admin
|
||||
b, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
|
||||
}
|
||||
|
||||
rv.Field(i).SetString(string(b))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// setUIConfigDisabled sets common.EnvConfig.UiConfigDisabled for the duration of the test, restoring the previous global afterwards
|
||||
func setUIConfigDisabled(t *testing.T, disabled bool) {
|
||||
t.Helper()
|
||||
|
||||
original := common.EnvConfig
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig = original
|
||||
})
|
||||
common.EnvConfig.UiConfigDisabled = disabled
|
||||
}
|
||||
|
||||
// newActorBackedService creates an AppConfigService wired to an in-memory test actor host.
|
||||
// The AppConfig singleton actor is registered and bootstrapped from db, which is also used to load any legacy config.
|
||||
func newActorBackedService(t *testing.T, db *gorm.DB) *AppConfigService {
|
||||
t.Helper()
|
||||
|
||||
var svc *AppConfigService
|
||||
testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
|
||||
var err error
|
||||
svc, err = NewService(t.Context(), h, db)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
require.NotNil(t, svc)
|
||||
|
||||
// The singleton actor is bootstrapped asynchronously once the host is ready.
|
||||
// Before bootstrap runs, the actor has no state and GetConfig decodes it into a non-nil but zero config, so wait until a non-zero (bootstrapped) config is available before returning.
|
||||
require.Eventually(t, func() bool {
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
return err == nil && cfg != nil && *cfg != (AppConfigModel{})
|
||||
}, 10*time.Second, 20*time.Millisecond, "config actor was not bootstrapped in time")
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// seedLegacyConfig writes a legacy config blob to the kv table so the AppConfig actor bootstraps from it.
|
||||
func seedLegacyConfig(t *testing.T, db *gorm.DB, values map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
blob, err := json.Marshal(values)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := string(blob)
|
||||
err = db.Create(&model.KV{Key: "config_migrated", Value: &value}).Error
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// findConfigValue returns the value for key in a slice of AppConfigVariable, and whether it was found.
|
||||
func findConfigValue(vars []AppConfigVariable, key string) (string, bool) {
|
||||
for _, v := range vars {
|
||||
if v.Key == key {
|
||||
return v.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestService_NewService(t *testing.T) {
|
||||
t.Run("bootstraps the default config when the database is empty", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, *getDefaultConfig(), *cfg)
|
||||
})
|
||||
|
||||
t.Run("bootstraps from the legacy config in the database", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
seedLegacyConfig(t, db, map[string]string{
|
||||
"appName": "Legacy App",
|
||||
"ldapEnabled": "true",
|
||||
})
|
||||
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppConfigValue("Legacy App"), cfg.AppName)
|
||||
assert.Equal(t, AppConfigValue("true"), cfg.LdapEnabled)
|
||||
// Keys not present in the legacy config keep their defaults
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, cfg.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("loads config from the environment when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
t.Setenv("APP_NAME", "Environment App")
|
||||
|
||||
// No actor host or database is needed when the UI config is disabled
|
||||
svc, err := NewService(t.Context(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc)
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppConfigValue("Environment App"), cfg.AppName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_GetConfig(t *testing.T) {
|
||||
t.Run("returns a fresh copy on each call", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
first, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mutating the returned config must not affect what the service returns later
|
||||
first.AppName = "Mutated"
|
||||
|
||||
second, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, getDefaultConfig().AppName, second.AppName)
|
||||
})
|
||||
|
||||
t.Run("returns the env config when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
svc := NewTestAppConfigService(&AppConfigModel{AppName: "From Env"})
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppConfigValue("From Env"), cfg.AppName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_UpdateAppConfig(t *testing.T) {
|
||||
t.Run("replaces the configuration and returns all variables", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
input := dto.AppConfigUpdateDto{
|
||||
AppName: "Replaced App",
|
||||
SessionDuration: "120",
|
||||
LdapEnabled: "true",
|
||||
SmtpTls: "tls",
|
||||
}
|
||||
res, err := svc.UpdateAppConfig(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The returned slice includes all variables, both public and private
|
||||
got, ok := findConfigValue(res, "appName")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Replaced App", got)
|
||||
got, ok = findConfigValue(res, "smtpTls")
|
||||
require.True(t, ok, "the returned slice should include private variables")
|
||||
assert.Equal(t, "tls", got)
|
||||
|
||||
// The change is persisted and visible on subsequent reads
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppConfigValue("Replaced App"), cfg.AppName)
|
||||
assert.Equal(t, AppConfigValue("120"), cfg.SessionDuration)
|
||||
assert.Equal(t, AppConfigValue("true"), cfg.LdapEnabled)
|
||||
})
|
||||
|
||||
t.Run("resets fields omitted from the DTO to their defaults", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
// First set some non-default values
|
||||
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
||||
AppName: "First",
|
||||
LdapEnabled: "true",
|
||||
SmtpTls: "tls",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Replace again with only AppName set: the rest must reset to their defaults
|
||||
_, err = svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "Second"})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppConfigValue("Second"), cfg.AppName)
|
||||
assert.Equal(t, getDefaultConfig().LdapEnabled, cfg.LdapEnabled)
|
||||
assert.Equal(t, getDefaultConfig().SmtpTls, cfg.SmtpTls)
|
||||
})
|
||||
|
||||
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
svc := NewTestAppConfigService(nil)
|
||||
|
||||
_, err := svc.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{AppName: "X"})
|
||||
require.Error(t, err)
|
||||
var target *common.UiConfigDisabledError
|
||||
assert.ErrorAs(t, err, &target)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_UpdateAppConfigValues(t *testing.T) {
|
||||
t.Run("updates a subset of keys and leaves the rest unchanged", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "appName", "Updated", "sessionDuration", "120")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppConfigValue("Updated"), cfg.AppName)
|
||||
assert.Equal(t, AppConfigValue("120"), cfg.SessionDuration)
|
||||
// A key that was not part of the update keeps its default
|
||||
assert.Equal(t, getDefaultConfig().LdapEnabled, cfg.LdapEnabled)
|
||||
})
|
||||
|
||||
t.Run("an empty value resets the property to its default", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
// Set a non-default value first
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "sessionDuration", "120")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then reset it with an empty value
|
||||
err = svc.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, cfg.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("an odd number of arguments returns an error", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "appName")
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid number of arguments received")
|
||||
})
|
||||
|
||||
t.Run("an unknown key returns an error and does not change the config", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "thisKeyDoesNotExist", "value")
|
||||
require.Error(t, err)
|
||||
|
||||
// The config must not have been modified
|
||||
cfg, err := svc.GetConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, *getDefaultConfig(), *cfg)
|
||||
})
|
||||
|
||||
t.Run("returns UiConfigDisabledError when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
svc := NewTestAppConfigService(nil)
|
||||
|
||||
// An even number of arguments so the count check passes and we reach the UI-config check
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "appName", "X")
|
||||
require.Error(t, err)
|
||||
var target *common.UiConfigDisabledError
|
||||
assert.ErrorAs(t, err, &target)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ListAppConfig(t *testing.T) {
|
||||
t.Run("returns only public variables when showAll is false", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
vars, err := svc.ListAppConfig(t.Context(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// appName is public and must be present
|
||||
_, ok := findConfigValue(vars, "appName")
|
||||
assert.True(t, ok, "public variable appName should be present")
|
||||
// smtpHost is not public and must be excluded
|
||||
_, ok = findConfigValue(vars, "smtpHost")
|
||||
assert.False(t, ok, "private variable smtpHost should be excluded")
|
||||
})
|
||||
|
||||
t.Run("returns all variables when showAll is true", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
vars, err := svc.ListAppConfig(t.Context(), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, ok := findConfigValue(vars, "appName")
|
||||
assert.True(t, ok)
|
||||
_, ok = findConfigValue(vars, "smtpHost")
|
||||
assert.True(t, ok, "private variables should be included when showAll is true")
|
||||
})
|
||||
|
||||
t.Run("reflects updates made through the service", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, false)
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newActorBackedService(t, db)
|
||||
|
||||
err := svc.UpdateAppConfigValues(t.Context(), "appName", "Listed App")
|
||||
require.NoError(t, err)
|
||||
|
||||
vars, err := svc.ListAppConfig(t.Context(), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := findConfigValue(vars, "appName")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Listed App", got)
|
||||
})
|
||||
|
||||
t.Run("redacts sensitive values when the UI config is disabled", func(t *testing.T) {
|
||||
setUIConfigDisabled(t, true)
|
||||
svc := NewTestAppConfigService(&AppConfigModel{
|
||||
SmtpPassword: "super-secret",
|
||||
})
|
||||
|
||||
vars, err := svc.ListAppConfig(t.Context(), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := findConfigValue(vars, "smtpPassword")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "XXXXXXXXXX", got)
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//go:build unit
|
||||
|
||||
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
||||
package appconfig
|
||||
|
||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
|
||||
if config == nil {
|
||||
// If there's no config, set the default one
|
||||
config = getDefaultConfig()
|
||||
}
|
||||
|
||||
service := &AppConfigService{
|
||||
envConfig: config,
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// NewTestConfig returns an application configuration for use in tests, falling back to the default configuration when none is provided
|
||||
func NewTestConfig(config *AppConfigModel) *AppConfigModel {
|
||||
if config == nil {
|
||||
config = getDefaultConfig()
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -49,12 +49,6 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
|
||||
local.WithLogger(log.With("scope", "actor-host")),
|
||||
local.WithRuntimePSKs(psk),
|
||||
local.WithShutdownGracePeriod(10 * time.Second),
|
||||
// TODO: Tweak these values once Pocket ID fully supports horizontal scaling.
|
||||
// The relaxed intervals are appropriate for a single active host, but should be
|
||||
// tuned for lower latency and better distribution across a multi-host cluster.
|
||||
local.WithHostHealthCheckDeadline(90 * time.Second),
|
||||
local.WithAlarmsPollInterval(5 * time.Minute),
|
||||
local.WithAlarmsFetchAheadInterval(5 * time.Minute),
|
||||
}
|
||||
|
||||
// Add the database connection
|
||||
|
||||
@@ -100,7 +100,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
services = append(services, actorsRun)
|
||||
|
||||
// Create all services
|
||||
svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler)
|
||||
svc, err := initServices(ctx, db, instanceID, httpClient, imageExtensions, fileStorage, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) (shutdown
|
||||
Level: level,
|
||||
})
|
||||
} else {
|
||||
handler = tint.NewTextHandler(os.Stdout, &tint.Options{
|
||||
handler = tint.NewHandler(os.Stdout, &tint.Options{
|
||||
TimeFormat: time.Stamp,
|
||||
Level: level,
|
||||
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
|
||||
|
||||
@@ -158,11 +158,11 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
|
||||
)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.oneTimeAccessService, svc.webauthnModule)
|
||||
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.appConfigService, svc.userGroupService)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||
|
||||
@@ -5,14 +5,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
@@ -21,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type services struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appConfigService *service.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
@@ -46,20 +44,10 @@ type services struct {
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
func initServices(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
instanceID string,
|
||||
actors *local.Host,
|
||||
httpClient *http.Client,
|
||||
imageExtensions map[string]string,
|
||||
fileStorage storage.FileStorage,
|
||||
scheduler *job.Scheduler,
|
||||
) (svc *services, err error) {
|
||||
func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
|
||||
// Init the app config service
|
||||
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
|
||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||
}
|
||||
@@ -68,14 +56,14 @@ func initServices(
|
||||
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
|
||||
svc.appLockService = service.NewAppLockService(db)
|
||||
|
||||
svc.emailService, err = service.NewEmailService(db)
|
||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create email service: %w", err)
|
||||
}
|
||||
|
||||
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||
svc.auditLogService = service.NewAuditLogService(db, svc.emailService, svc.geoLiteService, svc.appConfigService)
|
||||
svc.jwtService, err = service.NewJwtService(ctx, db, instanceID)
|
||||
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
||||
svc.jwtService, err = service.NewJwtService(ctx, db, instanceID, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||
}
|
||||
@@ -115,14 +103,14 @@ func initServices(
|
||||
return nil, fmt.Errorf("failed to create OIDC module: %w", err)
|
||||
}
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage)
|
||||
svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||
}
|
||||
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.scimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.userService, svc.userGroupService, fileStorage)
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||
|
||||
svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{
|
||||
DB: db,
|
||||
@@ -136,10 +124,10 @@ func initServices(
|
||||
DB: db,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
UserCreator: svc.userService,
|
||||
AppConfig: svc.appConfigService,
|
||||
UserCreator: svc.userService,
|
||||
})
|
||||
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService)
|
||||
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||
|
||||
svc.versionService = service.NewVersionService(httpClient)
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
|
||||
@@ -277,3 +277,13 @@ type InvalidEmailVerificationTokenError struct{}
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type EmailDomainNotAllowedError struct {
|
||||
Domain string
|
||||
}
|
||||
|
||||
func (e EmailDomainNotAllowedError) Error() string {
|
||||
return fmt.Sprintf("The email address must use the domain @%s", e.Domain)
|
||||
}
|
||||
|
||||
func (e EmailDomainNotAllowedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported
|
||||
type ErrUnsupportedActorMethod struct {
|
||||
Method string
|
||||
}
|
||||
|
||||
func (e ErrUnsupportedActorMethod) Error() string {
|
||||
return "method '" + e.Method + "' unsupported for actor invocation"
|
||||
}
|
||||
|
||||
func (e ErrUnsupportedActorMethod) Is(target error) bool {
|
||||
// Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod
|
||||
_, ok := errors.AsType[ErrUnsupportedActorMethod](target)
|
||||
return ok
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
@@ -20,7 +19,7 @@ import (
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
appConfigService *appconfig.AppConfigService,
|
||||
appConfigService *service.AppConfigService,
|
||||
emailService *service.EmailService,
|
||||
ldapService *service.LdapService,
|
||||
) {
|
||||
@@ -39,7 +38,7 @@ func NewAppConfigController(
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appConfigService *service.AppConfigService
|
||||
emailService *service.EmailService
|
||||
ldapService *service.LdapService
|
||||
}
|
||||
@@ -53,12 +52,7 @@ type AppConfigController struct {
|
||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||
// @Router /api/application-configuration [get]
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
configuration := dbConfig.ToAppConfigVariableSlice(false, true)
|
||||
configuration := acc.appConfigService.ListAppConfig(false)
|
||||
|
||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
@@ -92,12 +86,7 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Router /api/application-configuration/all [get]
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
configuration := dbConfig.ToAppConfigVariableSlice(true, true)
|
||||
configuration := acc.appConfigService.ListAppConfig(true)
|
||||
|
||||
var configVariablesDto []dto.AppConfigVariableDto
|
||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||
@@ -146,13 +135,7 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-configuration/sync-ldap [post]
|
||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = acc.ldapService.SyncAll(c.Request.Context(), dbConfig)
|
||||
err := acc.ldapService.SyncAll(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -168,15 +151,9 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-configuration/test-email [post]
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
dbConfig, err := acc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err = acc.emailService.SendTestEmail(c.Request.Context(), dbConfig, userID)
|
||||
err := acc.emailService.SendTestEmail(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
@@ -23,12 +21,12 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
// @Tags Users
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, appConfigService *appconfig.AppConfigService, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module) {
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) {
|
||||
uc := UserController{
|
||||
appConfigService: appConfigService,
|
||||
userService: userService,
|
||||
oneTimeAccessService: oneTimeAccessService,
|
||||
webAuthnService: webAuthnService,
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
|
||||
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
|
||||
@@ -63,10 +61,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
userService *service.UserService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
webAuthnService *webauthn.Module
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
// getUserGroupsHandler godoc
|
||||
@@ -209,13 +207,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id} [delete]
|
||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := uc.userService.DeleteUser(c.Request.Context(), dbConfig, c.Param("id"), false); err != nil {
|
||||
if err := uc.userService.DeleteUser(c.Request.Context(), c.Param("id"), false); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -256,19 +248,13 @@ func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
|
||||
// @Success 201 {object} dto.UserDto
|
||||
// @Router /api/users [post]
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input dto.UserCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := uc.userService.CreateUser(c.Request.Context(), dbConfig, input)
|
||||
user, err := uc.userService.CreateUser(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -466,19 +452,13 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/one-time-access-email [post]
|
||||
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), dbConfig, input.Email, input.RedirectPath)
|
||||
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -499,12 +479,6 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id}/one-time-access-email [post]
|
||||
func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input dto.OneTimeAccessEmailAsAdminDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
@@ -517,7 +491,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
err = uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), dbConfig, userID, ttl)
|
||||
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -534,12 +508,6 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/one-time-access-token/{token} [post]
|
||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
cfg, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
loginCode := c.Param("token")
|
||||
// reject invalid length login codes
|
||||
if len(loginCode) != 6 && len(loginCode) != 16 {
|
||||
@@ -548,20 +516,19 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
|
||||
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
|
||||
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var userDto dto.UserDto
|
||||
err = dto.MapStruct(user, &userDto)
|
||||
if err != nil {
|
||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
|
||||
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
@@ -599,12 +566,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
|
||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input dto.UserCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
@@ -618,7 +579,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
userID = c.Param("id")
|
||||
}
|
||||
|
||||
user, err := uc.userService.UpdateUser(c.Request.Context(), dbConfig, userID, input, updateOwnUser, false)
|
||||
user, err := uc.userService.UpdateUser(c.Request.Context(), userID, input, updateOwnUser, false)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -678,15 +639,9 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/me/send-email-verification [post]
|
||||
func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
if err := uc.userService.SendEmailVerification(c.Request.Context(), dbConfig, userID); err != nil {
|
||||
if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
@@ -16,9 +14,8 @@ import (
|
||||
// @Summary User group management controller
|
||||
// @Description Initializes all user group-related API endpoints
|
||||
// @Tags User Groups
|
||||
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, appConfigService *appconfig.AppConfigService, userGroupService *service.UserGroupService) {
|
||||
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
|
||||
ugc := UserGroupController{
|
||||
appConfigService: appConfigService,
|
||||
UserGroupService: userGroupService,
|
||||
}
|
||||
|
||||
@@ -36,7 +33,6 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
|
||||
}
|
||||
|
||||
type UserGroupController struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
UserGroupService *service.UserGroupService
|
||||
}
|
||||
|
||||
@@ -150,19 +146,13 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
// @Success 200 {object} dto.UserGroupDto "Updated user group"
|
||||
// @Router /api/user-groups/{id} [put]
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.Update(c.Request.Context(), dbConfig, c.Param("id"), input)
|
||||
group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -187,13 +177,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/user-groups/{id} [delete]
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
dbConfig, err := ugc.appConfigService.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := ugc.UserGroupService.Delete(c.Request.Context(), dbConfig, c.Param("id")); err != nil {
|
||||
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
|
||||
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
// emailDomainRegex validates the domain part of an email address (e.g. "example.com" or "mail.example.co.uk")
|
||||
var emailDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
func init() {
|
||||
engine := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
@@ -34,6 +37,9 @@ func init() {
|
||||
"client_id": func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
},
|
||||
"email_domain": func(fl validator.FieldLevel) bool {
|
||||
return ValidateEmailDomain(fl.Field().String())
|
||||
},
|
||||
"ttl": func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
@@ -106,3 +112,22 @@ func ValidateCallbackURL(str string) bool {
|
||||
func ValidateCallbackURLPattern(raw string) bool {
|
||||
return utils.ValidateCallbackURLPattern(raw) == nil
|
||||
}
|
||||
|
||||
// ValidateEmailDomain validates an optional email domain.
|
||||
// It accepts inputs with or without a leading "@"
|
||||
func ValidateEmailDomain(domain string) bool {
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
domain = strings.TrimPrefix(strings.ToLower(domain), "@")
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if !emailDomainRegex.MatchString(domain) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -112,6 +112,42 @@ func TestValidateCallbackURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmailDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid simple", "example.com", true},
|
||||
{"valid with leading at", "@example.com", true},
|
||||
{"valid subdomain", "mail.example.co.uk", true},
|
||||
{"valid uppercase", "EXAMPLE.COM", true},
|
||||
{"valid with at and uppercase", "@Example.Com", true},
|
||||
{"valid with hyphen", "my-domain.com", true},
|
||||
{"valid with digits", "example123.com", true},
|
||||
{"valid single char label", "a.com", true},
|
||||
{"empty", "", false},
|
||||
{"only at", "@", false},
|
||||
{"no tld", "example", false},
|
||||
{"tld too short", "example.c", false},
|
||||
{"trailing dot", "example.com.", false},
|
||||
{"leading dot", ".example.com", false},
|
||||
{"double dot", "example..com", false},
|
||||
{"label starts with hyphen", "-example.com", false},
|
||||
{"label ends with hyphen", "example-.com", false},
|
||||
{"numeric tld", "example.123", false},
|
||||
{"contains space", "exa mple.com", false},
|
||||
{"double at", "@@example.com", false},
|
||||
{"trailing space", "example.com ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateEmailDomain(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallbackURLPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -137,6 +137,15 @@ func TestMigrateFromAppConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// countLegacyInstanceID returns the number of "instanceId" rows left in the app_config_variables table
|
||||
countLegacyInstanceID := func(t *testing.T, db *gorm.DB) int64 {
|
||||
t.Helper()
|
||||
var count int64
|
||||
err := db.Table("app_config_variables").Where(`"key" = ?`, "instanceId").Count(&count).Error
|
||||
require.NoError(t, err)
|
||||
return count
|
||||
}
|
||||
|
||||
t.Run("moves an existing instance ID from app_config_variables into the kv table", func(t *testing.T) {
|
||||
legacyID := uuid.NewString()
|
||||
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMove, seedAppConfigInstanceID(legacyID))
|
||||
@@ -146,8 +155,8 @@ func TestMigrateFromAppConfig(t *testing.T) {
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, legacyID, stored)
|
||||
|
||||
// The final config migration removes the legacy table after freezing its remaining values
|
||||
require.False(t, db.Migrator().HasTable("app_config_variables"))
|
||||
// The legacy row must have been removed from app_config_variables
|
||||
require.Zero(t, countLegacyInstanceID(t, db))
|
||||
|
||||
// Load must return the migrated value without generating a new one
|
||||
id, err := Load(t.Context(), db)
|
||||
@@ -175,8 +184,7 @@ func TestMigrateFromAppConfig(t *testing.T) {
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, "kv-instance-id", stored)
|
||||
|
||||
// The final config migration removes the legacy table regardless of the conflict
|
||||
ok := db.Migrator().HasTable("app_config_variables")
|
||||
require.False(t, ok)
|
||||
// The legacy row must still be removed regardless of the conflict
|
||||
require.Zero(t, countLegacyInstanceID(t, db))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,18 +8,17 @@ import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
type ApiKeyEmailJobs struct {
|
||||
apiKeyModule *apikey.Module
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appConfigService *service.AppConfigService
|
||||
emailService *service.EmailService
|
||||
}
|
||||
|
||||
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailService *service.EmailService) error {
|
||||
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error {
|
||||
jobs := &ApiKeyEmailJobs{
|
||||
apiKeyModule: apiKeyModule,
|
||||
appConfigService: appConfigService,
|
||||
@@ -31,13 +30,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *a
|
||||
}
|
||||
|
||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||
dbConfig, err := j.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error load app config: %w", err)
|
||||
}
|
||||
|
||||
// Skip if the feature is disabled
|
||||
if !dbConfig.EmailApiKeyExpirationEnabled.IsTrue() {
|
||||
if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,7 +45,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
||||
continue
|
||||
}
|
||||
|
||||
err = service.SendEmail(ctx, j.emailService, dbConfig, email.Address{
|
||||
err = service.SendEmail(ctx, j.emailService, email.Address{
|
||||
Name: key.User.FullName(),
|
||||
Email: *key.User.Email,
|
||||
}, service.ApiKeyExpiringSoonTemplate, &service.ApiKeyExpiringSoonTemplateData{
|
||||
|
||||
@@ -2,19 +2,17 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
type LdapJobs struct {
|
||||
ldapService *service.LdapService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *appconfig.AppConfigService) error {
|
||||
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||
|
||||
// Register the job to run every hour (with some jitter)
|
||||
@@ -22,14 +20,9 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
|
||||
}
|
||||
|
||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||
dbConfig, err := j.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error load app config: %w", err)
|
||||
}
|
||||
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return j.ldapService.SyncAll(ctx, dbConfig)
|
||||
return j.ldapService.SyncAll(ctx)
|
||||
}
|
||||
|
||||
@@ -33,20 +33,23 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
instanceID, err := instanceid.Load(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
jwtService, err := service.NewJwtService(t.Context(), db, instanceID)
|
||||
jwtService, err := service.NewJwtService(t.Context(), db, instanceID, appConfigService)
|
||||
require.NoError(t, err)
|
||||
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil, nil)
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
|
||||
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db})
|
||||
require.NoError(t, err)
|
||||
|
||||
authMiddleware := NewAuthMiddleware(apiKeyModule, userService, jwtService)
|
||||
|
||||
user := createUserForAuthMiddlewareTest(t, db)
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user, "", time.Hour)
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKeyToken := "middleware-test-api-key-raw-token"
|
||||
|
||||
@@ -39,7 +39,6 @@ func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[strin
|
||||
for name, rl := range limiters {
|
||||
services[name] = rl.Service(svc)
|
||||
}
|
||||
|
||||
return services
|
||||
}
|
||||
|
||||
|
||||
203
backend/internal/model/app_config.go
Normal file
203
backend/internal/model/app_config.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type AppConfigVariable struct {
|
||||
Key string `gorm:"primaryKey;not null"`
|
||||
Value string
|
||||
}
|
||||
|
||||
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
|
||||
func (a *AppConfigVariable) IsTrue() bool {
|
||||
ok, _ := strconv.ParseBool(a.Value)
|
||||
return ok
|
||||
}
|
||||
|
||||
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
|
||||
func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
|
||||
val, err := strconv.Atoi(a.Value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(val) * time.Minute
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
// General
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||
// Email
|
||||
RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public
|
||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
|
||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
|
||||
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
|
||||
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
|
||||
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
|
||||
// LDAP
|
||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
|
||||
LdapBase AppConfigVariable `key:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify AppConfigVariable `key:"ldapSkipCertVerify"`
|
||||
LdapAttributeUserUniqueIdentifier AppConfigVariable `key:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername AppConfigVariable `key:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
||||
}
|
||||
|
||||
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
|
||||
// Use reflection to iterate through all fields
|
||||
cfgValue := reflect.ValueOf(c).Elem()
|
||||
cfgType := cfgValue.Type()
|
||||
|
||||
var res []AppConfigVariable
|
||||
|
||||
for i := range cfgType.NumField() {
|
||||
field := cfgType.Field(i)
|
||||
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're only showing public variables and this is not public, skip it
|
||||
if !showAll && attrs != "public" {
|
||||
continue
|
||||
}
|
||||
|
||||
value := cfgValue.Field(i).FieldByName("Value").String()
|
||||
|
||||
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
|
||||
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
|
||||
value = "XXXXXXXXXX"
|
||||
}
|
||||
|
||||
appConfigVariable := AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
res = append(res, appConfigVariable)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
// Find the field in the struct whose "key" tag matches
|
||||
for i := range rt.NumField() {
|
||||
// Grab only the first part of the key, if there's a comma with additional properties
|
||||
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
|
||||
keyFromTag := tagValue[0]
|
||||
isInternal = slices.Contains(tagValue, "internal")
|
||||
if keyFromTag != key {
|
||||
continue
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
return valueField.String(), isInternal, nil
|
||||
}
|
||||
|
||||
// If we are here, the config key was not found
|
||||
return "", false, AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
// Find the field in the struct whose "key" tag matches, then update that
|
||||
for i := range rt.NumField() {
|
||||
// Separate the key (before the comma) from any optional attributes after
|
||||
tagValue, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
|
||||
if tagValue != key {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the field is internal and noInternal is true, we skip that
|
||||
if noInternal && attrs == "internal" {
|
||||
return AppConfigInternalForbiddenError{field: key}
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
if !valueField.CanSet() {
|
||||
return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key)
|
||||
}
|
||||
|
||||
// Update the value
|
||||
valueField.SetString(value)
|
||||
|
||||
// Return once updated
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we're here, we have not found the right field to update
|
||||
return AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
type AppConfigKeyNotFoundError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
|
||||
x := AppConfigKeyNotFoundError{}
|
||||
return errors.As(target, &x)
|
||||
}
|
||||
|
||||
type AppConfigInternalForbiddenError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Error() string {
|
||||
return "field '" + e.field + "' is internal and can't be updated"
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError
|
||||
x := AppConfigInternalForbiddenError{}
|
||||
return errors.As(target, &x)
|
||||
}
|
||||
126
backend/internal/model/app_config_test.go
Normal file
126
backend/internal/model/app_config_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// We use model_test here to avoid an import cycle
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
expected time.Duration
|
||||
expectedSeconds int
|
||||
}{
|
||||
{
|
||||
name: "valid positive integer",
|
||||
value: "60",
|
||||
expected: 60 * time.Minute,
|
||||
expectedSeconds: 3600,
|
||||
},
|
||||
{
|
||||
name: "valid zero integer",
|
||||
value: "0",
|
||||
expected: 0,
|
||||
expectedSeconds: 0,
|
||||
},
|
||||
{
|
||||
name: "negative integer",
|
||||
value: "-30",
|
||||
expected: -30 * time.Minute,
|
||||
expectedSeconds: -1800,
|
||||
},
|
||||
{
|
||||
name: "invalid non-integer",
|
||||
value: "not-a-number",
|
||||
expected: 0,
|
||||
expectedSeconds: 0,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
value: "",
|
||||
expected: 0,
|
||||
expectedSeconds: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configVar := model.AppConfigVariable{
|
||||
Value: tt.value,
|
||||
}
|
||||
|
||||
result := configVar.AsDurationMinutes()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
assert.Equal(t, tt.expectedSeconds, int(result.Seconds()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This test ensures that the model.AppConfig and dto.AppConfigUpdateDto structs match:
|
||||
// - They should have the same properties, where the "json" tag of dto.AppConfigUpdateDto should match the "key" tag in model.AppConfig
|
||||
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig
|
||||
// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time
|
||||
func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
|
||||
appConfigType := reflect.TypeFor[model.AppConfig]()
|
||||
updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]()
|
||||
|
||||
// Process AppConfig fields
|
||||
appConfigFields := make(map[string]string)
|
||||
for field := range appConfigType.Fields() {
|
||||
if field.Tag.Get("key") == "" {
|
||||
// Skip internal fields
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the key name from the tag (takes the part before any comma)
|
||||
keyTag := field.Tag.Get("key")
|
||||
keyName, _, _ := strings.Cut(keyTag, ",")
|
||||
|
||||
appConfigFields[field.Name] = keyName
|
||||
}
|
||||
|
||||
// Process AppConfigUpdateDto fields
|
||||
dtoFields := make(map[string]string)
|
||||
for field := range updateDtoType.Fields() {
|
||||
// Extract the json name from the tag (takes the part before any binding constraints)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
jsonName, _, _ := strings.Cut(jsonTag, ",")
|
||||
|
||||
dtoFields[jsonName] = field.Name
|
||||
}
|
||||
|
||||
// Verify every AppConfig field has a matching DTO field with the same name
|
||||
for fieldName, keyName := range appConfigFields {
|
||||
if strings.HasSuffix(fieldName, "ImageType") {
|
||||
// Skip internal fields that shouldn't be in the DTO
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if there's a DTO field with a matching JSON tag
|
||||
_, exists := dtoFields[keyName]
|
||||
assert.True(t, exists, "Field %s with key '%s' in AppConfig has no matching field in AppConfigUpdateDto", fieldName, keyName)
|
||||
}
|
||||
|
||||
// Verify every DTO field has a matching AppConfig field
|
||||
for jsonName, fieldName := range dtoFields {
|
||||
// Find a matching field in AppConfig by key tag
|
||||
found := false
|
||||
for _, keyName := range appConfigFields {
|
||||
if keyName == jsonName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, found, "Field %s with json tag '%s' in AppConfigUpdateDto has no matching field in AppConfig", fieldName, jsonName)
|
||||
}
|
||||
}
|
||||
416
backend/internal/service/app_config_service.go
Normal file
416
backend/internal/service/app_config_service.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type AppConfigService struct {
|
||||
dbConfig atomic.Pointer[model.AppConfig]
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
|
||||
defer tracing.End(span, err)
|
||||
|
||||
// We need to assign to the "err" variable, do not inline this into the "if"
|
||||
err = service.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetDbConfig returns the application configuration.
|
||||
// Important: Treat the object as read-only: do not modify its properties directly!
|
||||
func (s *AppConfigService) GetDbConfig() *model.AppConfig {
|
||||
v := s.dbConfig.Load()
|
||||
if v == nil {
|
||||
// This indicates a development-time error
|
||||
panic("called GetDbConfig before DbConfig is loaded")
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
// Values are the default ones
|
||||
return &model.AppConfig{
|
||||
// General
|
||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
|
||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Email
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "true"},
|
||||
SmtpHost: model.AppConfigVariable{},
|
||||
SmtpPort: model.AppConfigVariable{},
|
||||
SmtpFrom: model.AppConfigVariable{},
|
||||
SmtpUser: model.AppConfigVariable{},
|
||||
SmtpPassword: model.AppConfigVariable{},
|
||||
SmtpTls: model.AppConfigVariable{Value: "none"},
|
||||
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
|
||||
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||
// LDAP
|
||||
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
||||
LdapUrl: model.AppConfigVariable{},
|
||||
LdapBindDn: model.AppConfigVariable{},
|
||||
LdapBindPassword: model.AppConfigVariable{},
|
||||
LdapBase: model.AppConfigVariable{},
|
||||
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
|
||||
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
|
||||
LdapSkipCertVerify: model.AppConfigVariable{Value: "false"},
|
||||
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{},
|
||||
LdapAttributeUserUsername: model.AppConfigVariable{},
|
||||
LdapAttributeUserEmail: model.AppConfigVariable{},
|
||||
LdapAttributeUserFirstName: model.AppConfigVariable{},
|
||||
LdapAttributeUserLastName: model.AppConfigVariable{},
|
||||
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
|
||||
LdapAttributeGroupName: model.AppConfigVariable{},
|
||||
LdapAdminGroupName: model.AppConfigVariable{},
|
||||
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) {
|
||||
// We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database
|
||||
// This works across multiple processes too
|
||||
tx = s.db.Begin()
|
||||
err = tx.Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin database transaction: %w", err)
|
||||
}
|
||||
|
||||
// With SQLite there's nothing else we need to do, because a transaction blocks the entire database
|
||||
// However, with Postgres we need to manually lock the table to prevent others from doing the same
|
||||
switch s.db.Name() {
|
||||
case "postgres":
|
||||
// We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled
|
||||
// Here we use a context with a 10s timeout in case the database is blocked for longer
|
||||
lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer lockCancel()
|
||||
err = tx.
|
||||
WithContext(lockCtx).
|
||||
Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE").
|
||||
Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err)
|
||||
}
|
||||
default:
|
||||
// Nothing to do here
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
// Perform an "upsert" if the key already exists, replacing the value
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).
|
||||
Create(&dbUpdate).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update config in database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Start the transaction
|
||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
|
||||
for field := range rt.Fields() {
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
// Skip values that are internal only and can't be updated
|
||||
if value == "" {
|
||||
// Ignore errors here as we know the key exists
|
||||
defaultValue, _, _ := defaultCfg.FieldByKey(key)
|
||||
err = cfg.UpdateField(key, defaultValue, true)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value, true)
|
||||
}
|
||||
|
||||
// If we tried to update an internal field, ignore the error (and do not update in the DB)
|
||||
if errors.Is(err, model.AppConfigInternalForbiddenError{}) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
// Return the updated config
|
||||
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateAppConfigValues updates the application configuration values in the database.
|
||||
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
// Start the transaction
|
||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
key := keysAndValues[i-1]
|
||||
value := keysAndValues[i]
|
||||
|
||||
// Ensure that the field is valid
|
||||
// We do this by grabbing the default value
|
||||
var defaultValue string
|
||||
defaultValue, isInternal, err := defaultCfg.FieldByKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
|
||||
}
|
||||
if !isInternal && common.EnvConfig.UiConfigDisabled {
|
||||
return &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
// Skip values that are internal only and can't be updated
|
||||
if value == "" {
|
||||
err = cfg.UpdateField(key, defaultValue, false)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value, false)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||
}
|
||||
|
||||
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
||||
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
||||
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.dbConfig.Store(dest)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||
// If the UI config is disabled, only load from the env
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
dest, err := s.loadDbConfigFromEnv(ctx, tx)
|
||||
return dest, err
|
||||
}
|
||||
|
||||
// First, start from the default configuration
|
||||
dest := s.getDefaultDbConfig()
|
||||
|
||||
// Load all configuration values from the database
|
||||
// This loads all values in a single shot
|
||||
var loaded []model.AppConfigVariable
|
||||
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer queryCancel()
|
||||
err := tx.
|
||||
WithContext(queryCtx).
|
||||
Find(&loaded).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through all values loaded from the database
|
||||
for _, v := range loaded {
|
||||
// Find the field in the struct whose "key" tag matches, then update that
|
||||
err = dest.UpdateField(v.Key, v.Value, false)
|
||||
|
||||
// We ignore the case of fields that don't exist, as there may be leftover data in the database
|
||||
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
|
||||
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||
// First, start from the default configuration
|
||||
dest := s.getDefaultDbConfig()
|
||||
|
||||
// Iterate through each field
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the key and internal tag values
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
|
||||
// Internal fields are loaded from the database as they can't be set from the environment
|
||||
if attrs == "internal" {
|
||||
var value string
|
||||
err := tx.WithContext(ctx).
|
||||
Model(&model.AppConfigVariable{}).
|
||||
Where("key = ?", key).
|
||||
Select("value").
|
||||
First(&value).Error
|
||||
if err == nil {
|
||||
rv.Field(i).FieldByName("Value").SetString(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||
|
||||
// Set the value if it's set
|
||||
value, ok := os.LookupEnv(envVarName)
|
||||
if ok {
|
||||
rv.Field(i).FieldByName("Value").SetString(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's sensitive, we also allow reading from file
|
||||
if attrs == "sensitive" {
|
||||
fileName := os.Getenv(envVarName + "_FILE")
|
||||
if fileName != "" {
|
||||
// #nosec G703 - Value is provided by admin
|
||||
b, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
|
||||
}
|
||||
rv.Field(i).FieldByName("Value").SetString(string(b))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
473
backend/internal/service/app_config_service_test.go
Normal file
473
backend/internal/service/app_config_service_test.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||
func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
|
||||
service := &AppConfigService{
|
||||
dbConfig: atomic.Pointer[model.AppConfig]{},
|
||||
}
|
||||
service.dbConfig.Store(config)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func TestLoadDbConfig(t *testing.T) {
|
||||
t.Run("empty config table", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
// Load the config
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config should be equal to default config
|
||||
require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig())
|
||||
})
|
||||
|
||||
t.Run("loads value from config table", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Populate the config table with some initial values
|
||||
err := db.
|
||||
Create([]model.AppConfigVariable{
|
||||
// Overrides default value
|
||||
{Key: "appName", Value: "Test App"},
|
||||
{Key: "sessionDuration", Value: "5"},
|
||||
// Does not have a default value
|
||||
{Key: "smtpHost", Value: "example"},
|
||||
}).
|
||||
Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Values should match expected ones
|
||||
expect := service.getDefaultDbConfig()
|
||||
expect.AppName.Value = "Test App"
|
||||
expect.SessionDuration.Value = "5"
|
||||
expect.SmtpHost.Value = "example"
|
||||
require.Equal(t, service.GetDbConfig(), expect)
|
||||
})
|
||||
|
||||
t.Run("ignores unknown config keys", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Add an entry with a key that doesn't exist in the config struct
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "__nonExistentKey", Value: "some value"},
|
||||
{Key: "appName", Value: "TestApp"}, // This one should still be loaded
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
// This should not fail, just ignore the unknown key
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "TestApp", config.AppName.Value)
|
||||
})
|
||||
|
||||
t.Run("loading config multiple times", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Initial state
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "appName", Value: "InitialApp"},
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value)
|
||||
|
||||
// Update the database value
|
||||
err = db.Model(&model.AppConfigVariable{}).
|
||||
Where("key = ?", "appName").
|
||||
Update("value", "UpdatedApp").Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the config again, it should reflect the updated value
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value)
|
||||
})
|
||||
|
||||
t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) {
|
||||
// Save the original state and restore it after the test
|
||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||
defer func() {
|
||||
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
|
||||
}()
|
||||
|
||||
// Set environment variables for testing
|
||||
t.Setenv("APP_NAME", "EnvTest App")
|
||||
t.Setenv("SESSION_DURATION", "45")
|
||||
|
||||
// Enable UiConfigDisabled to load from env
|
||||
common.EnvConfig.UiConfigDisabled = true
|
||||
|
||||
// Create database with config that should be ignored
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "appName", Value: "DB App"},
|
||||
{Key: "sessionDuration", Value: "120"},
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
// Load the config
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config should be loaded from env, not DB
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env")
|
||||
require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env")
|
||||
})
|
||||
|
||||
t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) {
|
||||
// Save the original state and restore it after the test
|
||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||
defer func() {
|
||||
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
|
||||
}()
|
||||
|
||||
// Set environment variables that should be ignored
|
||||
t.Setenv("APP_NAME", "EnvTest App")
|
||||
t.Setenv("SESSION_DURATION", "45")
|
||||
|
||||
// Make sure UiConfigDisabled is false to load from DB
|
||||
common.EnvConfig.UiConfigDisabled = false
|
||||
|
||||
// Create database with config values that should take precedence
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
err := db.Create([]model.AppConfigVariable{
|
||||
{Key: "appName", Value: "DB App"},
|
||||
{Key: "sessionDuration", Value: "120"},
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
// Load the config
|
||||
err = service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Config should be loaded from DB, not env
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env")
|
||||
require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAppConfigValues(t *testing.T) {
|
||||
t.Run("update single value", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update a single config value
|
||||
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify in-memory config was updated
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Test App", config.AppName.Value)
|
||||
|
||||
// Verify database was updated
|
||||
var dbValue model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&dbValue).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test App", dbValue.Value)
|
||||
})
|
||||
|
||||
t.Run("update multiple values", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update multiple config values
|
||||
err = service.UpdateAppConfigValues(
|
||||
t.Context(),
|
||||
"appName", "Test App",
|
||||
"sessionDuration", "30",
|
||||
"smtpHost", "mail.example.com",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify in-memory config was updated
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Test App", config.AppName.Value)
|
||||
require.Equal(t, "30", config.SessionDuration.Value)
|
||||
require.Equal(t, "mail.example.com", config.SmtpHost.Value)
|
||||
|
||||
// Verify database was updated
|
||||
var count int64
|
||||
db.Model(&model.AppConfigVariable{}).Count(&count)
|
||||
require.Equal(t, int64(3), count)
|
||||
|
||||
var appName, sessionDuration, smtpHost model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Test App", appName.Value)
|
||||
|
||||
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "30", sessionDuration.Value)
|
||||
|
||||
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "mail.example.com", smtpHost.Value)
|
||||
})
|
||||
|
||||
t.Run("empty value resets to default", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// First change the value
|
||||
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value)
|
||||
|
||||
// Now set it to empty which should use default value
|
||||
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig
|
||||
})
|
||||
|
||||
t.Run("error with odd number of arguments", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update with odd number of arguments
|
||||
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid number of arguments")
|
||||
})
|
||||
|
||||
t.Run("error with invalid key", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update with invalid key
|
||||
err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid configuration key")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAppConfig(t *testing.T) {
|
||||
t.Run("updates configuration values from DTO", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create update DTO
|
||||
input := dto.AppConfigUpdateDto{
|
||||
AppName: "Updated App Name",
|
||||
SessionDuration: "120",
|
||||
SmtpHost: "smtp.example.com",
|
||||
SmtpPort: "587",
|
||||
}
|
||||
|
||||
// Update config
|
||||
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify returned updated variables
|
||||
require.NotEmpty(t, updatedVars)
|
||||
|
||||
var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool
|
||||
for _, v := range updatedVars {
|
||||
switch v.Key {
|
||||
case "appName":
|
||||
require.Equal(t, "Updated App Name", v.Value)
|
||||
foundAppName = true
|
||||
case "sessionDuration":
|
||||
require.Equal(t, "120", v.Value)
|
||||
foundSessionDuration = true
|
||||
case "smtpHost":
|
||||
require.Equal(t, "smtp.example.com", v.Value)
|
||||
foundSmtpHost = true
|
||||
case "smtpPort":
|
||||
require.Equal(t, "587", v.Value)
|
||||
foundSmtpPort = true
|
||||
}
|
||||
}
|
||||
require.True(t, foundAppName)
|
||||
require.True(t, foundSessionDuration)
|
||||
require.True(t, foundSmtpHost)
|
||||
require.True(t, foundSmtpPort)
|
||||
|
||||
// Verify in-memory config was updated
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Updated App Name", config.AppName.Value)
|
||||
require.Equal(t, "120", config.SessionDuration.Value)
|
||||
require.Equal(t, "smtp.example.com", config.SmtpHost.Value)
|
||||
require.Equal(t, "587", config.SmtpPort.Value)
|
||||
|
||||
// Verify database was updated
|
||||
var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Updated App Name", appName.Value)
|
||||
|
||||
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "120", sessionDuration.Value)
|
||||
|
||||
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "smtp.example.com", smtpHost.Value)
|
||||
|
||||
err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "587", smtpPort.Value)
|
||||
})
|
||||
|
||||
t.Run("empty values reset to defaults", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
// Create a service with default config and modify some values
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// First set some non-default values
|
||||
err = service.UpdateAppConfigValues(t.Context(),
|
||||
"appName", "Custom App",
|
||||
"sessionDuration", "120",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create update DTO with empty values to reset to defaults
|
||||
input := dto.AppConfigUpdateDto{
|
||||
AppName: "", // Should reset to default "Pocket ID"
|
||||
SessionDuration: "", // Should reset to default "60"
|
||||
}
|
||||
|
||||
// Update config
|
||||
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify returned updated variables (they should be empty strings in DB)
|
||||
var foundAppName, foundSessionDuration bool
|
||||
for _, v := range updatedVars {
|
||||
switch v.Key {
|
||||
case "appName":
|
||||
require.Equal(t, "Pocket ID", v.Value) // Returns the default value
|
||||
foundAppName = true
|
||||
case "sessionDuration":
|
||||
require.Equal(t, "60", v.Value) // Returns the default value
|
||||
foundSessionDuration = true
|
||||
}
|
||||
}
|
||||
require.True(t, foundAppName)
|
||||
require.True(t, foundSessionDuration)
|
||||
|
||||
// Verify in-memory config was reset to defaults
|
||||
config := service.GetDbConfig()
|
||||
require.Equal(t, "Pocket ID", config.AppName.Value) // Default value
|
||||
require.Equal(t, "60", config.SessionDuration.Value) // Default value
|
||||
|
||||
// Verify database was updated with empty values
|
||||
for _, key := range []string{"appName", "sessionDuration"} {
|
||||
var loaded model.AppConfigVariable
|
||||
err = db.Where("key = ?", key).First(&loaded).Error
|
||||
require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key)
|
||||
require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
|
||||
// Save the original state and restore it after the test
|
||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||
defer func() {
|
||||
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
|
||||
}()
|
||||
|
||||
// Disable UI config
|
||||
common.EnvConfig.UiConfigDisabled = true
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
err := service.LoadDbConfig(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update config
|
||||
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
|
||||
AppName: "Should Not Update",
|
||||
})
|
||||
|
||||
// Should get a UiConfigDisabledError
|
||||
require.Error(t, err)
|
||||
var uiConfigDisabledErr *common.UiConfigDisabledError
|
||||
require.ErrorAs(t, err, &uiConfigDisabledErr)
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
@@ -15,17 +14,17 @@ import (
|
||||
|
||||
type AuditLogService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
emailService *EmailService
|
||||
geoliteService *GeoLiteService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
}
|
||||
|
||||
func NewAuditLogService(db *gorm.DB, emailService *EmailService, geoliteService *GeoLiteService, appConfigService *appconfig.AppConfigService) *AuditLogService {
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||
return &AuditLogService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
emailService: emailService,
|
||||
geoliteService: geoliteService,
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +64,7 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
|
||||
}
|
||||
|
||||
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
||||
// emailLoginNotificationEnabled gates whether the notification email is sent, so the caller decides using the config it already loaded
|
||||
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog {
|
||||
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
|
||||
createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
|
||||
if !ok {
|
||||
// At this point the transaction has been canceled already, and error has been logged
|
||||
@@ -92,22 +90,15 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
}
|
||||
|
||||
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
|
||||
if emailLoginNotificationEnabled && count <= 1 {
|
||||
if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
|
||||
go func() {
|
||||
// This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
|
||||
// We still want to have a context derived from the request's to carry over tracing info
|
||||
innerCtx := context.WithoutCancel(ctx)
|
||||
|
||||
// This runs after the request has completed, so we resolve the current config rather than threading the request's snapshot into the goroutine
|
||||
dbConfig, innerErr := s.appConfigService.GetConfig(innerCtx)
|
||||
if innerErr != nil {
|
||||
slog.ErrorContext(innerCtx, "Failed to load app configuration to send notification email", slog.Any("error", innerErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Note we don't use the transaction here because this is running in background
|
||||
var user model.User
|
||||
innerErr = s.db.
|
||||
innerErr := s.db.
|
||||
WithContext(innerCtx).
|
||||
Where("id = ?", userID).
|
||||
First(&user).
|
||||
@@ -121,7 +112,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
return
|
||||
}
|
||||
|
||||
innerErr = SendEmail(innerCtx, s.emailService, dbConfig, email.Address{
|
||||
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
}, NewLoginTemplate, &NewLoginTemplateData{
|
||||
|
||||
@@ -22,12 +22,10 @@ import (
|
||||
"github.com/ory/fosite/compose"
|
||||
fositejwt "github.com/ory/fosite/token/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
@@ -42,7 +40,7 @@ import (
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appConfigService *AppConfigService
|
||||
ldapService *LdapService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *AppLockService
|
||||
@@ -56,7 +54,7 @@ const (
|
||||
e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
|
||||
)
|
||||
|
||||
func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
@@ -220,9 +218,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
LogoutCallbackURLs: model.UrlList{"http://tailscale.localhost/auth/logout/callback"},
|
||||
IsGroupRestricted: true,
|
||||
CreatedByID: new(users[0].ID),
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -285,61 +280,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
farFuture := datatype.DateTime(time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
oauth2Session := oidc.OAuth2Session{
|
||||
Base: model.Base{
|
||||
ID: "551ab785-c830-47d3-8a07-60c9f3bb4859",
|
||||
},
|
||||
Kind: "access_token",
|
||||
Key: "cross-database-test-session",
|
||||
RequestID: "cross-database-test-request",
|
||||
AccessTokenSignature: "",
|
||||
Active: true,
|
||||
RequestData: `{"request":"value"}`,
|
||||
ExpiresAt: &farFuture,
|
||||
}
|
||||
if err := tx.Create(&oauth2Session).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Table("oauth2_jtis").Create(map[string]any{
|
||||
"id": "bd0c8bf2-66ec-487a-9dd5-7d9d78d73543",
|
||||
"created_at": datatype.DateTime(time.Now()),
|
||||
"jti": "cross-database-test-jti",
|
||||
"expires_at": farFuture,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interactionSession := oidc.InteractionSession{
|
||||
Base: model.Base{
|
||||
ID: "aaf5dd23-cd1f-4748-a2aa-baa6af94d800",
|
||||
},
|
||||
Scopes: datatype.StringList{"openid"},
|
||||
ClientID: oidcClients[0].ID,
|
||||
UserID: new(users[0].ID),
|
||||
ConsentRequired: true,
|
||||
RequestedAt: farFuture,
|
||||
Parameters: oidc.InteractionSessionParameters{
|
||||
"client_id": oidcClients[0].ID,
|
||||
},
|
||||
}
|
||||
if err := tx.Create(&interactionSession).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reauthenticationToken := webauthn.ReauthenticationToken{
|
||||
Base: model.Base{
|
||||
ID: "71839ace-d978-4e6f-8fb1-b8648a21031b",
|
||||
},
|
||||
Token: "cross-database-reauthentication-token",
|
||||
ExpiresAt: farFuture,
|
||||
UserID: users[0].ID,
|
||||
}
|
||||
if err := tx.Create(&reauthenticationToken).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessToken := model.OneTimeAccessToken{
|
||||
Token: "one-time-token",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
@@ -546,6 +486,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "b2c3d4e5-f6a7-4890-bcde-f01234567891",
|
||||
},
|
||||
Token: "DOMAIN1234567890",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 0,
|
||||
EmailDomain: new("example.com"),
|
||||
},
|
||||
}
|
||||
for _, token := range signupTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
@@ -684,8 +634,8 @@ func (s *TestService) ResetApplicationImages(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
||||
// Reset all application configuration values through the singleton actor
|
||||
_, err := s.appConfigService.UpdateAppConfig(ctx, dto.AppConfigUpdateDto{})
|
||||
// Reset all app config variables to their default values in the database
|
||||
err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -706,6 +656,12 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
||||
// The instance ID is loaded once at startup, so we also set it directly on the JWT service so it takes effect immediately
|
||||
s.jwtService.instanceID = testInstanceID
|
||||
|
||||
// Reload the app config from the database after resetting the values
|
||||
err = s.appConfigService.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the JWK
|
||||
if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
|
||||
return err
|
||||
@@ -721,39 +677,50 @@ func (s *TestService) ResetLock(ctx context.Context) error {
|
||||
|
||||
// SyncLdap triggers an LDAP synchronization
|
||||
func (s *TestService) SyncLdap(ctx context.Context) error {
|
||||
dbConfig, err := s.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
return s.ldapService.SyncAll(ctx, dbConfig)
|
||||
return s.ldapService.SyncAll(ctx)
|
||||
}
|
||||
|
||||
// SetLdapTestConfig updates the LDAP configuration used by the end-to-end test server
|
||||
// SetLdapTestConfig writes the test LDAP config variables directly to the database.
|
||||
func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
|
||||
err := s.appConfigService.UpdateAppConfigValues(ctx,
|
||||
"ldapUrl", "ldap://lldap:3890",
|
||||
"ldapBindDn", "uid=admin,ou=people,dc=pocket-id,dc=org",
|
||||
"ldapBindPassword", "admin_password",
|
||||
"ldapBase", "dc=pocket-id,dc=org",
|
||||
"ldapUserSearchFilter", "(objectClass=person)",
|
||||
"ldapUserGroupSearchFilter", "(objectClass=groupOfNames)",
|
||||
"ldapSkipCertVerify", "true",
|
||||
"ldapAttributeUserUniqueIdentifier", "uuid",
|
||||
"ldapAttributeUserUsername", "uid",
|
||||
"ldapAttributeUserEmail", "mail",
|
||||
"ldapAttributeUserFirstName", "givenName",
|
||||
"ldapAttributeUserLastName", "sn",
|
||||
"ldapAttributeGroupUniqueIdentifier", "uuid",
|
||||
"ldapAttributeGroupName", "uid",
|
||||
"ldapAttributeGroupMember", "member",
|
||||
"ldapAdminGroupName", "admin_group",
|
||||
"ldapSoftDeleteUsers", "true",
|
||||
"ldapEnabled", "true",
|
||||
)
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
ldapConfigs := map[string]string{
|
||||
"ldapUrl": "ldap://lldap:3890",
|
||||
"ldapBindDn": "uid=admin,ou=people,dc=pocket-id,dc=org",
|
||||
"ldapBindPassword": "admin_password",
|
||||
"ldapBase": "dc=pocket-id,dc=org",
|
||||
"ldapUserSearchFilter": "(objectClass=person)",
|
||||
"ldapUserGroupSearchFilter": "(objectClass=groupOfNames)",
|
||||
"ldapSkipCertVerify": "true",
|
||||
"ldapAttributeUserUniqueIdentifier": "uuid",
|
||||
"ldapAttributeUserUsername": "uid",
|
||||
"ldapAttributeUserEmail": "mail",
|
||||
"ldapAttributeUserFirstName": "givenName",
|
||||
"ldapAttributeUserLastName": "sn",
|
||||
"ldapAttributeGroupUniqueIdentifier": "uuid",
|
||||
"ldapAttributeGroupName": "uid",
|
||||
"ldapAttributeGroupMember": "member",
|
||||
"ldapAdminGroupName": "admin_group",
|
||||
"ldapSoftDeleteUsers": "true",
|
||||
"ldapEnabled": "true",
|
||||
}
|
||||
|
||||
for key, value := range ldapConfigs {
|
||||
configVar := model.AppConfigVariable{Key: key, Value: value}
|
||||
if err := tx.Create(&configVar).Error; err != nil {
|
||||
return fmt.Errorf("failed to create config variable '%s': %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set LDAP test config: %w", err)
|
||||
}
|
||||
|
||||
if err := s.appConfigService.LoadDbConfig(ctx); err != nil {
|
||||
return fmt.Errorf("failed to load app config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ import (
|
||||
"github.com/italypaleale/go-kit/emailer"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
db *gorm.DB
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
}
|
||||
|
||||
func NewEmailService(db *gorm.DB) (*EmailService, error) {
|
||||
func NewEmailService(db *gorm.DB, appConfigService *AppConfigService) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
@@ -37,13 +37,14 @@ func NewEmailService(db *gorm.DB) (*EmailService, error) {
|
||||
}
|
||||
|
||||
return &EmailService{
|
||||
db: db,
|
||||
htmlTemplates: htmlTemplates,
|
||||
textTemplates: textTemplates,
|
||||
appConfigService: appConfigService,
|
||||
db: db,
|
||||
htmlTemplates: htmlTemplates,
|
||||
textTemplates: textTemplates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (srv *EmailService) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, recipientUserId string) error {
|
||||
func (srv *EmailService) SendTestEmail(ctx context.Context, recipientUserId string) error {
|
||||
var user model.User
|
||||
err := srv.db.
|
||||
WithContext(ctx).
|
||||
@@ -57,18 +58,18 @@ func (srv *EmailService) SendTestEmail(ctx context.Context, dbConfig *appconfig.
|
||||
return &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
return SendEmail(ctx, srv, dbConfig,
|
||||
return SendEmail(ctx, srv,
|
||||
email.Address{
|
||||
Email: *user.Email,
|
||||
Name: user.FullName(),
|
||||
}, TestTemplate, nil)
|
||||
}
|
||||
|
||||
// SendEmail sends an email using the provided application configuration
|
||||
func SendEmail[V any](ctx context.Context, srv *EmailService, dbConfig *appconfig.AppConfigModel, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||
func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||
dbConfig := srv.appConfigService.GetDbConfig()
|
||||
|
||||
data := &email.TemplateData[V]{
|
||||
AppName: dbConfig.AppName.String(),
|
||||
AppName: dbConfig.AppName.Value,
|
||||
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
|
||||
Data: tData,
|
||||
}
|
||||
@@ -101,7 +102,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, dbConfig *appconfi
|
||||
}
|
||||
|
||||
// getEmailer builds an emailer.Emailer from the current app config.
|
||||
func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) {
|
||||
func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConfig) (emailer.Emailer, error) {
|
||||
// We support SMTP only (for now)
|
||||
connString, err := smtpConnString(dbConfig)
|
||||
if err != nil {
|
||||
@@ -115,8 +116,8 @@ func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *appconfig.App
|
||||
|
||||
// smtpConnString builds the SMTP connection string that go-kit's emailer expects:
|
||||
// smtp://<username>:<password>@<host>:<port>?fromAddress=<address>&fromName=<name>&tls=<none|starttls|tls>&insecureSkipVerify=<true|false>
|
||||
func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) {
|
||||
host := dbConfig.SmtpHost.String()
|
||||
func smtpConnString(dbConfig *model.AppConfig) (string, error) {
|
||||
host := dbConfig.SmtpHost.Value
|
||||
if host == "" {
|
||||
return "", errors.New("SMTP host is not configured")
|
||||
}
|
||||
@@ -125,28 +126,28 @@ func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) {
|
||||
Scheme: "smtp",
|
||||
Host: host,
|
||||
}
|
||||
port := dbConfig.SmtpPort.String()
|
||||
port := dbConfig.SmtpPort.Value
|
||||
if port != "" {
|
||||
u.Host = net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// Include credentials when set
|
||||
smtpUser := dbConfig.SmtpUser.String()
|
||||
smtpPassword := dbConfig.SmtpPassword.String()
|
||||
smtpUser := dbConfig.SmtpUser.Value
|
||||
smtpPassword := dbConfig.SmtpPassword.Value
|
||||
if smtpUser != "" || smtpPassword != "" {
|
||||
u.User = url.UserPassword(smtpUser, smtpPassword)
|
||||
}
|
||||
|
||||
// TLS values from config: none, starttls, tls
|
||||
tlsMode := dbConfig.SmtpTls.String()
|
||||
tlsMode := dbConfig.SmtpTls.Value
|
||||
if tlsMode == "" {
|
||||
tlsMode = "none"
|
||||
}
|
||||
|
||||
// Build the query string args
|
||||
q := url.Values{}
|
||||
q.Set("fromAddress", dbConfig.SmtpFrom.String())
|
||||
q.Set("fromName", dbConfig.AppName.String())
|
||||
q.Set("fromAddress", dbConfig.SmtpFrom.Value)
|
||||
q.Set("fromName", dbConfig.AppName.Value)
|
||||
q.Set("tls", tlsMode)
|
||||
if dbConfig.SmtpSkipCertVerify.IsTrue() {
|
||||
q.Set("insecureSkipVerify", "true")
|
||||
|
||||
@@ -54,14 +54,12 @@ func seedActorHostSchema(t *testing.T, db *gorm.DB) {
|
||||
func requireActorHostSchemaPreserved(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var tableRows int64
|
||||
err := db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error)
|
||||
require.Equal(t, int64(1), tableRows, "francis_ tables and their rows must be preserved by an import")
|
||||
|
||||
// The view is only valid if its backing table was preserved as well
|
||||
var viewCount int64
|
||||
err = db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error)
|
||||
require.Equal(t, int64(1), viewCount, "francis_ views must be preserved by an import")
|
||||
}
|
||||
|
||||
|
||||
@@ -43,18 +43,19 @@ const (
|
||||
)
|
||||
|
||||
type JwtService struct {
|
||||
db *gorm.DB
|
||||
envConfig *common.EnvConfigSchema
|
||||
privateKey jwk.Key
|
||||
keyId string
|
||||
instanceID string
|
||||
jwksEncoded []byte
|
||||
db *gorm.DB
|
||||
envConfig *common.EnvConfigSchema
|
||||
privateKey jwk.Key
|
||||
keyId string
|
||||
appConfigService *AppConfigService
|
||||
instanceID string
|
||||
jwksEncoded []byte
|
||||
}
|
||||
|
||||
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string) (*JwtService, error) {
|
||||
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService) (*JwtService, error) {
|
||||
service := &JwtService{}
|
||||
|
||||
err := service.init(ctx, db, instanceID, &common.EnvConfig)
|
||||
err := service.init(ctx, db, instanceID, appConfigService, &common.EnvConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,7 +63,8 @@ func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string) (*JwtSer
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) (err error) {
|
||||
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
|
||||
s.appConfigService = appConfigService
|
||||
s.envConfig = envConfig
|
||||
s.db = db
|
||||
s.instanceID = instanceID
|
||||
@@ -181,11 +183,12 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
|
||||
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
|
||||
|
||||
now := time.Now()
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
Expiration(now.Add(sessionDuration)).
|
||||
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
||||
IssuedAt(now).
|
||||
Issuer(s.envConfig.AppURL).
|
||||
JwtID(uuid.New().String()).
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -36,17 +35,17 @@ func newTestEnvConfig() *common.EnvConfigSchema {
|
||||
}
|
||||
}
|
||||
|
||||
func initJwtService(t *testing.T, db *gorm.DB, instanceID string, _ *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
|
||||
func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
|
||||
t.Helper()
|
||||
|
||||
service := &JwtService{}
|
||||
err := service.init(t.Context(), db, instanceID, envConfig)
|
||||
err := service.init(t.Context(), db, instanceID, appConfig, envConfig)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func setupJwtService(t *testing.T, instanceID string, appConfig *appconfig.AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
|
||||
func setupJwtService(t *testing.T, instanceID string, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -71,7 +70,7 @@ func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) {
|
||||
return testutils.NewDatabaseForTest(t), newTestEnvConfig()
|
||||
}
|
||||
|
||||
func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, key jwk.Key) string {
|
||||
func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string {
|
||||
t.Helper()
|
||||
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||
@@ -88,7 +87,9 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *
|
||||
}
|
||||
|
||||
func TestJwtService_Init(t *testing.T) {
|
||||
mockConfig := appconfig.NewTestAppConfigService(nil)
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
|
||||
t.Run("should generate new key when none exists", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -191,7 +192,9 @@ func TestJwtService_Init(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
mockConfig := appconfig.NewTestAppConfigService(nil)
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
mockEnvConfig := newTestEnvConfig()
|
||||
instanceID := newInstanceID(t, db)
|
||||
@@ -307,8 +310,9 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
const sessionDuration = time.Hour
|
||||
mockConfig := appconfig.NewTestAppConfigService(nil)
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
db, envConfig := newTestDbAndEnv(t)
|
||||
instanceID := newInstanceID(t, db)
|
||||
|
||||
@@ -321,7 +325,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -362,7 +366,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(adminUser, "", sessionDuration)
|
||||
tokenString, err := service.GenerateAccessToken(adminUser, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
@@ -385,7 +389,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{ID: "user-with-auth-method"},
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant, sessionDuration)
|
||||
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
@@ -396,6 +400,29 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
|
||||
})
|
||||
|
||||
t.Run("uses session duration from config", func(t *testing.T) {
|
||||
customMockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
|
||||
})
|
||||
service, _, _ := setupJwtService(t, instanceID, customMockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user456"},
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
expectedExp := time.Now().Add(30 * time.Minute)
|
||||
expiration, ok := claims.Expiration()
|
||||
assert.True(t, ok, "Expiration not found in token")
|
||||
timeDiff := expectedExp.Sub(expiration).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes")
|
||||
})
|
||||
|
||||
t.Run("works with Ed25519 keys", func(t *testing.T) {
|
||||
origKeyID := createEdDSAKeyJWK(t, db, instanceID, envConfig, mockConfig)
|
||||
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
|
||||
@@ -410,7 +437,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token with Ed25519 key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -448,7 +475,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token with ECDSA key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -486,7 +513,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, "", sessionDuration)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token with RSA key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -555,7 +582,7 @@ func TestTokenTypeValidator(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, privateKeyRaw any) string {
|
||||
func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
|
||||
@@ -570,7 +597,7 @@ var (
|
||||
rsaKeyPrecomputeOnce sync.Once
|
||||
)
|
||||
|
||||
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
|
||||
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
rsaKeyPrecomputeOnce.Do(func() {
|
||||
@@ -585,7 +612,7 @@ func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig
|
||||
return importKey(t, db, instanceID, envConfig, appConfig, rsaKeyPrecomputed)
|
||||
}
|
||||
|
||||
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
|
||||
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new P-256 ECDSA key
|
||||
@@ -597,7 +624,7 @@ func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *
|
||||
}
|
||||
|
||||
// Helper function to create an Ed25519 key and save it as JWK
|
||||
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string {
|
||||
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new Ed25519 key pair
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
@@ -30,12 +29,13 @@ import (
|
||||
)
|
||||
|
||||
type LdapService struct {
|
||||
db *gorm.DB
|
||||
httpClient *http.Client
|
||||
userService *UserService
|
||||
groupService *UserGroupService
|
||||
fileStorage storage.FileStorage
|
||||
clientFactory func(dbConfig *appconfig.AppConfigModel) (ldapClient, error)
|
||||
db *gorm.DB
|
||||
httpClient *http.Client
|
||||
appConfigService *AppConfigService
|
||||
userService *UserService
|
||||
groupService *UserGroupService
|
||||
fileStorage storage.FileStorage
|
||||
clientFactory func() (ldapClient, error)
|
||||
}
|
||||
|
||||
type savePicture struct {
|
||||
@@ -69,26 +69,29 @@ type ldapClient interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewLdapService(db *gorm.DB, httpClient *http.Client, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||
service := &LdapService{
|
||||
db: db,
|
||||
httpClient: httpClient,
|
||||
userService: userService,
|
||||
groupService: groupService,
|
||||
fileStorage: fileStorage,
|
||||
db: db,
|
||||
httpClient: httpClient,
|
||||
appConfigService: appConfigService,
|
||||
userService: userService,
|
||||
groupService: groupService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
|
||||
service.clientFactory = service.createClient
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
|
||||
func (s *LdapService) createClient() (ldapClient, error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
return nil, fmt.Errorf("LDAP is not enabled")
|
||||
}
|
||||
|
||||
// Setup LDAP connection
|
||||
client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{
|
||||
client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
|
||||
}))
|
||||
if err != nil {
|
||||
@@ -96,24 +99,23 @@ func (s *LdapService) createClient(dbConfig *appconfig.AppConfigModel) (ldapClie
|
||||
}
|
||||
|
||||
// Bind as service account
|
||||
err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String())
|
||||
err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// SyncAll synchronizes LDAP using the provided application configuration
|
||||
func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfigModel) error {
|
||||
func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
// Setup LDAP connection
|
||||
client, err := s.clientFactory(dbConfig)
|
||||
client, err := s.clientFactory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create LDAP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// First, we fetch all users and group from LDAP, which is our "desired state"
|
||||
desiredState, err := s.fetchDesiredState(ctx, client, dbConfig)
|
||||
desiredState, err := s.fetchDesiredState(ctx, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch LDAP state: %w", err)
|
||||
}
|
||||
@@ -126,13 +128,13 @@ func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
defer tx.Rollback()
|
||||
|
||||
// Reconcile users
|
||||
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs, dbConfig)
|
||||
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users: %w", err)
|
||||
}
|
||||
|
||||
// Reconcile groups
|
||||
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs, dbConfig)
|
||||
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync groups: %w", err)
|
||||
}
|
||||
@@ -165,15 +167,15 @@ func (s *LdapService) SyncAll(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (ldapDesiredState, error) {
|
||||
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
|
||||
// Fetch users first so we can use their DNs when resolving group members
|
||||
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client, dbConfig)
|
||||
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client)
|
||||
if err != nil {
|
||||
return ldapDesiredState{}, err
|
||||
}
|
||||
|
||||
// Then fetch groups to complete the desired LDAP state snapshot
|
||||
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN, dbConfig)
|
||||
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
|
||||
if err != nil {
|
||||
return ldapDesiredState{}, err
|
||||
}
|
||||
@@ -181,7 +183,7 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient,
|
||||
// Apply user admin flags from the desired group membership snapshot.
|
||||
// This intentionally uses the configured group member attribute rather than
|
||||
// relying on a user-side reverse-membership attribute such as memberOf.
|
||||
s.applyAdminGroupMembership(users, groups, dbConfig)
|
||||
s.applyAdminGroupMembership(users, groups)
|
||||
|
||||
return ldapDesiredState{
|
||||
users: users,
|
||||
@@ -191,14 +193,15 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup, dbConfig *appconfig.AppConfigModel) {
|
||||
if dbConfig.LdapAdminGroupName == "" {
|
||||
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
if dbConfig.LdapAdminGroupName.Value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
adminUsernames := make(map[string]struct{})
|
||||
for _, group := range desiredGroups {
|
||||
if group.input.Name != string(dbConfig.LdapAdminGroupName) {
|
||||
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -213,19 +216,21 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string, dbConfig *appconfig.AppConfigModel) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
|
||||
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Query LDAP for all groups we want to manage
|
||||
searchAttrs := []string{
|
||||
dbConfig.LdapAttributeGroupName.String(),
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.String(),
|
||||
dbConfig.LdapAttributeGroupMember.String(),
|
||||
dbConfig.LdapAttributeGroupName.Value,
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||
dbConfig.LdapAttributeGroupMember.Value,
|
||||
}
|
||||
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
dbConfig.LdapBase.String(),
|
||||
dbConfig.LdapBase.Value,
|
||||
ldap.ScopeWholeSubtree,
|
||||
0, 0, 0, false,
|
||||
dbConfig.LdapUserGroupSearchFilter.String(),
|
||||
dbConfig.LdapUserGroupSearchFilter.Value,
|
||||
searchAttrs,
|
||||
[]ldap.Control{},
|
||||
)
|
||||
@@ -239,21 +244,21 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.String()))
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
|
||||
// Skip groups without a valid LDAP ID
|
||||
if ldapID == "" {
|
||||
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.String()))
|
||||
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
ldapGroupIDs[ldapID] = struct{}{}
|
||||
|
||||
// Get group members and add to the correct Group
|
||||
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.String())
|
||||
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
|
||||
memberUsernames := make([]string, 0, len(groupMembers))
|
||||
for _, member := range groupMembers {
|
||||
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.String())
|
||||
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
@@ -262,8 +267,8 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
}
|
||||
|
||||
syncGroup := dto.UserGroupCreateDto{
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.String()),
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapID,
|
||||
}
|
||||
dto.Normalize(&syncGroup)
|
||||
@@ -284,26 +289,28 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
|
||||
return desiredGroups, ldapGroupIDs, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient, dbConfig *appconfig.AppConfigModel) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
||||
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Query LDAP for all users we want to manage
|
||||
searchAttrs := []string{
|
||||
"sn",
|
||||
"cn",
|
||||
dbConfig.LdapAttributeUserUniqueIdentifier.String(),
|
||||
dbConfig.LdapAttributeUserUsername.String(),
|
||||
dbConfig.LdapAttributeUserEmail.String(),
|
||||
dbConfig.LdapAttributeUserFirstName.String(),
|
||||
dbConfig.LdapAttributeUserLastName.String(),
|
||||
dbConfig.LdapAttributeUserProfilePicture.String(),
|
||||
dbConfig.LdapAttributeUserDisplayName.String(),
|
||||
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
|
||||
dbConfig.LdapAttributeUserUsername.Value,
|
||||
dbConfig.LdapAttributeUserEmail.Value,
|
||||
dbConfig.LdapAttributeUserFirstName.Value,
|
||||
dbConfig.LdapAttributeUserLastName.Value,
|
||||
dbConfig.LdapAttributeUserProfilePicture.Value,
|
||||
dbConfig.LdapAttributeUserDisplayName.Value,
|
||||
}
|
||||
|
||||
// Filters must start and finish with ()!
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
dbConfig.LdapBase.String(),
|
||||
dbConfig.LdapBase.Value,
|
||||
ldap.ScopeWholeSubtree,
|
||||
0, 0, 0, false,
|
||||
dbConfig.LdapUserSearchFilter.String(),
|
||||
dbConfig.LdapUserSearchFilter.Value,
|
||||
searchAttrs,
|
||||
[]ldap.Control{},
|
||||
)
|
||||
@@ -319,28 +326,28 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient,
|
||||
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()))
|
||||
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
|
||||
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
|
||||
usernamesByDN[normalizedDN] = username
|
||||
}
|
||||
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String()))
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
|
||||
// Skip users without a valid LDAP ID
|
||||
if ldapID == "" {
|
||||
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String()))
|
||||
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
ldapUserIDs[ldapID] = struct{}{}
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())),
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
EmailVerified: true,
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()),
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
// Admin status is computed after groups are loaded so it can use the
|
||||
// configured group member attribute instead of a hard-coded memberOf.
|
||||
IsAdmin: false,
|
||||
@@ -362,14 +369,16 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient,
|
||||
desiredUsers = append(desiredUsers, ldapDesiredUser{
|
||||
ldapID: ldapID,
|
||||
input: newUser,
|
||||
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()),
|
||||
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
|
||||
})
|
||||
}
|
||||
|
||||
return desiredUsers, ldapUserIDs, usernamesByDN, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string {
|
||||
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// First try the DN cache we built while loading users
|
||||
username, exists := usernamesByDN[normalizeLDAPDN(member)]
|
||||
if exists && username != "" {
|
||||
@@ -377,15 +386,14 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
}
|
||||
|
||||
// Then try to extract the username directly from the DN
|
||||
username = getDNProperty(usernameAttr, member)
|
||||
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||
if username != "" {
|
||||
return norm.NFC.String(username)
|
||||
}
|
||||
|
||||
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
|
||||
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
|
||||
_, err := ldap.ParseDN(member)
|
||||
if err != nil {
|
||||
if _, err := ldap.ParseDN(member); err != nil {
|
||||
return norm.NFC.String(member)
|
||||
}
|
||||
|
||||
@@ -395,7 +403,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
ldap.ScopeBaseObject,
|
||||
0, 0, 0, false,
|
||||
"(objectClass=*)",
|
||||
[]string{usernameAttr},
|
||||
[]string{dbConfig.LdapAttributeUserUsername.Value},
|
||||
[]ldap.Control{},
|
||||
)
|
||||
|
||||
@@ -405,7 +413,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
return ""
|
||||
}
|
||||
|
||||
username = userResult.Entries[0].GetAttributeValue(usernameAttr)
|
||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||
if username == "" {
|
||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||
return ""
|
||||
@@ -414,7 +422,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
|
||||
return norm.NFC.String(username)
|
||||
}
|
||||
|
||||
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) error {
|
||||
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
||||
if err != nil {
|
||||
@@ -454,7 +462,7 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx, dbConfig)
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
@@ -490,7 +498,9 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
|
||||
}
|
||||
|
||||
//nolint:gocognit
|
||||
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, dbConfig *appconfig.AppConfigModel) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||
if err != nil {
|
||||
@@ -521,7 +531,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
|
||||
userID := databaseUser.ID
|
||||
if databaseUser.ID == "" {
|
||||
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx, dbConfig)
|
||||
createdUser, err := s.userService.CreateUserInternal(ctx, desiredUser.input, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
continue
|
||||
@@ -532,7 +542,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
userID = createdUser.ID
|
||||
ldapUsersByID[desiredUser.ldapID] = createdUser
|
||||
} else {
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx, dbConfig)
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
continue
|
||||
@@ -571,7 +581,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
continue
|
||||
}
|
||||
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true, dbConfig)
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||
if err != nil {
|
||||
if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok {
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
@@ -110,8 +109,7 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||
LdapID: &oldGroupLdapID,
|
||||
}).Error)
|
||||
|
||||
err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var alice model.User
|
||||
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
|
||||
@@ -146,8 +144,8 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
|
||||
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
||||
appCfg := defaultTestLDAPAppConfig()
|
||||
appCfg.LdapUserGroupSearchFilter = "(objectClass=posixGroup)"
|
||||
appCfg.LdapAttributeGroupMember = "memberUid"
|
||||
appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"}
|
||||
appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"}
|
||||
|
||||
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
@@ -177,8 +175,7 @@ func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
||||
),
|
||||
))
|
||||
|
||||
err := service.SyncAll(t.Context(), appCfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var group model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error)
|
||||
@@ -220,8 +217,7 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
|
||||
),
|
||||
))
|
||||
|
||||
err := service.SyncAll(t.Context(), defaultTestLDAPAppConfig())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var users []model.User
|
||||
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
|
||||
@@ -241,7 +237,7 @@ func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
|
||||
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appConfig *appconfig.AppConfigModel
|
||||
appConfig *model.AppConfig
|
||||
groupEntry *ldap.Entry
|
||||
groupName string
|
||||
groupLookup string
|
||||
@@ -259,10 +255,10 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "configured group name attribute differs from DN RDN",
|
||||
appConfig: func() *appconfig.AppConfigModel {
|
||||
appConfig: func() *model.AppConfig {
|
||||
cfg := defaultTestLDAPAppConfig()
|
||||
cfg.LdapAttributeGroupName = "displayName"
|
||||
cfg.LdapAdminGroupName = "pocketid.admin"
|
||||
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
|
||||
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
|
||||
return cfg
|
||||
}(),
|
||||
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
@@ -292,8 +288,7 @@ func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
ldapSearchResult(tt.groupEntry),
|
||||
))
|
||||
|
||||
err := service.SyncAll(t.Context(), tt.appConfig)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var user model.User
|
||||
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
|
||||
@@ -313,7 +308,7 @@ func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB
|
||||
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
|
||||
}
|
||||
|
||||
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.AppConfigModel, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -321,45 +316,48 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.App
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
groupService := NewUserGroupService(db, nil)
|
||||
appConfig := NewTestAppConfigService(appConfigModel)
|
||||
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
)
|
||||
|
||||
service := NewLdapService(db, &http.Client{}, userService, groupService, fileStorage)
|
||||
service.clientFactory = func(dbConfig *appconfig.AppConfigModel) (ldapClient, error) {
|
||||
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
|
||||
service.clientFactory = func() (ldapClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return service, db
|
||||
}
|
||||
|
||||
func defaultTestLDAPAppConfig() *appconfig.AppConfigModel {
|
||||
return &appconfig.AppConfigModel{
|
||||
RequireUserEmail: "false",
|
||||
LdapEnabled: "true",
|
||||
LdapBase: "dc=example,dc=com",
|
||||
LdapUserSearchFilter: "(objectClass=person)",
|
||||
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
|
||||
LdapAttributeUserUniqueIdentifier: "entryUUID",
|
||||
LdapAttributeUserUsername: "uid",
|
||||
LdapAttributeUserEmail: "mail",
|
||||
LdapAttributeUserFirstName: "givenName",
|
||||
LdapAttributeUserLastName: "sn",
|
||||
LdapAttributeUserDisplayName: "displayName",
|
||||
LdapAttributeUserProfilePicture: "jpegPhoto",
|
||||
LdapAttributeGroupMember: "member",
|
||||
LdapAttributeGroupUniqueIdentifier: "entryUUID",
|
||||
LdapAttributeGroupName: "cn",
|
||||
LdapAdminGroupName: "admins",
|
||||
LdapSoftDeleteUsers: "true",
|
||||
func defaultTestLDAPAppConfig() *model.AppConfig {
|
||||
return &model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
LdapEnabled: model.AppConfigVariable{Value: "true"},
|
||||
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
|
||||
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
|
||||
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
|
||||
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
|
||||
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
|
||||
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
|
||||
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
|
||||
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
|
||||
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
|
||||
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,10 +37,11 @@ const (
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
previewBuilder oidcClientPreviewBuilder
|
||||
scimService *ScimService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
previewBuilder oidcClientPreviewBuilder
|
||||
scimService *ScimService
|
||||
|
||||
httpClient *http.Client
|
||||
fileStorage storage.FileStorage
|
||||
@@ -53,18 +54,20 @@ type oidcClientPreviewBuilder interface {
|
||||
func NewOidcService(
|
||||
db *gorm.DB,
|
||||
jwtService *JwtService,
|
||||
appConfigService *AppConfigService,
|
||||
previewBuilder oidcClientPreviewBuilder,
|
||||
scimService *ScimService,
|
||||
httpClient *http.Client,
|
||||
fileStorage storage.FileStorage,
|
||||
) (s *OidcService, err error) {
|
||||
s = &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
previewBuilder: previewBuilder,
|
||||
scimService: scimService,
|
||||
httpClient: httpClient,
|
||||
fileStorage: fileStorage,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
previewBuilder: previewBuilder,
|
||||
scimService: scimService,
|
||||
httpClient: httpClient,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
@@ -587,11 +590,23 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.OidcClient{}).
|
||||
Preload("UserAuthorizedOidcClients", "user_id = ?", userID).
|
||||
Where(`oidc_clients.is_group_restricted = ? OR EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, false, userGroupIDs)
|
||||
Preload("UserAuthorizedOidcClients", "user_id = ?", userID)
|
||||
|
||||
// If user has no groups, only return clients with no allowed user groups
|
||||
if len(userGroupIDs) == 0 {
|
||||
query = query.Where(`NOT EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`)
|
||||
} else {
|
||||
query = query.Where(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs)
|
||||
}
|
||||
|
||||
var clients []model.OidcClient
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
@@ -455,7 +454,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
func TestOidcService_CreateClient_withDescription(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
description := "A test client description"
|
||||
@@ -480,7 +479,7 @@ func TestOidcService_CreateClient_withDescription(t *testing.T) {
|
||||
func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
input := dto.OidcClientCreateDto{
|
||||
@@ -502,7 +501,7 @@ func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
|
||||
func TestOidcService_UpdateClient_description(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a client without a description
|
||||
@@ -539,45 +538,3 @@ func TestOidcService_UpdateClient_description(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, fetched.Description)
|
||||
}
|
||||
|
||||
func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
allowedGroup := model.UserGroup{Name: "allowed", FriendlyName: "Allowed"}
|
||||
otherGroup := model.UserGroup{Name: "other", FriendlyName: "Other"}
|
||||
require.NoError(t, db.Create(&allowedGroup).Error)
|
||||
require.NoError(t, db.Create(&otherGroup).Error)
|
||||
|
||||
userWithGroup := model.User{Username: "with-group", UserGroups: []model.UserGroup{allowedGroup}}
|
||||
userWithoutGroup := model.User{Username: "without-group"}
|
||||
require.NoError(t, db.Create(&userWithGroup).Error)
|
||||
require.NoError(t, db.Create(&userWithoutGroup).Error)
|
||||
|
||||
clients := []model.OidcClient{
|
||||
{Name: "Unrestricted", CallbackURLs: model.UrlList{"https://unrestricted.example.com/callback"}},
|
||||
{Name: "Restricted without groups", CallbackURLs: model.UrlList{"https://empty.example.com/callback"}, IsGroupRestricted: true},
|
||||
{Name: "Restricted to user group", CallbackURLs: model.UrlList{"https://allowed.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{allowedGroup}},
|
||||
{Name: "Restricted to other group", CallbackURLs: model.UrlList{"https://other.example.com/callback"}, IsGroupRestricted: true, AllowedUserGroups: []model.UserGroup{otherGroup}},
|
||||
}
|
||||
for i := range clients {
|
||||
require.NoError(t, db.Create(&clients[i]).Error)
|
||||
}
|
||||
|
||||
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"Unrestricted", "Restricted to user group"}, accessibleClientNames(groupClients))
|
||||
|
||||
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, utils.ListRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients))
|
||||
}
|
||||
|
||||
func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
|
||||
names := make([]string, len(clients))
|
||||
for i := range clients {
|
||||
names[i] = clients[i].Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -20,34 +18,38 @@ import (
|
||||
)
|
||||
|
||||
type OneTimeAccessService struct {
|
||||
db *gorm.DB
|
||||
userService *UserService
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
db *gorm.DB
|
||||
userService *UserService
|
||||
appConfigService *AppConfigService
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *OneTimeAccessService {
|
||||
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
|
||||
return &OneTimeAccessService{
|
||||
db: db,
|
||||
userService: userService,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
db: db,
|
||||
userService: userService,
|
||||
appConfigService: appConfigService,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
|
||||
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
|
||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
|
||||
if isDisabled {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
|
||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
|
||||
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
|
||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
|
||||
if isDisabled {
|
||||
return "", &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ct
|
||||
return "", err
|
||||
}
|
||||
|
||||
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig)
|
||||
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if deviceToken == nil {
|
||||
@@ -70,7 +72,7 @@ func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ct
|
||||
return *deviceToken, nil
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
|
||||
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -108,7 +110,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
|
||||
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
|
||||
}
|
||||
|
||||
errInternal := SendEmail(innerCtx, s.emailService, dbConfig, email.Address{
|
||||
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
@@ -149,7 +151,7 @@ func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, use
|
||||
// Commit
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error committing transaction: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
@@ -169,7 +171,7 @@ func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Cont
|
||||
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
||||
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -196,11 +198,7 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, d
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(
|
||||
oneTimeAccessToken.User,
|
||||
AuthenticationMethodOneTimePassword,
|
||||
dbConfig.SessionDuration.AsDurationMinutes(),
|
||||
)
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -213,16 +211,11 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, d
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(
|
||||
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
|
||||
ipAddress, userAgent,
|
||||
oneTimeAccessToken.User.ID, model.AuditLogData{},
|
||||
tx,
|
||||
)
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error committing transaction: %w", err)
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
return oneTimeAccessToken.User, accessToken, nil
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -15,11 +14,11 @@ import (
|
||||
|
||||
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
appConfig := appconfig.NewTestAppConfigService(nil)
|
||||
appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig())
|
||||
instanceID := newInstanceID(t, db)
|
||||
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
|
||||
auditLogService := NewAuditLogService(db, nil, &GeoLiteService{}, appConfig)
|
||||
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil)
|
||||
auditLogService := NewAuditLogService(db, appConfig, nil, &GeoLiteService{})
|
||||
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil, appConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "disabled-user"},
|
||||
@@ -36,8 +35,7 @@ func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&loginCode).Error)
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, loginCode.Token, "", "", "")
|
||||
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), loginCode.Token, "", "", "")
|
||||
|
||||
var userDisabledErr *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &userDisabledErr)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -16,12 +15,13 @@ import (
|
||||
)
|
||||
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, scimService: scimService}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
@@ -62,7 +62,7 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
|
||||
return group, err
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigModel, id string) error {
|
||||
func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -79,7 +79,7 @@ func (s *UserGroupService) Delete(ctx context.Context, cfg *appconfig.AppConfigM
|
||||
}
|
||||
|
||||
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
|
||||
if group.LdapID != nil && cfg.LdapEnabled.IsTrue() {
|
||||
if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
@@ -122,9 +122,10 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
Preload("Users").
|
||||
Create(&group).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
|
||||
}
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
@@ -135,13 +136,13 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *UserGroupService) Update(ctx context.Context, cfg *appconfig.AppConfigModel, id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||
func (s *UserGroupService) Update(ctx context.Context, id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
group, err = s.updateInternal(ctx, id, input, false, tx, cfg)
|
||||
group, err = s.updateInternal(ctx, id, input, false, tx)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
@@ -154,17 +155,15 @@ func (s *UserGroupService) Update(ctx context.Context, cfg *appconfig.AppConfigM
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *UserGroupService) updateInternal(ctx context.Context, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (group model.UserGroup, err error) {
|
||||
func (s *UserGroupService) updateInternal(ctx context.Context, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB) (group model.UserGroup, err error) {
|
||||
group, err = s.getInternal(ctx, id, tx)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
// Disallow updating the group if it is an LDAP group and LDAP is enabled
|
||||
if !isLdapSync && group.LdapID != nil {
|
||||
if cfg.LdapEnabled.IsTrue() {
|
||||
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
group.Name = input.Name
|
||||
@@ -217,7 +216,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
||||
// Fetch the users based on the userIds
|
||||
var users []model.User
|
||||
if len(userIds) > 0 {
|
||||
err = tx.
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("id IN (?)", userIds).
|
||||
Find(&users).
|
||||
|
||||
@@ -13,17 +13,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||
)
|
||||
|
||||
@@ -32,18 +31,20 @@ type UserService struct {
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
appImagesService *AppImagesService
|
||||
scimService *ScimService
|
||||
fileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
appImagesService: appImagesService,
|
||||
scimService: scimService,
|
||||
@@ -184,9 +185,9 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, allowLdapDelete bool) error {
|
||||
func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error {
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete, dbConfig)
|
||||
return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user '%s': %w", userID, err)
|
||||
@@ -202,8 +203,9 @@ func (s *UserService) DeleteUser(ctx context.Context, dbConfig *appconfig.AppCon
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool, cfg *appconfig.AppConfigModel) error {
|
||||
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error {
|
||||
var user model.User
|
||||
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", userID).
|
||||
@@ -215,10 +217,8 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
}
|
||||
|
||||
// Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
|
||||
if !allowLdapDelete && !user.Disabled && user.LdapID != nil {
|
||||
if cfg.LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserUpdateError{}
|
||||
}
|
||||
if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||
return &common.LdapUserUpdateError{}
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).Delete(&user).Error
|
||||
@@ -233,13 +233,13 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto) (model.User, error) {
|
||||
func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (model.User, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
user, err := s.CreateUserInternal(ctx, dbConfig, input, false, tx)
|
||||
user, err := s.CreateUserInternal(ctx, input, false, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
@@ -252,12 +252,8 @@ func (s *UserService) CreateUser(ctx context.Context, dbConfig *appconfig.AppCon
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
return s.createUserInternal(ctx, input, isLdapSync, tx, dbConfig)
|
||||
}
|
||||
|
||||
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
|
||||
if cfg.RequireUserEmail.IsTrue() && input.Email == nil {
|
||||
func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
@@ -317,13 +313,13 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
if !isLdapSync {
|
||||
if len(input.UserGroupIds) == 0 {
|
||||
err = s.applyDefaultGroups(ctx, &user, tx, cfg)
|
||||
err = s.applyDefaultGroups(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err = s.applyDefaultCustomClaims(ctx, &user, tx, cfg)
|
||||
err = s.applyDefaultCustomClaims(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
@@ -336,9 +332,11 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB, cfg *appconfig.AppConfigModel) error {
|
||||
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
var groupIDs []string
|
||||
v := cfg.SignupDefaultUserGroupIDs
|
||||
v := config.SignupDefaultUserGroupIDs.Value
|
||||
if v != "" && v != "[]" {
|
||||
err := json.Unmarshal([]byte(v), &groupIDs)
|
||||
if err != nil {
|
||||
@@ -398,9 +396,11 @@ func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []st
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB, cfg *appconfig.AppConfigModel) error {
|
||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
var claims []dto.CustomClaimCreateDto
|
||||
v := cfg.SignupDefaultCustomClaims
|
||||
v := config.SignupDefaultCustomClaims.Value
|
||||
if v != "" && v != "[]" {
|
||||
err := json.Unmarshal([]byte(v), &claims)
|
||||
if err != nil {
|
||||
@@ -417,13 +417,13 @@ func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
|
||||
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx, cfg)
|
||||
user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, isLdapSync, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
@@ -436,8 +436,8 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB, cfg *appconfig.AppConfigModel) (model.User, error) {
|
||||
if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil {
|
||||
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
@@ -453,8 +453,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
}
|
||||
|
||||
// Check if this is an LDAP user and LDAP is enabled
|
||||
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
|
||||
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
|
||||
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
|
||||
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
|
||||
|
||||
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
|
||||
// Restricted update: Only locale can be changed when:
|
||||
@@ -472,7 +472,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
|
||||
if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
|
||||
// Email has changed, reset email verification status
|
||||
user.EmailVerified = cfg.EmailsVerified.IsTrue()
|
||||
user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
|
||||
}
|
||||
|
||||
user.Email = updatedUser.Email
|
||||
@@ -639,7 +639,7 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error {
|
||||
func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -666,7 +666,7 @@ func (s *UserService) SendEmailVerification(ctx context.Context, dbConfig *appco
|
||||
return err
|
||||
}
|
||||
|
||||
return SendEmail(ctx, s.emailService, dbConfig, email.Address{
|
||||
return SendEmail(ctx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
}, EmailVerificationTemplate, &EmailVerificationTemplateData{
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
|
||||
func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService, *UserGroupService) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -25,19 +25,22 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
)
|
||||
groupService := NewUserGroupService(db, nil)
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
|
||||
return userService, groupService
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
|
||||
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
|
||||
userService, groupService := newTestUserService(t)
|
||||
appConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
})
|
||||
userService, groupService := newTestUserService(t, appConfig)
|
||||
|
||||
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
|
||||
Name: "members",
|
||||
@@ -49,7 +52,7 @@ func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
|
||||
// Create a user that is a member of the group
|
||||
// This mirrors signing up via an invite link that adds the user to a group
|
||||
email := "member@example.com"
|
||||
_, err = userService.CreateUser(t.Context(), config, dto.UserCreateDto{
|
||||
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
|
||||
Username: "member",
|
||||
Email: &email,
|
||||
FirstName: "Group",
|
||||
@@ -67,8 +70,10 @@ func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) {
|
||||
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
|
||||
userService, groupService := newTestUserService(t)
|
||||
appConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
})
|
||||
userService, groupService := newTestUserService(t, appConfig)
|
||||
|
||||
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
|
||||
Name: "default",
|
||||
@@ -80,11 +85,11 @@ func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) {
|
||||
// Configure the group as a default signup group
|
||||
defaultGroups, err := json.Marshal([]string{group.ID})
|
||||
require.NoError(t, err)
|
||||
config.SignupDefaultUserGroupIDs = appconfig.AppConfigValue(defaultGroups)
|
||||
appConfig.dbConfig.Load().SignupDefaultUserGroupIDs.Value = string(defaultGroups)
|
||||
|
||||
// Create a user without explicit group IDs, so the default groups apply
|
||||
email := "default@example.com"
|
||||
_, err = userService.CreateUser(t.Context(), config, dto.UserCreateDto{
|
||||
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
|
||||
Username: "defaultmember",
|
||||
Email: &email,
|
||||
FirstName: "Default",
|
||||
|
||||
@@ -18,14 +18,21 @@ type signupTokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
UserGroupIDs []string `json:"userGroupIds"`
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
type signupTokenDto struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
EmailDomain *string `json:"emailDomain" binding:"omitempty,email_domain"`
|
||||
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
// signupTokenInfoDto exposes the limited, publicly readable metadata of a signup token
|
||||
type signupTokenInfoDto struct {
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -17,10 +16,10 @@ const defaultSignupTokenDuration = time.Hour
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
appConfig AppConfigResolver
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
func newHandler(service *Service, appConfig AppConfigProvider) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
}
|
||||
|
||||
@@ -49,19 +48,13 @@ func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/signup/setup [post]
|
||||
func (h *handler) signUpInitialAdmin(c *gin.Context) {
|
||||
config, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input signUpDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input)
|
||||
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -73,7 +66,7 @@ func (h *handler) signUpInitialAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
|
||||
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
@@ -100,7 +93,7 @@ func (h *handler) createSignupToken(c *gin.Context) {
|
||||
ttl = defaultSignupTokenDuration
|
||||
}
|
||||
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs, input.EmailDomain)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -166,6 +159,28 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// signupTokenInfoHandler godoc
|
||||
// @Summary Get signup token info
|
||||
// @Description Get the public metadata (such as the required email domain) of a signup token
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param token path string true "Signup token"
|
||||
// @Success 200 {object} signupTokenInfoDto
|
||||
// @Router /api/signup/token/{token} [get]
|
||||
func (h *handler) signupTokenInfo(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
|
||||
signupToken, err := h.service.GetSignupTokenInfo(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, signupTokenInfoDto{
|
||||
EmailDomain: signupToken.EmailDomain,
|
||||
})
|
||||
}
|
||||
|
||||
// signupHandler godoc
|
||||
// @Summary Sign up
|
||||
// @Description Create a new user account
|
||||
@@ -176,12 +191,6 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
// @Success 201 {object} dto.UserDto
|
||||
// @Router /api/signup [post]
|
||||
func (h *handler) signup(c *gin.Context) {
|
||||
config, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var input signUpDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
@@ -191,13 +200,13 @@ func (h *handler) signup(c *gin.Context) {
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
user, accessToken, err := h.service.SignUp(c.Request.Context(), config, input, ipAddress, userAgent)
|
||||
user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
|
||||
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||
|
||||
var userDto dto.UserDto
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -11,11 +12,12 @@ import (
|
||||
type SignupToken struct {
|
||||
model.Base
|
||||
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
@@ -29,3 +31,24 @@ func (st *SignupToken) IsUsageLimitReached() bool {
|
||||
func (st *SignupToken) IsValid() bool {
|
||||
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||
}
|
||||
|
||||
// HasEmailDomainRestriction reports whether the token limits sign-ups to a specific email domain
|
||||
func (st *SignupToken) HasEmailDomainRestriction() bool {
|
||||
return st.EmailDomain != nil && *st.EmailDomain != ""
|
||||
}
|
||||
|
||||
// EmailMatchesDomain reports whether the given email address is allowed by the token's domain restriction
|
||||
// It returns true when the token has no restriction
|
||||
// The comparison is case-insensitive
|
||||
func (st *SignupToken) EmailMatchesDomain(email string) bool {
|
||||
if !st.HasEmailDomainRestriction() {
|
||||
return true
|
||||
}
|
||||
|
||||
at := strings.LastIndexByte(email, '@')
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(email[at+1:], *st.EmailDomain)
|
||||
}
|
||||
|
||||
39
backend/internal/usersignup/models_test.go
Normal file
39
backend/internal/usersignup/models_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestSignupTokenEmailMatchesDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
emailDomain *string
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{name: "no restriction allows any email", emailDomain: nil, email: "user@anything.com", want: true},
|
||||
{name: "empty restriction allows any email", emailDomain: strPtr(""), email: "user@anything.com", want: true},
|
||||
{name: "matching domain", emailDomain: strPtr("example.com"), email: "user@example.com", want: true},
|
||||
{name: "matching domain case-insensitive", emailDomain: strPtr("example.com"), email: "User@Example.COM", want: true},
|
||||
{name: "non-matching domain", emailDomain: strPtr("example.com"), email: "user@other.com", want: false},
|
||||
{name: "subdomain does not match", emailDomain: strPtr("example.com"), email: "user@mail.example.com", want: false},
|
||||
{name: "domain suffix does not match", emailDomain: strPtr("example.com"), email: "user@notexample.com", want: false},
|
||||
{name: "missing @ with restriction", emailDomain: strPtr("example.com"), email: "userexample.com", want: false},
|
||||
{name: "empty email with restriction", emailDomain: strPtr("example.com"), email: "", want: false},
|
||||
{name: "plus addressing still matches", emailDomain: strPtr("example.com"), email: "user+tag@example.com", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := &SignupToken{EmailDomain: tc.emailDomain}
|
||||
got := st.EmailMatchesDomain(tc.email)
|
||||
if got != tc.want {
|
||||
t.Errorf("EmailMatchesDomain(%q) with domain %v = %v, want %v", tc.email, tc.emailDomain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,28 @@ package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error)
|
||||
GenerateAccessToken(user model.User, authenticationMethod string) (string, error)
|
||||
}
|
||||
|
||||
type AuditLogger interface {
|
||||
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
|
||||
}
|
||||
|
||||
type UserCreator interface {
|
||||
CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error)
|
||||
type AppConfigProvider interface {
|
||||
GetDbConfig() *model.AppConfig
|
||||
}
|
||||
|
||||
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
|
||||
type AppConfigResolver interface {
|
||||
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
|
||||
type UserCreator interface {
|
||||
CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error)
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
@@ -34,8 +31,8 @@ type Dependencies struct {
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
AppConfig AppConfigProvider
|
||||
UserCreator UserCreator
|
||||
AppConfig AppConfigResolver
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
@@ -58,6 +55,7 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRate
|
||||
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
|
||||
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
|
||||
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
|
||||
apiGroup.GET("/signup/token/:token", signupRateLimit, m.handler.signupTokenInfo)
|
||||
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
|
||||
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -26,6 +25,7 @@ type Service struct {
|
||||
userCreator UserCreator
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) *Service {
|
||||
@@ -34,10 +34,11 @@ func newService(deps Dependencies) *Service {
|
||||
userCreator: deps.UserCreator,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
appConfig: deps.AppConfig,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) {
|
||||
func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -45,7 +46,8 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
|
||||
tokenProvided := signupData.Token != ""
|
||||
|
||||
if config.AllowUserSignups.String() != "open" && !tokenProvided {
|
||||
config := s.appConfig.GetDbConfig()
|
||||
if config.AllowUserSignups.Value != "open" && !tokenProvided {
|
||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
||||
}
|
||||
|
||||
@@ -70,6 +72,16 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
if signupToken.HasEmailDomainRestriction() {
|
||||
email := ""
|
||||
if signupData.Email != nil {
|
||||
email = *signupData.Email
|
||||
}
|
||||
if !signupToken.EmailMatchesDomain(email) {
|
||||
return model.User{}, "", &common.EmailDomainNotAllowedError{Domain: *signupToken.EmailDomain}
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range signupToken.UserGroups {
|
||||
userGroupIDs = append(userGroupIDs, group.ID)
|
||||
}
|
||||
@@ -82,15 +94,15 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
LastName: signupData.LastName,
|
||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||
UserGroupIds: userGroupIDs,
|
||||
EmailVerified: config.EmailsVerified.IsTrue(),
|
||||
EmailVerified: s.appConfig.GetDbConfig().EmailsVerified.IsTrue(),
|
||||
}
|
||||
|
||||
user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx)
|
||||
user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
accessToken, err := s.signer.GenerateAccessToken(user, "", config.SessionDuration.AsDurationMinutes())
|
||||
accessToken, err := s.signer.GenerateAccessToken(user, "")
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -120,7 +132,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppConfigModel, signUpData signUpDto) (model.User, string, error) {
|
||||
func (s *Service) SignUpInitialAdmin(ctx context.Context, signUpData signUpDto) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -143,12 +155,12 @@ func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppC
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx)
|
||||
user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, config.SessionDuration.AsDurationMinutes())
|
||||
token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -188,8 +200,26 @@ func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit)
|
||||
// GetSignupTokenInfo returns a signup token by its token string.
|
||||
// It's used to expose the limited, public metadata (such as the required email domain) needed to render the signup form.
|
||||
func (s *Service) GetSignupTokenInfo(ctx context.Context, token string) (SignupToken, error) {
|
||||
var signupToken SignupToken
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("token = ?", token).
|
||||
First(&signupToken).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SignupToken{}, &common.TokenInvalidOrExpiredError{}
|
||||
} else if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
|
||||
return signupToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string, emailDomain *string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit, emailDomain)
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
@@ -212,7 +242,7 @@ func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usag
|
||||
return *signupToken, nil
|
||||
}
|
||||
|
||||
func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
func newSignupToken(ttl time.Duration, usageLimit int, emailDomain *string) (*SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
@@ -221,10 +251,11 @@ func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
token := &SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
EmailDomain: emailDomain,
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,22 +13,16 @@ import (
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
appConfig AppConfigResolver
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
func newHandler(service *Service, appConfig AppConfigProvider) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
}
|
||||
|
||||
func (h *handler) beginRegistration(c *gin.Context) {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
options, err := h.service.BeginRegistration(c.Request.Context(), dbConfig, userID)
|
||||
options, err := h.service.BeginRegistration(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -74,12 +67,6 @@ func (h *handler) beginLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *handler) verifyLogin(c *gin.Context) {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
_ = c.Error(&common.MissingSessionIdError{})
|
||||
@@ -92,7 +79,7 @@ func (h *handler) verifyLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||
user, token, err := h.service.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -104,7 +91,7 @@ func (h *handler) verifyLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
|
||||
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
|
||||
@@ -8,24 +8,22 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
type TokenService interface {
|
||||
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error)
|
||||
GenerateAccessToken(user model.User, authenticationMethod string) (string, error)
|
||||
VerifyAccessToken(tokenString string) (jwt.Token, error)
|
||||
GetAuthenticationMethod(token jwt.Token) (string, error)
|
||||
}
|
||||
|
||||
type AuditLogger interface {
|
||||
Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
|
||||
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog
|
||||
CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog
|
||||
}
|
||||
|
||||
// AppConfigResolver loads the current application configuration, so handlers can pass it explicitly to the service methods that need it
|
||||
type AppConfigResolver interface {
|
||||
GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error)
|
||||
type AppConfigProvider interface {
|
||||
GetDbConfig() *model.AppConfig
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
@@ -34,7 +32,7 @@ type Dependencies struct {
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
AppConfig AppConfigResolver
|
||||
AppConfig AppConfigProvider
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -24,19 +23,17 @@ import (
|
||||
// It must match the value emitted by the JWT service in the access token's "amr" claim
|
||||
const authenticationMethodPhishingResistant = "phr"
|
||||
|
||||
const defaultRPDisplayName = "Pocket ID"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
webAuthn *gowebauthn.WebAuthn
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
db *gorm.DB
|
||||
webAuthn *gowebauthn.WebAuthn
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
appConfig AppConfigProvider
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) (*Service, error) {
|
||||
wa, err := gowebauthn.New(&gowebauthn.Config{
|
||||
// Set a default value, it will be set again later
|
||||
RPDisplayName: defaultRPDisplayName,
|
||||
RPDisplayName: deps.AppConfig.GetDbConfig().AppName.Value,
|
||||
RPID: utils.GetHostnameFromURL(deps.AppURL),
|
||||
RPOrigins: []string{deps.AppURL},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
@@ -60,21 +57,22 @@ func newService(deps Dependencies) (*Service, error) {
|
||||
}
|
||||
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
webAuthn: wa,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
db: deps.DB,
|
||||
webAuthn: wa,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
appConfig: deps.AppConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) (*PublicKeyCredentialCreationOptions, error) {
|
||||
s.updateWebAuthnConfig(dbConfig)
|
||||
|
||||
func (s *Service) BeginRegistration(ctx context.Context, userID string) (*PublicKeyCredentialCreationOptions, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
s.updateWebAuthnConfig()
|
||||
|
||||
var user model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
@@ -140,11 +138,10 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user
|
||||
}
|
||||
|
||||
session := gowebauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
CredParams: storedSession.CredentialParams,
|
||||
UserVerification: protocol.UserVerificationRequirement(storedSession.UserVerification),
|
||||
UserID: []byte(userID),
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
CredParams: storedSession.CredentialParams,
|
||||
UserID: []byte(userID),
|
||||
}
|
||||
|
||||
var user model.User
|
||||
@@ -230,7 +227,7 @@ func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOp
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
||||
func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -273,12 +270,12 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant, dbConfig.SessionDuration.AsDurationMinutes())
|
||||
token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx, dbConfig.EmailLoginNotificationEnabled.IsTrue())
|
||||
s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx)
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
@@ -376,8 +373,8 @@ func (s *Service) UpdateCredential(ctx context.Context, userID, credentialID, na
|
||||
}
|
||||
|
||||
// updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime
|
||||
func (s *Service) updateWebAuthnConfig(dbConfig *appconfig.AppConfigModel) {
|
||||
s.webAuthn.Config.RPDisplayName = dbConfig.AppName.String()
|
||||
func (s *Service) updateWebAuthnConfig() {
|
||||
s.webAuthn.Config.RPDisplayName = s.appConfig.GetDbConfig().AppName.Value
|
||||
}
|
||||
|
||||
func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -29,7 +28,7 @@ func newFakeSigner() *fakeSigner {
|
||||
return &fakeSigner{tokens: map[string]jwt.Token{}}
|
||||
}
|
||||
|
||||
func (s *fakeSigner) GenerateAccessToken(user model.User, authenticationMethod string, _ time.Duration) (string, error) {
|
||||
func (s *fakeSigner) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
|
||||
builder := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
IssuedAt(time.Now())
|
||||
@@ -86,7 +85,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
|
||||
t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) {
|
||||
service, signer, user := setupService(t)
|
||||
accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant, time.Hour)
|
||||
accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant)
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
||||
@@ -97,7 +96,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
|
||||
t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) {
|
||||
service, signer, user := setupService(t)
|
||||
accessToken, err := signer.GenerateAccessToken(user, "otp", time.Hour)
|
||||
accessToken, err := signer.GenerateAccessToken(user, "otp")
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
||||
@@ -109,7 +108,7 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
|
||||
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
|
||||
service, signer, user := setupService(t)
|
||||
accessToken, err := signer.GenerateAccessToken(user, "", time.Hour)
|
||||
accessToken, err := signer.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
||||
@@ -120,18 +119,6 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) {
|
||||
service, err := newService(Dependencies{
|
||||
DB: testutils.NewDatabaseForTest(t),
|
||||
AppURL: "https://example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, defaultRPDisplayName, service.webAuthn.Config.RPDisplayName)
|
||||
|
||||
service.updateWebAuthnConfig(&appconfig.AppConfigModel{AppName: "Custom App"})
|
||||
require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName)
|
||||
}
|
||||
|
||||
func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := &Service{db: db}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
|
||||
-- Recreate the standalone config table with the same schema it had before it was frozen
|
||||
CREATE TABLE app_config_variables
|
||||
(
|
||||
key VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Populate it from the frozen JSON document stored in the "kv" table
|
||||
-- json_each expands the JSON object back into one row per key/value pair.
|
||||
INSERT INTO app_config_variables (key, value)
|
||||
SELECT je.key, je.value
|
||||
FROM kv, json_each_text(kv."value"::json) AS je(key, value)
|
||||
WHERE kv."key" = 'config_migrated';
|
||||
|
||||
-- Remove the frozen config from the "kv" table
|
||||
DELETE FROM kv WHERE "key" = 'config_migrated';
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Freeze the app configuration
|
||||
-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key
|
||||
--
|
||||
-- json_object_agg aggregates all rows into a JSON object
|
||||
-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty
|
||||
INSERT INTO kv ("key", "value")
|
||||
SELECT 'config_migrated', json_object_agg("key", "value")::text
|
||||
FROM app_config_variables
|
||||
HAVING count(*) > 0;
|
||||
|
||||
-- Drop the now-frozen standalone config table
|
||||
DROP TABLE app_config_variables;
|
||||
@@ -1 +0,0 @@
|
||||
-- No-op on PostgreSQL
|
||||
@@ -1 +0,0 @@
|
||||
-- No-op on PostgreSQL because its OAuth storage types already match the export format
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE signup_tokens DROP COLUMN email_domain;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
|
||||
@@ -1,22 +0,0 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Recreate the standalone config table with the same schema it had before it was frozen
|
||||
CREATE TABLE app_config_variables
|
||||
(
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Populate it from the frozen JSON document stored in the "kv" table
|
||||
-- json_each expands the JSON object back into one row per key/value pair.
|
||||
INSERT INTO app_config_variables ("key", "value")
|
||||
SELECT je.key, je.value
|
||||
FROM kv, json_each(kv."value") AS je
|
||||
WHERE kv."key" = 'config_migrated';
|
||||
|
||||
-- Remove the frozen config from the "kv" table
|
||||
DELETE FROM kv WHERE "key" = 'config_migrated';
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -1,18 +0,0 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Freeze the app configuration
|
||||
-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key
|
||||
--
|
||||
-- json_group_object aggregates all rows into a JSON object
|
||||
-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty
|
||||
INSERT INTO kv ("key", "value")
|
||||
SELECT 'config_migrated', json_group_object("key", "value")
|
||||
FROM app_config_variables
|
||||
HAVING count(*) > 0;
|
||||
|
||||
-- Drop the now-frozen standalone config table
|
||||
DROP TABLE app_config_variables;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -1,151 +0,0 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE reauthentication_tokens_old (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO reauthentication_tokens_old (
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
FROM reauthentication_tokens;
|
||||
|
||||
DROP TABLE reauthentication_tokens;
|
||||
ALTER TABLE reauthentication_tokens_old RENAME TO reauthentication_tokens;
|
||||
|
||||
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens (token);
|
||||
CREATE INDEX idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_sessions_old (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
access_token_signature TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
request_data TEXT NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_sessions_old (
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
request_data,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
CAST(request_data AS TEXT),
|
||||
expires_at
|
||||
FROM oauth2_sessions;
|
||||
|
||||
DROP TABLE oauth2_sessions;
|
||||
ALTER TABLE oauth2_sessions_old RENAME TO oauth2_sessions;
|
||||
|
||||
CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key);
|
||||
CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id);
|
||||
CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_jtis_old (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_jtis_old (
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
FROM oauth2_jtis;
|
||||
|
||||
DROP TABLE oauth2_jtis;
|
||||
ALTER TABLE oauth2_jtis_old RENAME TO oauth2_jtis;
|
||||
|
||||
CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at);
|
||||
|
||||
CREATE TABLE interaction_sessions_old (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
consent_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
authentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
account_selection_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
scopes TEXT NOT NULL DEFAULT '[]',
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
requested_at INTEGER NOT NULL,
|
||||
reauthenticated_at INTEGER,
|
||||
parameters TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
INSERT INTO interaction_sessions_old (
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
scopes,
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
parameters
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
CAST(scopes AS TEXT),
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
CAST(parameters AS TEXT)
|
||||
FROM interaction_sessions;
|
||||
|
||||
DROP TABLE interaction_sessions;
|
||||
ALTER TABLE interaction_sessions_old RENAME TO interaction_sessions;
|
||||
|
||||
CREATE INDEX idx_interaction_sessions_client_id ON interaction_sessions (client_id);
|
||||
CREATE INDEX idx_interaction_sessions_user_id ON interaction_sessions (user_id);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -1,152 +0,0 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Align JSON and timestamp column types with the export format used for PostgreSQL
|
||||
CREATE TABLE reauthentication_tokens_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO reauthentication_tokens_new (
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
token,
|
||||
expires_at,
|
||||
user_id
|
||||
FROM reauthentication_tokens;
|
||||
|
||||
DROP TABLE reauthentication_tokens;
|
||||
ALTER TABLE reauthentication_tokens_new RENAME TO reauthentication_tokens;
|
||||
|
||||
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens (token);
|
||||
CREATE INDEX idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_sessions_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
access_token_signature TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
request_data BLOB NOT NULL,
|
||||
expires_at DATETIME
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_sessions_new (
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
request_data,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
kind,
|
||||
key,
|
||||
request_id,
|
||||
access_token_signature,
|
||||
active,
|
||||
CAST(request_data AS BLOB),
|
||||
expires_at
|
||||
FROM oauth2_sessions;
|
||||
|
||||
DROP TABLE oauth2_sessions;
|
||||
ALTER TABLE oauth2_sessions_new RENAME TO oauth2_sessions;
|
||||
|
||||
CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key);
|
||||
CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id);
|
||||
CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at);
|
||||
|
||||
CREATE TABLE oauth2_jtis_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
jti TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO oauth2_jtis_new (
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
jti,
|
||||
expires_at
|
||||
FROM oauth2_jtis;
|
||||
|
||||
DROP TABLE oauth2_jtis;
|
||||
ALTER TABLE oauth2_jtis_new RENAME TO oauth2_jtis;
|
||||
|
||||
CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at);
|
||||
|
||||
CREATE TABLE interaction_sessions_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
consent_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
authentication_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
account_selection_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
scopes BLOB NOT NULL DEFAULT X'5B5D',
|
||||
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
requested_at DATETIME NOT NULL,
|
||||
reauthenticated_at DATETIME,
|
||||
parameters BLOB NOT NULL DEFAULT X'7B7D'
|
||||
);
|
||||
|
||||
INSERT INTO interaction_sessions_new (
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
scopes,
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
parameters
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
consent_required,
|
||||
reauthentication_required,
|
||||
authentication_required,
|
||||
account_selection_required,
|
||||
CAST(scopes AS BLOB),
|
||||
client_id,
|
||||
user_id,
|
||||
requested_at,
|
||||
reauthenticated_at,
|
||||
CAST(parameters AS BLOB)
|
||||
FROM interaction_sessions;
|
||||
|
||||
DROP TABLE interaction_sessions;
|
||||
ALTER TABLE interaction_sessions_new RENAME TO interaction_sessions;
|
||||
|
||||
CREATE INDEX idx_interaction_sessions_client_id ON interaction_sessions (client_id);
|
||||
CREATE INDEX idx_interaction_sessions_user_id ON interaction_sessions (user_id);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE signup_tokens DROP COLUMN email_domain;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -19,7 +19,7 @@
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react-email": "6.9.0",
|
||||
"react-email": "6.7.0",
|
||||
"tsx": "^4.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +406,11 @@
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"email_domain": "Email Domain",
|
||||
"signup_token_email_domain_description": "Optionally restrict signups with this token to email addresses of a specific domain (e.g. example.com). Leave empty to allow any domain.",
|
||||
"invalid_email_domain": "Enter a valid domain, e.g. example.com",
|
||||
"email_must_use_domain": "The email address must use the domain {domain}",
|
||||
"email_domain_required_hint": "Must be an email address with the domain {domain}",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"user_creation": "User Creation",
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
let {
|
||||
callback,
|
||||
isLoading
|
||||
isLoading,
|
||||
requiredEmailDomain
|
||||
}: {
|
||||
callback: (user: UserSignUp) => Promise<boolean>;
|
||||
isLoading: boolean;
|
||||
requiredEmailDomain?: string | null;
|
||||
} = $props();
|
||||
|
||||
const initialData: UserSignUp = {
|
||||
@@ -25,11 +27,19 @@
|
||||
username: ''
|
||||
};
|
||||
|
||||
const emailSchema = requiredEmailDomain
|
||||
? z.email().refine((v) => v.toLowerCase().endsWith(`@${requiredEmailDomain.toLowerCase()}`), {
|
||||
message: m.email_must_use_domain({ domain: `@${requiredEmailDomain}` })
|
||||
})
|
||||
: get(appConfigStore).requireUserEmail
|
||||
? z.email()
|
||||
: emptyToUndefined(z.email().optional());
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().max(50),
|
||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||
username: usernameSchema,
|
||||
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
||||
email: emailSchema
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
@@ -53,7 +63,15 @@
|
||||
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||
<div class="mt-7 space-y-4">
|
||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||
<FormInput
|
||||
label={m.email()}
|
||||
bind:input={$inputs.email}
|
||||
type="email"
|
||||
placeholder={requiredEmailDomain ? `you@${requiredEmailDomain}` : undefined}
|
||||
description={requiredEmailDomain
|
||||
? m.email_domain_required_hint({ domain: `@${requiredEmailDomain}` })
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
sortable: true,
|
||||
value: (item) => formatDate(item.expiresAt)
|
||||
},
|
||||
{
|
||||
key: 'emailDomain',
|
||||
label: m.email_domain(),
|
||||
value: (item) => (item.emailDomain ? `@${item.emailDomain}` : '—')
|
||||
},
|
||||
{
|
||||
key: 'userGroups',
|
||||
label: m.user_groups(),
|
||||
|
||||
@@ -44,18 +44,28 @@
|
||||
ttl: number;
|
||||
usageLimit: number;
|
||||
userGroupIds: string[];
|
||||
emailDomain: string;
|
||||
};
|
||||
|
||||
const initialFormValues: SignupTokenForm = {
|
||||
ttl: defaultExpiration,
|
||||
usageLimit: 1,
|
||||
userGroupIds: []
|
||||
userGroupIds: [],
|
||||
emailDomain: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
ttl: z.number(),
|
||||
usageLimit: z.number().min(1).max(100),
|
||||
userGroupIds: z.array(z.string()).default([])
|
||||
userGroupIds: z.array(z.string()).default([]),
|
||||
emailDomain: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => v.replace(/^@/, ''))
|
||||
.refine((v) => v === '' || /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(v), {
|
||||
message: m.invalid_email_domain()
|
||||
})
|
||||
.default('')
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
|
||||
@@ -85,7 +95,8 @@
|
||||
signupToken = await userService.createSignupToken(
|
||||
data.ttl,
|
||||
data.usageLimit,
|
||||
data.userGroupIds
|
||||
data.userGroupIds,
|
||||
data.emailDomain
|
||||
);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
createdSignupData = data;
|
||||
@@ -165,6 +176,21 @@
|
||||
class="h-9"
|
||||
/>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="email-domain"
|
||||
label={m.email_domain()}
|
||||
description={m.signup_token_email_domain_description()}
|
||||
input={$inputs.emailDomain}
|
||||
>
|
||||
<Input
|
||||
id="email-domain"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
bind:value={$inputs.emailDomain.value}
|
||||
aria-invalid={$inputs.emailDomain.error ? 'true' : undefined}
|
||||
class="h-9"
|
||||
/>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="default-groups"
|
||||
label={m.user_groups()}
|
||||
@@ -197,6 +223,9 @@
|
||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
|
||||
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
|
||||
{#if createdSignupData?.emailDomain}
|
||||
<p>{m.email_domain()}: @{createdSignupData.emailDomain}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type { Passkey } from '$lib/types/passkey.type';
|
||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
||||
import type { SignupToken, SignupTokenInfo } from '$lib/types/signup-token.type';
|
||||
import type { UserGroup } from '$lib/types/user-group.type';
|
||||
import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
@@ -89,12 +89,23 @@ export default class UserService extends APIService {
|
||||
createSignupToken = async (
|
||||
ttl: string | number,
|
||||
usageLimit: number,
|
||||
userGroupIds: string[] = []
|
||||
userGroupIds: string[] = [],
|
||||
emailDomain?: string | null
|
||||
) => {
|
||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds });
|
||||
const res = await this.api.post(`/signup-tokens`, {
|
||||
ttl,
|
||||
usageLimit,
|
||||
userGroupIds,
|
||||
emailDomain: emailDomain || undefined
|
||||
});
|
||||
return res.data.token;
|
||||
};
|
||||
|
||||
getSignupTokenInfo = async (token: string) => {
|
||||
const res = await this.api.get(`/signup/token/${token}`);
|
||||
return res.data as SignupTokenInfo;
|
||||
};
|
||||
|
||||
exchangeOneTimeAccessToken = async (token: string) => {
|
||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||
return res.data as User;
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface SignupToken {
|
||||
expiresAt: string;
|
||||
usageLimit: number;
|
||||
usageCount: number;
|
||||
emailDomain?: string | null;
|
||||
userGroups: UserGroup[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SignupTokenInfo {
|
||||
emailDomain?: string | null;
|
||||
}
|
||||
|
||||
@@ -87,11 +87,10 @@
|
||||
placeholder={m.code()}
|
||||
aria-label={m.code()}
|
||||
bind:value={code}
|
||||
autofocus
|
||||
type="text"
|
||||
/>
|
||||
{:else}
|
||||
<InputOTP.Root maxlength={6} bind:value={code} autofocus>
|
||||
<InputOTP.Root maxlength={6} bind:value={code}>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells as cell}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-4 overflow-hidden pb-2 px-2">
|
||||
<div class="flex w-full flex-col gap-4 overflow-hidden pb-2">
|
||||
<FadeWrapper>
|
||||
<EmailVerificationStateBox />
|
||||
{@render children()}
|
||||
|
||||
@@ -75,7 +75,11 @@
|
||||
</p>
|
||||
{/if}
|
||||
{#if $appConfigStore.allowUserSignups === 'open' || data.token}
|
||||
<SignupForm callback={handleSignup} {isLoading} />
|
||||
<SignupForm
|
||||
callback={handleSignup}
|
||||
{isLoading}
|
||||
requiredEmailDomain={data.requiredEmailDomain}
|
||||
/>
|
||||
<div class="mt-10 flex w-full items-center justify-between gap-2">
|
||||
<a class="text-muted-foreground mt-5 flex text-sm" href="/login"
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const token = url.searchParams.get('token') || undefined;
|
||||
|
||||
let requiredEmailDomain: string | null = null;
|
||||
if (token) {
|
||||
// Best-effort lookup of the token's public metadata to hint the required email domain.
|
||||
// Failures (e.g. an invalid or expired token) are ignored here; the signup request itself enforces validity.
|
||||
const userService = new UserService();
|
||||
try {
|
||||
const info = await userService.getSignupTokenInfo(token);
|
||||
requiredEmailDomain = info.emailDomain ?? null;
|
||||
} catch {
|
||||
requiredEmailDomain = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: url.searchParams.get('token') || undefined
|
||||
token,
|
||||
requiredEmailDomain
|
||||
};
|
||||
};
|
||||
|
||||
832
pnpm-lock.yaml
generated
832
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -192,5 +192,14 @@ export const signupTokens = {
|
||||
usageLimit: 1,
|
||||
usageCount: 1,
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
domainRestricted: {
|
||||
id: 'b2c3d4e5-f6a7-4890-bcde-f01234567891',
|
||||
token: 'DOMAIN1234567890',
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageLimit: 5,
|
||||
usageCount: 0,
|
||||
emailDomain: 'example.com',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
"prettier": "^3.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.6.0"
|
||||
"adm-zip": "^0.5.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260722120000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
"oidc_clients",
|
||||
"signup_tokens",
|
||||
"apis",
|
||||
"api_permissions",
|
||||
"oidc_clients_allowed_api_permissions"
|
||||
],
|
||||
"version": 20260727000000,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
|
||||
"tables": {
|
||||
"apis": [
|
||||
{
|
||||
@@ -237,10 +229,6 @@
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
|
||||
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
|
||||
@@ -276,52 +264,6 @@
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
}
|
||||
],
|
||||
"oauth2_jtis": [
|
||||
{
|
||||
"id": "bd0c8bf2-66ec-487a-9dd5-7d9d78d73543",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"jti": "cross-database-test-jti",
|
||||
"expires_at": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"oauth2_sessions": [
|
||||
{
|
||||
"id": "551ab785-c830-47d3-8a07-60c9f3bb4859",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"kind": "access_token",
|
||||
"key": "cross-database-test-session",
|
||||
"request_id": "cross-database-test-request",
|
||||
"access_token_signature": "",
|
||||
"active": true,
|
||||
"request_data": "eyJyZXF1ZXN0IjoidmFsdWUifQ==",
|
||||
"expires_at": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"interaction_sessions": [
|
||||
{
|
||||
"id": "aaf5dd23-cd1f-4748-a2aa-baa6af94d800",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"consent_required": true,
|
||||
"reauthentication_required": false,
|
||||
"authentication_required": false,
|
||||
"account_selection_required": false,
|
||||
"scopes": "WyJvcGVuaWQiXQ==",
|
||||
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"requested_at": "2099-01-01T00:00:00Z",
|
||||
"reauthenticated_at": null,
|
||||
"parameters": "eyJjbGllbnRfaWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUifQ=="
|
||||
}
|
||||
],
|
||||
"reauthentication_tokens": [
|
||||
{
|
||||
"id": "71839ace-d978-4e6f-8fb1-b8648a21031b",
|
||||
"created_at": "2026-07-22T12:00:00Z",
|
||||
"token": "cross-database-reauthentication-token",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
}
|
||||
],
|
||||
"signup_tokens": [
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -329,7 +271,8 @@
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"token": "VALID1234567890A",
|
||||
"usage_count": 0,
|
||||
"usage_limit": 1
|
||||
"usage_limit": 1,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -337,7 +280,8 @@
|
||||
"id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||
"token": "PARTIAL567890ABC",
|
||||
"usage_count": 2,
|
||||
"usage_limit": 5
|
||||
"usage_limit": 5,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -345,7 +289,8 @@
|
||||
"id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
|
||||
"token": "EXPIRED34567890B",
|
||||
"usage_count": 1,
|
||||
"usage_limit": 3
|
||||
"usage_limit": 3,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -353,7 +298,17 @@
|
||||
"id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||
"token": "FULLYUSED567890C",
|
||||
"usage_count": 1,
|
||||
"usage_limit": 1
|
||||
"usage_limit": 1,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-26T12:39:02Z",
|
||||
"id": "b2c3d4e5-f6a7-4890-bcde-f01234567891",
|
||||
"token": "DOMAIN1234567890",
|
||||
"usage_count": 0,
|
||||
"usage_limit": 5,
|
||||
"email_domain": "example.com"
|
||||
}
|
||||
],
|
||||
"signup_tokens_user_groups": [
|
||||
|
||||
@@ -59,7 +59,7 @@ test('Export via stdout', async ({ baseURL }) => {
|
||||
compareExports(exampleExportPath, stdoutExtractPath);
|
||||
});
|
||||
|
||||
test('Import SQLite export', async () => {
|
||||
test('Import', async () => {
|
||||
// Reset the backend without seeding
|
||||
await cleanupBackend({ skipSeed: true });
|
||||
|
||||
@@ -83,7 +83,7 @@ test('Import SQLite export', async () => {
|
||||
compareExports(exampleExportPath, exportExtracted);
|
||||
});
|
||||
|
||||
test('Import SQLite export via stdin', async () => {
|
||||
test('Import via stdin', async () => {
|
||||
await cleanupBackend({ skipSeed: true });
|
||||
|
||||
const exampleExportArchivePath = path.join(tmpDir, 'example-export-stdin.zip');
|
||||
|
||||
@@ -62,6 +62,20 @@ test.describe('Signup Token Creation', () => {
|
||||
await expect(row.getByRole('cell', { name: userGroups.developers.name })).toBeVisible();
|
||||
await expect(row.getByRole('cell', { name: userGroups.designers.name })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Create signup token with email domain restriction', async ({ page }) => {
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page.getByRole('button', { name: 'Create options' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Create Signup Token' }).click();
|
||||
|
||||
await page.getByLabel('Email Domain').fill('@example.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// The success view shows the configured domain restriction
|
||||
await expect(page.getByText('@example.com').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Initial User Signup', () => {
|
||||
@@ -241,6 +255,69 @@ test.describe('User Signup', () => {
|
||||
await expect(page.getByText('Passkey missing')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - email domain hint and placeholder are shown', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
await page.goto(`/st/${signupTokens.domainRestricted.token}`);
|
||||
|
||||
await expect(
|
||||
page.getByText('Must be an email address with the domain @example.com')
|
||||
).toBeVisible();
|
||||
await expect(page.getByPlaceholder('you@example.com')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - wrong email domain shows validation error', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
await page.goto(`/st/${signupTokens.domainRestricted.token}`);
|
||||
|
||||
await page.getByLabel('First name').fill('Domain');
|
||||
await page.getByLabel('Last name').fill('User');
|
||||
await page.getByLabel('Username').fill('domainuser');
|
||||
await page.getByLabel('Email').fill('domain.user@wrong.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The email address must use the domain @example.com')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - matching email domain succeeds', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
await page.goto(`/st/${signupTokens.domainRestricted.token}`);
|
||||
|
||||
await page.getByLabel('First name').fill('Domain');
|
||||
await page.getByLabel('Last name').fill('User');
|
||||
await page.getByLabel('Username').fill('domainuser');
|
||||
await page.getByLabel('Email').fill('domain.user@example.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||
|
||||
await page.waitForURL('/signup/add-passkey');
|
||||
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - server enforces email domain restriction', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
// Bypass the client-side validation to verify the domain is enforced server-side
|
||||
const res = await page.request.post('/api/signup', {
|
||||
data: {
|
||||
username: 'apidomainuser',
|
||||
email: 'api.user@wrong.com',
|
||||
firstName: 'Api',
|
||||
lastName: 'User',
|
||||
token: signupTokens.domainRestricted.token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain('example.com');
|
||||
});
|
||||
|
||||
test('Token usage limit is enforced', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user