mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-08 20:21:27 +02:00
Merge branch 'main' into feat/huma-api-docs
This commit is contained in:
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.4.0
|
||||
uses: actions/setup-node@v6.5.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.1.1
|
||||
uses: actions/attest@v4.2.0
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4.1.1
|
||||
uses: actions/attest@v4.2.0
|
||||
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.4.0
|
||||
uses: actions/setup-node@v6.5.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.4.0
|
||||
uses: actions/setup-node@v6.5.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.1.1
|
||||
uses: actions/attest@v4.2.0
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4.1.1
|
||||
uses: actions/attest@v4.2.0
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
|
||||
2
.github/workflows/svelte-check.yml
vendored
2
.github/workflows/svelte-check.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v6.5.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -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.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/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/caarlos0/env/v11 v11.4.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
@@ -20,12 +20,13 @@ 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.13
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
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/go-kit v0.0.0-20260708054611-e276b65dd3be
|
||||
github.com/italypaleale/francis v0.1.0-beta.15
|
||||
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86
|
||||
github.com/italypaleale/go-sql-utils v0.2.4
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
@@ -33,8 +34,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.1.3
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/lmittmann/tint v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.23
|
||||
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 +61,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.53.0
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.65.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.32.0 // indirect
|
||||
github.com/alphadose/haxmap v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
@@ -80,10 +81,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.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/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/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 +111,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-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // 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
|
||||
@@ -118,7 +119,6 @@ require (
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
@@ -137,7 +137,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.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.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
|
||||
@@ -173,7 +173,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.21 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // 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
|
||||
@@ -228,13 +228,13 @@ require (
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
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.56.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.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
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/grpc v1.82.0 // indirect
|
||||
@@ -243,8 +243,8 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
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.73.5 // indirect
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
104
backend/go.sum
104
backend/go.sum
@@ -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.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/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/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.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/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/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.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/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/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=
|
||||
@@ -161,8 +161,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-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-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-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=
|
||||
@@ -176,8 +176,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.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
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-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=
|
||||
@@ -259,16 +259,16 @@ 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.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
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/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/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/francis v0.1.0-beta.15 h1:yVFJCcD1pP91rIesAb06Gp1K1tPj0ruh6hha5YEhtRw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.15/go.mod h1:KKwS+57OBD/MoHBVfbbMelA2vUx65fPiG9fhdWWapFc=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86 h1:719T7W8hLVjelch856Sern60QAPMn0fIE87i91YVcfw=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260725195228-78f113702f86/go.mod h1:0Sy3bN3qnSy2kgcJ05A2CsP6os5wmLC9lPraFr+0jGk=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4/go.mod h1:BJStxMfB6fzYVcOe0oZQCjGIPZQu76UBmg1Wuy6Z/7I=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||
@@ -352,12 +352,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.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/lmittmann/tint v1.2.0 h1:AogHRHy8HUJUnNJBHJlYa+fR4YY8mko2cnCp67xn9JY=
|
||||
github.com/lmittmann/tint v1.2.0/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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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-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=
|
||||
@@ -421,8 +421,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.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
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/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=
|
||||
@@ -622,8 +622,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A=
|
||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
@@ -635,8 +635,8 @@ golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -657,8 +657,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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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/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=
|
||||
@@ -726,8 +726,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -769,12 +769,12 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI=
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
|
||||
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.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
|
||||
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
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=
|
||||
@@ -783,8 +783,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.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
|
||||
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
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=
|
||||
@@ -793,8 +793,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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
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=
|
||||
|
||||
@@ -98,6 +98,34 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) {
|
||||
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
|
||||
}
|
||||
|
||||
// NewActorStateStore creates a minimal actor host that can read and write actor state directly, without joining the cluster or binding a network port.
|
||||
// It's meant for short-lived contexts such as CLI commands that need to persist actor state (for example, one-time access tokens) without running the full actor host.
|
||||
// The returned host must NOT be Run(): only direct state operations (Get/Set/Delete on state) are supported, and they require the actor state tables to already exist, which is the case whenever the server has run at least once against this database.
|
||||
func NewActorStateStore(db *gorm.DB, pg *pgxpool.Pool) (*local.Host, error) {
|
||||
opts := &NewActorsOpts{DB: db, Postgres: pg}
|
||||
if pg == nil {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
|
||||
}
|
||||
opts.SQLite = sqlDB
|
||||
}
|
||||
|
||||
providerOpt, err := opts.getProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return local.NewHost(
|
||||
// The address is required by the host but never bound, since the host is not Run
|
||||
local.WithAddress("127.0.0.1:1"),
|
||||
local.WithLogger(slog.Default().With("scope", "actor-state-store")),
|
||||
// The health-check deadline only needs to exceed the provider's query timeout to pass validation
|
||||
local.WithHostHealthCheckDeadline(90*time.Second),
|
||||
providerOpt,
|
||||
)
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
|
||||
switch {
|
||||
case o.Postgres != nil && o.SQLite != nil:
|
||||
|
||||
@@ -106,6 +106,9 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
services = append(services, svc.appLockService.RunRenewal)
|
||||
|
||||
// Migrate the pre-actor signup tokens into their actors, once the actor host is ready
|
||||
services = append(services, actorsReady.Await(svc.userSignUpModule.RunSignupTokenMigration))
|
||||
|
||||
// Acquire the lock from the app lock service
|
||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func init() {
|
||||
registerTestControllers = []func(api huma.API, db *gorm.DB, svc *services){
|
||||
func(api huma.API, db *gorm.DB, svc *services) {
|
||||
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
|
||||
testService, err := service.NewTestService(db, svc.actors, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
|
||||
@@ -99,7 +99,7 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) (shutdown
|
||||
Level: level,
|
||||
})
|
||||
} else {
|
||||
handler = tint.NewHandler(os.Stdout, &tint.Options{
|
||||
handler = tint.NewTextHandler(os.Stdout, &tint.Options{
|
||||
TimeFormat: time.Stamp,
|
||||
Level: level,
|
||||
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
|
||||
|
||||
@@ -159,7 +159,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate),
|
||||
)
|
||||
controller.NewOidcController(api, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
|
||||
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule)
|
||||
controller.NewAppConfigController(api, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
controller.NewAppImagesController(api, authMiddleware, fileSizeLimitMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(api, svc.auditLogService, authMiddleware)
|
||||
@@ -172,6 +172,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
authMiddleware.Huma(api),
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitSignup),
|
||||
)
|
||||
svc.oneTimeAccessModule.RegisterRoutes(api,
|
||||
authMiddleware.Huma(api),
|
||||
authMiddleware.WithAdminNotRequired().Huma(api),
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessToken),
|
||||
rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessEmail),
|
||||
)
|
||||
|
||||
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
|
||||
svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"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/onetimeaccess"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
|
||||
@@ -21,28 +22,29 @@ import (
|
||||
)
|
||||
|
||||
type services struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
auditLogService *service.AuditLogService
|
||||
jwtService *service.JwtService
|
||||
scimService *service.ScimService
|
||||
userService *service.UserService
|
||||
customClaimService *service.CustomClaimService
|
||||
oidcService *service.OidcService
|
||||
userGroupService *service.UserGroupService
|
||||
ldapService *service.LdapService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
auditLogService *service.AuditLogService
|
||||
jwtService *service.JwtService
|
||||
scimService *service.ScimService
|
||||
userService *service.UserService
|
||||
customClaimService *service.CustomClaimService
|
||||
oidcService *service.OidcService
|
||||
userGroupService *service.UserGroupService
|
||||
ldapService *service.LdapService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
|
||||
apiKeyModule *apikey.Module
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
apiModule *api.Module
|
||||
apiKeyModule *apikey.Module
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
oneTimeAccessModule *onetimeaccess.Module
|
||||
apiModule *api.Module
|
||||
actors *local.Host
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -56,7 +58,9 @@ func initServices(
|
||||
fileStorage storage.FileStorage,
|
||||
scheduler *job.Scheduler,
|
||||
) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
svc = &services{
|
||||
actors: actors,
|
||||
}
|
||||
|
||||
// Init the app config service
|
||||
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
|
||||
@@ -132,14 +136,30 @@ func initServices(
|
||||
return nil, fmt.Errorf("failed to create API key module: %w", err)
|
||||
}
|
||||
|
||||
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
|
||||
svc.userSignUpModule, err = usersignup.New(usersignup.Dependencies{
|
||||
DB: db,
|
||||
Actors: actors,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
UserCreator: svc.userService,
|
||||
AppConfig: svc.appConfigService,
|
||||
})
|
||||
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user signup module: %w", err)
|
||||
}
|
||||
|
||||
svc.oneTimeAccessModule, err = onetimeaccess.New(onetimeaccess.Dependencies{
|
||||
DB: db,
|
||||
Actors: actors,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
UserProvider: svc.userService,
|
||||
EmailSender: service.NewOneTimeAccessEmailSender(svc.emailService),
|
||||
AppConfig: svc.appConfigService,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create one-time access module: %w", err)
|
||||
}
|
||||
|
||||
svc.versionService = service.NewVersionService(httpClient)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"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/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
|
||||
)
|
||||
|
||||
var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
@@ -24,57 +24,47 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
userArg := args[0]
|
||||
|
||||
// Connect to the database
|
||||
db, _, err := bootstrap.NewDatabase(cmd.Context())
|
||||
db, pg, err := bootstrap.NewDatabase(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the access token
|
||||
var oneTimeAccessToken *model.OneTimeAccessToken
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// Load the user to retrieve the user ID
|
||||
var user model.User
|
||||
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer queryCancel()
|
||||
txErr := tx.
|
||||
WithContext(queryCtx).
|
||||
Where("username = ? OR email = ?", userArg, userArg).
|
||||
First(&user).
|
||||
Error
|
||||
switch {
|
||||
case errors.Is(txErr, gorm.ErrRecordNotFound):
|
||||
return errors.New("user not found")
|
||||
case txErr != nil:
|
||||
return fmt.Errorf("failed to query for user: %w", txErr)
|
||||
case user.ID == "":
|
||||
return errors.New("invalid user loaded: ID is empty")
|
||||
}
|
||||
// Load the user to retrieve the user ID
|
||||
var user model.User
|
||||
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer queryCancel()
|
||||
err = db.
|
||||
WithContext(queryCtx).
|
||||
Where("username = ? OR email = ?", userArg, userArg).
|
||||
First(&user).
|
||||
Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return errors.New("user not found")
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to query for user: %w", err)
|
||||
case user.ID == "":
|
||||
return errors.New("invalid user loaded: ID is empty")
|
||||
}
|
||||
|
||||
// Create a new access token that expires in 1 hour
|
||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
|
||||
if txErr != nil {
|
||||
return fmt.Errorf("failed to generate access token: %w", txErr)
|
||||
}
|
||||
|
||||
queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer queryCancel()
|
||||
txErr = tx.
|
||||
WithContext(queryCtx).
|
||||
Create(oneTimeAccessToken).
|
||||
Error
|
||||
if txErr != nil {
|
||||
return fmt.Errorf("failed to save access token: %w", txErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
// One-time access tokens are stored in the actor state store
|
||||
// The CLI doesn't run the full actor host, so it uses a minimal state store to persist the token directly
|
||||
actorStore, err := bootstrap.NewActorStateStore(db, pg)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to initialize the actor state store: %w", err)
|
||||
}
|
||||
|
||||
// Create a new access token that expires in 1 hour
|
||||
tokenCtx, tokenCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer tokenCancel()
|
||||
token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, user.ID, time.Hour, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create access token: %w", err)
|
||||
}
|
||||
|
||||
// Print the result
|
||||
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
|
||||
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
|
||||
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, token)
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -23,6 +23,11 @@ type oidcClientIDInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type oidcClientSecretInput struct {
|
||||
ID string `path:"id"`
|
||||
Body *dto.OidcClientSecretDto `required:"false"`
|
||||
}
|
||||
|
||||
type oidcClientListInput struct {
|
||||
utils.ListRequestOptions
|
||||
Search string `query:"search" required:"false"`
|
||||
@@ -294,8 +299,12 @@ func (oc *OidcController) updateClientHandler(ctx context.Context, input *oidcCl
|
||||
return mapOIDCClient(client)
|
||||
}
|
||||
|
||||
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientIDInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID)
|
||||
func (oc *OidcController) createClientSecretHandler(ctx context.Context, input *oidcClientSecretInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
body := dto.OidcClientSecretDto{}
|
||||
if input.Body != nil {
|
||||
body = *input.Body
|
||||
}
|
||||
secret, err := oc.oidcService.CreateClientSecret(ctx, input.ID, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,20 +10,15 @@ import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
|
||||
)
|
||||
|
||||
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
|
||||
type userListInput struct {
|
||||
utils.ListRequestOptions
|
||||
Search string `query:"search" required:"false"`
|
||||
@@ -65,37 +60,10 @@ type currentUserPictureUploadInput struct {
|
||||
RawBody huma.MultipartFormFiles[userPictureUploadForm]
|
||||
}
|
||||
|
||||
type oneTimeAccessOwnInput struct {
|
||||
Body dto.OneTimeAccessTokenCreateDto
|
||||
}
|
||||
|
||||
type oneTimeAccessAdminInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.OneTimeAccessTokenCreateDto
|
||||
}
|
||||
|
||||
type oneTimeAccessEmailAdminInput struct {
|
||||
ID string `path:"id"`
|
||||
Body dto.OneTimeAccessEmailAsAdminDto
|
||||
}
|
||||
|
||||
type oneTimeAccessEmailInput struct {
|
||||
Body dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||
}
|
||||
|
||||
type oneTimeAccessExchangeInput struct {
|
||||
Token string `path:"token"`
|
||||
}
|
||||
|
||||
type emailVerificationInput struct {
|
||||
Body dto.EmailVerificationDto
|
||||
}
|
||||
|
||||
type userCookieOutput struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
Body dto.UserDto
|
||||
}
|
||||
|
||||
type userPictureOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
ContentLength int64 `header:"Content-Length"`
|
||||
@@ -104,8 +72,8 @@ type userPictureOutput struct {
|
||||
}
|
||||
|
||||
// NewUserController registers user management endpoints
|
||||
func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *appconfig.AppConfigService) {
|
||||
controller := &UserController{userService: userService, oneTimeAccessService: oneTimeAccessService, webAuthnService: webAuthnService, appConfigService: appConfigService}
|
||||
func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, appConfigService *appconfig.AppConfigService, userService *service.UserService, webAuthnService *webauthn.Module) {
|
||||
controller := &UserController{appConfigService: appConfigService, userService: userService, webAuthnService: webAuthnService}
|
||||
adminAuth := authMiddleware.Huma(api)
|
||||
userAuth := authMiddleware.WithAdminNotRequired().Huma(api)
|
||||
|
||||
@@ -226,50 +194,6 @@ func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware,
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.updateCurrentUserProfilePictureHandler, userAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-own-one-time-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/users/me/one-time-access-token",
|
||||
Summary: "Create one-time access token for current user",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, controller.createOwnOneTimeAccessTokenHandler, userAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-user-one-time-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/users/{id}/one-time-access-token",
|
||||
Summary: "Create one-time access token for user",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, controller.createAdminOneTimeAccessTokenHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "request-user-one-time-access-email",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/users/{id}/one-time-access-email",
|
||||
Summary: "Request one-time access email for user",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.requestOneTimeAccessEmailAsAdminHandler, adminAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "exchange-one-time-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/one-time-access-token/{token}",
|
||||
Summary: "Exchange one-time access token",
|
||||
Tags: []string{"Users"},
|
||||
}, controller.exchangeOneTimeAccessTokenHandler, httpapi.WithMiddleware(rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessToken)))
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "request-one-time-access-email",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/one-time-access-email",
|
||||
Summary: "Request one-time access email",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, controller.requestOneTimeAccessEmailAsUnauthenticatedUserHandler, httpapi.WithMiddleware(rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessEmail)))
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "reset-user-profile-picture",
|
||||
Method: http.MethodDelete,
|
||||
@@ -308,10 +232,9 @@ func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware,
|
||||
}
|
||||
|
||||
type UserController struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
userService *service.UserService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
webAuthnService *webauthn.Module
|
||||
appConfigService *appconfig.AppConfigService
|
||||
userService *service.UserService
|
||||
webAuthnService *webauthn.Module
|
||||
}
|
||||
|
||||
func (uc *UserController) getUserGroupsHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[[]dto.UserGroupDto], error) {
|
||||
@@ -460,83 +383,6 @@ func (uc *UserController) updateProfilePicture(ctx context.Context, userID strin
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (uc *UserController) createOwnOneTimeAccessTokenHandler(ctx context.Context, _ *oneTimeAccessOwnInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
return uc.createOneTimeAccessToken(ctx, httpapi.UserID(ctx), defaultOneTimeAccessTokenDuration)
|
||||
}
|
||||
|
||||
func (uc *UserController) createAdminOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessAdminInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
ttl := input.Body.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
return uc.createOneTimeAccessToken(ctx, input.ID, ttl)
|
||||
}
|
||||
|
||||
func (uc *UserController) createOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
if userID == "" {
|
||||
return nil, &common.UserIdNotProvidedError{}
|
||||
}
|
||||
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(ctx, userID, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"token": token}}, nil
|
||||
}
|
||||
|
||||
func (uc *UserController) requestOneTimeAccessEmailAsUnauthenticatedUserHandler(ctx context.Context, input *oneTimeAccessEmailInput) (*emptyOutputWithCookie, error) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx, dbConfig, input.Body.Email, input.Body.RedirectPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &emptyOutputWithCookie{SetCookie: []http.Cookie{*cookie.NewDeviceTokenCookie(deviceToken)}}, nil
|
||||
}
|
||||
|
||||
func (uc *UserController) requestOneTimeAccessEmailAsAdminHandler(ctx context.Context, input *oneTimeAccessEmailAdminInput) (*httpapi.EmptyOutput, error) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
ttl := input.Body.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
if err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(ctx, dbConfig, input.ID, ttl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessExchangeInput) (*userCookieOutput, error) {
|
||||
dbConfig, err := uc.appConfigService.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if len(input.Token) != 6 && len(input.Token) != 16 {
|
||||
return nil, &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
deviceToken := ""
|
||||
if requestCookie, err := httpapi.Cookie(ctx, cookie.DeviceTokenCookieName); err == nil {
|
||||
deviceToken = requestCookie.Value
|
||||
}
|
||||
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(ctx, dbConfig, input.Token, deviceToken, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var output dto.UserDto
|
||||
if err := dto.MapStruct(user, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
|
||||
return &userCookieOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil
|
||||
}
|
||||
|
||||
func (uc *UserController) updateUserGroups(ctx context.Context, input *userGroupsInput) (*httpapi.BodyOutput[dto.UserDto], error) {
|
||||
user, err := uc.userService.UpdateUserGroups(ctx, input.ID, input.Body.UserGroupIds)
|
||||
if err != nil {
|
||||
@@ -578,10 +424,6 @@ func (uc *UserController) verifyEmailHandler(ctx context.Context, input *emailVe
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
type emptyOutputWithCookie struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
}
|
||||
|
||||
func mapUser(user model.User) (*httpapi.BodyOutput[dto.UserDto], error) {
|
||||
var output dto.UserDto
|
||||
if err := dto.MapStruct(user, &output); err != nil {
|
||||
|
||||
@@ -62,6 +62,10 @@ type OidcClientCreateDto struct {
|
||||
ID string `json:"id" required:"false" minLength:"2" maxLength:"128" pattern:"^[a-zA-Z0-9._-]+$" patternDescription:"letters, numbers, dots, underscores, and hyphens"`
|
||||
}
|
||||
|
||||
type OidcClientSecretDto struct {
|
||||
Secret string `json:"secret" required:"false" minLength:"16" pattern:"^[\\x20-\\x7E]+$" patternDescription:"printable ASCII characters" binding:"omitempty,min=16,printascii"`
|
||||
}
|
||||
|
||||
type OidcClientCredentialsDto struct {
|
||||
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
ID string `json:"id" required:"false" format:"uuid"`
|
||||
Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"`
|
||||
Email *string `json:"email" required:"false" format:"email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified" required:"false"`
|
||||
|
||||
@@ -23,6 +23,18 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "with custom id",
|
||||
input: UserCreateDto{
|
||||
ID: "e8b81981-1c92-46e3-bfbf-07096560e6bb",
|
||||
Username: "testuser",
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "missing username",
|
||||
input: UserCreateDto{
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"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/usersignup"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
|
||||
)
|
||||
|
||||
@@ -34,8 +33,6 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
|
||||
return errors.Join(
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
@@ -61,32 +58,6 @@ func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSignupTokens deletes signup tokens that have expired
|
||||
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
|
||||
count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clean expired signup tokens: %w", err)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired signup tokens", slog.Int64("count", count))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions.
|
||||
func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error {
|
||||
count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package model
|
||||
|
||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string
|
||||
DeviceToken *string
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
167
backend/internal/onetimeaccess/actor.go
Normal file
167
backend/internal/onetimeaccess/actor.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// One-time access tokens are stored entirely in the actor state store.
|
||||
// Each token is its own actor, whose actor ID is the token value itself.
|
||||
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job).
|
||||
|
||||
// TokenActorType is the actor type for the one-time access token actor
|
||||
const TokenActorType = "OneTimeAccessToken"
|
||||
|
||||
// Methods exposed by the one-time access token actor
|
||||
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates and deletes the token), and only afterwards performing the remaining work.
|
||||
// On failure, the caller compensates by restoring the token via the "restore" method as best-effort.
|
||||
const (
|
||||
// TokenMethodRestore stores a token's state, and is also how a consumed token is put back
|
||||
TokenMethodRestore = "restore"
|
||||
|
||||
tokenMethodConsume = "consume"
|
||||
)
|
||||
|
||||
// tokenConsumeStatus is the outcome of a "consume" invocation.
|
||||
type tokenConsumeStatus string
|
||||
|
||||
const (
|
||||
// tokenConsumeOK indicates the token was valid and has been consumed
|
||||
tokenConsumeOK tokenConsumeStatus = "ok"
|
||||
// tokenConsumeNotFound indicates the token doesn't exist (or has expired)
|
||||
tokenConsumeNotFound tokenConsumeStatus = "not_found"
|
||||
// tokenConsumeDeviceMismatch indicates the provided device token doesn't match
|
||||
tokenConsumeDeviceMismatch tokenConsumeStatus = "device_mismatch"
|
||||
)
|
||||
|
||||
// TokenState is the persisted state of a one-time access token actor.
|
||||
// The token value itself is the actor's ID, so it isn't repeated here.
|
||||
type TokenState struct {
|
||||
UserID string
|
||||
DeviceToken *string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// tokenConsumeRequest is the payload for the "consume" method
|
||||
type tokenConsumeRequest struct {
|
||||
DeviceToken string
|
||||
}
|
||||
|
||||
// tokenConsumeResponse is the response of the "consume" method
|
||||
type tokenConsumeResponse struct {
|
||||
Status tokenConsumeStatus
|
||||
// State is included only when Status is "ok", so the caller can restore it if a later step fails
|
||||
State TokenState
|
||||
}
|
||||
|
||||
// tokenActor is the actor that manages a single one-time access token
|
||||
type tokenActor struct {
|
||||
log *slog.Logger
|
||||
client actor.Client[TokenState]
|
||||
}
|
||||
|
||||
// NewTokenActor allocates a new one-time access token actor
|
||||
// It satisfies actor.Factory
|
||||
func NewTokenActor(actorID string, service *actor.Service) actor.Actor {
|
||||
return &tokenActor{
|
||||
log: slog.With(
|
||||
slog.String("scope", "actor"),
|
||||
slog.String("actorType", TokenActorType),
|
||||
),
|
||||
client: actor.NewActorClient[TokenState](TokenActorType, actorID, service),
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke implements actor.ActorInvoke
|
||||
func (a *tokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
switch method {
|
||||
case tokenMethodConsume:
|
||||
return a.consume(parentCtx, data)
|
||||
case TokenMethodRestore:
|
||||
return nil, a.restore(parentCtx, data)
|
||||
default:
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
}
|
||||
|
||||
// consume atomically validates the token and, if valid, deletes it.
|
||||
func (a *tokenActor) consume(parentCtx context.Context, data actor.Envelope) (tokenConsumeResponse, error) {
|
||||
var req tokenConsumeRequest
|
||||
if data != nil {
|
||||
err := data.Decode(&req)
|
||||
if err != nil {
|
||||
return tokenConsumeResponse{}, fmt.Errorf("request body is not valid for method '%s': %w", tokenMethodConsume, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return tokenConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// An empty UserID means there's no state: the token doesn't exist (or its state already expired and was purged)
|
||||
if state.UserID == "" || state.ExpiresAt.Before(time.Now()) {
|
||||
return tokenConsumeResponse{
|
||||
Status: tokenConsumeNotFound,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If the token requires a device token, it must match
|
||||
// A mismatch leaves the token untouched, mirroring the pre-actor behavior
|
||||
if state.DeviceToken != nil && req.DeviceToken != *state.DeviceToken {
|
||||
return tokenConsumeResponse{
|
||||
Status: tokenConsumeDeviceMismatch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// The token is valid: delete the state (one-time use)
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.DeleteState(ctx)
|
||||
if err != nil {
|
||||
return tokenConsumeResponse{}, fmt.Errorf("error deleting actor state: %w", err)
|
||||
}
|
||||
|
||||
return tokenConsumeResponse{
|
||||
Status: tokenConsumeOK,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// restore re-creates the token state, used to compensate when a step after consuming the token fails.
|
||||
func (a *tokenActor) restore(parentCtx context.Context, data actor.Envelope) error {
|
||||
if data == nil {
|
||||
return fmt.Errorf("request body is empty for method '%s'", TokenMethodRestore)
|
||||
}
|
||||
|
||||
var state TokenState
|
||||
err := data.Decode(&state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method '%s': %w", TokenMethodRestore, err)
|
||||
}
|
||||
|
||||
// If the token has meanwhile expired, there's nothing to restore
|
||||
ttl := time.Until(state.ExpiresAt)
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, state, &actor.SetStateOpts{
|
||||
TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +1,35 @@
|
||||
package dto
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
type tokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" required:"false"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||
type emailAsUnauthenticatedUserDto struct {
|
||||
Email string `json:"email" required:"true" format:"email" unorm:"nfc"`
|
||||
RedirectPath string `json:"redirectPath" required:"false"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsAdminDto struct {
|
||||
type emailAsAdminDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" required:"false"`
|
||||
}
|
||||
|
||||
func (d *OneTimeAccessTokenCreateDto) Resolve(huma.Context) []error {
|
||||
func (d *tokenCreateDto) Resolve(huma.Context) []error {
|
||||
return resolveTTL(d.TTL)
|
||||
}
|
||||
|
||||
func (d *OneTimeAccessEmailAsAdminDto) Resolve(huma.Context) []error {
|
||||
func (d *emailAsAdminDto) Resolve(huma.Context) []error {
|
||||
return resolveTTL(d.TTL)
|
||||
}
|
||||
|
||||
func resolveTTL(ttl utils.JSONDuration) []error {
|
||||
if ValidateTTL(ttl) {
|
||||
if dto.ValidateTTL(ttl) {
|
||||
return nil
|
||||
}
|
||||
return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}}
|
||||
132
backend/internal/onetimeaccess/handler.go
Normal file
132
backend/internal/onetimeaccess/handler.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
const defaultTokenDuration = 15 * time.Minute
|
||||
|
||||
type tokenCreateInput struct {
|
||||
ID string `path:"id"`
|
||||
Body tokenCreateDto
|
||||
}
|
||||
|
||||
type ownTokenCreateInput struct {
|
||||
Body tokenCreateDto
|
||||
}
|
||||
|
||||
type emailAsUnauthenticatedUserInput struct {
|
||||
Body emailAsUnauthenticatedUserDto
|
||||
}
|
||||
|
||||
type emailAsAdminInput struct {
|
||||
ID string `path:"id"`
|
||||
Body emailAsAdminDto
|
||||
}
|
||||
|
||||
type tokenExchangeInput struct {
|
||||
Token string `path:"token"`
|
||||
}
|
||||
|
||||
type cookieOutput struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
}
|
||||
|
||||
type userOutput struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
Body dto.UserDto
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
appConfig AppConfigResolver
|
||||
}
|
||||
|
||||
func newHandler(service *Service, appConfig AppConfigResolver) *handler {
|
||||
return &handler{service: service, appConfig: appConfig}
|
||||
}
|
||||
|
||||
func (h *handler) createOwnToken(ctx context.Context, _ *ownTokenCreateInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
return h.createToken(ctx, httpapi.UserID(ctx), defaultTokenDuration)
|
||||
}
|
||||
|
||||
func (h *handler) createTokenForUser(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
ttl := input.Body.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTokenDuration
|
||||
}
|
||||
return h.createToken(ctx, input.ID, ttl)
|
||||
}
|
||||
|
||||
func (h *handler) createToken(ctx context.Context, userID string, ttl time.Duration) (*httpapi.BodyOutput[map[string]string], error) {
|
||||
if userID == "" {
|
||||
return nil, &common.UserIdNotProvidedError{}
|
||||
}
|
||||
token, err := h.service.CreateToken(ctx, userID, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"token": token}}, nil
|
||||
}
|
||||
|
||||
func (h *handler) requestEmailAsUnauthenticatedUser(ctx context.Context, input *emailAsUnauthenticatedUserInput) (*cookieOutput, error) {
|
||||
dbConfig, err := h.appConfig.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
deviceToken, err := h.service.RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx, dbConfig, input.Body.Email, input.Body.RedirectPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cookieOutput{SetCookie: []http.Cookie{*cookie.NewDeviceTokenCookie(deviceToken)}}, nil
|
||||
}
|
||||
|
||||
func (h *handler) requestEmailAsAdmin(ctx context.Context, input *emailAsAdminInput) (*httpapi.EmptyOutput, error) {
|
||||
dbConfig, err := h.appConfig.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
ttl := input.Body.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTokenDuration
|
||||
}
|
||||
if err := h.service.RequestOneTimeAccessEmailAsAdmin(ctx, dbConfig, input.ID, ttl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
func (h *handler) exchangeToken(ctx context.Context, input *tokenExchangeInput) (*userOutput, error) {
|
||||
dbConfig, err := h.appConfig.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
if len(input.Token) != 6 && len(input.Token) != 16 {
|
||||
return nil, &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
deviceToken := ""
|
||||
if requestCookie, err := httpapi.Cookie(ctx, cookie.DeviceTokenCookieName); err == nil {
|
||||
deviceToken = requestCookie.Value
|
||||
}
|
||||
user, token, err := h.service.ExchangeToken(ctx, dbConfig, input.Token, deviceToken, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var output dto.UserDto
|
||||
if err := dto.MapStruct(user, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
|
||||
return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil
|
||||
}
|
||||
126
backend/internal/onetimeaccess/module.go
Normal file
126
backend/internal/onetimeaccess/module.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"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/utils/email"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
// EmailData is the data rendered in the one-time access email
|
||||
type EmailData struct {
|
||||
Code string
|
||||
LoginLink string
|
||||
LoginLinkWithCode string
|
||||
ExpirationString string
|
||||
}
|
||||
|
||||
// EmailSender sends the one-time access email
|
||||
type EmailSender interface {
|
||||
SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data EmailData) error
|
||||
}
|
||||
|
||||
type TokenService interface {
|
||||
GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (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 UserProvider interface {
|
||||
GetUser(ctx context.Context, userID string) (model.User, error)
|
||||
}
|
||||
|
||||
// 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 Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
UserProvider UserProvider
|
||||
EmailSender EmailSender
|
||||
AppConfig AppConfigResolver
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) (*Module, error) {
|
||||
// Register the actor that manages a one-time access token
|
||||
// Each token is its own actor, whose actor ID is the token's value
|
||||
err := deps.Actors.RegisterActor(TokenActorType, NewTokenActor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", TokenActorType, err)
|
||||
}
|
||||
|
||||
service := newService(deps, deps.Actors.Service())
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service, deps.AppConfig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the one-time access token endpoints
|
||||
// auth guards the admin routes and ownAuth the current user's own token, while the rate limiters throttle the public exchange and email endpoints
|
||||
func (m *Module) RegisterRoutes(api huma.API, auth, ownAuth func(*huma.Operation), exchangeRateLimit, emailRateLimit func(huma.Context, func(huma.Context))) {
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-own-one-time-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/users/me/one-time-access-token",
|
||||
Summary: "Create one-time access token for current user",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, m.handler.createOwnToken, ownAuth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "create-user-one-time-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/users/{id}/one-time-access-token",
|
||||
Summary: "Create one-time access token for user",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusCreated,
|
||||
}, m.handler.createTokenForUser, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "request-user-one-time-access-email",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/users/{id}/one-time-access-email",
|
||||
Summary: "Request one-time access email for user",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.handler.requestEmailAsAdmin, auth)
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "exchange-one-time-access-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/one-time-access-token/{token}",
|
||||
Summary: "Exchange one-time access token",
|
||||
Tags: []string{"Users"},
|
||||
}, m.handler.exchangeToken, httpapi.WithMiddleware(exchangeRateLimit))
|
||||
|
||||
httpapi.Register(api, huma.Operation{
|
||||
OperationID: "request-one-time-access-email",
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/one-time-access-email",
|
||||
Summary: "Request one-time access email",
|
||||
Tags: []string{"Users"},
|
||||
DefaultStatus: http.StatusNoContent,
|
||||
}, m.handler.requestEmailAsUnauthenticatedUser, httpapi.WithMiddleware(emailRateLimit))
|
||||
}
|
||||
283
backend/internal/onetimeaccess/service.go
Normal file
283
backend/internal/onetimeaccess/service.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
// authenticationMethodOneTimePassword identifies one-time password/code authentication
|
||||
// It must match the value emitted by the JWT service in the access token's "amr" claim
|
||||
const authenticationMethodOneTimePassword = "otp"
|
||||
|
||||
// TokenStore is the minimal interface needed to persist a one-time access token in the actor state store.
|
||||
// It's satisfied by both *actor.Service (used by the running application) and *local.Host (used by CLI commands, which don't run the full actor host).
|
||||
type TokenStore interface {
|
||||
SetState(ctx context.Context, actorType string, actorID string, state any, opts *actor.SetStateOpts) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
actorService *actor.Service
|
||||
userProvider UserProvider
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
emailSender EmailSender
|
||||
}
|
||||
|
||||
func newService(deps Dependencies, actorService *actor.Service) *Service {
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
actorService: actorService,
|
||||
userProvider: deps.UserProvider,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
emailSender: deps.EmailSender,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, ttl time.Duration) error {
|
||||
if !dbConfig.EmailOneTimeAccessAsAdminEnabled.IsTrue() {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
|
||||
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
|
||||
return "", &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
var userId string
|
||||
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Do not return error if user not found to prevent email enumeration
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if deviceToken == nil {
|
||||
return "", errors.New("device token expected but not returned")
|
||||
}
|
||||
|
||||
return *deviceToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
|
||||
// Load the user to ensure it exists and has an email address
|
||||
user, err := s.userProvider.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return nil, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
oneTimeAccessToken, deviceToken, err := StoreToken(ctx, s.actorService, user.ID, ttl, withDeviceToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
link := common.EnvConfig.AppURL + "/lc"
|
||||
linkWithCode := link + "/" + oneTimeAccessToken
|
||||
|
||||
// Add redirect path to the link
|
||||
if strings.HasPrefix(redirectPath, "/") {
|
||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
|
||||
}
|
||||
|
||||
innerErr := s.emailSender.SendOneTimeAccessEmail(innerCtx, dbConfig, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
}, EmailData{
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
ExpirationString: utils.DurationToString(ttl),
|
||||
})
|
||||
if innerErr != nil {
|
||||
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", innerErr), slog.String("address", *user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return deviceToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
||||
// Load the user to ensure it exists
|
||||
_, err = s.userProvider.GetUser(ctx, userID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", &common.UserNotFoundError{}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, _, err = StoreToken(ctx, s.actorService, userID, ttl, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
|
||||
// Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it.
|
||||
// It must happen outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite.
|
||||
res, err := s.actorService.Invoke(ctx, TokenActorType, token, tokenMethodConsume, tokenConsumeRequest{
|
||||
DeviceToken: deviceToken,
|
||||
})
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error invoking one-time access token actor: %w", err)
|
||||
}
|
||||
|
||||
var consumeRes tokenConsumeResponse
|
||||
err = res.Decode(&consumeRes)
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error decoding one-time access token actor response: %w", err)
|
||||
}
|
||||
|
||||
switch consumeRes.Status {
|
||||
case tokenConsumeNotFound:
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
case tokenConsumeDeviceMismatch:
|
||||
return model.User{}, "", &common.DeviceCodeInvalid{}
|
||||
case tokenConsumeOK:
|
||||
// All good, continue below
|
||||
default:
|
||||
return model.User{}, "", fmt.Errorf("unexpected status from one-time access token actor: %s", consumeRes.Status)
|
||||
}
|
||||
|
||||
// The token has now been consumed. From this point on, if we hit an error we compensate by restoring the token (this is best-effort).
|
||||
user, accessToken, err := s.completeTokenExchange(ctx, dbConfig, consumeRes.State, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
s.restoreToken(ctx, token, consumeRes.State)
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// completeTokenExchange performs the work that follows consuming a token: loading the user, validating it, and issuing an access token.
|
||||
func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig.AppConfigModel, state TokenState, ipAddress, userAgent string) (model.User, string, error) {
|
||||
var user model.User
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("id = ?", state.UserID).
|
||||
First(&user).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
} else if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
accessToken, err := s.signer.GenerateAccessToken(
|
||||
user,
|
||||
authenticationMethodOneTimePassword,
|
||||
dbConfig.SessionDuration.AsDurationMinutes(),
|
||||
)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
s.auditLog.Create(
|
||||
ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
|
||||
ipAddress, userAgent,
|
||||
user.ID,
|
||||
model.AuditLogData{},
|
||||
s.db,
|
||||
)
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// restoreToken restores a token that was consumed but whose exchange could not be completed.
|
||||
// It's a best-effort compensation: if it fails (or the process crashes before it runs) we accept that the token was consumed unnecessarily.
|
||||
func (s *Service) restoreToken(parentCtx context.Context, token string, state TokenState) {
|
||||
// Use a context that is not canceled when the original request ends
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.actorService.Invoke(ctx, TokenActorType, token, TokenMethodRestore, state)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to restore one-time access token after a failed exchange", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// StoreToken generates a new one-time access token and persists it in the actor state store, with a TTL matching its lifetime.
|
||||
// It returns the token value and, when requested, the associated device token.
|
||||
func StoreToken(ctx context.Context, store TokenStore, userID string, ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
|
||||
token, deviceToken, err = generateToken(ttl, withDeviceToken)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
state := TokenState{
|
||||
UserID: userID,
|
||||
DeviceToken: deviceToken,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
|
||||
err = store.SetState(ctx, TokenActorType, token, state, &actor.SetStateOpts{TTL: ttl})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error saving one-time access token state: %w", err)
|
||||
}
|
||||
|
||||
return token, deviceToken, nil
|
||||
}
|
||||
|
||||
// generateToken generates the random token value (and optional device token) for a one-time access token.
|
||||
func generateToken(ttl time.Duration, withDeviceToken bool) (token string, deviceToken *string, err error) {
|
||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||
tokenLength := 16
|
||||
if ttl <= 15*time.Minute {
|
||||
tokenLength = 6
|
||||
}
|
||||
|
||||
token, err = utils.GenerateRandomUnambiguousString(tokenLength)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if withDeviceToken {
|
||||
dt, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
deviceToken = &dt
|
||||
}
|
||||
|
||||
return token, deviceToken, nil
|
||||
}
|
||||
172
backend/internal/onetimeaccess/service_test.go
Normal file
172
backend/internal/onetimeaccess/service_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package onetimeaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"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/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
type fakeSigner struct{}
|
||||
|
||||
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
|
||||
return "access-token", nil
|
||||
}
|
||||
|
||||
type fakeAuditLogger struct {
|
||||
events []model.AuditLogEvent
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
|
||||
f.events = append(f.events, event)
|
||||
return model.AuditLog{}, true
|
||||
}
|
||||
|
||||
type fakeUserProvider struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (f fakeUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
|
||||
var user model.User
|
||||
err := f.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
|
||||
return user, err
|
||||
}
|
||||
|
||||
type fakeEmailSender struct{}
|
||||
|
||||
func (fakeEmailSender) SendOneTimeAccessEmail(_ context.Context, _ *appconfig.AppConfigModel, _ email.Address, _ EmailData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newServiceForTest sets up a Service backed by an in-memory test actor host, and returns it together with the host and the audit logger it records into
|
||||
func newServiceForTest(t *testing.T, db *gorm.DB) (*Service, *local.Host, *fakeAuditLogger) {
|
||||
t.Helper()
|
||||
|
||||
auditLog := &fakeAuditLogger{}
|
||||
|
||||
var svc *Service
|
||||
host := testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
|
||||
err := h.RegisterActor(TokenActorType, NewTokenActor)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc = newService(Dependencies{
|
||||
DB: db,
|
||||
Signer: fakeSigner{},
|
||||
AuditLog: auditLog,
|
||||
UserProvider: fakeUserProvider{db: db},
|
||||
EmailSender: fakeEmailSender{},
|
||||
}, h.Service())
|
||||
})
|
||||
require.NotNil(t, svc)
|
||||
|
||||
return svc, host, auditLog
|
||||
}
|
||||
|
||||
func TestExchangeTokenSuccess(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc, host, auditLog := newServiceForTest(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "enabled-user"},
|
||||
Username: "enabled-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, exchangedUser.ID)
|
||||
require.NotEmpty(t, accessToken)
|
||||
|
||||
// The token must have been consumed
|
||||
var state TokenState
|
||||
err = host.GetState(t.Context(), TokenActorType, token, &state)
|
||||
require.ErrorIs(t, err, actor.ErrStateNotFound)
|
||||
|
||||
// A sign-in audit log must have been created
|
||||
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events)
|
||||
}
|
||||
|
||||
func TestExchangeTokenInvalidToken(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc, _, _ := newServiceForTest(t, db)
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
_, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
|
||||
|
||||
var invalidErr *common.TokenInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidErr)
|
||||
}
|
||||
|
||||
func TestExchangeTokenDeviceMismatch(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc, host, _ := newServiceForTest(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "device-user"},
|
||||
Username: "device-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
// Store a token that requires a device token
|
||||
token, deviceToken, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, deviceToken)
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
_, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
|
||||
|
||||
var deviceErr *common.DeviceCodeInvalid
|
||||
require.ErrorAs(t, err, &deviceErr)
|
||||
|
||||
// The token must not have been consumed on a device-token mismatch
|
||||
var state TokenState
|
||||
err = host.GetState(t.Context(), TokenActorType, token, &state)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, state.UserID)
|
||||
}
|
||||
|
||||
func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc, host, auditLog := newServiceForTest(t, db)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "disabled-user"},
|
||||
Username: "disabled-user",
|
||||
Disabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
// Store a one-time access token for the disabled user in the actor state store
|
||||
token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")
|
||||
|
||||
var userDisabledErr *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &userDisabledErr)
|
||||
require.Empty(t, exchangedUser.ID)
|
||||
require.Empty(t, accessToken)
|
||||
|
||||
// The token must have been restored (not consumed), since the exchange failed because the user is disabled
|
||||
var state TokenState
|
||||
err = host.GetState(t.Context(), TokenActorType, token, &state)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, state.UserID)
|
||||
|
||||
require.Empty(t, auditLog.events)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/google/uuid"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
@@ -31,6 +33,7 @@ import (
|
||||
"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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -41,6 +44,7 @@ import (
|
||||
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
actors *local.Host
|
||||
jwtService *JwtService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
ldapService *LdapService
|
||||
@@ -56,9 +60,10 @@ 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, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
actors: actors,
|
||||
appConfigService: appConfigService,
|
||||
jwtService: jwtService,
|
||||
ldapService: ldapService,
|
||||
@@ -136,29 +141,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
oneTimeAccessTokens := []model.OneTimeAccessToken{{
|
||||
Base: model.Base{
|
||||
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
|
||||
},
|
||||
Token: "HPe6k6uiDRRVuAQV",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
|
||||
},
|
||||
Token: "YCGDtftvsvYWiXd0",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, token := range oneTimeAccessTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
userGroups := []model.UserGroup{
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -220,6 +202,9 @@ 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{
|
||||
@@ -282,12 +267,58 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
accessToken := model.OneTimeAccessToken{
|
||||
Token: "one-time-token",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
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(&accessToken).Error; err != nil {
|
||||
if err := tx.Create(&reauthenticationToken).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -448,53 +479,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
signupTokens := []usersignup.SignupToken{
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
},
|
||||
Token: "VALID1234567890A",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 0,
|
||||
UserGroups: []model.UserGroup{
|
||||
userGroups[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||
},
|
||||
Token: "PARTIAL567890ABC",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 2,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
|
||||
},
|
||||
Token: "EXPIRED34567890B",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
|
||||
UsageLimit: 3,
|
||||
UsageCount: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||
},
|
||||
Token: "FULLYUSED567890C",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
},
|
||||
}
|
||||
for _, token := range signupTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
emailVerificationTokens := []model.EmailVerificationToken{
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -541,6 +525,116 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// One-time access tokens and signup tokens live in the actor state store, so they're seeded separately from the DB transaction above.
|
||||
err = s.seedOneTimeAccessTokens(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seed one-time access tokens: %w", err)
|
||||
}
|
||||
|
||||
err = s.seedSignupTokens(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seed signup tokens: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor.
|
||||
// The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm.
|
||||
func (s *TestService) seedSignupTokens(ctx context.Context) error {
|
||||
now := time.Now().Round(time.Second)
|
||||
tokens := map[string]usersignup.SignupTokenState{
|
||||
"VALID1234567890A": {
|
||||
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 0,
|
||||
UserGroupIDs: []string{"c7ae7c01-28a3-4f3c-9572-1ee734ea8368"},
|
||||
CreatedAt: now,
|
||||
},
|
||||
"PARTIAL567890ABC": {
|
||||
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 2,
|
||||
CreatedAt: now,
|
||||
},
|
||||
"FULLYUSED567890C": {
|
||||
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
// The actor state store isn't wiped by ResetDatabase, so remove any signup token left over from a previous test first
|
||||
err := s.deleteAllSignupTokens(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Each signup token is its own actor, whose actor ID is the token's value
|
||||
for token, state := range tokens {
|
||||
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, token, usersignup.SignupTokenMethodCreate, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seed signup token %q: %w", token, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAllSignupTokens removes every signup token currently stored in the actor state store
|
||||
func (s *TestService) deleteAllSignupTokens(ctx context.Context) error {
|
||||
var after string
|
||||
for {
|
||||
res, err := s.actors.Service().ListStates(ctx, usersignup.SignupTokenActorType, &actor.ListStatesOpts{After: after})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list signup tokens: %w", err)
|
||||
}
|
||||
|
||||
for _, st := range res.States {
|
||||
_, err = s.actors.Service().Invoke(ctx, usersignup.SignupTokenActorType, st.ActorID, usersignup.SignupTokenMethodDelete, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete signup token %q: %w", st.ActorID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty cursor means we've just read the last page
|
||||
after = res.AfterID()
|
||||
if after == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedOneTimeAccessTokens seeds the one-time access tokens used by E2E tests into the actor state store.
|
||||
// Expired tokens are intentionally not seeded: with actor-backed storage an expired token is simply one that has no state, which the exchange flow already reports as invalid/expired.
|
||||
func (s *TestService) seedOneTimeAccessTokens(ctx context.Context) error {
|
||||
tokens := []struct {
|
||||
token string
|
||||
ttl time.Duration
|
||||
}{
|
||||
{token: "HPe6k6uiDRRVuAQV", ttl: time.Hour},
|
||||
{token: "one-time-token", ttl: time.Hour},
|
||||
}
|
||||
|
||||
for _, t := range tokens {
|
||||
state := onetimeaccess.TokenState{
|
||||
UserID: e2eRefreshTokenUserID,
|
||||
ExpiresAt: time.Now().Add(t.ttl).Round(time.Second),
|
||||
}
|
||||
// Seed through the actor's "restore" method (which sets the state) rather than writing the
|
||||
// state directly: if an actor for this token is still active from a previous test (for
|
||||
// example, one whose token was already consumed), invoking it refreshes its in-memory cache
|
||||
// too, whereas a direct state write would leave that cache stale.
|
||||
_, err := s.actors.Service().Invoke(ctx, onetimeaccess.TokenActorType, t.token, onetimeaccess.TokenMethodRestore, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seed one-time access token %q: %w", t.token, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (string, error) {
|
||||
func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string, input dto.OidcClientSecretDto) (string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -283,9 +283,12 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (
|
||||
return "", err
|
||||
}
|
||||
|
||||
clientSecret, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
clientSecret := input.Secret
|
||||
if clientSecret == "" {
|
||||
clientSecret, err = utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.DefaultCost)
|
||||
@@ -589,23 +592,11 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.OidcClient{}).
|
||||
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)
|
||||
}
|
||||
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)
|
||||
|
||||
var clients []model.OidcClient
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
@@ -15,6 +17,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -501,6 +504,29 @@ func TestOidcService_CreateClient_withoutDescription(t *testing.T) {
|
||||
assert.Empty(t, fetched.Description)
|
||||
}
|
||||
|
||||
func TestOidcService_CreateClientSecret_withCustomSecret(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
s, err := NewOidcService(db, nil, nil, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := model.OidcClient{Name: "Test Client"}
|
||||
err = db.Create(&client).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
customSecret := "custom-client-secret-with-a-minimum-length"
|
||||
input := dto.OidcClientSecretDto{Secret: customSecret}
|
||||
|
||||
secret, err := s.CreateClientSecret(t.Context(), client.ID, input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, customSecret, secret)
|
||||
|
||||
var fetched model.OidcClient
|
||||
err = db.First(&fetched, "id = ?", client.ID).Error
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fetched.Secret), []byte(customSecret)))
|
||||
}
|
||||
|
||||
func TestOidcService_UpdateClient_description(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
@@ -541,3 +567,45 @@ 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
|
||||
}
|
||||
|
||||
29
backend/internal/service/one_time_access_email_sender.go
Normal file
29
backend/internal/service/one_time_access_email_sender.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
// OneTimeAccessEmailSender sends the one-time access email.
|
||||
// It adapts the email service, which owns the email templates, to the interface the onetimeaccess module depends on.
|
||||
type OneTimeAccessEmailSender struct {
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewOneTimeAccessEmailSender(emailService *EmailService) *OneTimeAccessEmailSender {
|
||||
return &OneTimeAccessEmailSender{emailService: emailService}
|
||||
}
|
||||
|
||||
// SendOneTimeAccessEmail implements onetimeaccess.EmailSender
|
||||
func (s *OneTimeAccessEmailSender) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data onetimeaccess.EmailData) error {
|
||||
return SendEmail(ctx, s.emailService, dbConfig, to, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Code: data.Code,
|
||||
LoginLink: data.LoginLink,
|
||||
LoginLinkWithCode: data.LoginLinkWithCode,
|
||||
ExpirationString: data.ExpirationString,
|
||||
})
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
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"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type OneTimeAccessService struct {
|
||||
db *gorm.DB
|
||||
userService *UserService
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *OneTimeAccessService {
|
||||
return &OneTimeAccessService{
|
||||
db: db,
|
||||
userService: userService,
|
||||
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() {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false, dbConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, redirectPath string) (string, error) {
|
||||
if !dbConfig.EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue() {
|
||||
return "", &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
var userId string
|
||||
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Do not return error if user not found to prevent email enumeration
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true, dbConfig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if deviceToken == nil {
|
||||
return "", errors.New("device token expected but not returned")
|
||||
}
|
||||
|
||||
return *deviceToken, nil
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool, dbConfig *appconfig.AppConfigModel) (*string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
user, err := s.userService.getUserInternal(ctx, userID, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return nil, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
link := common.EnvConfig.AppURL + "/lc"
|
||||
linkWithCode := link + "/" + oneTimeAccessToken
|
||||
|
||||
// Add redirect path to the link
|
||||
if strings.HasPrefix(redirectPath, "/") {
|
||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
|
||||
}
|
||||
|
||||
errInternal := SendEmail(innerCtx, s.emailService, dbConfig, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
ExpirationString: utils.DurationToString(ttl),
|
||||
})
|
||||
if errInternal != nil {
|
||||
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return deviceToken, nil
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Load the user to ensure it exists
|
||||
_, err = s.userService.getUserInternal(ctx, userID, tx)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", &common.UserNotFoundError{}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create the one-time access token
|
||||
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Commit
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error committing transaction: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
|
||||
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
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) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
|
||||
Preload("User").
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&oneTimeAccessToken).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
return model.User{}, "", err
|
||||
}
|
||||
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
|
||||
return model.User{}, "", &common.DeviceCodeInvalid{}
|
||||
}
|
||||
if oneTimeAccessToken.User.Disabled {
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(
|
||||
oneTimeAccessToken.User,
|
||||
AuthenticationMethodOneTimePassword,
|
||||
dbConfig.SessionDuration.AsDurationMinutes(),
|
||||
)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Delete(&oneTimeAccessToken).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
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 oneTimeAccessToken.User, accessToken, nil
|
||||
}
|
||||
|
||||
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
|
||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||
tokenLength := 16
|
||||
if ttl <= 15*time.Minute {
|
||||
tokenLength = 6
|
||||
}
|
||||
|
||||
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var deviceToken *string
|
||||
if withDeviceToken {
|
||||
dt, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceToken = &dt
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
o := &model.OneTimeAccessToken{
|
||||
UserID: userID,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
Token: token,
|
||||
DeviceToken: deviceToken,
|
||||
}
|
||||
|
||||
return o, nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
appConfig := appconfig.NewTestAppConfigService(nil)
|
||||
instanceID := newInstanceID(t, db)
|
||||
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
|
||||
auditLogService := NewAuditLogService(db, nil, &GeoLiteService{}, appConfig)
|
||||
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "disabled-user"},
|
||||
Username: "disabled-user",
|
||||
Disabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
loginCode := model.OneTimeAccessToken{
|
||||
Base: model.Base{ID: "disabled-user-login-code"},
|
||||
Token: "ABCDEF",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
|
||||
UserID: user.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(&loginCode).Error)
|
||||
|
||||
dbConfig := appconfig.NewTestConfig(nil)
|
||||
exchangedUser, accessToken, err := oneTimeAccessService.ExchangeOneTimeAccessToken(t.Context(), dbConfig, loginCode.Token, "", "", "")
|
||||
|
||||
var userDisabledErr *common.UserDisabledError
|
||||
require.ErrorAs(t, err, &userDisabledErr)
|
||||
require.Empty(t, exchangedUser.ID)
|
||||
require.Empty(t, accessToken)
|
||||
|
||||
var remainingLoginCode model.OneTimeAccessToken
|
||||
require.NoError(t, db.Where("token = ?", loginCode.Token).First(&remainingLoginCode).Error)
|
||||
|
||||
var auditLogCount int64
|
||||
require.NoError(t, db.Model(&model.AuditLog{}).Where("user_id = ?", user.ID).Count(&auditLogCount).Error)
|
||||
require.Zero(t, auditLogCount)
|
||||
}
|
||||
@@ -285,6 +285,9 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
Disabled: input.Disabled,
|
||||
UserGroups: userGroups,
|
||||
}
|
||||
if input.ID != "" {
|
||||
user.ID = input.ID
|
||||
}
|
||||
if input.LdapID != "" {
|
||||
user.LdapID = &input.LdapID
|
||||
}
|
||||
|
||||
217
backend/internal/usersignup/actor.go
Normal file
217
backend/internal/usersignup/actor.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// Signup tokens are stored entirely in the actor state store.
|
||||
// Each token is its own actor, whose actor ID is the token value itself.
|
||||
// The state is persisted with a TTL equal to the token's lifetime, so it's purged automatically when the token expires (there's no separate cleanup job and no expiration alarm).
|
||||
// Listing tokens uses ListStates, which only returns states that haven't expired yet.
|
||||
|
||||
// SignupTokenActorType is the actor type for the signup token actor
|
||||
const SignupTokenActorType = "SignupToken"
|
||||
|
||||
// Methods exposed by the signup token actor
|
||||
// Because we cannot invoke an actor while a DB transaction is open (that would deadlock on SQLite), consuming a token is done by invoking the actor first (which atomically validates it and increments its usage count), and only afterwards performing the remaining work.
|
||||
// On failure, the caller compensates by releasing the token via the "release" method as best-effort.
|
||||
const (
|
||||
// SignupTokenMethodCreate stores a new signup token, replacing any existing state
|
||||
SignupTokenMethodCreate = "create"
|
||||
// SignupTokenMethodDelete removes a signup token
|
||||
SignupTokenMethodDelete = "delete"
|
||||
|
||||
signupTokenMethodMigrate = "migrate"
|
||||
signupTokenMethodConsume = "consume"
|
||||
signupTokenMethodRelease = "release"
|
||||
)
|
||||
|
||||
// signupTokenConsumeStatus is the outcome of a "consume" invocation.
|
||||
type signupTokenConsumeStatus string
|
||||
|
||||
const (
|
||||
// signupTokenConsumeOK indicates the token was valid and one use has been consumed
|
||||
signupTokenConsumeOK signupTokenConsumeStatus = "ok"
|
||||
// signupTokenConsumeNotFound indicates the token doesn't exist (or has expired)
|
||||
signupTokenConsumeNotFound signupTokenConsumeStatus = "not_found"
|
||||
// signupTokenConsumeLimitReached indicates the token has no uses left
|
||||
signupTokenConsumeLimitReached signupTokenConsumeStatus = "limit_reached"
|
||||
)
|
||||
|
||||
// SignupTokenState is the persisted state of a signup token actor.
|
||||
// The token value itself is the actor's ID, so it isn't repeated here.
|
||||
type SignupTokenState struct {
|
||||
ID string
|
||||
UsageLimit int
|
||||
UsageCount int
|
||||
UserGroupIDs []string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// signupTokenConsumeResponse is the response of the "consume" method
|
||||
type signupTokenConsumeResponse struct {
|
||||
Status signupTokenConsumeStatus
|
||||
// UserGroupIDs is set only when Status is "ok", and contains the groups the new user should join
|
||||
UserGroupIDs []string
|
||||
}
|
||||
|
||||
// signupTokenActor is the actor that manages a single signup token
|
||||
type signupTokenActor struct {
|
||||
log *slog.Logger
|
||||
client actor.Client[SignupTokenState]
|
||||
}
|
||||
|
||||
// NewSignupTokenActor allocates a new signup token actor
|
||||
// It satisfies actor.Factory
|
||||
func NewSignupTokenActor(actorID string, service *actor.Service) actor.Actor {
|
||||
return &signupTokenActor{
|
||||
log: slog.With(
|
||||
slog.String("scope", "actor"),
|
||||
slog.String("actorType", SignupTokenActorType),
|
||||
),
|
||||
client: actor.NewActorClient[SignupTokenState](SignupTokenActorType, actorID, service),
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke implements actor.ActorInvoke
|
||||
func (a *signupTokenActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
switch method {
|
||||
case SignupTokenMethodCreate:
|
||||
return nil, a.create(parentCtx, data, false)
|
||||
case signupTokenMethodMigrate:
|
||||
return nil, a.create(parentCtx, data, true)
|
||||
case signupTokenMethodConsume:
|
||||
return a.consume(parentCtx)
|
||||
case signupTokenMethodRelease:
|
||||
return nil, a.release(parentCtx)
|
||||
case SignupTokenMethodDelete:
|
||||
return nil, a.delete(parentCtx)
|
||||
default:
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
}
|
||||
|
||||
// create stores the token's state.
|
||||
// When onlyIfMissing is true the write is skipped if the actor already has state: this is used by the one-time migration of the pre-actor tokens, so a token that has already been migrated is never reset.
|
||||
func (a *signupTokenActor) create(parentCtx context.Context, data actor.Envelope, onlyIfMissing bool) error {
|
||||
if data == nil {
|
||||
return fmt.Errorf("request body is empty for method '%s'", SignupTokenMethodCreate)
|
||||
}
|
||||
|
||||
var state SignupTokenState
|
||||
err := data.Decode(&state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method '%s': %w", SignupTokenMethodCreate, err)
|
||||
}
|
||||
|
||||
if onlyIfMissing {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
current, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// An empty ID means there's no state yet
|
||||
if current.ID != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return a.setState(parentCtx, state)
|
||||
}
|
||||
|
||||
// consume atomically validates the token and, if it's still usable, records one more use.
|
||||
func (a *signupTokenActor) consume(parentCtx context.Context) (signupTokenConsumeResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return signupTokenConsumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// An empty ID means there's no state: the token doesn't exist (or its state already expired and was purged)
|
||||
if state.ID == "" || state.ExpiresAt.Before(time.Now()) {
|
||||
return signupTokenConsumeResponse{
|
||||
Status: signupTokenConsumeNotFound,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if state.UsageCount >= state.UsageLimit {
|
||||
return signupTokenConsumeResponse{
|
||||
Status: signupTokenConsumeLimitReached,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Consume one use of the token
|
||||
state.UsageCount++
|
||||
err = a.setState(parentCtx, state)
|
||||
if err != nil {
|
||||
return signupTokenConsumeResponse{}, err
|
||||
}
|
||||
|
||||
return signupTokenConsumeResponse{
|
||||
Status: signupTokenConsumeOK,
|
||||
UserGroupIDs: state.UserGroupIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// release reverts the usage count increment performed while consuming the token, to compensate when the signup could not be completed.
|
||||
func (a *signupTokenActor) release(parentCtx context.Context) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// The token is gone (for example, it expired and was purged) or was never consumed: nothing to compensate
|
||||
if state.ID == "" || state.UsageCount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.UsageCount--
|
||||
return a.setState(parentCtx, state)
|
||||
}
|
||||
|
||||
// delete removes the token.
|
||||
func (a *signupTokenActor) delete(parentCtx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err := a.client.DeleteState(ctx)
|
||||
if err != nil && !errors.Is(err, actor.ErrStateNotFound) {
|
||||
// Deleting a token that doesn't exist (for example, one that expired in the meanwhile) already reaches the desired end state
|
||||
return fmt.Errorf("error deleting actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setState saves the state with a TTL matching the token's remaining lifetime, so it's purged automatically once the token expires.
|
||||
// Saving is skipped if the token has already expired, since there would be nothing left to store.
|
||||
func (a *signupTokenActor) setState(parentCtx context.Context, state SignupTokenState) error {
|
||||
ttl := time.Until(state.ExpiresAt)
|
||||
if ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err := a.client.SetState(ctx, state, &actor.SetStateOpts{
|
||||
TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
173
backend/internal/usersignup/actor_test.go
Normal file
173
backend/internal/usersignup/actor_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// newSignupTokenActorService starts a test actor host with the signup token actor registered and returns its service
|
||||
func newSignupTokenActorService(t *testing.T) *actor.Service {
|
||||
t.Helper()
|
||||
|
||||
var svc *actor.Service
|
||||
testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
|
||||
err := h.RegisterActor(SignupTokenActorType, NewSignupTokenActor)
|
||||
require.NoError(t, err)
|
||||
svc = h.Service()
|
||||
})
|
||||
require.NotNil(t, svc)
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func createSignupTokenForTest(t *testing.T, svc *actor.Service, token string, state SignupTokenState) {
|
||||
t.Helper()
|
||||
_, err := svc.Invoke(t.Context(), SignupTokenActorType, token, SignupTokenMethodCreate, state)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func consumeSignupTokenForTest(t *testing.T, svc *actor.Service, token string) signupTokenConsumeResponse {
|
||||
t.Helper()
|
||||
res, err := svc.Invoke(t.Context(), SignupTokenActorType, token, signupTokenMethodConsume, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var out signupTokenConsumeResponse
|
||||
err = res.Decode(&out)
|
||||
require.NoError(t, err)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// listSignupTokenIDsForTest returns the actor IDs (that is, the token values) of every stored signup token
|
||||
func listSignupTokenIDsForTest(t *testing.T, svc *actor.Service) []string {
|
||||
t.Helper()
|
||||
res, err := svc.ListStates(t.Context(), SignupTokenActorType, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
ids := make([]string, len(res.States))
|
||||
for i, st := range res.States {
|
||||
ids[i] = st.ActorID
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestSignupTokenActorConsume(t *testing.T) {
|
||||
svc := newSignupTokenActorService(t)
|
||||
|
||||
createSignupTokenForTest(t, svc, "token-1", SignupTokenState{
|
||||
ID: "id-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
UsageLimit: 1,
|
||||
UserGroupIDs: []string{"group-a", "group-b"},
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// First consume succeeds and returns the token's user groups
|
||||
res := consumeSignupTokenForTest(t, svc, "token-1")
|
||||
require.Equal(t, signupTokenConsumeOK, res.Status)
|
||||
require.Equal(t, []string{"group-a", "group-b"}, res.UserGroupIDs)
|
||||
|
||||
// Second consume fails: the usage limit (1) has been reached
|
||||
res = consumeSignupTokenForTest(t, svc, "token-1")
|
||||
require.Equal(t, signupTokenConsumeLimitReached, res.Status)
|
||||
}
|
||||
|
||||
func TestSignupTokenActorConsumeNotFound(t *testing.T) {
|
||||
svc := newSignupTokenActorService(t)
|
||||
|
||||
res := consumeSignupTokenForTest(t, svc, "does-not-exist")
|
||||
require.Equal(t, signupTokenConsumeNotFound, res.Status)
|
||||
}
|
||||
|
||||
// TestSignupTokenActorCreateExpired verifies that a token that has already expired is never stored, since its state TTL would be in the past
|
||||
func TestSignupTokenActorCreateExpired(t *testing.T) {
|
||||
svc := newSignupTokenActorService(t)
|
||||
|
||||
createSignupTokenForTest(t, svc, "token-expired", SignupTokenState{
|
||||
ID: "id-expired",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
UsageLimit: 1,
|
||||
CreatedAt: time.Now().Add(-time.Hour),
|
||||
})
|
||||
|
||||
require.Empty(t, listSignupTokenIDsForTest(t, svc))
|
||||
require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-expired").Status)
|
||||
}
|
||||
|
||||
func TestSignupTokenActorRelease(t *testing.T) {
|
||||
svc := newSignupTokenActorService(t)
|
||||
|
||||
createSignupTokenForTest(t, svc, "token-2", SignupTokenState{
|
||||
ID: "id-2",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
UsageLimit: 2,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Consume both uses
|
||||
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
|
||||
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
|
||||
require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-2").Status)
|
||||
|
||||
// Release one use (compensation)
|
||||
_, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-2", signupTokenMethodRelease, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Consuming succeeds again now that a use was released
|
||||
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-2").Status)
|
||||
}
|
||||
|
||||
func TestSignupTokenActorDelete(t *testing.T) {
|
||||
svc := newSignupTokenActorService(t)
|
||||
|
||||
createSignupTokenForTest(t, svc, "token-3", SignupTokenState{
|
||||
ID: "id-3",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
UsageLimit: 1,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
require.Equal(t, []string{"token-3"}, listSignupTokenIDsForTest(t, svc))
|
||||
|
||||
_, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-3", SignupTokenMethodDelete, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Empty(t, listSignupTokenIDsForTest(t, svc))
|
||||
|
||||
// The token can no longer be consumed
|
||||
require.Equal(t, signupTokenConsumeNotFound, consumeSignupTokenForTest(t, svc, "token-3").Status)
|
||||
|
||||
// Deleting a token that no longer exists is a no-op
|
||||
_, err = svc.Invoke(t.Context(), SignupTokenActorType, "token-3", SignupTokenMethodDelete, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestSignupTokenActorMigrateDoesNotOverwrite verifies that the one-time migration never resets a token that was already migrated and used since
|
||||
func TestSignupTokenActorMigrateDoesNotOverwrite(t *testing.T) {
|
||||
svc := newSignupTokenActorService(t)
|
||||
|
||||
state := SignupTokenState{
|
||||
ID: "id-4",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
UsageLimit: 2,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
createSignupTokenForTest(t, svc, "token-4", state)
|
||||
|
||||
// Use the token once
|
||||
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-4").Status)
|
||||
|
||||
// Re-running the migration must not reset the usage count
|
||||
_, err := svc.Invoke(t.Context(), SignupTokenActorType, "token-4", signupTokenMethodMigrate, state)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only one use is left, so a single consume succeeds and the next one doesn't
|
||||
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc, "token-4").Status)
|
||||
require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc, "token-4").Status)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// CleanupExpiredSignupTokens deletes signup tokens that have expired
|
||||
// It returns the number of rows removed
|
||||
func CleanupExpiredSignupTokens(ctx context.Context, db *gorm.DB) (int64, error) {
|
||||
st := db.
|
||||
WithContext(ctx).
|
||||
Delete(&SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
return st.RowsAffected, st.Error
|
||||
}
|
||||
105
backend/internal/usersignup/migration.go
Normal file
105
backend/internal/usersignup/migration.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
// This file holds the one-time migration of the pre-actor signup tokens.
|
||||
// The "actor tokens" migration freezes the signup_tokens table (and its user-group associations) into a JSON document stored in the "kv" table under the "signup_tokens_migrated" key.
|
||||
// It's loaded here to create the per-token actors on first startup.
|
||||
|
||||
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
|
||||
const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential
|
||||
|
||||
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
|
||||
// All timestamps are expressed as Unix seconds.
|
||||
type migratedSignupToken struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
UserGroupIDs []string `json:"userGroupIds"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// migrateSignupTokens creates an actor for every signup token frozen into the kv table by the migration.
|
||||
// It requires the actor state store to be available, so it must run after the actor host is ready.
|
||||
// It is idempotent: tokens that have already been migrated are left untouched, so a token that has been used since it was migrated is never reset.
|
||||
func (s *Service) migrateSignupTokens(ctx context.Context) error {
|
||||
migrated, err := loadMigratedSignupTokens(ctx, s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(migrated) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var count int
|
||||
for _, m := range migrated {
|
||||
// Skip tokens that have already expired, since there would be nothing left to store
|
||||
expiresAt := time.Unix(m.ExpiresAt, 0)
|
||||
if !expiresAt.After(time.Now()) {
|
||||
continue
|
||||
}
|
||||
|
||||
state := SignupTokenState{
|
||||
ID: m.ID,
|
||||
ExpiresAt: expiresAt,
|
||||
UsageLimit: m.UsageLimit,
|
||||
UsageCount: m.UsageCount,
|
||||
UserGroupIDs: m.UserGroupIDs,
|
||||
CreatedAt: time.Unix(m.CreatedAt, 0),
|
||||
}
|
||||
|
||||
// The token's value is the actor's ID
|
||||
// The "migrate" method only writes the state if the actor doesn't have one already
|
||||
_, err = s.actorService.Invoke(ctx, SignupTokenActorType, m.Token, signupTokenMethodMigrate, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error migrating signup token '%s': %w", m.ID, err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Migrated signup tokens to actors", slog.Int("count", count))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadMigratedSignupTokens reads the signup tokens frozen into the kv table by the migration
|
||||
// It returns nil if there's nothing to migrate
|
||||
func loadMigratedSignupTokens(ctx context.Context, db *gorm.DB) ([]migratedSignupToken, error) {
|
||||
row := model.KV{
|
||||
Key: signupTokensMigratedKey,
|
||||
}
|
||||
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 are no migrated signup tokens in the database, nothing to do
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("failed to load migrated signup tokens from the database: %w", err)
|
||||
case row.Value == nil || len(*row.Value) == 0:
|
||||
// Also no migrated signup tokens, nothing to do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var migrated []migratedSignupToken
|
||||
err = json.Unmarshal([]byte(*row.Value), &migrated)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing migrated signup tokens: %w", err)
|
||||
}
|
||||
|
||||
return migrated, nil
|
||||
}
|
||||
207
backend/internal/usersignup/migration_test.go
Normal file
207
backend/internal/usersignup/migration_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
// versionBeforeMoveTokens is the migration version right before the "actor tokens" migration.
|
||||
const versionBeforeMoveTokens = 20260722120000
|
||||
|
||||
// seedSignupTokensForMigration seeds two signup tokens (one with a user group, one without) into the pre-migration schema.
|
||||
func seedSignupTokensForMigration(t *testing.T, db *gorm.DB, createdAt, expiresAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
// An unrelated, non-JSON kv entry, to ensure the freeze/restore queries don't choke on other kv keys
|
||||
err := db.Exec(
|
||||
`INSERT INTO kv ("key", "value") VALUES ('instance_id', ?)`,
|
||||
"not-json-instance-id",
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// A user group referenced by one of the tokens
|
||||
err = db.Exec(
|
||||
`INSERT INTO user_groups (id, created_at, friendly_name, name) VALUES (?, ?, ?, ?)`,
|
||||
"grp-1", createdAt.Unix(), "Group One", "group-one",
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// A token with a user group
|
||||
err = db.Exec(
|
||||
`INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"tok-1", createdAt.Unix(), "TOKENWITHGROUP01", expiresAt.Unix(), 3, 1,
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
err = db.Exec(
|
||||
`INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id) VALUES (?, ?)`,
|
||||
"tok-1", "grp-1",
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
// A token without user groups
|
||||
err = db.Exec(
|
||||
`INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"tok-2", createdAt.Unix(), "TOKENNOGROUP0002", expiresAt.Unix(), 1, 0,
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLoadMigratedSignupTokens(t *testing.T) {
|
||||
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
|
||||
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) {
|
||||
seedSignupTokensForMigration(t, db, createdAt, expiresAt)
|
||||
})
|
||||
|
||||
// The migration must have dropped the signup_tokens tables
|
||||
ok := db.Migrator().HasTable("signup_tokens")
|
||||
require.False(t, ok, "signup_tokens table should have been dropped")
|
||||
ok = db.Migrator().HasTable("signup_tokens_user_groups")
|
||||
require.False(t, ok, "signup_tokens_user_groups table should have been dropped")
|
||||
|
||||
tokens, err := loadMigratedSignupTokens(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 2)
|
||||
|
||||
byID := make(map[string]migratedSignupToken, len(tokens))
|
||||
for _, tok := range tokens {
|
||||
byID[tok.ID] = tok
|
||||
}
|
||||
|
||||
tok1 := byID["tok-1"]
|
||||
require.Equal(t, "TOKENWITHGROUP01", tok1.Token)
|
||||
require.Equal(t, 3, tok1.UsageLimit)
|
||||
require.Equal(t, 1, tok1.UsageCount)
|
||||
require.Equal(t, []string{"grp-1"}, tok1.UserGroupIDs)
|
||||
require.Equal(t, expiresAt.Unix(), tok1.ExpiresAt)
|
||||
require.Equal(t, createdAt.Unix(), tok1.CreatedAt)
|
||||
|
||||
tok2 := byID["tok-2"]
|
||||
require.Equal(t, "TOKENNOGROUP0002", tok2.Token)
|
||||
require.Equal(t, 1, tok2.UsageLimit)
|
||||
require.Equal(t, 0, tok2.UsageCount)
|
||||
require.Empty(t, tok2.UserGroupIDs)
|
||||
}
|
||||
|
||||
// TestMigrateSignupTokens verifies that the frozen signup tokens are turned into per-token actors, and that already-expired ones are skipped
|
||||
func TestMigrateSignupTokens(t *testing.T) {
|
||||
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
|
||||
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) {
|
||||
seedSignupTokensForMigration(t, db, createdAt, expiresAt)
|
||||
|
||||
// A token that has already expired: it must not be migrated
|
||||
err := db.Exec(
|
||||
`INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"tok-expired", createdAt.Unix(), "EXPIREDTOKEN0003", time.Now().Add(-time.Hour).Unix(), 1, 0,
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
svc := newSignupServiceForTest(t, db, fakeUserCreator{})
|
||||
|
||||
err := svc.migrateSignupTokens(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := svc.listSignupTokenStates(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
|
||||
byToken := make(map[string]SignupTokenState, len(entries))
|
||||
for _, e := range entries {
|
||||
byToken[e.Token] = e.State
|
||||
}
|
||||
|
||||
tok1 := byToken["TOKENWITHGROUP01"]
|
||||
require.Equal(t, "tok-1", tok1.ID)
|
||||
require.Equal(t, 3, tok1.UsageLimit)
|
||||
require.Equal(t, 1, tok1.UsageCount)
|
||||
require.Equal(t, []string{"grp-1"}, tok1.UserGroupIDs)
|
||||
require.Equal(t, expiresAt.Unix(), tok1.ExpiresAt.Unix())
|
||||
require.Equal(t, createdAt.Unix(), tok1.CreatedAt.Unix())
|
||||
|
||||
tok2 := byToken["TOKENNOGROUP0002"]
|
||||
require.Equal(t, "tok-2", tok2.ID)
|
||||
require.Empty(t, tok2.UserGroupIDs)
|
||||
|
||||
// The expired token must not have been migrated
|
||||
require.NotContains(t, byToken, "EXPIREDTOKEN0003")
|
||||
|
||||
// The migration is idempotent: re-running it doesn't reset a token that has been used since
|
||||
require.Equal(t, signupTokenConsumeOK, consumeSignupTokenForTest(t, svc.actorService, "TOKENNOGROUP0002").Status)
|
||||
err = svc.migrateSignupTokens(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, signupTokenConsumeLimitReached, consumeSignupTokenForTest(t, svc.actorService, "TOKENNOGROUP0002").Status)
|
||||
}
|
||||
|
||||
// TestLoadMigratedSignupTokensEmpty verifies that when there were no signup tokens, nothing is frozen and nothing is loaded.
|
||||
func TestLoadMigratedSignupTokensEmpty(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
tokens, err := loadMigratedSignupTokens(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, tokens)
|
||||
}
|
||||
|
||||
// TestMoveTokensToActorStateDown verifies that rolling the migration back recreates the signup token tables and restores their contents from the frozen kv document.
|
||||
func TestMoveTokensToActorStateDown(t *testing.T) {
|
||||
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
|
||||
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMoveTokens, func(t *testing.T, db *gorm.DB) {
|
||||
seedSignupTokensForMigration(t, db, createdAt, expiresAt)
|
||||
})
|
||||
|
||||
// The tables were frozen and dropped by the up migration
|
||||
ok := db.Migrator().HasTable("signup_tokens")
|
||||
require.False(t, ok)
|
||||
|
||||
// Roll the migration back
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
m, cleanup, err := utils.GetEmbeddedMigrateInstance(t.Context(), sqlDB)
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
err = m.Migrate(versionBeforeMoveTokens)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The tables must have been recreated and repopulated from the frozen document
|
||||
ok = db.Migrator().HasTable("signup_tokens")
|
||||
require.True(t, ok)
|
||||
ok = db.Migrator().HasTable("signup_tokens_user_groups")
|
||||
require.True(t, ok)
|
||||
|
||||
type row struct {
|
||||
ID string
|
||||
Token string
|
||||
UsageLimit int
|
||||
UsageCount int
|
||||
}
|
||||
var rows []row
|
||||
err = db.Raw(`SELECT id, token, usage_limit, usage_count FROM signup_tokens ORDER BY id`).Scan(&rows).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []row{
|
||||
{ID: "tok-1", Token: "TOKENWITHGROUP01", UsageLimit: 3, UsageCount: 1},
|
||||
{ID: "tok-2", Token: "TOKENNOGROUP0002", UsageLimit: 1, UsageCount: 0},
|
||||
}, rows)
|
||||
|
||||
var groupID string
|
||||
err = db.Raw(`SELECT user_group_id FROM signup_tokens_user_groups WHERE signup_token_id = ?`, "tok-1").Scan(&groupID).Error
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "grp-1", groupID)
|
||||
|
||||
// The frozen document must have been removed from the kv table
|
||||
var kvCount int64
|
||||
err = db.Raw(`SELECT count(*) FROM kv WHERE "key" = ?`, signupTokensMigratedKey).Scan(&kvCount).Error
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, kvCount)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
@@ -15,17 +13,5 @@ type SignupToken struct {
|
||||
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;"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
return time.Time(st.ExpiresAt).Before(time.Now())
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsUsageLimitReached() bool {
|
||||
return st.UsageCount >= st.UsageLimit
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsValid() bool {
|
||||
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||
UserGroups []model.UserGroup `json:"userGroups"`
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -32,7 +34,8 @@ type AppConfigResolver interface {
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
@@ -45,12 +48,32 @@ type Module struct {
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) *Module {
|
||||
service := newService(deps)
|
||||
func New(deps Dependencies) (*Module, error) {
|
||||
// Register the actor that manages a signup token
|
||||
// Each token is its own actor, whose actor ID is the token's value
|
||||
err := deps.Actors.RegisterActor(SignupTokenActorType, NewSignupTokenActor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", SignupTokenActorType, err)
|
||||
}
|
||||
|
||||
service := newService(deps, deps.Actors.Service())
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service, deps.AppConfig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RunSignupTokenMigration performs the one-time migration of the pre-actor signup tokens, then blocks until the context is canceled.
|
||||
// It's meant to be started as a background service gated on the actor host being ready, since the migration needs the actor state store.
|
||||
// Note that it must not return before the context is canceled, as the service runner stops the application as soon as any of its services returns.
|
||||
func (m *Module) RunSignupTokenMigration(ctx context.Context) error {
|
||||
err := m.service.migrateSignupTokens(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate signup tokens: %w", err)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the signup and signup-token management endpoints
|
||||
|
||||
@@ -2,12 +2,15 @@ package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"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"
|
||||
@@ -22,57 +25,49 @@ import (
|
||||
const authenticationMethodOneTimePassword = "otp"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
userCreator UserCreator
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
db *gorm.DB
|
||||
actorService *actor.Service
|
||||
userCreator UserCreator
|
||||
signer TokenService
|
||||
auditLog AuditLogger
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) *Service {
|
||||
func newService(deps Dependencies, actorService *actor.Service) *Service {
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
userCreator: deps.UserCreator,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
db: deps.DB,
|
||||
actorService: actorService,
|
||||
userCreator: deps.UserCreator,
|
||||
signer: deps.Signer,
|
||||
auditLog: deps.AuditLog,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
tokenProvided := signupData.Token != ""
|
||||
|
||||
if config.AllowUserSignups.String() != "open" && !tokenProvided {
|
||||
return model.User{}, "", &common.OpenSignupDisabledError{}
|
||||
}
|
||||
|
||||
var signupToken SignupToken
|
||||
var userGroupIDs []string
|
||||
if tokenProvided {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("UserGroups").
|
||||
Where("token = ?", signupData.Token).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&signupToken).
|
||||
Error
|
||||
// Consume the signup token by invoking its actor: this atomically validates it and increments its usage count
|
||||
// Note: must invoke outside of a DB transaction, since invoking an actor while a transaction is open would deadlock on SQLite
|
||||
res, err := s.actorService.Invoke(ctx, SignupTokenActorType, signupData.Token, signupTokenMethodConsume, nil)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
return model.User{}, "", err
|
||||
return model.User{}, "", fmt.Errorf("error invoking signup token actor: %w", err)
|
||||
}
|
||||
|
||||
if !signupToken.IsValid() {
|
||||
var consumeRes signupTokenConsumeResponse
|
||||
err = res.Decode(&consumeRes)
|
||||
if err != nil {
|
||||
return model.User{}, "", fmt.Errorf("error decoding signup token actor response: %w", err)
|
||||
}
|
||||
|
||||
if consumeRes.Status != signupTokenConsumeOK {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
for _, group := range signupToken.UserGroups {
|
||||
userGroupIDs = append(userGroupIDs, group.ID)
|
||||
}
|
||||
userGroupIDs = consumeRes.UserGroupIDs
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
@@ -85,6 +80,27 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
EmailVerified: config.EmailsVerified.IsTrue(),
|
||||
}
|
||||
|
||||
// The token has now been consumed
|
||||
// From this point on, if we hit an error we compensate by releasing the token (best-effort)
|
||||
user, accessToken, err := s.createSignedUpUser(ctx, config, userToCreate, signupData.Token, tokenProvided, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
if tokenProvided {
|
||||
s.releaseSignupToken(ctx, signupData.Token)
|
||||
}
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// createSignedUpUser creates the user and issues an access token within a single transaction.
|
||||
// It performs no actor calls, so it's safe to keep the transaction open for its whole duration.
|
||||
func (s *Service) createSignedUpUser(ctx context.Context, config *appconfig.AppConfigModel, userToCreate dto.UserCreateDto, token string, tokenProvided bool, ipAddress, userAgent string) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
user, err := s.userCreator.CreateUserInternal(ctx, config, userToCreate, false, tx)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
@@ -97,15 +113,8 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
|
||||
if tokenProvided {
|
||||
s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
|
||||
"signupToken": signupToken.Token,
|
||||
"signupToken": token,
|
||||
}, tx)
|
||||
|
||||
signupToken.UsageCount++
|
||||
|
||||
err = tx.WithContext(ctx).Save(&signupToken).Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
} else {
|
||||
s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
|
||||
"method": "open_signup",
|
||||
@@ -120,6 +129,19 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// releaseSignupToken reverts the usage count increment performed while consuming a token, used to compensate when the signup could not be completed.
|
||||
// It's a best-effort compensation: if it fails (or the process crashes before it runs) we accept that a token use was consumed unnecessarily
|
||||
func (s *Service) releaseSignupToken(parentCtx context.Context, token string) {
|
||||
// Use a context that is not canceled when the original request ends
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.actorService.Invoke(ctx, SignupTokenActorType, token, signupTokenMethodRelease, nil)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to release signup token after a failed signup", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppConfigModel, signUpData signUpDto) (model.User, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
@@ -177,55 +199,273 @@ func (s *Service) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB)
|
||||
}
|
||||
|
||||
func (s *Service) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]SignupToken, utils.PaginationResponse, error) {
|
||||
var tokens []SignupToken
|
||||
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&SignupToken{})
|
||||
// Each signup token is its own actor, so we enumerate the stored states (expired ones are filtered out by the state store), then sort and paginate in memory
|
||||
entries, err := s.listSignupTokenStates(ctx)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
|
||||
return tokens, pagination, err
|
||||
// Resolve the referenced user groups so they can be included in the response
|
||||
groupsByID, err := s.loadUserGroupsByID(ctx, entries)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
tokens := make([]SignupToken, len(entries))
|
||||
for i, e := range entries {
|
||||
tokens[i] = signupTokenModelFromState(e.Token, e.State, resolveUserGroups(e.State.UserGroupIDs, groupsByID))
|
||||
}
|
||||
|
||||
return paginateSignupTokens(tokens, listRequestOptions)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
|
||||
// Tokens are addressed by their value (the actor ID), while the API deletes them by ID, so we look up the matching token first
|
||||
entries, err := s.listSignupTokenStates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.State.ID != tokenID {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = s.actorService.Invoke(ctx, SignupTokenActorType, e.Token, SignupTokenMethodDelete, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting signup token via actor: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The token doesn't exist (or has expired): deleting it already reaches the desired end state
|
||||
return nil
|
||||
}
|
||||
|
||||
// signupTokenEntry pairs a signup token's value (which is its actor ID) with its stored state
|
||||
type signupTokenEntry struct {
|
||||
Token string
|
||||
State SignupTokenState
|
||||
}
|
||||
|
||||
// listSignupTokenStates returns every signup token currently stored in the actor state store.
|
||||
// Expired tokens are not returned, since the state store filters out states whose TTL has passed.
|
||||
func (s *Service) listSignupTokenStates(ctx context.Context) ([]signupTokenEntry, error) {
|
||||
var (
|
||||
entries []signupTokenEntry
|
||||
after string
|
||||
)
|
||||
for {
|
||||
res, err := s.actorService.ListStates(ctx, SignupTokenActorType, &actor.ListStatesOpts{
|
||||
IncludeData: true,
|
||||
After: after,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error listing signup token states: %w", err)
|
||||
}
|
||||
|
||||
for _, st := range res.States {
|
||||
if st.Data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var state SignupTokenState
|
||||
err = st.Data.Decode(&state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding state of signup token actor '%s': %w", st.ActorID, err)
|
||||
}
|
||||
|
||||
entries = append(entries, signupTokenEntry{
|
||||
Token: st.ActorID,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
|
||||
// An empty cursor means we've just read the last page
|
||||
after = res.AfterID()
|
||||
if after == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit)
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
|
||||
// Load the referenced user groups to validate them and to include them in the response
|
||||
var userGroups []model.UserGroup
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("id IN ?", userGroupIDs).
|
||||
Find(&userGroups).
|
||||
Error
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
signupToken.UserGroups = userGroups
|
||||
|
||||
err = s.db.WithContext(ctx).Create(signupToken).Error
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
if len(userGroupIDs) > 0 {
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("id IN ?", userGroupIDs).
|
||||
Find(&userGroups).
|
||||
Error
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return *signupToken, nil
|
||||
}
|
||||
validGroupIDs := make([]string, len(userGroups))
|
||||
for i, g := range userGroups {
|
||||
validGroupIDs[i] = g.ID
|
||||
}
|
||||
|
||||
func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
state := SignupTokenState{
|
||||
ID: uuid.NewString(),
|
||||
ExpiresAt: now.Add(ttl),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
UserGroupIDs: validGroupIDs,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
// The token's value is the actor's ID
|
||||
_, err = s.actorService.Invoke(ctx, SignupTokenActorType, randomString, SignupTokenMethodCreate, state)
|
||||
if err != nil {
|
||||
return SignupToken{}, fmt.Errorf("error creating signup token via actor: %w", err)
|
||||
}
|
||||
|
||||
return signupTokenModelFromState(randomString, state, userGroups), nil
|
||||
}
|
||||
|
||||
// loadUserGroupsByID loads every user group referenced by the given tokens, keyed by ID.
|
||||
func (s *Service) loadUserGroupsByID(ctx context.Context, entries []signupTokenEntry) (map[string]model.UserGroup, error) {
|
||||
idSet := make(map[string]struct{})
|
||||
for _, e := range entries {
|
||||
for _, id := range e.State.UserGroupIDs {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(idSet) == 0 {
|
||||
return map[string]model.UserGroup{}, nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
var groups []model.UserGroup
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("id IN ?", ids).
|
||||
Find(&groups).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
token := &SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
byID := make(map[string]model.UserGroup, len(groups))
|
||||
for _, g := range groups {
|
||||
byID[g.ID] = g
|
||||
}
|
||||
|
||||
return token, nil
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
// resolveUserGroups maps the given group IDs to the corresponding UserGroup objects, preserving order and skipping any that no longer exist.
|
||||
func resolveUserGroups(ids []string, byID map[string]model.UserGroup) []model.UserGroup {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := make([]model.UserGroup, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
g, ok := byID[id]
|
||||
if ok {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// signupTokenModelFromState builds the API/model representation of a signup token from its actor ID (the token's value) and stored state.
|
||||
func signupTokenModelFromState(token string, state SignupTokenState, groups []model.UserGroup) SignupToken {
|
||||
return SignupToken{
|
||||
Base: model.Base{
|
||||
ID: state.ID,
|
||||
CreatedAt: datatype.DateTime(state.CreatedAt),
|
||||
},
|
||||
Token: token,
|
||||
ExpiresAt: datatype.DateTime(state.ExpiresAt),
|
||||
UsageLimit: state.UsageLimit,
|
||||
UsageCount: state.UsageCount,
|
||||
UserGroups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
// paginateSignupTokens sorts and paginates the in-memory list of signup tokens, mirroring the behavior of the DB-backed pagination utility.
|
||||
func paginateSignupTokens(tokens []SignupToken, params utils.ListRequestOptions) ([]SignupToken, utils.PaginationResponse, error) {
|
||||
sortSignupTokens(tokens, params.SortColumn, params.SortDirection)
|
||||
|
||||
page := max(params.Page, 1)
|
||||
pageSize := params.Limit
|
||||
switch {
|
||||
case pageSize < 1:
|
||||
pageSize = 20
|
||||
case pageSize > 100:
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
totalItems := int64(len(tokens))
|
||||
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
|
||||
if totalItems == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
if int64(page) > totalPages {
|
||||
page = int(totalPages)
|
||||
}
|
||||
|
||||
start := min((page-1)*pageSize, len(tokens))
|
||||
end := min(start+pageSize, len(tokens))
|
||||
|
||||
return tokens[start:end], utils.PaginationResponse{
|
||||
TotalPages: totalPages,
|
||||
TotalItems: totalItems,
|
||||
CurrentPage: page,
|
||||
ItemsPerPage: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sortSignupTokens sorts the tokens by the given column and direction.
|
||||
// It defaults to sorting by creation date ascending, matching the DB-backed listing.
|
||||
func sortSignupTokens(tokens []SignupToken, column, direction string) {
|
||||
desc := utils.NormalizeSortDirection(direction) == "desc"
|
||||
|
||||
less := func(i, j int) bool {
|
||||
caI := time.Time(tokens[i].CreatedAt)
|
||||
caJ := time.Time(tokens[j].CreatedAt)
|
||||
return caI.Before(caJ)
|
||||
}
|
||||
switch column {
|
||||
case "expiresAt":
|
||||
less = func(i, j int) bool {
|
||||
eaI := time.Time(tokens[i].ExpiresAt)
|
||||
eaJ := time.Time(tokens[j].ExpiresAt)
|
||||
return eaI.Before(eaJ)
|
||||
}
|
||||
case "usageLimit":
|
||||
less = func(i, j int) bool { return tokens[i].UsageLimit < tokens[j].UsageLimit }
|
||||
case "usageCount":
|
||||
less = func(i, j int) bool {
|
||||
return tokens[i].UsageCount < tokens[j].UsageCount
|
||||
}
|
||||
case "createdAt", "":
|
||||
// Use the default comparator (creation date)
|
||||
default:
|
||||
// Unknown or non-sortable column: keep the default (creation date) ordering
|
||||
}
|
||||
|
||||
sort.SliceStable(tokens, func(i, j int) bool {
|
||||
if desc {
|
||||
return less(j, i)
|
||||
}
|
||||
return less(i, j)
|
||||
})
|
||||
}
|
||||
|
||||
127
backend/internal/usersignup/service_test.go
Normal file
127
backend/internal/usersignup/service_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
type fakeUserCreator struct {
|
||||
err error
|
||||
user model.User
|
||||
}
|
||||
|
||||
func (f fakeUserCreator) CreateUserInternal(_ context.Context, _ *appconfig.AppConfigModel, _ dto.UserCreateDto, _ bool, _ *gorm.DB) (model.User, error) {
|
||||
if f.err != nil {
|
||||
return model.User{}, f.err
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
type fakeSigner struct{}
|
||||
|
||||
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
|
||||
return "access-token", nil
|
||||
}
|
||||
|
||||
type fakeAuditLogger struct{}
|
||||
|
||||
func (fakeAuditLogger) Create(_ context.Context, _ model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
|
||||
return model.AuditLog{}, true
|
||||
}
|
||||
|
||||
func newSignupServiceForTest(t *testing.T, db *gorm.DB, userCreator UserCreator) *Service {
|
||||
t.Helper()
|
||||
actorService := newSignupTokenActorService(t)
|
||||
return newService(Dependencies{
|
||||
DB: db,
|
||||
UserCreator: userCreator,
|
||||
Signer: fakeSigner{},
|
||||
AuditLog: fakeAuditLogger{},
|
||||
}, actorService)
|
||||
}
|
||||
|
||||
func signupTokenUsageCount(t *testing.T, svc *Service, tokenID string) int {
|
||||
t.Helper()
|
||||
tokens, _, err := svc.ListSignupTokens(t.Context(), listAllOptions())
|
||||
require.NoError(t, err)
|
||||
for _, tok := range tokens {
|
||||
if tok.ID == tokenID {
|
||||
return tok.UsageCount
|
||||
}
|
||||
}
|
||||
t.Fatalf("signup token %q not found", tokenID)
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestSignUpConsumesTokenOnSuccess(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newSignupServiceForTest(t, db, fakeUserCreator{user: model.User{Base: model.Base{ID: "new-user"}}})
|
||||
|
||||
token, err := svc.CreateSignupToken(t.Context(), time.Hour, 2, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
config := appconfig.NewTestConfig(nil)
|
||||
user, accessToken, err := svc.SignUp(t.Context(), config, signUpDto{
|
||||
Username: "newuser",
|
||||
Token: token.Token,
|
||||
}, "1.2.3.4", "test-agent")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new-user", user.ID)
|
||||
require.Equal(t, "access-token", accessToken)
|
||||
|
||||
// The token's usage count must have been incremented and not rolled back
|
||||
require.Equal(t, 1, signupTokenUsageCount(t, svc, token.ID))
|
||||
}
|
||||
|
||||
func TestSignUpCompensatesTokenOnFailure(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
boom := errors.New("could not create user")
|
||||
svc := newSignupServiceForTest(t, db, fakeUserCreator{err: boom})
|
||||
|
||||
token, err := svc.CreateSignupToken(t.Context(), time.Hour, 2, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
config := appconfig.NewTestConfig(nil)
|
||||
_, _, err = svc.SignUp(t.Context(), config, signUpDto{
|
||||
Username: "newuser",
|
||||
Token: token.Token,
|
||||
}, "1.2.3.4", "test-agent")
|
||||
require.ErrorIs(t, err, boom)
|
||||
|
||||
// The usage count increment must have been compensated (reverted back to 0)
|
||||
require.Equal(t, 0, signupTokenUsageCount(t, svc, token.ID))
|
||||
}
|
||||
|
||||
func TestSignUpRejectsInvalidToken(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := newSignupServiceForTest(t, db, fakeUserCreator{user: model.User{Base: model.Base{ID: "new-user"}}})
|
||||
|
||||
config := appconfig.NewTestConfig(nil)
|
||||
_, _, err := svc.SignUp(t.Context(), config, signUpDto{
|
||||
Username: "newuser",
|
||||
Token: "not-a-real-token",
|
||||
}, "1.2.3.4", "test-agent")
|
||||
|
||||
var invalidErr *common.TokenInvalidOrExpiredError
|
||||
require.ErrorAs(t, err, &invalidErr)
|
||||
}
|
||||
|
||||
// listAllOptions returns list options that return every token on a single page.
|
||||
func listAllOptions() utils.ListRequestOptions {
|
||||
var opts utils.ListRequestOptions
|
||||
opts.Page = 1
|
||||
opts.Limit = 100
|
||||
return opts
|
||||
}
|
||||
@@ -248,8 +248,10 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
|
||||
}
|
||||
|
||||
session := gowebauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
UserVerification: protocol.UserVerificationRequirement(storedSession.UserVerification),
|
||||
CredParams: storedSession.CredentialParams,
|
||||
}
|
||||
|
||||
var user *model.User
|
||||
@@ -450,8 +452,10 @@ func (s *Service) CreateReauthenticationTokenWithWebauthn(ctx context.Context, s
|
||||
}
|
||||
|
||||
session := gowebauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
UserVerification: protocol.UserVerificationRequirement(storedSession.UserVerification),
|
||||
CredParams: storedSession.CredentialParams,
|
||||
}
|
||||
|
||||
// Validate the credential assertion
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op on PostgreSQL
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op on PostgreSQL because its OAuth storage types already match the export format
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Recreate the one_time_access_tokens table with the schema it had before it was dropped.
|
||||
CREATE TABLE one_time_access_tokens
|
||||
(
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
device_token VARCHAR(16)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||
|
||||
-- Recreate the signup token tables with the schema they had before they were frozen.
|
||||
CREATE TABLE signup_tokens (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
|
||||
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
|
||||
|
||||
CREATE TABLE signup_tokens_user_groups
|
||||
(
|
||||
signup_token_id UUID NOT NULL,
|
||||
user_group_id UUID NOT NULL,
|
||||
PRIMARY KEY (signup_token_id, user_group_id),
|
||||
FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Restore the signup tokens from the frozen JSON document stored in the "kv" table.
|
||||
-- json_array_elements expands the JSON array into one row per token object.
|
||||
INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count)
|
||||
SELECT
|
||||
(e ->> 'id')::uuid,
|
||||
to_timestamp((e ->> 'createdAt')::bigint),
|
||||
e ->> 'token',
|
||||
to_timestamp((e ->> 'expiresAt')::bigint),
|
||||
(e ->> 'usageLimit')::int,
|
||||
(e ->> 'usageCount')::int
|
||||
FROM kv, json_array_elements(kv."value"::json) AS e
|
||||
WHERE kv."key" = 'signup_tokens_migrated';
|
||||
|
||||
-- Restore the token/user-group associations, expanding each token's nested userGroupIds array.
|
||||
INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id)
|
||||
SELECT
|
||||
(e ->> 'id')::uuid,
|
||||
g.value::uuid
|
||||
FROM kv, json_array_elements(kv."value"::json) AS e, json_array_elements_text(e -> 'userGroupIds') AS g
|
||||
WHERE kv."key" = 'signup_tokens_migrated';
|
||||
|
||||
-- Remove the frozen signup tokens from the "kv" table.
|
||||
DELETE FROM kv WHERE "key" = 'signup_tokens_migrated';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- One-time access tokens are now stored in the actor state store, so the table is no longer needed.
|
||||
DROP TABLE IF EXISTS one_time_access_tokens;
|
||||
|
||||
-- Freeze the signup tokens.
|
||||
-- Encode every signup token (with its user group IDs) as a single JSON array and store it in the "kv" table under the "signup_tokens_migrated" key, so the singleton signup token actor can seed its state from it on first startup.
|
||||
-- The "HAVING count(*) > 0" clause ensures nothing is written to the "kv" table when there are no signup tokens.
|
||||
-- Timestamps are stored as Unix seconds so the frozen format is identical across databases.
|
||||
INSERT INTO kv ("key", "value")
|
||||
SELECT 'signup_tokens_migrated', json_agg(
|
||||
json_build_object(
|
||||
'id', st.id,
|
||||
'token', st.token,
|
||||
'expiresAt', extract(epoch FROM st.expires_at)::bigint,
|
||||
'usageLimit', st.usage_limit,
|
||||
'usageCount', st.usage_count,
|
||||
'createdAt', extract(epoch FROM st.created_at)::bigint,
|
||||
'userGroupIds', COALESCE(
|
||||
(SELECT json_agg(stug.user_group_id) FROM signup_tokens_user_groups stug WHERE stug.signup_token_id = st.id),
|
||||
'[]'::json
|
||||
)
|
||||
)
|
||||
)::text
|
||||
FROM signup_tokens st
|
||||
HAVING count(*) > 0;
|
||||
|
||||
-- Drop the now-frozen signup token tables.
|
||||
DROP TABLE signup_tokens_user_groups;
|
||||
DROP TABLE signup_tokens;
|
||||
@@ -0,0 +1,151 @@
|
||||
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;
|
||||
@@ -0,0 +1,152 @@
|
||||
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,62 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- Recreate the one_time_access_tokens table with the schema it had before it was dropped.
|
||||
CREATE TABLE one_time_access_tokens
|
||||
(
|
||||
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,
|
||||
device_token TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||
|
||||
-- Recreate the signup token tables with the schema they had before they were frozen.
|
||||
CREATE TABLE signup_tokens (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
|
||||
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
|
||||
|
||||
CREATE TABLE signup_tokens_user_groups
|
||||
(
|
||||
signup_token_id TEXT NOT NULL,
|
||||
user_group_id TEXT NOT NULL,
|
||||
PRIMARY KEY (signup_token_id, user_group_id),
|
||||
FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Restore the signup tokens from the frozen JSON document stored in the "kv" table.
|
||||
-- json_each expands the JSON array into one row per token object.
|
||||
INSERT INTO signup_tokens (id, created_at, token, expires_at, usage_limit, usage_count)
|
||||
SELECT
|
||||
json_extract(e.value, '$.id'),
|
||||
json_extract(e.value, '$.createdAt'),
|
||||
json_extract(e.value, '$.token'),
|
||||
json_extract(e.value, '$.expiresAt'),
|
||||
json_extract(e.value, '$.usageLimit'),
|
||||
json_extract(e.value, '$.usageCount')
|
||||
FROM kv, json_each(kv."value") AS e
|
||||
WHERE kv."key" = 'signup_tokens_migrated';
|
||||
|
||||
-- Restore the token/user-group associations, expanding each token's nested userGroupIds array.
|
||||
INSERT INTO signup_tokens_user_groups (signup_token_id, user_group_id)
|
||||
SELECT
|
||||
json_extract(e.value, '$.id'),
|
||||
g.value
|
||||
FROM kv, json_each(kv."value") AS e, json_each(json_extract(e.value, '$.userGroupIds')) AS g
|
||||
WHERE kv."key" = 'signup_tokens_migrated';
|
||||
|
||||
-- Remove the frozen signup tokens from the "kv" table.
|
||||
DELETE FROM kv WHERE "key" = 'signup_tokens_migrated';
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,35 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
-- One-time access tokens are now stored in the actor state store, so the table is no longer needed.
|
||||
DROP TABLE IF EXISTS one_time_access_tokens;
|
||||
|
||||
-- Freeze the signup tokens.
|
||||
-- Encode every signup token (with its user group IDs) as a single JSON array and store it in the "kv" table under the "signup_tokens_migrated" key, so the singleton signup token actor can seed its state from it on first startup.
|
||||
-- The "HAVING count(*) > 0" clause ensures nothing is written to the "kv" table when there are no signup tokens.
|
||||
-- Timestamps are stored as Unix seconds, matching how DateTime values are persisted on SQLite.
|
||||
INSERT INTO kv ("key", "value")
|
||||
SELECT 'signup_tokens_migrated', json_group_array(
|
||||
json_object(
|
||||
'id', st.id,
|
||||
'token', st.token,
|
||||
'expiresAt', st.expires_at,
|
||||
'usageLimit', st.usage_limit,
|
||||
'usageCount', st.usage_count,
|
||||
'createdAt', st.created_at,
|
||||
'userGroupIds', json((
|
||||
SELECT COALESCE(json_group_array(stug.user_group_id), json_array())
|
||||
FROM signup_tokens_user_groups stug
|
||||
WHERE stug.signup_token_id = st.id
|
||||
))
|
||||
)
|
||||
)
|
||||
FROM signup_tokens st
|
||||
HAVING count(*) > 0;
|
||||
|
||||
-- Drop the now-frozen signup token tables.
|
||||
DROP TABLE signup_tokens_user_groups;
|
||||
DROP TABLE signup_tokens;
|
||||
|
||||
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.7.0",
|
||||
"react-email": "6.9.0",
|
||||
"tsx": "^4.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"my_account": "Minha Conta",
|
||||
"logout": "Sair",
|
||||
"confirm": "Confirmar",
|
||||
"docs": "Documentos",
|
||||
"docs": "Documentação",
|
||||
"key": "Chave",
|
||||
"value": "Valor",
|
||||
"remove_custom_claim": "Tirar reivindicação personalizada",
|
||||
"remove_custom_claim": "Remover reivindicação personalizada",
|
||||
"add_custom_claim": "Adicionar reivindicação personalizada",
|
||||
"add_another": "Adicionar outro",
|
||||
"select_a_date": "Selecione a data",
|
||||
"select_a_date": "Selecione uma data",
|
||||
"select_file": "Selecionar Arquivo",
|
||||
"profile_picture": "Foto de Perfil",
|
||||
"profile_picture_is_managed_by_ldap_server": "A foto de perfil é gerenciada pelo servidor LDAP e não pode ser alterada aqui.",
|
||||
@@ -28,7 +28,7 @@
|
||||
"login_background": "Histórico de login",
|
||||
"logo": "Logotipo",
|
||||
"login_code": "Código de Login",
|
||||
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crie um código de login de uso único para que o usuário possa entrar sem precisar de uma chave de acesso.",
|
||||
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crie um código de login para o usuário entrar uma vez sem chave de acesso.",
|
||||
"one_hour": "1 hora",
|
||||
"twelve_hours": "12 horas",
|
||||
"one_day": "1 dia",
|
||||
@@ -46,9 +46,9 @@
|
||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "O autenticador não suporta nenhum dos algoritmos solicitados",
|
||||
"webauthn_error_invalid_rp_id": "A identificação da parte confiável configurada não está válida.",
|
||||
"webauthn_error_invalid_domain": "O domínio configurado não está certo.",
|
||||
"contact_administrator_to_fix": "Fala com o administrador pra resolver esse problema.",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "A operação não foi permitida ou expirou.",
|
||||
"webauthn_not_supported_by_browser": "As chaves de acesso não são suportadas por este navegador. Por favor, use um método alternativo de login.",
|
||||
"contact_administrator_to_fix": "Fala com o administrador para resolver esse problema.",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "A operação não foi permitida ou expirou",
|
||||
"webauthn_not_supported_by_browser": "Chaves de acesso não são suportadas por este navegador. Por favor, use um método de login alternativo.",
|
||||
"critical_error_occurred_contact_administrator": "Ocorreu um erro grave. Por favor, entre em contato com o administrador.",
|
||||
"sign_in_to": "Entrar em {name}",
|
||||
"account_selection_signin_confirmation": "Queres usar a seguinte conta para continuar <b>{name}</b>?",
|
||||
|
||||
5429
frontend/package-lock.json
generated
Normal file
5429
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,11 @@
|
||||
placeholder={m.code()}
|
||||
aria-label={m.code()}
|
||||
bind:value={code}
|
||||
autofocus
|
||||
type="text"
|
||||
/>
|
||||
{:else}
|
||||
<InputOTP.Root maxlength={6} bind:value={code}>
|
||||
<InputOTP.Root maxlength={6} bind:value={code} autofocus>
|
||||
{#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">
|
||||
<div class="flex w-full flex-col gap-4 overflow-hidden pb-2 px-2">
|
||||
<FadeWrapper>
|
||||
<EmailVerificationStateBox />
|
||||
{@render children()}
|
||||
|
||||
832
pnpm-lock.yaml
generated
832
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,6 @@
|
||||
"prettier": "^3.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.17"
|
||||
"adm-zip": "^0.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260708130000,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
|
||||
"version": 20260723000000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
"oidc_clients",
|
||||
"signup_tokens",
|
||||
"apis",
|
||||
"api_permissions",
|
||||
"oidc_clients_allowed_api_permissions"
|
||||
],
|
||||
"tables": {
|
||||
"apis": [
|
||||
{
|
||||
@@ -229,6 +237,10 @@
|
||||
"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"
|
||||
@@ -238,70 +250,50 @@
|
||||
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
|
||||
}
|
||||
],
|
||||
"one_time_access_tokens": [
|
||||
"oauth2_jtis": [
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-25T13:39:02Z",
|
||||
"id": "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
|
||||
"token": "HPe6k6uiDRRVuAQV",
|
||||
"device_token": null,
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-25T12:39:01Z",
|
||||
"id": "d3afae24-fe2d-4a98-abec-cf0b8525096a",
|
||||
"token": "YCGDtftvsvYWiXd0",
|
||||
"device_token": null,
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-25T13:39:02Z",
|
||||
"id": "defd5164-9d9b-4228-bbce-708e33f49360",
|
||||
"token": "one-time-token",
|
||||
"device_token": null,
|
||||
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"signup_tokens": [
|
||||
"oauth2_sessions": [
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-26T12:39:02Z",
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"token": "VALID1234567890A",
|
||||
"usage_count": 0,
|
||||
"usage_limit": 1
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-12-02T12:39:02Z",
|
||||
"id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||
"token": "PARTIAL567890ABC",
|
||||
"usage_count": 2,
|
||||
"usage_limit": 5
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-24T12:39:02Z",
|
||||
"id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
|
||||
"token": "EXPIRED34567890B",
|
||||
"usage_count": 1,
|
||||
"usage_limit": 3
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-26T12:39:02Z",
|
||||
"id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||
"token": "FULLYUSED567890C",
|
||||
"usage_count": 1,
|
||||
"usage_limit": 1
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"signup_tokens_user_groups": [
|
||||
"interaction_sessions": [
|
||||
{
|
||||
"signup_token_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"user_authorized_oidc_clients": [
|
||||
|
||||
@@ -59,7 +59,7 @@ test('Export via stdout', async ({ baseURL }) => {
|
||||
compareExports(exampleExportPath, stdoutExtractPath);
|
||||
});
|
||||
|
||||
test('Import', async () => {
|
||||
test('Import SQLite export', async () => {
|
||||
// Reset the backend without seeding
|
||||
await cleanupBackend({ skipSeed: true });
|
||||
|
||||
@@ -83,7 +83,7 @@ test('Import', async () => {
|
||||
compareExports(exampleExportPath, exportExtracted);
|
||||
});
|
||||
|
||||
test('Import via stdin', async () => {
|
||||
test('Import SQLite export via stdin', async () => {
|
||||
await cleanupBackend({ skipSeed: true });
|
||||
|
||||
const exampleExportArchivePath = path.join(tmpDir, 'example-export-stdin.zip');
|
||||
|
||||
Reference in New Issue
Block a user