Compare commits

...

45 Commits

Author SHA1 Message Date
Elias Schneider
57cb8f8795 release: 0.46.0 2025-04-13 20:31:09 +02:00
Elias Schneider
fcb18b8c3c chore(translations): update translations via Crowdin (#427)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-13 20:30:43 +02:00
Alessandro (Ale) Segala
796bc7ed34 fix: improve LDAP error handling (#425)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-12 18:38:19 -04:00
Arne Skaar Fismen
72061ba427 feat(onboarding): Added button when you don't have a passkey added. (#426)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-12 02:27:01 +00:00
dependabot[bot]
d04167cada chore(deps-dev): bump vite from 6.2.5 to 6.2.6 in /frontend in the npm_and_yarn group across 1 directory (#433) 2025-04-11 20:07:40 -05:00
Alessandro (Ale) Segala
f83bab9e17 refactor: simplify app_config service and fix race conditions (#423) 2025-04-10 13:41:22 +02:00
Elias Schneider
4ba68938dd fix: ignore profile picture cache after profile picture gets updated 2025-04-09 15:51:58 +02:00
Elias Schneider
658a9ca6dd fix: add missing rollback for LDAP sync 2025-04-09 14:05:53 +02:00
Andreas Schneider
7e5d16be9b feat: implement token introspection (#405)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-09 07:18:03 +00:00
Elias Schneider
8d6c1e5c08 chore(translations): update translations via Crowdin (#420) 2025-04-09 02:09:01 -05:00
Elias Schneider
ce6e27d0ff refactor: rollback db changes with defer everywhere 2025-04-06 23:40:56 +02:00
Elias Schneider
3ebff09d63 chore(translations): update translations via Crowdin (#416)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-06 22:15:05 +02:00
Elias Schneider
ccc18d716f fix: use UUID for temporary file names 2025-04-06 15:11:19 +02:00
Alessandro (Ale) Segala
ec626ee797 fix: use transactions when operations involve multiple database queries (#392)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-06 15:04:08 +02:00
Kyle Mendell
c810fec8c4 docs: update swagger description to use markdown (#418) 2025-04-05 16:07:56 +02:00
Alessandro (Ale) Segala
9e88926283 fix: ensure indexes on audit_logs table (#415)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-04 17:05:32 +00:00
dependabot[bot]
731113183e chore(deps-dev): bump vite from 6.2.4 to 6.2.5 in /frontend in the npm_and_yarn group across 1 directory (#417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 16:15:37 +00:00
Elias Schneider
4627f365a2 chore(translations): fix mistakes in source strings 2025-04-04 13:55:15 +02:00
Elias Schneider
1762629596 perf: run async operations in parallel in server load functions 2025-04-04 11:39:13 +02:00
Alessandro (Ale) Segala
2f7646105e fix: ensure file descriptors are closed + other bugs (#413) 2025-04-04 10:04:36 +02:00
Elias Schneider
980780e48b chore(translations): update translations via Crowdin (#414) 2025-04-04 09:06:44 +02:00
Kyle Mendell
b65e693e12 feat: global audit log (#320)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-03 10:11:49 -05:00
Kyle Mendell
734c6813ea fix: create reusable default profile pictures (#406)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-03 08:06:56 -05:00
dependabot[bot]
0d31c0ec6c chore(deps-dev): bump vite from 6.2.3 to 6.2.4 in /frontend in the npm_and_yarn group across 1 directory (#410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 14:04:02 -05:00
jose_d
4806c1e09b chore(translations): improve czech translation strings (#408) 2025-03-31 08:22:06 -05:00
Elias Schneider
cf3084cfa8 refactor: remove cors exception from middleware as this is handled by the handler 2025-03-30 22:30:22 +02:00
Kyle Mendell
9881a1df9e feat: modernize ui (#381)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-30 13:19:14 -05:00
Elias Schneider
5dcf69e974 release: 0.45.0 2025-03-30 00:12:19 +01:00
Alessandro (Ale) Segala
519d58d88c fix: use WAL for SQLite by default and set busy_timeout (#388)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 23:12:48 +01:00
Alessandro (Ale) Segala
b3b43a56af refactor: do not include test controller in production builds (#402)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-29 22:11:25 +00:00
Elias Schneider
fc68cf7eb2 chore(translations): add Brazilian Portuguese 2025-03-29 23:03:18 +01:00
Elias Schneider
8ca7873802 chore(translations): update translations via Crowdin (#394)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 22:59:24 +01:00
Elias Schneider
591bf841f5 Merge remote-tracking branch 'origin/main' 2025-03-29 22:56:04 +01:00
Kyle Mendell
8f8884d208 refactor: add swagger title and version info (#399) 2025-03-29 21:55:47 +00:00
Elias Schneider
7e658276f0 fix: ldap users aren't deleted if removed from ldap server 2025-03-29 22:55:44 +01:00
Gutyina Gergő
583a1f8fee chore(deps): install inlang plugins from npm (#401)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 22:50:51 +01:00
Rich
b935a4824a ci/cd: migrate backend linter to v2. fixed unit test workflow (#400) 2025-03-28 04:00:55 -05:00
Elias Schneider
cbd1bbdf74 fix: use value receiver for AuditLogData 2025-03-27 22:41:19 +01:00
Alessandro (Ale) Segala
96876a99c5 feat: add support for ECDSA and EdDSA keys (#359)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-27 18:20:39 +01:00
Elias Schneider
5c198c280c refactor: fix code smells 2025-03-27 17:46:10 +01:00
Elias Schneider
c9e0073b63 refactor: fix code smells 2025-03-27 16:48:36 +01:00
Elias Schneider
6fa26c97be ci/cd: run linter only on backend changes 2025-03-27 16:18:15 +01:00
Elias Schneider
6746dbf41e chore(translations): update translations via Crowdin (#386) 2025-03-27 15:15:22 +00:00
Rich
4ac1196d8d ci/cd: add basic static analysis for backend (#389) 2025-03-27 16:13:56 +01:00
Sam
4d049bbe24 docs: update .env.example to reflect the new documentation location (#385) 2025-03-25 21:53:23 +00:00
159 changed files with 12283 additions and 7141 deletions

View File

@@ -1,4 +1,4 @@
# See the README for more information: https://github.com/pocket-id/pocket-id?tab=readme-ov-file#environment-variables # See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
PUBLIC_APP_URL=http://localhost PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false TRUST_PROXY=false
MAXMIND_LICENSE_KEY= MAXMIND_LICENSE_KEY=

39
.github/workflows/backend-linter.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Run Backend Linter
on:
push:
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
- "backend/**"
permissions:
# Required: allow read access to the content for analysis.
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
with:
version: v2.0.2
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -27,6 +27,7 @@ jobs:
with: with:
tags: pocket-id/pocket-id:test tags: pocket-id/pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
- name: Upload Docker image artifact - name: Upload Docker image artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -51,6 +52,7 @@ jobs:
with: with:
name: docker-image name: docker-image
path: /tmp path: /tmp
- name: Load Docker Image - name: Load Docker Image
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
@@ -69,6 +71,8 @@ jobs:
-e APP_ENV=test \ -e APP_ENV=test \
pocket-id/pocket-id:test pocket-id/pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
@@ -81,6 +85,14 @@ jobs:
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-sqlite
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres: test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -140,9 +152,11 @@ jobs:
-p 80:80 \ -p 80:80 \
-e APP_ENV=test \ -e APP_ENV=test \
-e DB_PROVIDER=postgres \ -e DB_PROVIDER=postgres \
-e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \ -e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
pocket-id/pocket-id:test pocket-id/pocket-id:test
docker logs -f pocket-id-postgres &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
@@ -154,3 +168,11 @@ jobs:
path: frontend/tests/.report path: frontend/tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-postgres
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

View File

@@ -25,6 +25,7 @@ jobs:
- name: Run backend unit tests - name: Run backend unit tests
working-directory: backend working-directory: backend
run: | run: |
set -e -o pipefail
go test -v ./... | tee /tmp/TestResults.log go test -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()

View File

@@ -1 +1 @@
0.44.0 0.46.0

4
.vscode/launch.json vendored
View File

@@ -5,7 +5,7 @@
"name": "Backend", "name": "Backend",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"envFile": "${workspaceFolder}/backend/.env.example", "envFile": "${workspaceFolder}/backend/cmd/.env",
"env": { "env": {
"APP_ENV": "development" "APP_ENV": "development"
}, },
@@ -16,7 +16,7 @@
"name": "Frontend", "name": "Frontend",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"envFile": "${workspaceFolder}/frontend/.env.example", "envFile": "${workspaceFolder}/frontend/.env",
"cwd": "${workspaceFolder}/frontend", "cwd": "${workspaceFolder}/frontend",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": [ "runtimeArgs": [

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"go.buildTags": "e2etest"
}

View File

@@ -1,3 +1,44 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.45.0...v) (2025-04-13)
### Features
* global audit log ([#320](https://github.com/pocket-id/pocket-id/issues/320)) ([b65e693](https://github.com/pocket-id/pocket-id/commit/b65e693e12be2e7e4cb75a74d6fd43bacb3f6a94))
* implement token introspection ([#405](https://github.com/pocket-id/pocket-id/issues/405)) ([7e5d16b](https://github.com/pocket-id/pocket-id/commit/7e5d16be9bdfccfa113924547e313886681d11bb))
* modernize ui ([#381](https://github.com/pocket-id/pocket-id/issues/381)) ([9881a1d](https://github.com/pocket-id/pocket-id/commit/9881a1df9efe32608ab116db71c0e4f66dae171c))
* **onboarding:** Added button when you don't have a passkey added. ([#426](https://github.com/pocket-id/pocket-id/issues/426)) ([72061ba](https://github.com/pocket-id/pocket-id/commit/72061ba4278a007437cee3a205c3076d58bde644))
### Bug Fixes
* add missing rollback for LDAP sync ([658a9ca](https://github.com/pocket-id/pocket-id/commit/658a9ca6dd8d2304ff3639a000bab02e91ff68a6))
* create reusable default profile pictures ([#406](https://github.com/pocket-id/pocket-id/issues/406)) ([734c681](https://github.com/pocket-id/pocket-id/commit/734c6813eaef166235ae801747e3652d17ae0e2a))
* ensure file descriptors are closed + other bugs ([#413](https://github.com/pocket-id/pocket-id/issues/413)) ([2f76461](https://github.com/pocket-id/pocket-id/commit/2f7646105e26423f47cbe49dae97e40c4a01a025))
* ensure indexes on audit_logs table ([#415](https://github.com/pocket-id/pocket-id/issues/415)) ([9e88926](https://github.com/pocket-id/pocket-id/commit/9e88926283a7a663bfc7fd4f4aa16bd02f614176))
* ignore profile picture cache after profile picture gets updated ([4ba6893](https://github.com/pocket-id/pocket-id/commit/4ba68938dd2a631c633fcb65d8c35cb039d3f59c))
* improve LDAP error handling ([#425](https://github.com/pocket-id/pocket-id/issues/425)) ([796bc7e](https://github.com/pocket-id/pocket-id/commit/796bc7ed3453839b1dc8d846b71fe9fac9a2d646))
* use transactions when operations involve multiple database queries ([#392](https://github.com/pocket-id/pocket-id/issues/392)) ([ec626ee](https://github.com/pocket-id/pocket-id/commit/ec626ee7977306539fd1d70cc9091590f0a54af6))
* use UUID for temporary file names ([ccc18d7](https://github.com/pocket-id/pocket-id/commit/ccc18d716f16a7ef1775d30982e2ba7b5ff159a6))
### Performance Improvements
* run async operations in parallel in server load functions ([1762629](https://github.com/pocket-id/pocket-id/commit/17626295964244c5582806bd0f413da2c799d5ad))
## [](https://github.com/pocket-id/pocket-id/compare/v0.44.0...v) (2025-03-29)
### Features
* add support for ECDSA and EdDSA keys ([#359](https://github.com/pocket-id/pocket-id/issues/359)) ([96876a9](https://github.com/pocket-id/pocket-id/commit/96876a99c586508b72c27669ab200ff6a29db771))
### Bug Fixes
* ldap users aren't deleted if removed from ldap server ([7e65827](https://github.com/pocket-id/pocket-id/commit/7e658276f04d08a1f5117796e55d45e310204dab))
* use value receiver for `AuditLogData` ([cbd1bbd](https://github.com/pocket-id/pocket-id/commit/cbd1bbdf741eedd03e93598d67623c75c74b6212))
* use WAL for SQLite by default and set busy_timeout ([#388](https://github.com/pocket-id/pocket-id/issues/388)) ([519d58d](https://github.com/pocket-id/pocket-id/commit/519d58d88c906abc5139e35933bdeba0396c10a2))
## [](https://github.com/pocket-id/pocket-id/compare/v0.43.1...v) (2025-03-25) ## [](https://github.com/pocket-id/pocket-id/compare/v0.43.1...v) (2025-03-25)

View File

@@ -49,7 +49,7 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go.
1. Open the `backend` folder 1. Open the `backend` folder
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development` 2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
3. Start the backend with `go run cmd/main.go` 3. Start the backend with `go run -tags e2etest ./cmd`
### Frontend ### Frontend

View File

@@ -1,3 +1,6 @@
# Tags passed to "go build"
ARG BUILD_TAGS=""
# Stage 1: Build Frontend # Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -8,7 +11,8 @@ RUN npm run build
RUN npm prune --production RUN npm prune --production
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.23-alpine AS backend-builder FROM golang:1.24-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /app/backend WORKDIR /app/backend
COPY ./backend/go.mod ./backend/go.sum ./ COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download RUN go mod download
@@ -17,7 +21,12 @@ RUN apk add --no-cache gcc musl-dev
COPY ./backend ./ COPY ./backend ./
WORKDIR /app/backend/cmd WORKDIR /app/backend/cmd
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend . RUN CGO_ENABLED=1 \
GOOS=linux \
go build \
-tags "${BUILD_TAGS}" \
-o /app/backend/pocket-id-backend \
.
# Stage 3: Production Image # Stage 3: Production Image
FROM node:22-alpine FROM node:22-alpine

64
backend/.golangci.yml Normal file
View File

@@ -0,0 +1,64 @@
version: "2"
run:
tests: true
timeout: 5m
linters:
default: none
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- contextcheck
- copyloopvar
- durationcheck
- errcheck
- errchkjson
- errorlint
- exhaustive
- gocheckcompilerdirectives
- gochecksumtype
- gocognit
- gocritic
- gosec
- gosmopolitan
- govet
- ineffassign
- loggercheck
- makezero
- musttag
- nilerr
- nilnesserr
- noctx
- protogetter
- reassign
- recvcheck
- rowserrcheck
- spancheck
- sqlclosecheck
- staticcheck
- testifylint
- unused
- usestdlibvars
- zerologlint
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- internal/service/test_service.go
formatters:
enable:
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -4,6 +4,10 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/bootstrap" "github.com/pocket-id/pocket-id/backend/internal/bootstrap"
) )
// @title Pocket ID API
// @version 1.0
// @description.markdown
func main() { func main() {
bootstrap.Bootstrap() bootstrap.Bootstrap()
} }

View File

@@ -1,6 +1,6 @@
module github.com/pocket-id/pocket-id/backend module github.com/pocket-id/pocket-id/backend
go 1.23.1 go 1.24
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
@@ -14,11 +14,10 @@ require (
github.com/go-ldap/ldap/v3 v3.4.10 github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
github.com/go-webauthn/webauthn v0.11.2 github.com/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
@@ -45,6 +44,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect github.com/go-webauthn/x v0.1.16 // indirect
github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect github.com/google/go-tpm v0.9.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect

View File

@@ -145,8 +145,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA= github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms= github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 h1:HHT8iW+UcPBgBr5A3soZQQsL5cBor/u6BkLB+wzY/R0= github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc= github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=

View File

@@ -1,17 +1,24 @@
package bootstrap package bootstrap
import ( import (
"context"
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
func Bootstrap() { func Bootstrap() {
ctx := context.TODO()
initApplicationImages() initApplicationImages()
migrateConfigDBConnstring()
db := newDatabase() db := newDatabase()
appConfigService := service.NewAppConfigService(db) appConfigService := service.NewAppConfigService(ctx, db)
migrateKey() migrateKey()
initRouter(db, appConfigService) initRouter(ctx, db, appConfigService)
} }

View File

@@ -0,0 +1,34 @@
package bootstrap
import (
"log"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// Performs the migration of the database connection string
// See: https://github.com/pocket-id/pocket-id/pull/388
func migrateConfigDBConnstring() {
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Check if we're using the deprecated SqliteDBPath env var
if common.EnvConfig.SqliteDBPath != "" {
connString := "file:" + common.EnvConfig.SqliteDBPath + "?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate"
common.EnvConfig.DbConnectionString = connString
common.EnvConfig.SqliteDBPath = ""
log.Printf("[WARN] Env var 'SQLITE_DB_PATH' is deprecated - use 'DB_CONNECTION_STRING' instead with the value: '%s'", connString)
}
case common.DbProviderPostgres:
// Check if we're using the deprecated PostgresConnectionString alias
if common.EnvConfig.PostgresConnectionString != "" {
common.EnvConfig.DbConnectionString = common.EnvConfig.PostgresConnectionString
common.EnvConfig.PostgresConnectionString = ""
log.Print("[WARN] Env var 'POSTGRES_CONNECTION_STRING' is deprecated - use 'DB_CONNECTION_STRING' instead with the same value")
}
default:
// We don't do anything here in the default case
// This is an error, but will be handled later on
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"time" "time"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
@@ -38,6 +39,7 @@ func newDatabase() (db *gorm.DB) {
case common.DbProviderPostgres: case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default: default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider) log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
if err != nil { if err != nil {
@@ -56,17 +58,17 @@ func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations // Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider)) source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil { if err != nil {
return fmt.Errorf("failed to create embedded migration source: %v", err) return fmt.Errorf("failed to create embedded migration source: %w", err)
} }
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver) m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil { if err != nil {
return fmt.Errorf("failed to create migration instance: %v", err) return fmt.Errorf("failed to create migration instance: %w", err)
} }
err = m.Up() err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) { if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %v", err) return fmt.Errorf("failed to apply migrations: %w", err)
} }
return nil return nil
@@ -78,9 +80,18 @@ func connectDatabase() (db *gorm.DB, err error) {
// Choose the correct database provider // Choose the correct database provider
switch common.EnvConfig.DbProvider { switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite: case common.DbProviderSqlite:
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath) if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
}
dialector = sqlite.Open(common.EnvConfig.DbConnectionString)
case common.DbProviderPostgres: case common.DbProviderPostgres:
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString) if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
default: default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
@@ -91,14 +102,14 @@ func connectDatabase() (db *gorm.DB, err error) {
Logger: getLogger(), Logger: getLogger(),
}) })
if err == nil { if err == nil {
break return db, nil
} else {
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
} }
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
} }
return db, err return nil, err
} }
func getLogger() logger.Interface { func getLogger() logger.Interface {

View File

@@ -0,0 +1,21 @@
//go:build e2etest
package bootstrap
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// When building for E2E tests, add the e2etest controller
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService){
func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService) {
testService := service.NewTestService(db, appConfigService, jwtService)
controller.NewTestController(apiGroup, testService)
},
}
}

View File

@@ -92,7 +92,10 @@ func loadKeyPEM(path string) (jwk.Key, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err) return nil, fmt.Errorf("failed to generate key ID: %w", err)
} }
key.Set(jwk.KeyIDKey, keyId) err = key.Set(jwk.KeyIDKey, keyId)
if err != nil {
return nil, fmt.Errorf("failed to set key ID: %w", err)
}
// Populate other required fields // Populate other required fields
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning) _ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)

View File

@@ -101,25 +101,25 @@ func TestLoadKeyPEM(t *testing.T) {
// Check key ID is set // Check key ID is set
var keyID string var keyID string
err = key.Get(jwk.KeyIDKey, &keyID) err = key.Get(jwk.KeyIDKey, &keyID)
assert.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, keyID) assert.NotEmpty(t, keyID)
// Check algorithm is set // Check algorithm is set
var alg jwa.SignatureAlgorithm var alg jwa.SignatureAlgorithm
err = key.Get(jwk.AlgorithmKey, &alg) err = key.Get(jwk.AlgorithmKey, &alg)
assert.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, alg) assert.NotEmpty(t, alg)
// Check key usage is set // Check key usage is set
var keyUsage string var keyUsage string
err = key.Get(jwk.KeyUsageKey, &keyUsage) err = key.Get(jwk.KeyUsageKey, &keyUsage)
assert.NoError(t, err) require.NoError(t, err)
assert.Equal(t, service.KeyUsageSigning, keyUsage) assert.Equal(t, service.KeyUsageSigning, keyUsage)
}) })
t.Run("file not found", func(t *testing.T) { t.Run("file not found", func(t *testing.T) {
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem")) key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
assert.Error(t, err) require.Error(t, err)
assert.Nil(t, key) assert.Nil(t, key)
}) })
@@ -129,7 +129,7 @@ func TestLoadKeyPEM(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
key, err := loadKeyPEM(invalidPath) key, err := loadKeyPEM(invalidPath)
assert.Error(t, err) require.Error(t, err)
assert.Nil(t, key) assert.Nil(t, key)
}) })
} }

View File

@@ -1,6 +1,7 @@
package bootstrap package bootstrap
import ( import (
"context"
"log" "log"
"net" "net"
"time" "time"
@@ -16,7 +17,10 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { // This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService)
func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppConfigService) {
// Set the appropriate Gin mode based on the environment // Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv { switch common.EnvConfig.AppEnv {
case "production": case "production":
@@ -33,17 +37,16 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Initialize services // Initialize services
emailService, err := service.NewEmailService(appConfigService, db) emailService, err := service.NewEmailService(appConfigService, db)
if err != nil { if err != nil {
log.Fatalf("Unable to create email service: %s", err) log.Fatalf("Unable to create email service: %v", err)
} }
geoLiteService := service.NewGeoLiteService() geoLiteService := service.NewGeoLiteService(ctx)
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService) auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService) userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService) userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
apiKeyService := service.NewApiKeyService(db) apiKeyService := service.NewApiKeyService(db)
@@ -55,8 +58,9 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60)) r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
job.RegisterLdapJobs(ldapService, appConfigService) job.RegisterLdapJobs(ctx, ldapService, appConfigService)
job.RegisterDbCleanupJobs(db) job.RegisterDbCleanupJobs(ctx, db)
job.RegisterFileCleanupJobs(ctx, db)
// Initialize middleware for specific routes // Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService) authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
@@ -75,7 +79,9 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Add test controller in non-production environments // Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AppEnv != "production" {
controller.NewTestController(apiGroup, testService) for _, f := range registerTestControllers {
f(apiGroup, db, appConfigService, jwtService)
}
} }
// Set up base routes // Set up base routes

View File

@@ -20,8 +20,9 @@ type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"` AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER"`
SqliteDBPath string `env:"SQLITE_DB_PATH"` DbConnectionString string `env:"DB_CONNECTION_STRING"`
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` SqliteDBPath string `env:"SQLITE_DB_PATH"` // Deprecated: use "DB_CONNECTION_STRING" instead
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` // Deprecated: use "DB_CONNECTION_STRING" instead
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"` Port string `env:"BACKEND_PORT"`
@@ -35,7 +36,8 @@ type EnvConfigSchema struct {
var EnvConfig = &EnvConfigSchema{ var EnvConfig = &EnvConfigSchema{
AppEnv: "production", AppEnv: "production",
DbProvider: "sqlite", DbProvider: "sqlite",
SqliteDBPath: "data/pocket-id.db", DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
SqliteDBPath: "",
PostgresConnectionString: "", PostgresConnectionString: "",
UploadPath: "data/uploads", UploadPath: "data/uploads",
KeysPath: "data/keys", KeysPath: "data/keys",
@@ -56,12 +58,12 @@ func init() {
// Validate the environment variables // Validate the environment variables
switch EnvConfig.DbProvider { switch EnvConfig.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
if EnvConfig.SqliteDBPath == "" { if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable") log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
} }
case DbProviderPostgres: case DbProviderPostgres:
if EnvConfig.PostgresConnectionString == "" { if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable") log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
} }
default: default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")

View File

@@ -1,6 +1,7 @@
package common package common
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
) )
@@ -17,10 +18,16 @@ type AlreadyInUseError struct {
} }
func (e *AlreadyInUseError) Error() string { func (e *AlreadyInUseError) Error() string {
return fmt.Sprintf("%s is already in use", e.Property) return e.Property + " is already in use"
} }
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 } func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
func (e *AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
x := &AlreadyInUseError{}
return errors.As(target, &x)
}
type SetupAlreadyCompletedError struct{} type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" } func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }

View File

@@ -49,19 +49,19 @@ func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil { if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest) apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
if err != nil { if err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
var apiKeysDto []dto.ApiKeyDto var apiKeysDto []dto.ApiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -83,19 +83,19 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
var input dto.ApiKeyCreateDto var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil { if err := ctx.ShouldBindJSON(&input); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input) apiKey, token, err := c.apiKeyService.CreateApiKey(ctx.Request.Context(), userID, input)
if err != nil { if err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
var apiKeyDto dto.ApiKeyDto var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil { if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -116,8 +116,8 @@ func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID") userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id") apiKeyID := ctx.Param("id")
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil { if err := c.apiKeyService.RevokeApiKey(ctx.Request.Context(), userID, apiKeyID); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }

View File

@@ -1,8 +1,8 @@
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -60,19 +60,15 @@ type AppConfigController struct {
// @Failure 500 {object} object "{"error": "error message"}" // @Failure 500 {object} object "{"error": "error message"}"
// @Router /application-configuration [get] // @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false) configuration := acc.appConfigService.ListAppConfig(false)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.PublicAppConfigVariableDto var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
c.JSON(200, configVariablesDto) c.JSON(http.StatusOK, configVariablesDto)
} }
// listAllAppConfigHandler godoc // listAllAppConfigHandler godoc
@@ -85,19 +81,15 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /application-configuration/all [get] // @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true) configuration := acc.appConfigService.ListAppConfig(true)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
c.JSON(200, configVariablesDto) c.JSON(http.StatusOK, configVariablesDto)
} }
// updateAppConfigHandler godoc // updateAppConfigHandler godoc
@@ -113,19 +105,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input) savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var configVariablesDto []dto.AppConfigVariableDto var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil { if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -143,17 +135,17 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
// @Success 200 {file} binary "Logo image" // @Success 200 {file} binary "Logo image"
// @Router /api/application-configuration/logo [get] // @Router /api/application-configuration/logo [get]
func (acc *AppConfigController) getLogoHandler(c *gin.Context) { func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true" dbConfig := acc.appConfigService.GetDbConfig()
var imageName string lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageType string
var imageName, imageType string
if lightLogo { if lightLogo {
imageName = "logoLight" imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value imageType = dbConfig.LogoLightImageType.Value
} else { } else {
imageName = "logoDark" imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value imageType = dbConfig.LogoDarkImageType.Value
} }
acc.getImage(c, imageName, imageType) acc.getImage(c, imageName, imageType)
@@ -181,7 +173,7 @@ func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
// @Failure 404 {object} object "{"error": "File not found"}" // @Failure 404 {object} object "{"error": "File not found"}"
// @Router /api/application-configuration/background-image [get] // @Router /api/application-configuration/background-image [get]
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) { func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
acc.getImage(c, "background", imageType) acc.getImage(c, "background", imageType)
} }
@@ -196,17 +188,17 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/application-configuration/logo [put] // @Router /api/application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) { func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true" dbConfig := acc.appConfigService.GetDbConfig()
var imageName string lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageType string
var imageName, imageType string
if lightLogo { if lightLogo {
imageName = "logoLight" imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value imageType = dbConfig.LogoLightImageType.Value
} else { } else {
imageName = "logoDark" imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value imageType = dbConfig.LogoDarkImageType.Value
} }
acc.updateImage(c, imageName, imageType) acc.updateImage(c, imageName, imageType)
@@ -224,13 +216,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) { func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
fileType := utils.GetFileExtension(file.Filename) fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" { if fileType != "ico" {
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"}) _ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return return
} }
acc.updateImage(c, "favicon", "ico") acc.updateImage(c, "favicon", "ico")
@@ -246,13 +238,13 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/application-configuration/background-image [put] // @Router /api/application-configuration/background-image [put]
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) { func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
acc.updateImage(c, "background", imageType) acc.updateImage(c, "background", imageType)
} }
// getImage is a helper function to serve image files // getImage is a helper function to serve image files
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) { func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType) imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType
mimeType := utils.GetImageMimeType(imageType) mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType) c.Header("Content-Type", mimeType)
@@ -263,13 +255,13 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) { func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType) err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -284,9 +276,9 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
// @Security BearerAuth // @Security BearerAuth
// @Router /api/application-configuration/sync-ldap [post] // @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) { func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll() err := acc.ldapService.SyncAll(c.Request.Context())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -303,9 +295,9 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
func (acc *AppConfigController) testEmailHandler(c *gin.Context) { func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID) err := acc.emailService.SendTestEmail(c.Request.Context(), userID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -20,7 +20,10 @@ func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.Audi
auditLogService: auditLogService, auditLogService: auditLogService,
} }
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler) group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
} }
type AuditLogController struct { type AuditLogController struct {
@@ -39,17 +42,19 @@ type AuditLogController struct {
// @Router /api/audit-logs [get] // @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err) err := c.ShouldBindQuery(&sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return return
} }
userID := c.GetString("userID") userID := c.GetString("userID")
// Fetch audit logs for the user // Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest) logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -57,7 +62,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var logsDtos []dto.AuditLogDto var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos) err = dto.MapStructList(logs, &logsDtos)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -72,3 +77,86 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
Pagination: pagination, Pagination: pagination,
}) })
} }
// listAllAuditLogsHandler godoc
// @Summary List all audit logs
// @Description Get a paginated list of all audit logs (admin only)
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param user_id query string false "Filter by user ID"
// @Param event query string false "Filter by event type"
// @Param client_name query string false "Filter by client name"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
var filters dto.AuditLogFilterDto
if err := c.ShouldBindQuery(&filters); err != nil {
_ = c.Error(err)
return
}
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
if err != nil {
_ = c.Error(err)
return
}
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
_ = c.Error(err)
return
}
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.Username = logs[i].User.Username
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
}
// listClientNamesHandler godoc
// @Summary List client names
// @Description Get a list of all client names for audit log filtering
// @Tags Audit Logs
// @Success 200 {array} string "List of client names"
// @Router /api/audit-logs/filters/client-names [get]
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, names)
}
// listUserNamesWithIdsHandler godoc
// @Summary List users with IDs
// @Description Get a list of all usernames with their IDs for audit log filtering
// @Tags Audit Logs
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
// @Router /api/audit-logs/filters/users [get]
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, users)
}

View File

@@ -41,9 +41,9 @@ type CustomClaimController struct {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/custom-claims/suggestions [get] // @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions() claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -64,20 +64,20 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
var input []dto.CustomClaimCreateDto var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
userId := c.Param("userId") userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input) claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var customClaimsDto []dto.CustomClaimDto var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil { if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -99,20 +99,20 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.C
var input []dto.CustomClaimCreateDto var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
userGroupId := c.Param("userGroupId") userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input) claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var customClaimsDto []dto.CustomClaimDto var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil { if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -1,9 +1,12 @@
//go:build e2etest
package controller package controller
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -19,22 +22,22 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) { func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil { if err := tc.TestService.ResetDatabase(); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
if err := tc.TestService.ResetApplicationImages(); err != nil { if err := tc.TestService.ResetApplicationImages(); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
if err := tc.TestService.SeedDatabase(); err != nil { if err := tc.TestService.SeedDatabase(); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
if err := tc.TestService.ResetAppConfig(); err != nil { if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -6,14 +6,13 @@ import (
"net/url" "net/url"
"strings" "strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"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/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service" "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"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
) )
// NewOidcController creates a new controller for OIDC related endpoints // NewOidcController creates a new controller for OIDC related endpoints
@@ -31,6 +30,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/userinfo", oc.userInfoHandler) group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler) group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler) group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
group.POST("/oidc/introspect", oc.introspectTokenHandler)
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler) group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler) group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
@@ -65,13 +65,13 @@ type OidcController struct {
func (oc *OidcController) authorizeHandler(c *gin.Context) { func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent()) code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -96,13 +96,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) { func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope) hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -128,19 +128,19 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcCreateTokensDto var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
// Validate that code is provided for authorization_code grant type // Validate that code is provided for authorization_code grant type
if input.GrantType == "authorization_code" && input.Code == "" { if input.GrantType == "authorization_code" && input.Code == "" {
c.Error(&common.OidcMissingAuthorizationCodeError{}) _ = c.Error(&common.OidcMissingAuthorizationCodeError{})
return return
} }
// Validate that refresh_token is provided for refresh_token grant type // Validate that refresh_token is provided for refresh_token grant type
if input.GrantType == "refresh_token" && input.RefreshToken == "" { if input.GrantType == "refresh_token" && input.RefreshToken == "" {
c.Error(&common.OidcMissingRefreshTokenError{}) _ = c.Error(&common.OidcMissingRefreshTokenError{})
return return
} }
@@ -153,6 +153,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
} }
idToken, accessToken, refreshToken, expiresIn, err := oc.oidcService.CreateTokens( idToken, accessToken, refreshToken, expiresIn, err := oc.oidcService.CreateTokens(
c.Request.Context(),
input.Code, input.Code,
input.GrantType, input.GrantType,
clientID, clientID,
@@ -162,7 +163,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
) )
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -195,43 +196,36 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
// @Security OAuth2AccessToken // @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [get] // @Router /api/oidc/userinfo [get]
func (oc *OidcController) userInfoHandler(c *gin.Context) { func (oc *OidcController) userInfoHandler(c *gin.Context) {
authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") _, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
if len(authHeaderSplit) != 2 { if !ok || authToken == "" {
c.Error(&common.MissingAccessToken{}) _ = c.Error(&common.MissingAccessToken{})
return return
} }
token := authHeaderSplit[1] token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
userID := jwtClaims.Subject userID, ok := token.Subject()
clientId := jwtClaims.Audience[0] if !ok {
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId) _ = c.Error(&common.TokenInvalidError{})
return
}
clientID, ok := token.Audience()
if !ok || len(clientID) != 1 {
_ = c.Error(&common.TokenInvalidError{})
return
}
claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0])
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
c.JSON(http.StatusOK, claims) c.JSON(http.StatusOK, claims)
} }
// userInfoHandler godoc (POST method)
// @Summary Get user information (POST method)
// @Description Get user information based on the access token using POST
// @Tags OIDC
// @Accept json
// @Produce json
// @Success 200 {object} object "User claims based on requested scopes"
// @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [post]
func (oc *OidcController) userInfoHandlerPost(c *gin.Context) {
// Implementation is the same as GET
}
// EndSessionHandler godoc // EndSessionHandler godoc
// @Summary End OIDC session // @Summary End OIDC session
// @Description End user session and handle OIDC logout // @Description End user session and handle OIDC logout
@@ -247,20 +241,21 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto var input dto.OidcLogoutDto
// Bind query parameters to the struct // Bind query parameters to the struct
if c.Request.Method == http.MethodGet { switch c.Request.Method {
case http.MethodGet:
if err := c.ShouldBindQuery(&input); err != nil { if err := c.ShouldBindQuery(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
} else if c.Request.Method == http.MethodPost { case http.MethodPost:
// Bind form parameters to the struct // Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
} }
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID")) callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil { if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected // If the validation fails, the user has to confirm the logout manually and doesn't get redirected
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err) log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
@@ -296,6 +291,38 @@ func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// Implementation is the same as GET // Implementation is the same as GET
} }
// introspectToken godoc
// @Summary Introspect OIDC tokens
// @Description Pass an access_token to verify if it is considered valid.
// @Tags OIDC
// @Produce json
// @Param token formData string true "The token to be introspected."
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
// @Router /api/oidc/introspect [post]
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
var input dto.OidcIntrospectDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret have to be passed over the Authorization header. This kind of
// authentication allows us to keep the endpoint protected (since it could be used to
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
clientID, clientSecret, _ := c.Request.BasicAuth()
response, err := oc.oidcService.IntrospectToken(clientID, clientSecret, input.Token)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// getClientMetaDataHandler godoc // getClientMetaDataHandler godoc
// @Summary Get client metadata // @Summary Get client metadata
// @Description Get OIDC client metadata for discovery and configuration // @Description Get OIDC client metadata for discovery and configuration
@@ -306,9 +333,9 @@ func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// @Router /api/oidc/clients/{id}/meta [get] // @Router /api/oidc/clients/{id}/meta [get]
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) { func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientId := c.Param("id") clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId) client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -319,7 +346,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
return return
} }
c.Error(err) _ = c.Error(err)
} }
// getClientHandler godoc // getClientHandler godoc
@@ -333,9 +360,9 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
// @Router /api/oidc/clients/{id} [get] // @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) { func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id") clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId) client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -346,7 +373,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
return return
} }
c.Error(err) _ = c.Error(err)
} }
// listClientsHandler godoc // listClientsHandler godoc
@@ -365,19 +392,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil { if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest) clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var clientsDto []dto.OidcClientDto var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil { if err := dto.MapStructList(clients, &clientsDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -400,19 +427,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
func (oc *OidcController) createClientHandler(c *gin.Context) { func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
client, err := oc.oidcService.CreateClient(input, c.GetString("userID")) client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var clientDto dto.OidcClientWithAllowedUserGroupsDto var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil { if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -428,9 +455,9 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/oidc/clients/{id} [delete] // @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) { func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id")) err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -451,19 +478,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
func (oc *OidcController) updateClientHandler(c *gin.Context) { func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
client, err := oc.oidcService.UpdateClient(c.Param("id"), input) client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var clientDto dto.OidcClientWithAllowedUserGroupsDto var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil { if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -480,9 +507,9 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/oidc/clients/{id}/secret [post] // @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) { func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id")) secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -500,9 +527,9 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
// @Success 200 {file} binary "Logo image" // @Success 200 {file} binary "Logo image"
// @Router /api/oidc/clients/{id}/logo [get] // @Router /api/oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) { func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id")) imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -523,13 +550,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file) err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -545,9 +572,9 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [delete] // @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id")) err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -568,19 +595,19 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input) oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var oidcClientDto dto.OidcClientDto var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil { if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -2,7 +2,6 @@ package controller
import ( import (
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
@@ -66,15 +65,15 @@ type UserController struct {
// @Router /api/users/{id}/groups [get] // @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) { func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(userID) groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var groupsDto []dto.UserGroupDtoWithUsers var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil { if err := dto.MapStructList(groups, &groupsDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -96,19 +95,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil { if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest) users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var usersDto []dto.UserDto var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil { if err := dto.MapStructList(users, &usersDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -126,15 +125,15 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/users/{id} [get] // @Router /api/users/{id} [get]
func (uc *UserController) getUserHandler(c *gin.Context) { func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.Param("id")) user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -148,15 +147,15 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/users/me [get] // @Router /api/users/me [get]
func (uc *UserController) getCurrentUserHandler(c *gin.Context) { func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.GetString("userID")) user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -171,8 +170,8 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
// @Success 204 "No Content" // @Success 204 "No Content"
// @Router /api/users/{id} [delete] // @Router /api/users/{id} [delete]
func (uc *UserController) deleteUserHandler(c *gin.Context) { func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil { if err := uc.userService.DeleteUser(c.Request.Context(), c.Param("id"), false); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -189,19 +188,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
func (uc *UserController) createUserHandler(c *gin.Context) { func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
user, err := uc.userService.CreateUser(input) user, err := uc.userService.CreateUser(c.Request.Context(), input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -228,8 +227,8 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/users/me [put] // @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" { if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
c.Error(&common.AccountEditNotAllowedError{}) _ = c.Error(&common.AccountEditNotAllowedError{})
return return
} }
uc.updateUser(c, true) uc.updateUser(c, true)
@@ -246,13 +245,19 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
picture, size, err := uc.userService.GetProfilePicture(userID) picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
if picture != nil {
defer picture.Close()
}
c.Header("Cache-Control", "public, max-age=300") _, ok := c.GetQuery("skipCache")
if !ok {
c.Header("Cache-Control", "public, max-age=900")
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
} }
@@ -271,18 +276,18 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
fileHeader, err := c.FormFile("file") fileHeader, err := c.FormFile("file")
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
defer file.Close() defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil { if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -302,18 +307,18 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
userID := c.GetString("userID") userID := c.GetString("userID")
fileHeader, err := c.FormFile("file") fileHeader, err := c.FormFile("file")
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
defer file.Close() defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil { if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -323,16 +328,16 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) { func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
var input dto.OneTimeAccessTokenCreateDto var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
if own { if own {
input.UserID = c.GetString("userID") input.UserID = c.GetString("userID")
} }
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -358,13 +363,13 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath) err := uc.userService.RequestOneTimeAccessEmail(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -379,20 +384,19 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post] // @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent()) user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value) maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
maxAge := sessionDurationInMinutesParsed * 60
cookie.AddAccessTokenCookie(c, maxAge, token) cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
@@ -405,20 +409,19 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/setup [post] // @Router /api/one-time-access-token/setup [post]
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin() user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value) maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
maxAge := sessionDurationInMinutesParsed * 60
cookie.AddAccessTokenCookie(c, maxAge, token) cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
@@ -435,19 +438,19 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
func (uc *UserController) updateUserGroups(c *gin.Context) { func (uc *UserController) updateUserGroups(c *gin.Context) {
var input dto.UserUpdateUserGroupDto var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds) user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -458,7 +461,7 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -469,15 +472,15 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id") userID = c.Param("id")
} }
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false) user, err := uc.userService.UpdateUser(c.Request.Context(), userID, input, updateOwnUser, false)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -496,7 +499,7 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil { if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -514,7 +517,7 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
userID := c.GetString("userID") userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil { if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -47,16 +47,18 @@ type UserGroupController struct {
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount] // @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get] // @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) { func (ugc *UserGroupController) list(c *gin.Context) {
ctx := c.Request.Context()
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil { if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest) groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -65,12 +67,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
for i, group := range groups { for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID) groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
groupsDto[i] = groupDto groupsDto[i] = groupDto
@@ -93,15 +95,15 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/user-groups/{id} [get] // @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) { func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id")) group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -121,19 +123,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
func (ugc *UserGroupController) create(c *gin.Context) { func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
group, err := ugc.UserGroupService.Create(input) group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -154,19 +156,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
func (ugc *UserGroupController) update(c *gin.Context) { func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false) group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -184,8 +186,8 @@ func (ugc *UserGroupController) update(c *gin.Context) {
// @Security BearerAuth // @Security BearerAuth
// @Router /api/user-groups/{id} [delete] // @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) { func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil { if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -206,19 +208,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
func (ugc *UserGroupController) updateUsers(c *gin.Context) { func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs) group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -2,7 +2,6 @@ package controller
import ( import (
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
@@ -38,9 +37,9 @@ type WebauthnController struct {
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID) options, err := wc.webAuthnService.BeginRegistration(c.Request.Context(), userID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -51,20 +50,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName) sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil { if err != nil {
c.Error(&common.MissingSessionIdError{}) _ = c.Error(&common.MissingSessionIdError{})
return return
} }
userID := c.GetString("userID") userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request) credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var credentialDto dto.WebauthnCredentialDto var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil { if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -72,9 +71,9 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
} }
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) { func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin() options, err := wc.webAuthnService.BeginLogin(c.Request.Context())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -85,30 +84,29 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) { func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName) sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil { if err != nil {
c.Error(&common.MissingSessionIdError{}) _ = c.Error(&common.MissingSessionIdError{})
return return
} }
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) user, token, err := wc.webAuthnService.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value) maxAge := int(wc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
maxAge := sessionDurationInMinutesParsed * 60
cookie.AddAccessTokenCookie(c, maxAge, token) cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
@@ -116,15 +114,15 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) { func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID) credentials, err := wc.webAuthnService.ListCredentials(c.Request.Context(), userID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var credentialDtos []dto.WebauthnCredentialDto var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil { if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -135,9 +133,9 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
credentialID := c.Param("id") credentialID := c.Param("id")
err := wc.webAuthnService.DeleteCredential(userID, credentialID) err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -150,19 +148,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
var input dto.WebauthnCredentialUpdateDto var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name) credential, err := wc.webAuthnService.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
var credentialDto dto.WebauthnCredentialDto var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil { if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -1,9 +1,13 @@
package controller package controller
import ( import (
"encoding/json"
"fmt"
"log"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -14,12 +18,21 @@ import (
// @Tags Well Known // @Tags Well Known
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) { func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: jwtService} wkc := &WellKnownController{jwtService: jwtService}
// Pre-compute the OIDC configuration document, which is static
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler) group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler) group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
} }
type WellKnownController struct { type WellKnownController struct {
jwtService *service.JwtService jwtService *service.JwtService
oidcConfig []byte
} }
// jwksHandler godoc // jwksHandler godoc
@@ -32,7 +45,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) { func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON() jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -46,20 +59,29 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
// @Success 200 {object} object "OpenID Connect configuration" // @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get] // @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
}
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL appUrl := common.EnvConfig.AppURL
config := map[string]interface{}{ alg, err := wkc.jwtService.GetKeyAlg()
if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
}
config := map[string]any{
"issuer": appUrl, "issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize", "authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token", "token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session", "end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{"authorization_code", "refresh_token"}, "grant_types_supported": []string{"authorization_code", "refresh_token"},
"scopes_supported": []string{"openid", "profile", "email", "groups"}, "scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"}, "id_token_signing_alg_values_supported": []string{alg.String()},
} }
c.JSON(http.StatusOK, config) return json.Marshal(config)
} }

View File

@@ -16,7 +16,7 @@ type AppConfigUpdateDto struct {
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
SmtHost string `json:"smtpHost"` SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`

View File

@@ -15,5 +15,12 @@ type AuditLogDto struct {
City string `json:"city"` City string `json:"city"`
Device string `json:"device"` Device string `json:"device"`
UserID string `json:"userID"` UserID string `json:"userID"`
Username string `json:"username"`
Data model.AuditLogData `json:"data"` Data model.AuditLogData `json:"data"`
} }
type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"`
}

View File

@@ -40,13 +40,11 @@ func MapStruct[S any, D any](source S, destination *D) error {
} }
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error { func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ { for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i) destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i) destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous { if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil { if err := mapStructInternal(sourceVal, destField); err != nil {
return err return err
} }
@@ -55,63 +53,57 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
sourceField := sourceVal.FieldByName(destFieldType.Name) sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() { if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types if err := mapField(sourceField, destField); err != nil {
if sourceField.Type() == destField.Type() { return err
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
// Direct assignment for slices of primitive types or non-struct elements
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
// Recursively map slices of structs
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
// Get the element from both source and destination slice
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
// Recursively map the struct elements
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
// Set the mapped element in the new slice
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
// Recursively map nested structs
if err := mapStructInternal(sourceField, destField); err != nil {
return err
}
} else {
// Type switch for specific type conversions
switch sourceField.Interface().(type) {
case datatype.DateTime:
// Convert datatype.DateTime to time.Time
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
} }
} }
} }
return nil
}
func mapField(sourceField reflect.Value, destField reflect.Value) error {
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)
case sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice:
return mapSlice(sourceField, destField)
case sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct:
return mapStructInternal(sourceField, destField)
default:
return mapSpecialTypes(sourceField, destField)
}
return nil
}
func mapSlice(sourceField reflect.Value, destField reflect.Value) error {
if sourceField.Type().Elem() == destField.Type().Elem() {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
return nil
}
func mapSpecialTypes(sourceField reflect.Value, destField reflect.Value) error {
if _, ok := sourceField.Interface().(datatype.DateTime); ok {
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
return nil return nil
} }

View File

@@ -55,6 +55,10 @@ type OidcCreateTokensDto struct {
RefreshToken string `form:"refresh_token"` RefreshToken string `form:"refresh_token"`
} }
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
}
type OidcUpdateAllowedUserGroupsDto struct { type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"` UserGroupIDs []string `json:"userGroupIds" binding:"required"`
} }
@@ -73,3 +77,16 @@ type OidcTokenResponseDto struct {
RefreshToken string `json:"refresh_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
type OidcIntrospectionResponseDto struct {
Active bool `json:"active"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
Expiration int64 `json:"exp,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
Audience []string `json:"aud,omitempty"`
Issuer string `json:"iss,omitempty"`
Identifier string `json:"jti,omitempty"`
}

View File

@@ -1,10 +1,11 @@
package dto package dto
import ( import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log" "log"
"regexp" "regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
) )
var validateUsername validator.Func = func(fl validator.FieldLevel) bool { var validateUsername validator.Func = func(fl validator.FieldLevel) bool {

View File

@@ -1,75 +0,0 @@
package job
import (
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
jobs := &Jobs{db: db}
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
registerJob(scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens)
scheduler.Start()
}
type Jobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcRefreshTokens() error {
return j.db.Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
_, err := scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
gocron.WithEventListeners(
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
log.Printf("Job %q run successfully", name)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err)
}),
),
)
if err != nil {
log.Fatalf("Failed to register job %q: %v", name, err)
}
}

View File

@@ -0,0 +1,73 @@
package job
import (
"context"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
func RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
jobs := &DbCleanupJobs{db: db}
registerJob(ctx, scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(ctx, scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(ctx, scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
registerJob(ctx, scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens)
registerJob(ctx, scheduler, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs)
scheduler.Start()
}
type DbCleanupJobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return j.db.
WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return j.db.
WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
return j.db.
WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return j.db.
WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).
Error
}

View File

@@ -0,0 +1,84 @@
package job
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
jobs := &FileCleanupJobs{db: db}
registerJob(ctx, scheduler, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures)
scheduler.Start()
}
type FileCleanupJobs struct {
db *gorm.DB
}
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) error {
var users []model.User
err := j.db.
WithContext(ctx).
Find(&users).
Error
if err != nil {
return fmt.Errorf("failed to fetch users: %w", err)
}
// Create a map to track which initials are in use
initialsInUse := make(map[string]struct{})
for _, user := range users {
initialsInUse[user.Initials()] = struct{}{}
}
defaultPicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults"
if _, err := os.Stat(defaultPicturesDir); os.IsNotExist(err) {
return nil
}
files, err := os.ReadDir(defaultPicturesDir)
if err != nil {
return fmt.Errorf("failed to read default profile pictures directory: %w", err)
}
filesDeleted := 0
for _, file := range files {
if file.IsDir() {
continue // Skip directories
}
filename := file.Name()
initials := strings.TrimSuffix(filename, ".png")
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil {
log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err)
} else {
filesDeleted++
}
}
}
log.Printf("Deleted %d unused default profile pictures", filesDeleted)
return nil
}

View File

@@ -0,0 +1,29 @@
package job
import (
"context"
"log"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
)
func registerJob(ctx context.Context, scheduler gocron.Scheduler, name string, interval string, job func(ctx context.Context) error) {
_, err := scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
gocron.WithContext(ctx),
gocron.WithEventListeners(
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
log.Printf("Job %q run successfully", name)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err)
}),
),
)
if err != nil {
log.Fatalf("Failed to register job %q: %v", name, err)
}
}

View File

@@ -1,6 +1,7 @@
package job package job
import ( import (
"context"
"log" "log"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
@@ -12,28 +13,30 @@ type LdapJobs struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
} }
func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *service.AppConfigService) { func RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
scheduler, err := gocron.NewScheduler() scheduler, err := gocron.NewScheduler()
if err != nil { if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err) log.Fatalf("Failed to create a new scheduler: %v", err)
} }
// Register the job to run every hour // Register the job to run every hour
registerJob(scheduler, "SyncLdap", "0 * * * *", jobs.syncLdap) registerJob(ctx, scheduler, "SyncLdap", "0 * * * *", jobs.syncLdap)
// Run the job immediately on startup // Run the job immediately on startup
if err := jobs.syncLdap(); err != nil { err = jobs.syncLdap(ctx)
log.Printf("Failed to sync LDAP: %s", err) if err != nil {
log.Printf("Failed to sync LDAP: %v", err)
} }
scheduler.Start() scheduler.Start()
} }
func (j *LdapJobs) syncLdap() error { func (j *LdapJobs) syncLdap(ctx context.Context) error {
if j.appConfigService.DbConfig.LdapEnabled.Value == "true" { if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return j.ldapService.SyncAll() return nil
} }
return nil
return j.ldapService.SyncAll(ctx)
} }

View File

@@ -23,7 +23,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
userID, isAdmin, err := m.Verify(c, adminRequired) userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil { if err != nil {
c.Abort() c.Abort()
c.Error(err) _ = c.Error(err)
return return
} }
@@ -36,7 +36,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
apiKey := c.GetHeader("X-API-KEY") apiKey := c.GetHeader("X-API-KEY")
user, err := m.apiKeyService.ValidateApiKey(apiKey) user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil { if err != nil {
return "", false, &common.NotSignedInError{} return "", false, &common.NotSignedInError{}
} }

View File

@@ -84,6 +84,6 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
// Both JWT and API key auth failed // Both JWT and API key auth failed
c.Abort() c.Abort()
c.Error(err) _ = c.Error(err)
} }
} }

View File

@@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
) )
@@ -14,16 +16,17 @@ func NewCorsMiddleware() *CorsMiddleware {
func (m *CorsMiddleware) Add() gin.HandlerFunc { func (m *CorsMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Allow all origins for the token endpoint // Allow all origins for the token endpoint
if c.FullPath() == "/api/oidc/token" { switch c.FullPath() {
case "/api/oidc/token", "/api/oidc/introspect":
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
} else { default:
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL) c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
} }
c.Writer.Header().Set("Access-Control-Allow-Headers", "*") c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" { if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204) c.AbortWithStatus(204)
return return
} }

View File

@@ -19,7 +19,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil { if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)} err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err) _ = c.Error(err)
c.Abort() c.Abort()
return return
} }

View File

@@ -19,11 +19,10 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID, isAdmin, err := m.Verify(c, adminRequired) userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil { if err != nil {
c.Abort() c.Abort()
c.Error(err) _ = c.Error(err)
return return
} }
@@ -33,27 +32,37 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
} }
} }
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) {
// Extract the token from the cookie // Extract the token from the cookie
token, err := c.Cookie(cookie.AccessTokenCookieName) accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil { if err != nil {
// Try to extract the token from the Authorization header if it's not in the cookie // Try to extract the token from the Authorization header if it's not in the cookie
authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") var ok bool
if len(authorizationHeaderSplit) != 2 { _, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, &common.NotSignedInError{} return "", false, &common.NotSignedInError{}
} }
token = authorizationHeaderSplit[1]
} }
claims, err := m.jwtService.VerifyAccessToken(token) token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil { if err != nil {
return "", false, &common.NotSignedInError{} return "", false, &common.NotSignedInError{}
} }
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
// Check if the user is an admin // Check if the user is an admin
if adminRequired && !claims.IsAdmin { isAdmin, err = service.GetIsAdmin(token)
if err != nil {
return "", false, &common.TokenInvalidError{}
}
if adminRequired && !isAdmin {
return "", false, &common.MissingPermissionError{} return "", false, &common.MissingPermissionError{}
} }
return claims.Subject, claims.IsAdmin, nil return subject, isAdmin, nil
} }

View File

@@ -36,7 +36,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst, &mu, clients) limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() { if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{}) _ = c.Error(&common.TooManyRequestsError{})
c.Abort() c.Abort()
return return
} }

View File

@@ -1,8 +1,6 @@
package model package model
import ( import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ApiKey struct { type ApiKey struct {
Base Base

View File

@@ -1,60 +1,183 @@
package model package model
import ( import (
"errors"
"fmt"
"reflect"
"strconv" "strconv"
"strings"
"time"
) )
type AppConfigVariable struct { type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null"` Key string `gorm:"primaryKey;not null"`
Type string Value string
IsPublic bool
IsInternal bool
Value string
DefaultValue string
} }
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a *AppConfigVariable) IsTrue() bool { func (a *AppConfigVariable) IsTrue() bool {
ok, _ := strconv.ParseBool(a.Value) ok, _ := strconv.ParseBool(a.Value)
return ok return ok
} }
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(a.Value)
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
type AppConfig struct { type AppConfig struct {
// General // General
AppName AppConfigVariable AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable EmailsVerified AppConfigVariable `key:"emailsVerified"`
AllowOwnAccountEdit AppConfigVariable AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
// Internal // Internal
BackgroundImageType AppConfigVariable BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
// Email // Email
SmtpHost AppConfigVariable SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpTls AppConfigVariable SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
EmailOneTimeAccessEnabled AppConfigVariable EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public
// LDAP // LDAP
LdapEnabled AppConfigVariable LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable LdapUrl AppConfigVariable `key:"ldapUrl"`
LdapBindDn AppConfigVariable LdapBindDn AppConfigVariable `key:"ldapBindDn"`
LdapBindPassword AppConfigVariable LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
LdapBase AppConfigVariable LdapBase AppConfigVariable `key:"ldapBase"`
LdapUserSearchFilter AppConfigVariable LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigVariable LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify AppConfigVariable LdapSkipCertVerify AppConfigVariable `key:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier AppConfigVariable LdapAttributeUserUniqueIdentifier AppConfigVariable `key:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername AppConfigVariable LdapAttributeUserUsername AppConfigVariable `key:"ldapAttributeUserUsername"`
LdapAttributeUserEmail AppConfigVariable LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigVariable LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigVariable LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
LdapAttributeUserProfilePicture AppConfigVariable LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigVariable LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigVariable LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
LdapAttributeAdminGroup AppConfigVariable LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"`
}
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
// Use reflection to iterate through all fields
cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type()
res := make([]AppConfigVariable, cfgType.NumField())
for i := range cfgType.NumField() {
field := cfgType.Field(i)
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
if key == "" {
continue
}
// If we're only showing public variables and this is not public, skip it
if !showAll && attrs != "public" {
continue
}
fieldValue := cfgValue.Field(i)
res[i] = AppConfigVariable{
Key: key,
Value: fieldValue.FieldByName("Value").String(),
}
}
return res
}
func (c *AppConfig) FieldByKey(key string) (string, error) {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches
for i := range rt.NumField() {
// Grab only the first part of the key, if there's a comma with additional properties
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
if tagValue != key {
continue
}
valueField := rv.Field(i).FieldByName("Value")
return valueField.String(), nil
}
// If we are here, the config key was not found
return "", AppConfigKeyNotFoundError{field: key}
}
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches, then update that
for i := range rt.NumField() {
// Separate the key (before the comma) from any optional attributes after
tagValue, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
if tagValue != key {
continue
}
// If the field is internal and noInternal is true, we skip that
if noInternal && attrs == "internal" {
return AppConfigInternalForbiddenError{field: key}
}
valueField := rv.Field(i).FieldByName("Value")
if !valueField.CanSet() {
return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key)
}
// Update the value
valueField.SetString(value)
// Return once updated
return nil
}
// If we're here, we have not found the right field to update
return AppConfigKeyNotFoundError{field: key}
}
type AppConfigKeyNotFoundError struct {
field string
}
func (e AppConfigKeyNotFoundError) Error() string {
return fmt.Sprintf("cannot find config key '%s'", e.field)
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
x := AppConfigKeyNotFoundError{}
return errors.As(target, &x)
}
type AppConfigInternalForbiddenError struct {
field string
}
func (e AppConfigInternalForbiddenError) Error() string {
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
}
func (e AppConfigInternalForbiddenError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError
x := AppConfigInternalForbiddenError{}
return errors.As(target, &x)
} }

View File

@@ -0,0 +1,129 @@
// We use model_test here to avoid an import cycle
package model_test
import (
"reflect"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
tests := []struct {
name string
value string
expected time.Duration
expectedSeconds int
}{
{
name: "valid positive integer",
value: "60",
expected: 60 * time.Minute,
expectedSeconds: 3600,
},
{
name: "valid zero integer",
value: "0",
expected: 0,
expectedSeconds: 0,
},
{
name: "negative integer",
value: "-30",
expected: -30 * time.Minute,
expectedSeconds: -1800,
},
{
name: "invalid non-integer",
value: "not-a-number",
expected: 0,
expectedSeconds: 0,
},
{
name: "empty string",
value: "",
expected: 0,
expectedSeconds: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configVar := model.AppConfigVariable{
Value: tt.value,
}
result := configVar.AsDurationMinutes()
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.expectedSeconds, int(result.Seconds()))
})
}
}
// This test ensures that the model.AppConfig and dto.AppConfigUpdateDto structs match:
// - They should have the same properties, where the "json" tag of dto.AppConfigUpdateDto should match the "key" tag in model.AppConfig
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig
// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time
func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
appConfigType := reflect.TypeOf(model.AppConfig{})
updateDtoType := reflect.TypeOf(dto.AppConfigUpdateDto{})
// Process AppConfig fields
appConfigFields := make(map[string]string)
for i := 0; i < appConfigType.NumField(); i++ {
field := appConfigType.Field(i)
if field.Tag.Get("key") == "" {
// Skip internal fields
continue
}
// Extract the key name from the tag (takes the part before any comma)
keyTag := field.Tag.Get("key")
keyName, _, _ := strings.Cut(keyTag, ",")
appConfigFields[field.Name] = keyName
}
// Process AppConfigUpdateDto fields
dtoFields := make(map[string]string)
for i := 0; i < updateDtoType.NumField(); i++ {
field := updateDtoType.Field(i)
// Extract the json name from the tag (takes the part before any binding constraints)
jsonTag := field.Tag.Get("json")
jsonName, _, _ := strings.Cut(jsonTag, ",")
dtoFields[jsonName] = field.Name
}
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") {
// Skip internal fields that shouldn't be in the DTO
continue
}
// Check if there's a DTO field with a matching JSON tag
_, exists := dtoFields[keyName]
assert.True(t, exists, "Field %s with key '%s' in AppConfig has no matching field in AppConfigUpdateDto", fieldName, keyName)
}
// Verify every DTO field has a matching AppConfig field
for jsonName, fieldName := range dtoFields {
// Find a matching field in AppConfig by key tag
found := false
for _, keyName := range appConfigFields {
if keyName == jsonName {
found = true
break
}
}
assert.True(t, found, "Field %s with json tag '%s' in AppConfigUpdateDto has no matching field in AppConfig", fieldName, jsonName)
}
}

View File

@@ -3,7 +3,7 @@ package model
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"errors" "fmt"
) )
type AuditLog struct { type AuditLog struct {
@@ -14,13 +14,16 @@ type AuditLog struct {
Country string `sortable:"true"` Country string `sortable:"true"`
City string `sortable:"true"` City string `sortable:"true"`
UserAgent string `sortable:"true"` UserAgent string `sortable:"true"`
UserID string Username string `gorm:"-"`
Data AuditLogData Data AuditLogData
UserID string
User User
} }
type AuditLogData map[string]string type AuditLogData map[string]string //nolint:recvcheck
type AuditLogEvent string type AuditLogEvent string //nolint:recvcheck
const ( const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN" AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
@@ -31,7 +34,7 @@ const (
// Scan and Value methods for GORM to handle the custom type // Scan and Value methods for GORM to handle the custom type
func (e *AuditLogEvent) Scan(value interface{}) error { func (e *AuditLogEvent) Scan(value any) error {
*e = AuditLogEvent(value.(string)) *e = AuditLogEvent(value.(string))
return nil return nil
} }
@@ -40,11 +43,14 @@ func (e AuditLogEvent) Value() (driver.Value, error) {
return string(e), nil return string(e), nil
} }
func (d *AuditLogData) Scan(value interface{}) error { func (d *AuditLogData) Scan(value any) error {
if v, ok := value.([]byte); ok { switch v := value.(type) {
case []byte:
return json.Unmarshal(v, d) return json.Unmarshal(v, d)
} else { case string:
return errors.New("type assertion to []byte failed") return json.Unmarshal([]byte(v), d)
default:
return fmt.Errorf("unsupported type: %T", value)
} }
} }

View File

@@ -4,7 +4,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -3,7 +3,7 @@ package model
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"errors" "fmt"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
@@ -71,13 +71,16 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil return nil
} }
type UrlList []string type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value interface{}) error { func (cu *UrlList) Scan(value interface{}) error {
if v, ok := value.([]byte); ok { switch v := value.(type) {
case []byte:
return json.Unmarshal(v, cu) return json.Unmarshal(v, cu)
} else { case string:
return errors.New("type assertion to []byte failed") return json.Unmarshal([]byte(v), cu)
default:
return fmt.Errorf("unsupported type: %T", value)
} }
} }

View File

@@ -8,7 +8,7 @@ import (
) )
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres // DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time type DateTime time.Time //nolint:recvcheck
func (date *DateTime) Scan(value interface{}) (err error) { func (date *DateTime) Scan(value interface{}) (err error) {
*date = DateTime(value.(time.Time)) *date = DateTime(value.(time.Time))

View File

@@ -1,9 +1,12 @@
package model package model
import ( import (
"strings"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
type User struct { type User struct {
@@ -63,6 +66,12 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
func (u User) FullName() string { return u.FirstName + " " + u.LastName } func (u User) FullName() string { return u.FirstName + " " + u.LastName }
func (u User) Initials() string {
return strings.ToUpper(
utils.GetFirstCharacter(u.FirstName) + utils.GetFirstCharacter(u.LastName),
)
}
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string Token string

View File

@@ -3,7 +3,7 @@ package model
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"errors" "fmt"
"time" "time"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
@@ -45,15 +45,17 @@ type PublicKeyCredentialRequestOptions struct {
Timeout time.Duration Timeout time.Duration
} }
type AuthenticatorTransportList []protocol.AuthenticatorTransport type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type // Scan and Value methods for GORM to handle the custom type
func (atl *AuthenticatorTransportList) Scan(value interface{}) error { func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
switch v := value.(type) {
if v, ok := value.([]byte); ok { case []byte:
return json.Unmarshal(v, atl) return json.Unmarshal(v, atl)
} else { case string:
return errors.New("type assertion to []byte failed") return json.Unmarshal([]byte(v), atl)
default:
return fmt.Errorf("unsupported type: %T", value)
} }
} }

View File

@@ -1,16 +1,18 @@
package service package service
import ( import (
"context"
"errors" "errors"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"log"
"time" "time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "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"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type ApiKeyService struct { type ApiKeyService struct {
@@ -21,8 +23,11 @@ func NewApiKeyService(db *gorm.DB) *ApiKeyService {
return &ApiKeyService{db: db} return &ApiKeyService{db: db}
} }
func (s *ApiKeyService) ListApiKeys(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) { func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
query := s.db.Where("user_id = ?", userID).Model(&model.ApiKey{}) query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&model.ApiKey{})
var apiKeys []model.ApiKey var apiKeys []model.ApiKey
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
@@ -33,7 +38,7 @@ func (s *ApiKeyService) ListApiKeys(userID string, sortedPaginationRequest utils
return apiKeys, pagination, nil return apiKeys, pagination, nil
} }
func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) { func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) {
// Check if expiration is in the future // Check if expiration is in the future
if !input.ExpiresAt.ToTime().After(time.Now()) { if !input.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{} return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
@@ -53,7 +58,11 @@ func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (
UserID: userID, UserID: userID,
} }
if err := s.db.Create(&apiKey).Error; err != nil { err = s.db.
WithContext(ctx).
Create(&apiKey).
Error
if err != nil {
return model.ApiKey{}, "", err return model.ApiKey{}, "", err
} }
@@ -61,29 +70,44 @@ func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (
return apiKey, token, nil return apiKey, token, nil
} }
func (s *ApiKeyService) RevokeApiKey(userID, apiKeyID string) error { func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey var apiKey model.ApiKey
if err := s.db.Where("id = ? AND user_id = ?", apiKeyID, userID).First(&apiKey).Error; err != nil { err := s.db.
WithContext(ctx).
Where("id = ? AND user_id = ?", apiKeyID, userID).
Delete(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.APIKeyNotFoundError{} return &common.APIKeyNotFoundError{}
} }
return err return err
} }
return s.db.Delete(&apiKey).Error return nil
} }
func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) { func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
if apiKey == "" { if apiKey == "" {
return model.User{}, &common.NoAPIKeyProvidedError{} return model.User{}, &common.NoAPIKeyProvidedError{}
} }
var key model.ApiKey now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey) hashedKey := utils.CreateSha256Hash(apiKey)
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?", var key model.ApiKey
hashedKey, datatype.DateTime(time.Now())).Preload("User").First(&key).Error; err != nil { err := s.db.
WithContext(ctx).
Model(&model.ApiKey{}).
Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&model.ApiKey{
LastUsedAt: utils.Ptr(datatype.DateTime(now)),
}).
Preload("User").
First(&key).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, &common.InvalidAPIKeyError{} return model.User{}, &common.InvalidAPIKeyError{}
} }
@@ -91,12 +115,5 @@ func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
return model.User{}, err return model.User{}, err
} }
// Update last used time
now := datatype.DateTime(time.Now())
key.LastUsedAt = &now
if err := s.db.Save(&key).Error; err != nil {
log.Printf("Failed to update last used time: %v", err)
}
return key.User, nil return key.User, nil
} }

View File

@@ -1,396 +1,427 @@
package service package service
import ( import (
"context"
"errors"
"fmt" "fmt"
"log" "log"
"mime/multipart" "mime/multipart"
"os" "os"
"reflect" "reflect"
"strings"
"sync/atomic"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "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/model"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
) )
type AppConfigService struct { type AppConfigService struct {
DbConfig *model.AppConfig dbConfig atomic.Pointer[model.AppConfig]
db *gorm.DB db *gorm.DB
} }
func NewAppConfigService(db *gorm.DB) *AppConfigService { func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
service := &AppConfigService{ service := &AppConfigService{
DbConfig: &defaultDbConfig, db: db,
db: db,
} }
if err := service.InitDbConfig(); err != nil {
err := service.LoadDbConfig(ctx)
if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err) log.Fatalf("Failed to initialize app config service: %v", err)
} }
return service return service
} }
var defaultDbConfig = model.AppConfig{ // GetDbConfig returns the application configuration.
// General // Important: Treat the object as read-only: do not modify its properties directly!
AppName: model.AppConfigVariable{ func (s *AppConfigService) GetDbConfig() *model.AppConfig {
Key: "appName", v := s.dbConfig.Load()
Type: "string", if v == nil {
IsPublic: true, // This indicates a development-time error
DefaultValue: "Pocket ID", panic("called GetDbConfig before DbConfig is loaded")
}, }
SessionDuration: model.AppConfigVariable{
Key: "sessionDuration", return v
Type: "number",
DefaultValue: "60",
},
EmailsVerified: model.AppConfigVariable{
Key: "emailsVerified",
Type: "bool",
DefaultValue: "false",
},
AllowOwnAccountEdit: model.AppConfigVariable{
Key: "allowOwnAccountEdit",
Type: "bool",
IsPublic: true,
DefaultValue: "true",
},
// Internal
BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType",
Type: "string",
IsInternal: true,
DefaultValue: "jpg",
},
LogoLightImageType: model.AppConfigVariable{
Key: "logoLightImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
LogoDarkImageType: model.AppConfigVariable{
Key: "logoDarkImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
SmtpTls: model.AppConfigVariable{
Key: "smtpTls",
Type: "string",
DefaultValue: "none",
},
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapUserSearchFilter: model.AppConfigVariable{
Key: "ldapUserSearchFilter",
Type: "string",
DefaultValue: "(objectClass=person)",
},
LdapUserGroupSearchFilter: model.AppConfigVariable{
Key: "ldapUserGroupSearchFilter",
Type: "string",
DefaultValue: "(objectClass=groupOfNames)",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeUserProfilePicture: model.AppConfigVariable{
Key: "ldapAttributeUserProfilePicture",
Type: "string",
},
LdapAttributeGroupMember: model.AppConfigVariable{
Key: "ldapAttributeGroupMember",
Type: "string",
DefaultValue: "member",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
} }
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// Values are the default ones
return &model.AppConfig{
// General
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
// Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
// Email
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
SmtpFrom: model.AppConfigVariable{},
SmtpUser: model.AppConfigVariable{},
SmtpPassword: model.AppConfigVariable{},
SmtpTls: model.AppConfigVariable{Value: "none"},
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
LdapBindDn: model.AppConfigVariable{},
LdapBindPassword: model.AppConfigVariable{},
LdapBase: model.AppConfigVariable{},
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
LdapSkipCertVerify: model.AppConfigVariable{Value: "false"},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeUserUsername: model.AppConfigVariable{},
LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeGroupName: model.AppConfigVariable{},
LdapAttributeAdminGroup: model.AppConfigVariable{},
}
}
func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) {
// We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database
// This works across multiple processes too
tx = s.db.Begin()
err = tx.Error
if err != nil {
return nil, fmt.Errorf("failed to begin database transaction: %w", err)
}
// With SQLite there's nothing else we need to do, because a transaction blocks the entire database
// However, with Postgres we need to manually lock the table to prevent others from doing the same
switch s.db.Name() {
case "postgres":
// We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled
// Here we use a context with a 10s timeout in case the database is blocked for longer
lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second)
defer lockCancel()
err = tx.
WithContext(lockCtx).
Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE").
Error
if err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err)
}
default:
// Nothing to do here
}
return tx, nil
}
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
err := tx.
WithContext(ctx).
Clauses(clause.OnConflict{
// Perform an "upsert" if the key already exists, replacing the value
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&dbUpdate).
Error
if err != nil {
return fmt.Errorf("failed to update config in database: %w", err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled { if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{} return nil, &common.UiConfigDisabledError{}
} }
tx := s.db.Begin() // If EmailLoginNotificationEnabled is set to false (explicitly), disable the EmailOneTimeAccessEnabled
rt := reflect.ValueOf(input).Type() if input.EmailLoginNotificationEnabled == "false" {
rv := reflect.ValueOf(input) input.EmailOneTimeAccessEnabled = "false"
var savedConfigVariables []model.AppConfigVariable
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
appConfigVariable.Value = value
if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
} }
tx.Commit() // Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return nil, err
}
defer func() {
tx.Rollback()
}()
if err := s.LoadDbConfigFromDb(); err != nil { // From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for i := range rt.NumField() {
field := rt.Field(i)
value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
// Skip values that are internal only and can't be updated
if value == "" {
// Ignore errors here as we know the key exists
defaultValue, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue, true)
} else {
err = cfg.UpdateField(key, value, true)
}
// If we tried to update an internal field, ignore the error (and do not update in the DB)
if errors.Is(err, model.AppConfigInternalForbiddenError{}) {
continue
} else if err != nil {
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return nil, err return nil, err
} }
return savedConfigVariables, nil // Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true)
return res, nil
} }
func (s *AppConfigService) UpdateImageType(imageName string, fileType string) error { // UpdateAppConfigValues
key := fmt.Sprintf("%sImageType", imageName) func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
err := s.db.Model(&model.AppConfigVariable{}).Where("key = ?", key).Update("value", fileType).Error if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return err
}
defer func() {
tx.Rollback()
}()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 0; i < len(keysAndValues); i += 2 {
key := keysAndValues[i]
value := keysAndValues[i+1]
// Ensure that the field is valid
// We do this by grabbing the default value
var defaultValue string
defaultValue, err = defaultCfg.FieldByKey(key)
if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
}
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
// Skip values that are internal only and can't be updated
if value == "" {
err = cfg.UpdateField(key, defaultValue, false)
} else {
err = cfg.UpdateField(key, value, false)
}
if err != nil {
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil { if err != nil {
return err return err
} }
return s.LoadDbConfigFromDb() // Commit the changes to the DB, then finally save the updated config in the object
} err = tx.Commit().Error
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable
var err error
if showAll {
err = s.db.Find(&configuration).Error
} else {
err = s.db.Find(&configuration, "is_public = true").Error
}
if err != nil { if err != nil {
return nil, err return fmt.Errorf("failed to commit transaction: %w", err)
} }
for i := range configuration { s.dbConfig.Store(cfg)
if common.EnvConfig.UiConfigDisabled {
// Set the value to the environment variable if the UI config is disabled
configuration[i].Value = s.getConfigVariableFromEnvironmentVariable(configuration[i].Key, configuration[i].DefaultValue)
} else if configuration[i].Value == "" && configuration[i].DefaultValue != "" { return nil
// Set the value to the default value if it is empty
configuration[i].Value = configuration[i].DefaultValue
}
}
return configuration, nil
} }
func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, imageName string, oldImageType string) error { func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll)
}
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
fileType := utils.GetFileExtension(uploadedFile.Filename) fileType := utils.GetFileExtension(uploadedFile.Filename)
mimeType := utils.GetImageMimeType(fileType) mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" { if mimeType == "" {
return &common.FileTypeNotSupportedError{} return &common.FileTypeNotSupportedError{}
} }
// Delete the old image if it has a different file type // Save the updated image
imagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + fileType
err = utils.SaveFile(uploadedFile, imagePath)
if err != nil {
return err
}
// Delete the old image if it has a different file type, then update the type in the database
if fileType != oldImageType { if fileType != oldImageType {
oldImagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType) oldImagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + oldImageType
if err := os.Remove(oldImagePath); err != nil { err = os.Remove(oldImagePath)
if err != nil {
return err return err
} }
}
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType) // Update the file type in the database
if err := utils.SaveFile(uploadedFile, imagePath); err != nil { err = s.UpdateAppConfigValues(ctx, imageName+"ImageType", fileType)
return err if err != nil {
} return err
}
// Update the file type in the database
if err := s.UpdateImageType(imageName, fileType); err != nil {
return err
} }
return nil return nil
} }
// InitDbConfig creates the default configuration values in the database if they do not exist, // LoadDbConfig loads the configuration values from the database into the DbConfig struct.
// updates existing configurations if they differ from the default, and deletes any configurations func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
// that are not in the default configuration. var dest *model.AppConfig
func (s *AppConfigService) InitDbConfig() error {
// Reflect to get the underlying value of DbConfig and its default configuration
defaultConfigReflectValue := reflect.ValueOf(defaultDbConfig)
defaultKeys := make(map[string]struct{})
// Iterate over the fields of DbConfig // If the UI config is disabled, only load from the env
for i := 0; i < defaultConfigReflectValue.NumField(); i++ { if common.EnvConfig.UiConfigDisabled {
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.AppConfigVariable) dest, err = s.loadDbConfigFromEnv()
} else {
dest, err = s.loadDbConfigInternal(ctx, s.db)
}
if err != nil {
return err
}
defaultKeys[defaultConfigVar.Key] = struct{}{} // Update the value in the object
s.dbConfig.Store(dest)
var storedConfigVar model.AppConfigVariable return nil
if err := s.db.First(&storedConfigVar, "key = ?", defaultConfigVar.Key).Error; err != nil { }
// If the configuration does not exist, create it
if err := s.db.Create(&defaultConfigVar).Error; err != nil { func (s *AppConfigService) loadDbConfigFromEnv() (*model.AppConfig, error) {
return err // First, start from the default configuration
} dest := s.getDefaultDbConfig()
// Iterate through each field
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the value of the key tag, taking only what's before the comma
// The env var name is the key converted to SCREAMING_SNAKE_CASE
key, _, _ := strings.Cut(field.Tag.Get("key"), ",")
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).FieldByName("Value").SetString(value)
}
}
return dest, nil
}
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Load all configuration values from the database
// This loads all values in a single shot
loaded := []model.AppConfigVariable{}
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// If the value is empty, it means we are using the default value
if v.Value == "" {
continue continue
} }
// Update existing configuration if it differs from the default // Find the field in the struct whose "key" tag matches, then update that
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue { err = dest.UpdateField(v.Key, v.Value, false)
storedConfigVar.Type = defaultConfigVar.Type
storedConfigVar.IsPublic = defaultConfigVar.IsPublic // We ignore the case of fields that don't exist, as there may be leftover data in the database
storedConfigVar.IsInternal = defaultConfigVar.IsInternal if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
if err := s.db.Save(&storedConfigVar).Error; err != nil {
return err
}
} }
} }
// Delete any configurations not in the default keys return dest, nil
var allConfigVars []model.AppConfigVariable
if err := s.db.Find(&allConfigVars).Error; err != nil {
return err
}
for _, config := range allConfigVars {
if _, exists := defaultKeys[config.Key]; !exists {
if err := s.db.Delete(&config).Error; err != nil {
return err
}
}
}
return s.LoadDbConfigFromDb()
}
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfigFromDb() error {
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
dbConfigField := dbConfigReflectValue.Field(i)
currentConfigVar := dbConfigField.Interface().(model.AppConfigVariable)
var storedConfigVar model.AppConfigVariable
if err := s.db.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
return err
}
if common.EnvConfig.UiConfigDisabled {
storedConfigVar.Value = s.getConfigVariableFromEnvironmentVariable(currentConfigVar.Key, storedConfigVar.DefaultValue)
} else if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
storedConfigVar.Value = storedConfigVar.DefaultValue
}
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
}
return nil
}
func (s *AppConfigService) getConfigVariableFromEnvironmentVariable(key, fallbackValue string) string {
environmentVariableName := utils.CamelCaseToScreamingSnakeCase(key)
if value, exists := os.LookupEnv(environmentVariableName); exists {
return value
}
return fallbackValue
} }

View File

@@ -0,0 +1,561 @@
package service
import (
"sync/atomic"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"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"
"github.com/stretchr/testify/require"
)
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
service := &AppConfigService{
dbConfig: atomic.Pointer[model.AppConfig]{},
}
service.dbConfig.Store(config)
return service
}
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
// Load the config
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be equal to default config
require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig())
})
t.Run("loads value from config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
Create([]model.AppConfigVariable{
// Should be set to the default value because it's an empty string
{Key: "appName", Value: ""},
// Overrides default value
{Key: "sessionDuration", Value: "5"},
// Does not have a default value
{Key: "smtpHost", Value: "example"},
}).
Error
require.NoError(t, err)
// Load the config
service := &AppConfigService{
db: db,
}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Values should match expected ones
expect := service.getDefaultDbConfig()
expect.SessionDuration.Value = "5"
expect.SmtpHost.Value = "example"
require.Equal(t, service.GetDbConfig(), expect)
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
{Key: "__nonExistentKey", Value: "some value"},
{Key: "appName", Value: "TestApp"}, // This one should still be loaded
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// This should not fail, just ignore the unknown key
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
config := service.GetDbConfig()
require.Equal(t, "TestApp", config.AppName.Value)
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "InitialApp"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value)
// Update the database value
err = db.Model(&model.AppConfigVariable{}).
Where("key = ?", "appName").
Update("value", "UpdatedApp").Error
require.NoError(t, err)
// Load the config again, it should reflect the updated value
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value)
})
t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Set environment variables for testing
t.Setenv("APP_NAME", "EnvTest App")
t.Setenv("SESSION_DURATION", "45")
// Enable UiConfigDisabled to load from env
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newAppConfigTestDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// Load the config
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be loaded from env, not DB
config := service.GetDbConfig()
require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env")
require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env")
})
t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Set environment variables that should be ignored
t.Setenv("APP_NAME", "EnvTest App")
t.Setenv("SESSION_DURATION", "45")
// Make sure UiConfigDisabled is false to load from DB
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newAppConfigTestDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// Load the config
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be loaded from DB, not env
config := service.GetDbConfig()
require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env")
require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env")
})
}
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Update a single config value
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App")
require.NoError(t, err)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Test App", config.AppName.Value)
// Verify database was updated
var dbValue model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&dbValue).Error
require.NoError(t, err)
require.Equal(t, "Test App", dbValue.Value)
})
t.Run("update multiple values", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Update multiple config values
err = service.UpdateAppConfigValues(
t.Context(),
"appName", "Test App",
"sessionDuration", "30",
"smtpHost", "mail.example.com",
)
require.NoError(t, err)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Test App", config.AppName.Value)
require.Equal(t, "30", config.SessionDuration.Value)
require.Equal(t, "mail.example.com", config.SmtpHost.Value)
// Verify database was updated
var count int64
db.Model(&model.AppConfigVariable{}).Count(&count)
require.Equal(t, int64(3), count)
var appName, sessionDuration, smtpHost model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
require.NoError(t, err)
require.Equal(t, "Test App", appName.Value)
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
require.NoError(t, err)
require.Equal(t, "30", sessionDuration.Value)
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
require.NoError(t, err)
require.Equal(t, "mail.example.com", smtpHost.Value)
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First change the value
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30")
require.NoError(t, err)
require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value)
// Now set it to empty which should use default value
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
require.NoError(t, err)
require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update with odd number of arguments
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid number of arguments")
})
t.Run("error with invalid key", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update with invalid key
err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid configuration key")
})
}
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Create update DTO
input := dto.AppConfigUpdateDto{
AppName: "Updated App Name",
SessionDuration: "120",
SmtpHost: "smtp.example.com",
SmtpPort: "587",
}
// Update config
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify returned updated variables
require.NotEmpty(t, updatedVars)
var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool
for _, v := range updatedVars {
switch v.Key {
case "appName":
require.Equal(t, "Updated App Name", v.Value)
foundAppName = true
case "sessionDuration":
require.Equal(t, "120", v.Value)
foundSessionDuration = true
case "smtpHost":
require.Equal(t, "smtp.example.com", v.Value)
foundSmtpHost = true
case "smtpPort":
require.Equal(t, "587", v.Value)
foundSmtpPort = true
}
}
require.True(t, foundAppName)
require.True(t, foundSessionDuration)
require.True(t, foundSmtpHost)
require.True(t, foundSmtpPort)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Updated App Name", config.AppName.Value)
require.Equal(t, "120", config.SessionDuration.Value)
require.Equal(t, "smtp.example.com", config.SmtpHost.Value)
require.Equal(t, "587", config.SmtpPort.Value)
// Verify database was updated
var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
require.NoError(t, err)
require.Equal(t, "Updated App Name", appName.Value)
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
require.NoError(t, err)
require.Equal(t, "120", sessionDuration.Value)
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
require.NoError(t, err)
require.Equal(t, "smtp.example.com", smtpHost.Value)
err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error
require.NoError(t, err)
require.Equal(t, "587", smtpPort.Value)
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First set some non-default values
err = service.UpdateAppConfigValues(t.Context(),
"appName", "Custom App",
"sessionDuration", "120",
)
require.NoError(t, err)
// Create update DTO with empty values to reset to defaults
input := dto.AppConfigUpdateDto{
AppName: "", // Should reset to default "Pocket ID"
SessionDuration: "", // Should reset to default "60"
}
// Update config
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify returned updated variables (they should be empty strings in DB)
var foundAppName, foundSessionDuration bool
for _, v := range updatedVars {
switch v.Key {
case "appName":
require.Equal(t, "Pocket ID", v.Value) // Returns the default value
foundAppName = true
case "sessionDuration":
require.Equal(t, "60", v.Value) // Returns the default value
foundSessionDuration = true
}
}
require.True(t, foundAppName)
require.True(t, foundSessionDuration)
// Verify in-memory config was reset to defaults
config := service.GetDbConfig()
require.Equal(t, "Pocket ID", config.AppName.Value) // Default value
require.Equal(t, "60", config.SessionDuration.Value) // Default value
// Verify database was updated with empty values
for _, key := range []string{"appName", "sessionDuration"} {
var loaded model.AppConfigVariable
err = db.Where("key = ?", key).First(&loaded).Error
require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key)
require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key)
}
})
t.Run("auto disables EmailOneTimeAccessEnabled when EmailLoginNotificationEnabled is false", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First enable both settings
err = service.UpdateAppConfigValues(t.Context(),
"emailLoginNotificationEnabled", "true",
"emailOneTimeAccessEnabled", "true",
)
require.NoError(t, err)
// Verify both are enabled
config := service.GetDbConfig()
require.True(t, config.EmailLoginNotificationEnabled.IsTrue())
require.True(t, config.EmailOneTimeAccessEnabled.IsTrue())
// Now disable EmailLoginNotificationEnabled
input := dto.AppConfigUpdateDto{
EmailLoginNotificationEnabled: "false",
// Don't set EmailOneTimeAccessEnabled, it should be auto-disabled
}
// Update config
_, err = service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify EmailOneTimeAccessEnabled was automatically disabled
config = service.GetDbConfig()
require.False(t, config.EmailLoginNotificationEnabled.IsTrue())
require.False(t, config.EmailOneTimeAccessEnabled.IsTrue())
})
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newAppConfigTestDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update config
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
AppName: "Should Not Update",
})
// Should get a UiConfigDisabledError
require.Error(t, err)
var uiConfigDisabledErr *common.UiConfigDisabledError
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
}
// Implements gorm's logger.Writer interface
type testLoggerAdapter struct {
t *testing.T
}
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
func newAppConfigTestDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
require.NoError(t, err, "Failed to connect to test database")
// Create the app_config_variables table
err = db.Exec(`
CREATE TABLE app_config_variables
(
key VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)
`).Error
require.NoError(t, err, "Failed to create test config table")
return db
}

View File

@@ -1,9 +1,12 @@
package service package service
import ( import (
"context"
"fmt"
"log" "log"
userAgentParser "github.com/mileusna/useragent" userAgentParser "github.com/mileusna/useragent"
"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/model"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
@@ -22,10 +25,10 @@ func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailSe
} }
// Create creates a new audit log entry in the database // Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog { func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) model.AuditLog {
country, city, err := s.geoliteService.GetLocationByIP(ipAddress) country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil { if err != nil {
log.Printf("Failed to get IP location: %v\n", err) log.Printf("Failed to get IP location: %v", err)
} }
auditLog := model.AuditLog{ auditLog := model.AuditLog{
@@ -39,8 +42,12 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
} }
// Save the audit log in the database // Save the audit log in the database
if err := s.db.Create(&auditLog).Error; err != nil { err = tx.
log.Printf("Failed to create audit log: %v\n", err) WithContext(ctx).
Create(&auditLog).
Error
if err != nil {
log.Printf("Failed to create audit log: %v", err)
return model.AuditLog{} return model.AuditLog{}
} }
@@ -48,24 +55,41 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
} }
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before // CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog { func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}) createdAuditLog := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
// Count the number of times the user has logged in from the same device // Count the number of times the user has logged in from the same device
var count int64 var count int64
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error err := tx.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).
Count(&count).
Error
if err != nil { if err != nil {
log.Printf("Failed to count audit logs: %v\n", err) log.Printf("Failed to count audit logs: %v\n", err)
return createdAuditLog return createdAuditLog
} }
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email // If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 { if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() { go func() {
var user model.User innerCtx := context.Background()
s.db.Where("id = ?", userID).First(&user)
err := SendEmail(s.emailService, email.Address{ // Note we don't use the transaction here because this is running in background
var user model.User
innerErr := s.db.
WithContext(innerCtx).
Where("id = ?", userID).
First(&user).
Error
if innerErr != nil {
log.Printf("Failed to load user: %v", innerErr)
}
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
Name: user.Username, Name: user.Username,
Email: user.Email, Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{ }, NewLoginTemplate, &NewLoginTemplateData{
@@ -75,8 +99,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
Device: s.DeviceStringFromUserAgent(userAgent), Device: s.DeviceStringFromUserAgent(userAgent),
DateTime: createdAuditLog.CreatedAt.UTC(), DateTime: createdAuditLog.CreatedAt.UTC(),
}) })
if err != nil { if innerErr != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err) log.Printf("Failed to send email to '%s': %v", user.Email, innerErr)
} }
}() }()
} }
@@ -85,9 +109,12 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
} }
// ListAuditLogsForUser retrieves all audit logs for a given user ID // ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) { func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID) query := s.db.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ?", userID)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
return logs, pagination, err return logs, pagination, err
@@ -97,3 +124,99 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent) ua := userAgentParser.Parse(userAgent)
return ua.Name + " on " + ua.OS + " " + ua.OSVersion return ua.Name + " on " + ua.OS + " " + ua.OSVersion
} }
func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest, filters dto.AuditLogFilterDto) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
WithContext(ctx).
Preload("User").
Model(&model.AuditLog{})
if filters.UserID != "" {
query = query.Where("user_id = ?", filters.UserID)
}
if filters.Event != "" {
query = query.Where("event = ?", filters.Event)
}
if filters.ClientName != "" {
dialect := s.db.Name()
switch dialect {
case "sqlite":
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
case "postgres":
query = query.Where("data->>'clientName' = ?", filters.ClientName)
default:
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
}
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
if err != nil {
return nil, pagination, err
}
return logs, pagination, nil
}
func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[string]string, err error) {
query := s.db.
WithContext(ctx).
Joins("User").
Model(&model.AuditLog{}).
Select("DISTINCT User.id, User.username").
Where("User.username IS NOT NULL")
type Result struct {
ID string `gorm:"column:id"`
Username string `gorm:"column:username"`
}
var results []Result
if err := query.Find(&results).Error; err != nil {
return nil, fmt.Errorf("failed to query user IDs: %w", err)
}
users = make(map[string]string, len(results))
for _, result := range results {
users[result.ID] = result.Username
}
return users, nil
}
func (s *AuditLogService) ListClientNames(ctx context.Context) (clientNames []string, err error) {
dialect := s.db.Name()
query := s.db.
WithContext(ctx).
Model(&model.AuditLog{})
switch dialect {
case "sqlite":
query = query.
Select("DISTINCT json_extract(data, '$.clientName') AS client_name").
Where("json_extract(data, '$.clientName') IS NOT NULL")
case "postgres":
query = query.
Select("DISTINCT data->>'clientName' AS client_name").
Where("data->>'clientName' IS NOT NULL")
default:
return nil, fmt.Errorf("unsupported database dialect: %s", dialect)
}
type Result struct {
ClientName string `gorm:"column:client_name"`
}
var results []Result
if err := query.Find(&results).Error; err != nil {
return nil, fmt.Errorf("failed to query client IDs: %w", err)
}
clientNames = make([]string, len(results))
for i, result := range results {
clientNames[i] = result.ClientName
}
return clientNames, nil
}

View File

@@ -1,34 +1,14 @@
package service package service
import ( import (
"context"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Reserved claims
var reservedClaims = map[string]struct{}{
"given_name": {},
"family_name": {},
"name": {},
"email": {},
"preferred_username": {},
"groups": {},
"sub": {},
"iss": {},
"aud": {},
"exp": {},
"iat": {},
"auth_time": {},
"nonce": {},
"acr": {},
"amr": {},
"azp": {},
"nbf": {},
"jti": {},
}
type CustomClaimService struct { type CustomClaimService struct {
db *gorm.DB db *gorm.DB
} }
@@ -39,8 +19,29 @@ func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username // isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
func isReservedClaim(key string) bool { func isReservedClaim(key string) bool {
_, ok := reservedClaims[key] switch key {
return ok case "given_name",
"family_name",
"name",
"email",
"preferred_username",
"groups",
"sub",
"iss",
"aud",
"exp",
"iat",
"auth_time",
"nonce",
"acr",
"amr",
"azp",
"nbf",
"jti":
return true
default:
return false
}
} }
// idType is the type of the id used to identify the user or user group // idType is the type of the id used to identify the user or user group
@@ -52,28 +53,37 @@ const (
) )
// UpdateCustomClaimsForUser updates the custom claims for a user // UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(UserID, userID, claims) return s.updateCustomClaims(ctx, UserID, userID, claims)
} }
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group // UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(UserGroupID, userGroupID, claims) return s.updateCustomClaims(ctx, UserGroupID, userGroupID, claims)
} }
// updateCustomClaims updates the custom claims for a user or user group // updateCustomClaims updates the custom claims for a user or user group
func (s *CustomClaimService) updateCustomClaims(idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) { func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice // Check for duplicate keys in the claims slice
seenKeys := make(map[string]bool) seenKeys := make(map[string]struct{})
for _, claim := range claims { for _, claim := range claims {
if seenKeys[claim.Key] { if _, ok := seenKeys[claim.Key]; ok {
return nil, &common.DuplicateClaimError{Key: claim.Key} return nil, &common.DuplicateClaimError{Key: claim.Key}
} }
seenKeys[claim.Key] = true seenKeys[claim.Key] = struct{}{}
} }
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var existingClaims []model.CustomClaim var existingClaims []model.CustomClaim
err := s.db.Where(string(idType), value).Find(&existingClaims).Error err := tx.
WithContext(ctx).
Where(string(idType), value).
Find(&existingClaims).
Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -87,8 +97,12 @@ func (s *CustomClaimService) updateCustomClaims(idType idType, value string, cla
break break
} }
} }
if !found { if !found {
err = s.db.Delete(&existingClaim).Error err = tx.
WithContext(ctx).
Delete(&existingClaim).
Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -105,14 +119,20 @@ func (s *CustomClaimService) updateCustomClaims(idType idType, value string, cla
Value: claim.Value, Value: claim.Value,
} }
if idType == UserID { switch idType {
case UserID:
customClaim.UserID = &value customClaim.UserID = &value
} else if idType == UserGroupID { case UserGroupID:
customClaim.UserGroupID = &value customClaim.UserGroupID = &value
} }
// Update the claim if it already exists or create a new one // Update the claim if it already exists or create a new one
err = s.db.Where(string(idType)+" = ? AND key = ?", value, claim.Key).Assign(&customClaim).FirstOrCreate(&model.CustomClaim{}).Error err = tx.
WithContext(ctx).
Where(string(idType)+" = ? AND key = ?", value, claim.Key).
Assign(&customClaim).
FirstOrCreate(&model.CustomClaim{}).
Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -120,7 +140,16 @@ func (s *CustomClaimService) updateCustomClaims(idType idType, value string, cla
// Get the updated claims // Get the updated claims
var updatedClaims []model.CustomClaim var updatedClaims []model.CustomClaim
err = s.db.Where(string(idType)+" = ?", value).Find(&updatedClaims).Error err = tx.
WithContext(ctx).
Where(string(idType)+" = ?", value).
Find(&updatedClaims).
Error
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -128,23 +157,31 @@ func (s *CustomClaimService) updateCustomClaims(idType idType, value string, cla
return updatedClaims, nil return updatedClaims, nil
} }
func (s *CustomClaimService) GetCustomClaimsForUser(userID string) ([]model.CustomClaim, error) { func (s *CustomClaimService) GetCustomClaimsForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim var customClaims []model.CustomClaim
err := s.db.Where("user_id = ?", userID).Find(&customClaims).Error err := tx.
WithContext(ctx).
Where("user_id = ?", userID).
Find(&customClaims).
Error
return customClaims, err return customClaims, err
} }
func (s *CustomClaimService) GetCustomClaimsForUserGroup(userGroupID string) ([]model.CustomClaim, error) { func (s *CustomClaimService) GetCustomClaimsForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim var customClaims []model.CustomClaim
err := s.db.Where("user_group_id = ?", userGroupID).Find(&customClaims).Error err := tx.
WithContext(ctx).
Where("user_group_id = ?", userGroupID).
Find(&customClaims).
Error
return customClaims, err return customClaims, err
} }
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of, // GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
// prioritizing the user's claims over user group claims with the same key. // prioritizing the user's claims over user group claims with the same key.
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string) ([]model.CustomClaim, error) { func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
// Get the custom claims of the user // Get the custom claims of the user
customClaims, err := s.GetCustomClaimsForUser(userID) customClaims, err := s.GetCustomClaimsForUser(ctx, userID, tx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -157,7 +194,9 @@ func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string)
// Get all user groups of the user // Get all user groups of the user
var userGroupsOfUser []model.UserGroup var userGroupsOfUser []model.UserGroup
err = s.db.Preload("CustomClaims"). err = tx.
WithContext(ctx).
Preload("CustomClaims").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id"). Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID). Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error Find(&userGroupsOfUser).Error
@@ -185,10 +224,12 @@ func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(userID string)
} }
// GetSuggestions returns a list of custom claim keys that have been used before // GetSuggestions returns a list of custom claim keys that have been used before
func (s *CustomClaimService) GetSuggestions() ([]string, error) { func (s *CustomClaimService) GetSuggestions(ctx context.Context) ([]string, error) {
var customClaimsKeys []string var customClaimsKeys []string
err := s.db.Model(&model.CustomClaim{}). err := s.db.
WithContext(ctx).
Model(&model.CustomClaim{}).
Group("key"). Group("key").
Order("COUNT(*) DESC"). Order("COUNT(*) DESC").
Pluck("key", &customClaimsKeys).Error Pluck("key", &customClaimsKeys).Error

View File

@@ -1,6 +1,9 @@
//go:build e2etest
package service package service
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
@@ -32,6 +35,7 @@ func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService} return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
} }
//nolint:gocognit
func (s *TestService) SeedDatabase() error { func (s *TestService) SeedDatabase() error {
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
users := []model.User{ users := []model.User{
@@ -185,11 +189,8 @@ func (s *TestService) SeedDatabase() error {
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \ // openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout) // openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
publicKeyPasskey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==") publicKeyPasskey1, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKeyPasskey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==") publicKeyPasskey2, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
if err != nil {
return err
}
webauthnCredentials := []model.WebauthnCredential{ webauthnCredentials := []model.WebauthnCredential{
{ {
Name: "Passkey 1", Name: "Passkey 1",
@@ -299,26 +300,22 @@ func (s *TestService) ResetApplicationImages() error {
return nil return nil
} }
func (s *TestService) ResetAppConfig() error { func (s *TestService) ResetAppConfig(ctx context.Context) error {
// Reseed the config variables // Reset all app config variables to their default values in the database
if err := s.appConfigService.InitDbConfig(); err != nil { err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error
return err if err != nil {
}
// Reset all app config variables to their default values
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
return err return err
} }
// Reload the app config from the database after resetting the values // Reload the app config from the database after resetting the values
return s.appConfigService.LoadDbConfigFromDb() return s.appConfigService.LoadDbConfig(ctx)
} }
func (s *TestService) SetJWTKeys() { func (s *TestService) SetJWTKeys() {
const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}` const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`
privateKey, _ := jwk.ParseKey([]byte(privateKeyString)) privateKey, _ := jwk.ParseKey([]byte(privateKeyString))
s.jwtService.SetKey(privateKey) _ = s.jwtService.SetKey(privateKey)
} }
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key // getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key

View File

@@ -2,24 +2,28 @@ package service
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"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"
"gorm.io/gorm"
htemplate "html/template" htemplate "html/template"
"io"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net/textproto" "net/textproto"
"os" "os"
"strings"
ttemplate "text/template" ttemplate "text/template"
"time" "time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/google/uuid" "github.com/google/uuid"
"strings" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
) )
type EmailService struct { type EmailService struct {
@@ -48,22 +52,28 @@ func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailSer
}, nil }, nil
} }
func (srv *EmailService) SendTestEmail(recipientUserId string) error { func (srv *EmailService) SendTestEmail(ctx context.Context, recipientUserId string) error {
var user model.User var user model.User
if err := srv.db.First(&user, "id = ?", recipientUserId).Error; err != nil { err := srv.db.
WithContext(ctx).
First(&user, "id = ?", recipientUserId).
Error
if err != nil {
return err return err
} }
return SendEmail(srv, return SendEmail(ctx, srv,
email.Address{ email.Address{
Email: user.Email, Email: user.Email,
Name: user.FullName(), Name: user.FullName(),
}, TestTemplate, nil) }, TestTemplate, nil)
} }
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
dbConfig := srv.appConfigService.GetDbConfig()
data := &email.TemplateData[V]{ data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value, AppName: dbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo", LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
Data: tData, Data: tData,
} }
@@ -78,8 +88,8 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
c.AddHeader("Subject", template.Title(data)) c.AddHeader("Subject", template.Title(data))
c.AddAddressHeader("From", []email.Address{ c.AddAddressHeader("From", []email.Address{
{ {
Email: srv.appConfigService.DbConfig.SmtpFrom.Value, Email: dbConfig.SmtpFrom.Value,
Name: srv.appConfigService.DbConfig.AppName.Value, Name: dbConfig.AppName.Value,
}, },
}) })
c.AddAddressHeader("To", []email.Address{toEmail}) c.AddAddressHeader("To", []email.Address{toEmail})
@@ -94,7 +104,7 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
// so we use the domain of the from address instead (the same as Thunderbird does) // so we use the domain of the from address instead (the same as Thunderbird does)
// if the address does not have an @ (which would be unusual), we use hostname // if the address does not have an @ (which would be unusual), we use hostname
from_address := srv.appConfigService.DbConfig.SmtpFrom.Value from_address := dbConfig.SmtpFrom.Value
domain := "" domain := ""
if strings.Contains(from_address, "@") { if strings.Contains(from_address, "@") {
domain = strings.Split(from_address, "@")[1] domain = strings.Split(from_address, "@")[1]
@@ -107,10 +117,19 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
domain = hostname domain = hostname
} }
} }
c.AddHeader("Message-ID", "<" + uuid.New().String() + "@" + domain + ">") c.AddHeader("Message-ID", "<"+uuid.New().String()+"@"+domain+">")
c.Body(body) c.Body(body)
// Check if the context is still valid before attemtping to connect
// We need to do this because the smtp library doesn't have context support
select {
case <-ctx.Done():
return ctx.Err()
default:
// All good
}
// Connect to the SMTP server // Connect to the SMTP server
client, err := srv.getSmtpClient() client, err := srv.getSmtpClient()
if err != nil { if err != nil {
@@ -118,6 +137,14 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
} }
defer client.Close() defer client.Close()
// Check if the context is still valid before sending the email
select {
case <-ctx.Done():
return ctx.Err()
default:
// All good
}
// Send the email // Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil { if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err) return fmt.Errorf("send email content: %w", err)
@@ -127,16 +154,18 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
} }
func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) { func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
port := srv.appConfigService.DbConfig.SmtpPort.Value dbConfig := srv.appConfigService.GetDbConfig()
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
port := dbConfig.SmtpPort.Value
smtpAddress := dbConfig.SmtpHost.Value + ":" + port
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true", InsecureSkipVerify: dbConfig.SmtpSkipCertVerify.IsTrue(), //nolint:gosec
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value, ServerName: dbConfig.SmtpHost.Value,
} }
// Connect to the SMTP server based on TLS setting // Connect to the SMTP server based on TLS setting
switch srv.appConfigService.DbConfig.SmtpTls.Value { switch dbConfig.SmtpTls.Value {
case "none": case "none":
client, err = smtp.Dial(smtpAddress) client, err = smtp.Dial(smtpAddress)
case "tls": case "tls":
@@ -147,7 +176,7 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
tlsConfig, tlsConfig,
) )
default: default:
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", srv.appConfigService.DbConfig.SmtpTls.Value) return nil, fmt.Errorf("invalid SMTP TLS setting: %s", dbConfig.SmtpTls.Value)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
@@ -161,8 +190,8 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
} }
// Set up the authentication if user or password are set // Set up the authentication if user or password are set
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value smtpUser := dbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value smtpPassword := dbConfig.SmtpPassword.Value
if smtpUser != "" || smtpPassword != "" { if smtpUser != "" || smtpPassword != "" {
// Authenticate with plain auth // Authenticate with plain auth
@@ -198,7 +227,7 @@ func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error { func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
// Set the sender // Set the sender
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value, nil); err != nil { if err := client.Mail(srv.appConfigService.GetDbConfig().SmtpFrom.Value, nil); err != nil {
return fmt.Errorf("failed to set sender: %w", err) return fmt.Errorf("failed to set sender: %w", err)
} }
@@ -214,7 +243,7 @@ func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Add
} }
// Write the email content // Write the email content
_, err = w.Write([]byte(c.String())) _, err = io.Copy(w, strings.NewReader(c.String()))
if err != nil { if err != nil {
return fmt.Errorf("failed to write email data: %w", err) return fmt.Errorf("failed to write email data: %w", err)
} }

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -41,7 +42,7 @@ var tailscaleIPNets = []*net.IPNet{
} }
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database. // NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
func NewGeoLiteService() *GeoLiteService { func NewGeoLiteService(ctx context.Context) *GeoLiteService {
service := &GeoLiteService{} service := &GeoLiteService{}
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl { if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
@@ -51,8 +52,9 @@ func NewGeoLiteService() *GeoLiteService {
} }
go func() { go func() {
if err := service.updateDatabase(); err != nil { err := service.updateDatabase(ctx)
log.Printf("Failed to update GeoLite2 City database: %v\n", err) if err != nil {
log.Printf("Failed to update GeoLite2 City database: %v", err)
} }
}() }()
@@ -110,7 +112,7 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
} }
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days. // UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) updateDatabase() error { func (s *GeoLiteService) updateDatabase(parentCtx context.Context) error {
if s.disableUpdater { if s.disableUpdater {
// Avoid updating the GeoLite2 City database. // Avoid updating the GeoLite2 City database.
return nil return nil
@@ -124,8 +126,15 @@ func (s *GeoLiteService) updateDatabase() error {
log.Println("Updating GeoLite2 City database...") log.Println("Updating GeoLite2 City database...")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey) downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
// Download the database tar.gz file ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
resp, err := http.Get(downloadUrl) defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to download database: %w", err) return fmt.Errorf("failed to download database: %w", err)
} }
@@ -164,6 +173,9 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tarReader := tar.NewReader(gzr) tarReader := tar.NewReader(gzr)
var totalSize int64
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
// Iterate over the files in the tar archive // Iterate over the files in the tar archive
for { for {
header, err := tarReader.Next() header, err := tarReader.Next()
@@ -176,6 +188,11 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
// Check if the file is the GeoLite2-City.mmdb file // Check if the file is the GeoLite2-City.mmdb file
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" { if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
totalSize += header.Size
if totalSize > maxTotalSize {
return errors.New("total decompressed size exceeds maximum allowed limit")
}
// extract to a temporary file to avoid having a corrupted db in case of write failure. // extract to a temporary file to avoid having a corrupted db in case of write failure.
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath) baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp") tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
@@ -185,7 +202,7 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tempName := tmpFile.Name() tempName := tmpFile.Name()
// Write the file contents directly to the target location // Write the file contents directly to the target location
if _, err := io.Copy(tmpFile, tarReader); err != nil { if _, err := io.Copy(tmpFile, tarReader); err != nil { //nolint:gosec
// if fails to write, then cleanup and throw an error // if fails to write, then cleanup and throw an error
tmpFile.Close() tmpFile.Close()
os.Remove(tempName) os.Remove(tempName)

View File

@@ -11,13 +11,14 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"slices" "strings"
"strconv"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/model"
@@ -34,6 +35,19 @@ const (
// KeyUsageSigning is the usage for the private keys, for the "use" property // KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig" KeyUsageSigning = "sig"
// IsAdminClaim is a boolean claim used in access tokens for admin users
// This may be omitted on non-admin tokens
IsAdminClaim = "isAdmin"
// AccessTokenJWTType is the media type for access tokens
AccessTokenJWTType = "AT+JWT"
// IDTokenJWTType is the media type for ID tokens
IDTokenJWTType = "ID+JWT"
// Acceptable clock skew for verifying tokens
clockSkew = time.Minute
) )
type JwtService struct { type JwtService struct {
@@ -61,11 +75,6 @@ func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) e
return s.loadOrGenerateKey(keysPath) return s.loadOrGenerateKey(keysPath)
} }
type AccessTokenJWTClaims struct {
jwt.RegisteredClaims
IsAdmin bool `json:"isAdmin,omitempty"`
}
// loadOrGenerateKey loads the private key from the given path or generates it if not existing. // loadOrGenerateKey loads the private key from the given path or generates it if not existing.
func (s *JwtService) loadOrGenerateKey(keysPath string) error { func (s *JwtService) loadOrGenerateKey(keysPath string) error {
var key jwk.Key var key jwk.Key
@@ -170,133 +179,184 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
} }
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) { func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
sessionDurationInMinutes, _ := strconv.Atoi(s.appConfigService.DbConfig.SessionDuration.Value) now := time.Now()
claim := AccessTokenJWTClaims{ token, err := jwt.NewBuilder().
RegisteredClaims: jwt.RegisteredClaims{ Subject(user.ID).
Subject: user.ID, Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)), IssuedAt(now).
IssuedAt: jwt.NewNumericDate(time.Now()), Issuer(common.EnvConfig.AppURL).
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL}, Build()
},
IsAdmin: user.IsAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err) return "", fmt.Errorf("failed to build token: %w", err)
} }
signed, err := token.SignedString(privateKeyRaw) err = SetAudienceString(token, common.EnvConfig.AppURL)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetIsAdmin(token, user.IsAdmin)
if err != nil {
return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err) return "", fmt.Errorf("failed to sign token: %w", err)
} }
return signed, nil return string(signed), nil
} }
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) { func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (any, error) { alg, _ := s.privateKey.Algorithm()
return s.getPublicKeyRaw() token, err := jwt.ParseString(
}) tokenString,
if err != nil || !token.Valid { jwt.WithValidate(true),
return nil, errors.New("couldn't handle this token") jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(common.EnvConfig.AppURL),
jwt.WithIssuer(common.EnvConfig.AppURL),
)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
} }
claims, isValid := token.Claims.(*AccessTokenJWTClaims) return token, nil
if !isValid {
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
return nil, errors.New("audience doesn't match")
}
return claims, nil
} }
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) { func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
// Initialize with capacity for userClaims, + 4 fixed claims, + 2 claims which may be set in some cases, to avoid re-allocations now := time.Now()
claims := make(jwt.MapClaims, len(userClaims)+6) token, err := jwt.NewBuilder().
claims["aud"] = clientID Expiration(now.Add(1 * time.Hour)).
claims["exp"] = jwt.NewNumericDate(time.Now().Add(1 * time.Hour)) IssuedAt(now).
claims["iat"] = jwt.NewNumericDate(time.Now()) Issuer(common.EnvConfig.AppURL).
claims["iss"] = common.EnvConfig.AppURL Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
for k, v := range userClaims { for k, v := range userClaims {
claims[k] = v err = token.Set(k, v)
if err != nil {
return "", fmt.Errorf("failed to set claim '%s': %w", k, err)
}
} }
if nonce != "" { if nonce != "" {
claims["nonce"] = nonce err = token.Set("nonce", nonce)
if err != nil {
return "", fmt.Errorf("failed to set claim 'nonce': %w", err)
}
} }
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) headers, err := CreateTokenTypeHeader(IDTokenJWTType)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err) return "", fmt.Errorf("failed to set token type: %w", err)
} }
return token.SignedString(privateKeyRaw) alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey, jws.WithProtectedHeaders(headers)))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
} }
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) { func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { alg, _ := s.privateKey.Algorithm()
return s.getPublicKeyRaw()
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) { opts := make([]jwt.ParseOption, 0)
return nil, errors.New("couldn't handle this token")
// These options are always present
opts = append(opts,
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
)
// By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp"
// In case we want to accept expired tokens (during logout), we need to set the validators explicitly without validating "exp"
if acceptExpiredTokens {
// This is equivalent to the default validators except it doesn't validate "exp"
opts = append(opts,
jwt.WithResetValidators(true),
jwt.WithValidator(jwt.IsIssuedAtValid()),
jwt.WithValidator(jwt.IsNbfValid()),
)
} }
claims, isValid := token.Claims.(*jwt.RegisteredClaims) token, err := jwt.ParseString(tokenString, opts...)
if !isValid { if err != nil {
return nil, errors.New("can't parse claims") return nil, fmt.Errorf("failed to parse token: %w", err)
} }
return claims, nil err = VerifyTokenTypeHeader(tokenString, IDTokenJWTType)
if err != nil {
return nil, fmt.Errorf("failed to verify token type: %w", err)
}
return token, nil
} }
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) { func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{ now := time.Now()
Subject: user.ID, token, err := jwt.NewBuilder().
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), Subject(user.ID).
IssuedAt: jwt.NewNumericDate(time.Now()), Expiration(now.Add(1 * time.Hour)).
Audience: jwt.ClaimStrings{clientID}, IssuedAt(now).
Issuer: common.EnvConfig.AppURL, Issuer(common.EnvConfig.AppURL).
} Build()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err) return "", fmt.Errorf("failed to build token: %w", err)
} }
return token.SignedString(privateKeyRaw) err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
headers, err := CreateTokenTypeHeader(AccessTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set token type: %w", err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey, jws.WithProtectedHeaders(headers)))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
} }
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) { func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { alg, _ := s.privateKey.Algorithm()
return s.getPublicKeyRaw() token, err := jwt.ParseString(
}) tokenString,
if err != nil || !token.Valid { jwt.WithValidate(true),
return nil, errors.New("couldn't handle this token") jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
} }
claims, isValid := token.Claims.(*jwt.RegisteredClaims) err = VerifyTokenTypeHeader(tokenString, AccessTokenJWTType)
if !isValid { if err != nil {
return nil, errors.New("can't parse claims") return nil, fmt.Errorf("failed to verify token type: %w", err)
} }
return claims, nil return token, nil
} }
// GetPublicJWK returns the JSON Web Key (JWK) for the public key. // GetPublicJWK returns the JSON Web Key (JWK) for the public key.
@@ -325,17 +385,18 @@ func (s *JwtService) GetPublicJWKSAsJSON() ([]byte, error) {
return s.jwksEncoded, nil return s.jwksEncoded, nil
} }
func (s *JwtService) getPublicKeyRaw() (any, error) { // GetKeyAlg returns the algorithm of the key
pubKey, err := s.privateKey.PublicKey() func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
if err != nil { if len(s.jwksEncoded) == 0 {
return nil, fmt.Errorf("failed to get public key: %w", err) return nil, errors.New("key is not initialized")
} }
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw) alg, ok := s.privateKey.Algorithm()
if err != nil { if !ok || alg == nil {
return nil, fmt.Errorf("failed to export raw public key: %w", err) return nil, errors.New("failed to retrieve algorithm for key")
} }
return pubKeyRaw, nil
return alg, nil
} }
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) { func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
@@ -438,3 +499,73 @@ func generateRandomKeyID() (string, error) {
} }
return base64.RawURLEncoding.EncodeToString(buf), nil return base64.RawURLEncoding.EncodeToString(buf), nil
} }
// GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) {
return false, nil
}
var isAdmin bool
err := token.Get(IsAdminClaim, &isAdmin)
return isAdmin, err
}
// CreateTokenTypeHeader creates a new JWS header with the given token type
func CreateTokenTypeHeader(tokenType string) (jws.Headers, error) {
headers := jws.NewHeaders()
err := headers.Set(jws.TypeKey, tokenType)
if err != nil {
return nil, fmt.Errorf("failed to set token type: %w", err)
}
return headers, nil
}
// SetIsAdmin sets the "isAdmin" claim in the token
func SetIsAdmin(token jwt.Token, isAdmin bool) error {
// Only set if true
if !isAdmin {
return nil
}
return token.Set(IsAdminClaim, isAdmin)
}
// SetAudienceString sets the "aud" claim with a value that is a string, and not an array
// This is permitted by RFC 7519, and it's done here for backwards-compatibility
func SetAudienceString(token jwt.Token, audience string) error {
return token.Set(jwt.AudienceKey, audience)
}
// VerifyTokenTypeHeader verifies that the "typ" header in the token matches the expected type
func VerifyTokenTypeHeader(tokenBytes string, expectedTokenType string) error {
// Parse the raw token string purely as a JWS message structure
// We don't need to verify the signature at this stage, just inspect headers.
msg, err := jws.Parse([]byte(tokenBytes))
if err != nil {
return fmt.Errorf("failed to parse token as JWS message: %w", err)
}
// Get the list of signatures attached to the message. Usually just one for JWT.
signatures := msg.Signatures()
if len(signatures) == 0 {
return errors.New("JWS message contains no signatures")
}
protectedHeaders := signatures[0].ProtectedHeaders()
if protectedHeaders == nil {
return fmt.Errorf("JWS signature has no protected headers")
}
// Retrieve the 'typ' header value from the PROTECTED headers.
var typHeaderValue string
err = protectedHeaders.Get(jws.TypeKey, &typHeaderValue)
if err != nil {
return fmt.Errorf("token is missing required protected header '%s'", jws.TypeKey)
}
if !strings.EqualFold(typHeaderValue, expectedTokenType) {
return fmt.Errorf("'%s' header mismatch: expected '%s', got '%s'", jws.TypeKey, expectedTokenType, typHeaderValue)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"errors" "errors"
@@ -11,8 +12,10 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"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/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
@@ -26,46 +29,44 @@ type LdapService struct {
} }
func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService) *LdapService { func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService) *LdapService {
return &LdapService{db: db, appConfigService: appConfigService, userService: userService, groupService: groupService} return &LdapService{
db: db,
appConfigService: appConfigService,
userService: userService,
groupService: groupService,
}
} }
func (s *LdapService) createClient() (*ldap.Conn, error) { func (s *LdapService) createClient() (*ldap.Conn, error) {
if s.appConfigService.DbConfig.LdapEnabled.Value != "true" { dbConfig := s.appConfigService.GetDbConfig()
if !dbConfig.LdapEnabled.IsTrue() {
return nil, fmt.Errorf("LDAP is not enabled") return nil, fmt.Errorf("LDAP is not enabled")
} }
// Setup LDAP connection // Setup LDAP connection
ldapURL := s.appConfigService.DbConfig.LdapUrl.Value client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true" InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify})) }))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err) return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
} }
// Bind as service account // Bind as service account
bindDn := s.appConfigService.DbConfig.LdapBindDn.Value err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value)
bindPassword := s.appConfigService.DbConfig.LdapBindPassword.Value
err = client.Bind(bindDn, bindPassword)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err) return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
} }
return client, nil return client, nil
} }
func (s *LdapService) SyncAll() error { func (s *LdapService) SyncAll(ctx context.Context) error {
err := s.SyncUsers() // Start a transaction
if err != nil { tx := s.db.Begin()
return fmt.Errorf("failed to sync users: %w", err) defer func() {
} tx.Rollback()
}()
err = s.SyncGroups()
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
return nil
}
func (s *LdapService) SyncGroups() error {
// Setup LDAP connection // Setup LDAP connection
client, err := s.createClient() client, err := s.createClient()
if err != nil { if err != nil {
@@ -73,251 +74,344 @@ func (s *LdapService) SyncGroups() error {
} }
defer client.Close() defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value err = s.SyncUsers(ctx, tx, client)
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value if err != nil {
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value return fmt.Errorf("failed to sync users: %w", err)
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
searchAttrs := []string{
nameAttribute,
uniqueIdentifierAttribute,
groupMemberOfAttribute,
} }
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{}) err = s.SyncGroups(ctx, tx, client)
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
// Commit the changes
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit changes to database: %w", err)
}
return nil
}
//nolint:gocognit
func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
dbConfig := s.appConfigService.GetDbConfig()
searchAttrs := []string{
dbConfig.LdapAttributeGroupName.Value,
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
dbConfig.LdapAttributeGroupMember.Value,
}
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value,
ldap.ScopeWholeSubtree,
0, 0, 0, false,
dbConfig.LdapUserGroupSearchFilter.Value,
searchAttrs,
[]ldap.Control{},
)
result, err := client.Search(searchReq) result, err := client.Search(searchReq)
if err != nil { if err != nil {
return fmt.Errorf("failed to query LDAP: %w", err) return fmt.Errorf("failed to query LDAP: %w", err)
} }
// Create a mapping for groups that exist // Create a mapping for groups that exist
ldapGroupIDs := make(map[string]bool) ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries { for _, value := range result.Entries {
var membersUserId []string ldapId := value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
// Skip groups without a valid LDAP ID // Skip groups without a valid LDAP ID
if ldapId == "" { if ldapId == "" {
log.Printf("Skipping LDAP group without a valid unique identifier (attribute: %s)", uniqueIdentifierAttribute) log.Printf("Skipping LDAP group without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
continue continue
} }
ldapGroupIDs[ldapId] = true ldapGroupIDs[ldapId] = struct{}{}
// Try to find the group in the database // Try to find the group in the database
var databaseGroup model.UserGroup var databaseGroup model.UserGroup
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup) err = tx.
WithContext(ctx).
Where("ldap_id = ?", ldapId).
First(&databaseGroup).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return fmt.Errorf("failed to query for LDAP group ID '%s': %w", ldapId, err)
}
// Get group members and add to the correct Group // Get group members and add to the correct Group
groupMembers := value.GetAttributeValues(groupMemberOfAttribute) groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
membersUserId := make([]string, 0, len(groupMembers))
for _, member := range groupMembers { for _, member := range groupMembers {
// Normal output of this would be CN=username,ou=people,dc=example,dc=com ldapId := getDNProperty("uid", member)
// Splitting at the "=" and "," then just grabbing the username for that string if ldapId == "" {
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0] continue
}
var databaseUser model.User var databaseUser model.User
err := s.db.Where("username = ? AND ldap_id IS NOT NULL", singleMember).First(&databaseUser).Error err = tx.
if err != nil { WithContext(ctx).
if errors.Is(err, gorm.ErrRecordNotFound) { Where("username = ? AND ldap_id IS NOT NULL", ldapId).
// The user collides with a non-LDAP user, so we skip it First(&databaseUser).
continue Error
} else { if errors.Is(err, gorm.ErrRecordNotFound) {
return err // The user collides with a non-LDAP user, so we skip it
} continue
} else if err != nil {
return fmt.Errorf("failed to query for existing user '%s': %w", ldapId, err)
} }
membersUserId = append(membersUserId, databaseUser.ID) membersUserId = append(membersUserId, databaseUser.ID)
} }
syncGroup := dto.UserGroupCreateDto{ syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(nameAttribute), Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(nameAttribute), FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute), LdapID: value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value),
} }
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
newGroup, err := s.groupService.Create(syncGroup) newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) return fmt.Errorf("failed to create group '%s': %w", syncGroup.Name, err)
} else { }
if _, err = s.groupService.UpdateUsers(newGroup.ID, membersUserId); err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) _, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, membersUserId, tx)
} if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
} }
} else { } else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true) _, err = s.groupService.updateInternal(ctx, databaseGroup.ID, syncGroup, true, tx)
_, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) return fmt.Errorf("failed to update group '%s': %w", syncGroup.Name, err)
return err
} }
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, membersUserId, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
}
} }
} }
// Get all LDAP groups from the database // Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup var ldapGroupsInDb []model.UserGroup
if err := s.db.Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil { err = tx.
fmt.Println(fmt.Errorf("failed to fetch groups from database: %v", err)) WithContext(ctx).
Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").
Select("ldap_id").
Error
if err != nil {
return fmt.Errorf("failed to fetch groups from database: %w", err)
} }
// Delete groups that no longer exist in LDAP // Delete groups that no longer exist in LDAP
for _, group := range ldapGroupsInDb { for _, group := range ldapGroupsInDb {
if _, exists := ldapGroupIDs[*group.LdapID]; !exists { if _, exists := ldapGroupIDs[*group.LdapID]; exists {
if err := s.db.Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).Error; err != nil { continue
log.Printf("Failed to delete group %s with: %v", group.Name, err)
} else {
log.Printf("Deleted group %s", group.Name)
}
} }
err = tx.
WithContext(ctx).
Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).
Error
if err != nil {
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
log.Printf("Deleted group '%s'", group.Name)
} }
return nil return nil
} }
func (s *LdapService) SyncUsers() error { //nolint:gocognit
// Setup LDAP connection func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
client, err := s.createClient() dbConfig := s.appConfigService.GetDbConfig()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeUserUniqueIdentifier.Value
usernameAttribute := s.appConfigService.DbConfig.LdapAttributeUserUsername.Value
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
searchAttrs := []string{ searchAttrs := []string{
"memberOf", "memberOf",
"sn", "sn",
"cn", "cn",
uniqueIdentifierAttribute, dbConfig.LdapAttributeUserUniqueIdentifier.Value,
usernameAttribute, dbConfig.LdapAttributeUserUsername.Value,
emailAttribute, dbConfig.LdapAttributeUserEmail.Value,
firstNameAttribute, dbConfig.LdapAttributeUserFirstName.Value,
lastNameAttribute, dbConfig.LdapAttributeUserLastName.Value,
profilePictureAttribute, dbConfig.LdapAttributeUserProfilePicture.Value,
} }
// Filters must start and finish with ()! // Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{}) searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value,
ldap.ScopeWholeSubtree,
0, 0, 0, false,
dbConfig.LdapUserSearchFilter.Value,
searchAttrs,
[]ldap.Control{},
)
result, err := client.Search(searchReq) result, err := client.Search(searchReq)
if err != nil { if err != nil {
fmt.Println(fmt.Errorf("failed to query LDAP: %w", err)) return fmt.Errorf("failed to query LDAP: %w", err)
} }
// Create a mapping for users that exist // Create a mapping for users that exist
ldapUserIDs := make(map[string]bool) ldapUserIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries { for _, value := range result.Entries {
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute) ldapId := value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value)
// Skip users without a valid LDAP ID // Skip users without a valid LDAP ID
if ldapId == "" { if ldapId == "" {
log.Printf("Skipping LDAP user without a valid unique identifier (attribute: %s)", uniqueIdentifierAttribute) log.Printf("Skipping LDAP user without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeUserUniqueIdentifier.Value)
continue continue
} }
ldapUserIDs[ldapId] = true ldapUserIDs[ldapId] = struct{}{}
// Get the user from the database // Get the user from the database
var databaseUser model.User var databaseUser model.User
s.db.Where("ldap_id = ?", ldapId).First(&databaseUser) err = tx.
WithContext(ctx).
Where("ldap_id = ?", ldapId).
First(&databaseUser).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
}
// Check if user is admin by checking if they are in the admin group // Check if user is admin by checking if they are in the admin group
isAdmin := false isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") { for _, group := range value.GetAttributeValues("memberOf") {
if strings.Contains(group, adminGroupAttribute) { if getDNProperty("cn", group) == dbConfig.LdapAttributeAdminGroup.Value {
isAdmin = true isAdmin = true
break
} }
} }
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(usernameAttribute), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(emailAttribute), Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(firstNameAttribute), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(lastNameAttribute), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin, IsAdmin: isAdmin,
LdapID: ldapId, LdapID: ldapId,
} }
if databaseUser.ID == "" { if databaseUser.ID == "" {
_, err = s.userService.CreateUser(newUser) _, err = s.userService.createUserInternal(ctx, newUser, true, tx)
if err != nil { if errors.Is(err, &common.AlreadyInUseError{}) {
log.Printf("Error syncing user %s: %s", newUser.Username, err) log.Printf("Skipping creating LDAP user '%s': %v", newUser.Username, err)
continue
} else if err != nil {
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
} }
} else { } else {
_, err = s.userService.UpdateUser(databaseUser.ID, newUser, false, true) _, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if err != nil { if errors.Is(err, &common.AlreadyInUseError{}) {
log.Printf("Error syncing user %s: %s", newUser.Username, err) log.Printf("Skipping updating LDAP user '%s': %v", newUser.Username, err)
continue
} else if err != nil {
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
} }
} }
// Save profile picture // Save profile picture
if pictureString := value.GetAttributeValue(profilePictureAttribute); pictureString != "" { pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
if err := s.SaveProfilePicture(databaseUser.ID, pictureString); err != nil { if pictureString != "" {
log.Printf("Error saving profile picture for user %s: %s", newUser.Username, err) err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
if err != nil {
// This is not a fatal error
log.Printf("Error saving profile picture for user %s: %v", newUser.Username, err)
} }
} }
} }
// Get all LDAP users from the database // Get all LDAP users from the database
var ldapUsersInDb []model.User var ldapUsersInDb []model.User
if err := s.db.Find(&ldapUsersInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil { err = tx.
fmt.Println(fmt.Errorf("failed to fetch users from database: %v", err)) WithContext(ctx).
Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
Select("ldap_id").
Error
if err != nil {
return fmt.Errorf("failed to fetch users from database: %w", err)
} }
// Delete users that no longer exist in LDAP // Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb { for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists { if _, exists := ldapUserIDs[*user.LdapID]; exists {
if err := s.userService.DeleteUser(user.ID); err != nil { continue
log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else {
log.Printf("Deleted user %s", user.Username)
}
} }
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
if err != nil {
return fmt.Errorf("failed to delete user '%s': %w", user.Username, err)
}
log.Printf("Deleted user '%s'", user.Username)
} }
return nil return nil
} }
func (s *LdapService) SaveProfilePicture(userId string, pictureString string) error { func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
var reader io.Reader var reader io.Reader
if _, err := url.ParseRequestURI(pictureString); err == nil { _, err := url.ParseRequestURI(pictureString)
// If the photo is a URL, download it if err == nil {
response, err := http.Get(pictureString) ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
var req *http.Request
req, err = http.NewRequestWithContext(ctx, http.MethodGet, pictureString, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
var res *http.Response
res, err = http.DefaultClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to download profile picture: %w", err) return fmt.Errorf("failed to download profile picture: %w", err)
} }
defer response.Body.Close() defer res.Body.Close()
reader = response.Body
reader = res.Body
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil { } else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
// If the photo is a base64 encoded string, decode it // If the photo is a base64 encoded string, decode it
reader = bytes.NewReader(decodedPhoto) reader = bytes.NewReader(decodedPhoto)
} else { } else {
// If the photo is a string, we assume that it's a binary string // If the photo is a string, we assume that it's a binary string
reader = bytes.NewReader([]byte(pictureString)) reader = bytes.NewReader([]byte(pictureString))
} }
// Update the profile picture // Update the profile picture
if err := s.userService.UpdateProfilePicture(userId, reader); err != nil { err = s.userService.UpdateProfilePicture(userId, reader)
if err != nil {
return fmt.Errorf("failed to update profile picture: %w", err) return fmt.Errorf("failed to update profile picture: %w", err)
} }
return nil return nil
} }
// getDNProperty returns the value of a property from a LDAP identifier
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
func getDNProperty(property string, str string) string {
// Example format is "CN=username,ou=people,dc=example,dc=com"
// First we split at the comma
property = strings.ToLower(property)
l := len(property) + 1
for _, v := range strings.Split(str, ",") {
v = strings.TrimSpace(v)
if len(v) > l && strings.ToLower(v)[0:l] == property+"=" {
return v[l:]
}
}
// CN not found, return an empty string
return ""
}

View File

@@ -0,0 +1,73 @@
package service
import (
"testing"
)
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string
property string
dn string
expectedResult string
}{
{
name: "simple case",
property: "cn",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "username",
},
{
name: "property not found",
property: "uid",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "",
},
{
name: "mixed case property",
property: "CN",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "username",
},
{
name: "mixed case DN",
property: "cn",
dn: "CN=username,OU=people,DC=example,DC=com",
expectedResult: "username",
},
{
name: "spaces in DN",
property: "cn",
dn: "cn=username, ou=people, dc=example, dc=com",
expectedResult: "username",
},
{
name: "value with special characters",
property: "cn",
dn: "cn=user.name+123,ou=people,dc=example,dc=com",
expectedResult: "user.name+123",
},
{
name: "empty DN",
property: "cn",
dn: "",
expectedResult: "",
},
{
name: "empty property",
property: "",
dn: "cn=username,ou=people,dc=example,dc=com",
expectedResult: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDNProperty(tt.property, tt.dn)
if result != tt.expectedResult {
t.Errorf("getDNProperty(%q, %q) = %q, want %q",
tt.property, tt.dn, result, tt.expectedResult)
}
})
}
}

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -9,9 +10,12 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"regexp" "regexp"
"slices"
"strings" "strings"
"time" "time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -39,9 +43,19 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppCo
} }
} }
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) { func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
if err := s.db.Preload("AllowedUserGroups").First(&client, "id = ?", input.ClientID).Error; err != nil { err := tx.
WithContext(ctx).
Preload("AllowedUserGroups").
First(&client, "id = ?", input.ClientID).
Error
if err != nil {
return "", "", err return "", "", err
} }
@@ -58,7 +72,12 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
// Check if the user group is allowed to authorize the client // Check if the user group is allowed to authorize the client
var user model.User var user model.User
if err := s.db.Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil { err = tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return "", "", err return "", "", err
} }
@@ -67,7 +86,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
} }
// Check if the user has already authorized the client with the given scope // Check if the user has already authorized the client with the given scope
hasAuthorizedClient, err := s.HasAuthorizedClient(input.ClientID, userID, input.Scope) hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -80,39 +99,55 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
Scope: input.Scope, Scope: input.Scope,
} }
if err := s.db.Create(&userAuthorizedClient).Error; err != nil { err = tx.
if errors.Is(err, gorm.ErrDuplicatedKey) { WithContext(ctx).
// The client has already been authorized but with a different scope so we need to update the scope Create(&userAuthorizedClient).
if err := s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error; err != nil { Error
return "", "", err if errors.Is(err, gorm.ErrDuplicatedKey) {
} // The client has already been authorized but with a different scope so we need to update the scope
} else { if err := tx.
WithContext(ctx).
Model(&userAuthorizedClient).Update("scope", input.Scope).Error; err != nil {
return "", "", err return "", "", err
} }
} else if err != nil {
return "", "", err
} }
} }
// Create the authorization code // Create the authorization code
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod) code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
// Log the authorization event // Log the authorization event
if hasAuthorizedClient { if hasAuthorizedClient {
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}) s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
} else { } else {
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}) s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
}
err = tx.Commit().Error
if err != nil {
return "", "", err
} }
return code, callbackURL, nil return code, callbackURL, nil
} }
// HasAuthorizedClient checks if the user has already authorized the client with the given scope // HasAuthorizedClient checks if the user has already authorized the client with the given scope
func (s *OidcService) HasAuthorizedClient(clientID, userID, scope string) (bool, error) { func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID, scope string) (bool, error) {
return s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
}
func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, userID, scope string, tx *gorm.DB) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient var userAuthorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).Error; err != nil { err := tx.
WithContext(ctx).
First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil return false, nil
} }
@@ -145,21 +180,30 @@ func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client mode
return isAllowedToAuthorize return isAllowedToAuthorize
} }
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier, refreshToken string) (idToken string, accessToken string, newRefreshToken string, exp int, err error) { func (s *OidcService) CreateTokens(ctx context.Context, code, grantType, clientID, clientSecret, codeVerifier, refreshToken string) (idToken string, accessToken string, newRefreshToken string, exp int, err error) {
switch grantType { switch grantType {
case "authorization_code": case "authorization_code":
return s.createTokenFromAuthorizationCode(code, clientID, clientSecret, codeVerifier) return s.createTokenFromAuthorizationCode(ctx, code, clientID, clientSecret, codeVerifier)
case "refresh_token": case "refresh_token":
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(refreshToken, clientID, clientSecret) accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, refreshToken, clientID, clientSecret)
return "", accessToken, newRefreshToken, exp, err return "", accessToken, newRefreshToken, exp, err
default: default:
return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{} return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{}
} }
} }
func (s *OidcService) createTokenFromAuthorizationCode(code, clientID, clientSecret, codeVerifier string) (idToken string, accessToken string, refreshToken string, exp int, err error) { func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code, clientID, clientSecret, codeVerifier string) (idToken string, accessToken string, refreshToken string, exp int, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return "", "", "", 0, err return "", "", "", 0, err
} }
@@ -176,7 +220,11 @@ func (s *OidcService) createTokenFromAuthorizationCode(code, clientID, clientSec
} }
var authorizationCodeMetaData model.OidcAuthorizationCode var authorizationCodeMetaData model.OidcAuthorizationCode
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error err = tx.
WithContext(ctx).
Preload("User").
First(&authorizationCodeMetaData, "code = ?", code).
Error
if err != nil { if err != nil {
return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{} return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{}
} }
@@ -192,7 +240,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(code, clientID, clientSec
return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{} return "", "", "", 0, &common.OidcInvalidAuthorizationCodeError{}
} }
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID) userClaims, err := s.getUserClaimsForClientInternal(ctx, authorizationCodeMetaData.UserID, clientID, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return "", "", "", 0, err
} }
@@ -203,26 +251,49 @@ func (s *OidcService) createTokenFromAuthorizationCode(code, clientID, clientSec
} }
// Generate a refresh token // Generate a refresh token
refreshToken, err = s.createRefreshToken(clientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope) refreshToken, err = s.createRefreshToken(ctx, clientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, tx)
if err != nil { if err != nil {
return "", "", "", 0, err return "", "", "", 0, err
} }
accessToken, err = s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID) accessToken, err = s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID)
if err != nil {
return "", "", "", 0, err
}
s.db.Delete(&authorizationCodeMetaData) err = tx.
WithContext(ctx).
Delete(&authorizationCodeMetaData).
Error
if err != nil {
return "", "", "", 0, err
}
err = tx.Commit().Error
if err != nil {
return "", "", "", 0, err
}
return idToken, accessToken, refreshToken, 3600, nil return idToken, accessToken, refreshToken, 3600, nil
} }
func (s *OidcService) createTokenFromRefreshToken(refreshToken, clientID, clientSecret string) (accessToken string, newRefreshToken string, exp int, err error) { func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshToken, clientID, clientSecret string) (accessToken string, newRefreshToken string, exp int, err error) {
if refreshToken == "" { if refreshToken == "" {
return "", "", 0, &common.OidcMissingRefreshTokenError{} return "", "", 0, &common.OidcMissingRefreshTokenError{}
} }
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Get the client to check if it's public // Get the client to check if it's public
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return "", "", 0, err return "", "", 0, err
} }
@@ -240,7 +311,9 @@ func (s *OidcService) createTokenFromRefreshToken(refreshToken, clientID, client
// Verify refresh token // Verify refresh token
var storedRefreshToken model.OidcRefreshToken var storedRefreshToken model.OidcRefreshToken
err = s.db.Preload("User"). err = tx.
WithContext(ctx).
Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())). Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())).
First(&storedRefreshToken). First(&storedRefreshToken).
Error Error
@@ -263,29 +336,140 @@ func (s *OidcService) createTokenFromRefreshToken(refreshToken, clientID, client
} }
// Generate a new refresh token and invalidate the old one // Generate a new refresh token and invalidate the old one
newRefreshToken, err = s.createRefreshToken(clientID, storedRefreshToken.UserID, storedRefreshToken.Scope) newRefreshToken, err = s.createRefreshToken(ctx, clientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
if err != nil { if err != nil {
return "", "", 0, err return "", "", 0, err
} }
// Delete the used refresh token // Delete the used refresh token
s.db.Delete(&storedRefreshToken) err = tx.
WithContext(ctx).
Delete(&storedRefreshToken).
Error
if err != nil {
return "", "", 0, err
}
err = tx.Commit().Error
if err != nil {
return "", "", 0, err
}
return accessToken, newRefreshToken, 3600, nil return accessToken, newRefreshToken, 3600, nil
} }
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) { func (s *OidcService) IntrospectToken(clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
if clientID == "" || clientSecret == "" {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
// Get the client to check if we are authorized.
var client model.OidcClient var client model.OidcClient
if err := s.db.Preload("CreatedBy").Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error; err != nil { if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
// Verify the client secret. This endpoint may not be used by public clients.
if client.IsPublic {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
token, err := s.jwtService.VerifyOauthAccessToken(tokenString)
if err != nil {
if errors.Is(err, jwt.ParseError()) {
// It's apparently not a valid JWT token, so we check if it's a valid refresh_token.
return s.introspectRefreshToken(tokenString)
}
// Every failure we get means the token is invalid. Nothing more to do with the error.
introspectDto.Active = false
return introspectDto, nil
}
introspectDto.Active = true
introspectDto.TokenType = "access_token"
if token.Has("scope") {
var asString string
var asStrings []string
if err := token.Get("scope", &asString); err == nil {
introspectDto.Scope = asString
} else if err := token.Get("scope", &asStrings); err == nil {
introspectDto.Scope = strings.Join(asStrings, " ")
}
}
if expiration, hasExpiration := token.Expiration(); hasExpiration {
introspectDto.Expiration = expiration.Unix()
}
if issuedAt, hasIssuedAt := token.IssuedAt(); hasIssuedAt {
introspectDto.IssuedAt = issuedAt.Unix()
}
if notBefore, hasNotBefore := token.NotBefore(); hasNotBefore {
introspectDto.NotBefore = notBefore.Unix()
}
if subject, hasSubject := token.Subject(); hasSubject {
introspectDto.Subject = subject
}
if audience, hasAudience := token.Audience(); hasAudience {
introspectDto.Audience = audience
}
if issuer, hasIssuer := token.Issuer(); hasIssuer {
introspectDto.Issuer = issuer
}
if identifier, hasIdentifier := token.JwtID(); hasIdentifier {
introspectDto.Identifier = identifier
}
return introspectDto, nil
}
func (s *OidcService) introspectRefreshToken(refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
var storedRefreshToken model.OidcRefreshToken
err = s.db.Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())).
First(&storedRefreshToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
introspectDto.Active = false
return introspectDto, nil
}
return introspectDto, err
}
introspectDto.Active = true
introspectDto.TokenType = "refresh_token"
return introspectDto, nil
}
func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.OidcClient, error) {
return s.getClientInternal(ctx, clientID, s.db)
}
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB) (model.OidcClient, error) {
var client model.OidcClient
err := tx.
WithContext(ctx).
Preload("CreatedBy").
Preload("AllowedUserGroups").
First(&client, "id = ?", clientID).
Error
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
return client, nil return client, nil
} }
func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) { func (s *OidcService) ListClients(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient var clients []model.OidcClient
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{}) query := s.db.
WithContext(ctx).
Preload("CreatedBy").
Model(&model.OidcClient{})
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
query = query.Where("name LIKE ?", searchPattern) query = query.Where("name LIKE ?", searchPattern)
@@ -299,7 +483,7 @@ func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest uti
return clients, pagination, nil return clients, pagination, nil
} }
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) { func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{ client := model.OidcClient{
Name: input.Name, Name: input.Name,
CallbackURLs: input.CallbackURLs, CallbackURLs: input.CallbackURLs,
@@ -309,16 +493,30 @@ func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string)
PkceEnabled: input.IsPublic || input.PkceEnabled, PkceEnabled: input.IsPublic || input.PkceEnabled,
} }
if err := s.db.Create(&client).Error; err != nil { err := s.db.
WithContext(ctx).
Create(&client).
Error
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
return client, nil return client, nil
} }
func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) { func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil { err := tx.
WithContext(ctx).
Preload("CreatedBy").
First(&client, "id = ?", clientID).
Error
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
@@ -328,29 +526,48 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
client.IsPublic = input.IsPublic client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled client.PkceEnabled = input.IsPublic || input.PkceEnabled
if err := s.db.Save(&client).Error; err != nil { err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return model.OidcClient{}, err
}
err = tx.Commit().Error
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
return client, nil return client, nil
} }
func (s *OidcService) DeleteClient(clientID string) error { func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err := s.db.
return err WithContext(ctx).
} Where("id = ?", clientID).
Delete(&client).
if err := s.db.Delete(&client).Error; err != nil { Error
if err != nil {
return err return err
} }
return nil return nil
} }
func (s *OidcService) CreateClientSecret(clientID string) (string, error) { func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return "", err return "", err
} }
@@ -365,16 +582,29 @@ func (s *OidcService) CreateClientSecret(clientID string) (string, error) {
} }
client.Secret = string(hashedSecret) client.Secret = string(hashedSecret)
if err := s.db.Save(&client).Error; err != nil { err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err return "", err
} }
return clientSecret, nil return clientSecret, nil
} }
func (s *OidcService) GetClientLogo(clientID string) (string, string, error) { func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (string, string, error) {
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err := s.db.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return "", "", err return "", "", err
} }
@@ -382,26 +612,35 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
return "", "", errors.New("image not found") return "", "", errors.New("image not found")
} }
imageType := *client.ImageType imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, imageType) mimeType := utils.GetImageMimeType(*client.ImageType)
mimeType := utils.GetImageMimeType(imageType)
return imagePath, mimeType, nil return imagePath, mimeType, nil
} }
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error { func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error {
fileType := utils.GetFileExtension(file.Filename) fileType := utils.GetFileExtension(file.Filename)
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return &common.FileTypeNotSupportedError{} return &common.FileTypeNotSupportedError{}
} }
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType) imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + clientID + "." + fileType
if err := utils.SaveFile(file, imagePath); err != nil { err := utils.SaveFile(file, imagePath)
if err != nil {
return err return err
} }
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return err return err
} }
@@ -413,16 +652,34 @@ func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHead
} }
client.ImageType = &fileType client.ImageType = &fileType
if err := s.db.Save(&client).Error; err != nil { err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err return err
} }
return nil return nil
} }
func (s *OidcService) DeleteClientLogo(clientID string) error { func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return err return err
} }
@@ -430,38 +687,71 @@ func (s *OidcService) DeleteClientLogo(clientID string) error {
return errors.New("image not found") return errors.New("image not found")
} }
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType) client.ImageType = nil
err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return err
}
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType
if err := os.Remove(imagePath); err != nil { if err := os.Remove(imagePath); err != nil {
return err return err
} }
client.ImageType = nil err = tx.Commit().Error
if err := s.db.Save(&client).Error; err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) { func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return claims, nil
}
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient var authorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil { err := tx.
WithContext(ctx).
Preload("User.UserGroups").
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
Error
if err != nil {
return nil, err return nil, err
} }
user := authorizedOidcClient.User user := authorizedOidcClient.User
scope := authorizedOidcClient.Scope scopes := strings.Split(authorizedOidcClient.Scope, " ")
claims := map[string]interface{}{ claims := map[string]interface{}{
"sub": user.ID, "sub": user.ID,
} }
if strings.Contains(scope, "email") { if slices.Contains(scopes, "email") {
claims["email"] = user.Email claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true" claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
} }
if strings.Contains(scope, "groups") { if slices.Contains(scopes, "groups") {
userGroups := make([]string, len(user.UserGroups)) userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups { for i, group := range user.UserGroups {
userGroups[i] = group.Name userGroups[i] = group.Name
@@ -474,17 +764,17 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FullName(), "name": user.FullName(),
"preferred_username": user.Username, "preferred_username": user.Username,
"picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID), "picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png",
} }
if strings.Contains(scope, "profile") { if slices.Contains(scopes, "profile") {
// Add profile claims // Add profile claims
for k, v := range profileClaims { for k, v := range profileClaims {
claims[k] = v claims[k] = v
} }
// Add custom claims // Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID) customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, userID, tx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -492,8 +782,8 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
for _, customClaim := range customClaims { for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string // The value of the custom claim can be a JSON object or a string
var jsonValue interface{} var jsonValue interface{}
json.Unmarshal([]byte(customClaim.Value), &jsonValue) err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if jsonValue != nil { if err == nil {
// It's JSON so we store it as an object // It's JSON so we store it as an object
claims[customClaim.Key] = jsonValue claims[customClaim.Key] = jsonValue
} else { } else {
@@ -502,15 +792,21 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
} }
} }
} }
if strings.Contains(scope, "email") {
if slices.Contains(scopes, "email") {
claims["email"] = user.Email claims["email"] = user.Email
} }
return claims, nil return claims, nil
} }
func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) { func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
client, err = s.GetClient(id) tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client, err = s.getClientInternal(ctx, id, tx)
if err != nil { if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
@@ -518,18 +814,37 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
// Fetch the user groups based on UserGroupIDs in input // Fetch the user groups based on UserGroupIDs in input
var groups []model.UserGroup var groups []model.UserGroup
if len(input.UserGroupIDs) > 0 { if len(input.UserGroupIDs) > 0 {
if err := s.db.Where("id IN (?)", input.UserGroupIDs).Find(&groups).Error; err != nil { err = tx.
WithContext(ctx).
Where("id IN (?)", input.UserGroupIDs).
Find(&groups).
Error
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
} }
// Replace the current user groups with the new set of user groups // Replace the current user groups with the new set of user groups
if err := s.db.Model(&client).Association("AllowedUserGroups").Replace(groups); err != nil { err = tx.
WithContext(ctx).
Model(&client).
Association("AllowedUserGroups").
Replace(groups)
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
// Save the updated client // Save the updated client
if err := s.db.Save(&client).Error; err != nil { err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return model.OidcClient{}, err
}
err = tx.Commit().Error
if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
@@ -537,28 +852,36 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
} }
// ValidateEndSession returns the logout callback URL for the client if all the validations pass // ValidateEndSession returns the logout callback URL for the client if all the validations pass
func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) (string, error) { func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
// If no ID token hint is provided, return an error // If no ID token hint is provided, return an error
if input.IdTokenHint == "" { if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{} return "", &common.TokenInvalidError{}
} }
// If the ID token hint is provided, verify the ID token // If the ID token hint is provided, verify the ID token
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint) // Here we also accept expired ID tokens, which are fine per spec
token, err := s.jwtService.VerifyIdToken(input.IdTokenHint, true)
if err != nil { if err != nil {
return "", &common.TokenInvalidError{} return "", &common.TokenInvalidError{}
} }
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request // If the client ID is provided check if the client ID in the ID token matches the client ID in the request
if input.ClientId != "" && claims.Audience[0] != input.ClientId { clientID, ok := token.Audience()
if !ok || len(clientID) == 0 {
return "", &common.TokenInvalidError{}
}
if input.ClientId != "" && clientID[0] != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{} return "", &common.OidcClientIdNotMatchingError{}
} }
clientId := claims.Audience[0]
// Check if the user has authorized the client before // Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil { err = s.db.
WithContext(ctx).
Preload("Client").
First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID).
Error
if err != nil {
return "", &common.OidcMissingAuthorizationError{} return "", &common.OidcMissingAuthorizationError{}
} }
@@ -576,7 +899,7 @@ func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string)
} }
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) { func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32) randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil { if err != nil {
return "", err return "", err
@@ -595,7 +918,11 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
CodeChallengeMethodSha256: &codeChallengeMethodSha256, CodeChallengeMethodSha256: &codeChallengeMethodSha256,
} }
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil { err = tx.
WithContext(ctx).
Create(&oidcAuthorizationCode).
Error
if err != nil {
return "", err return "", err
} }
@@ -641,7 +968,7 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
return "", &common.OidcInvalidCallbackURLError{} return "", &common.OidcInvalidCallbackURLError{}
} }
func (s *OidcService) createRefreshToken(clientID string, userID string, scope string) (string, error) { func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40) refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil { if err != nil {
return "", err return "", err
@@ -659,7 +986,11 @@ func (s *OidcService) createRefreshToken(clientID string, userID string, scope s
Scope: scope, Scope: scope,
} }
if err := s.db.Create(&m).Error; err != nil { err = tx.
WithContext(ctx).
Create(&m).
Error
if err != nil {
return "", err return "", err
} }

View File

@@ -1,13 +1,15 @@
package service package service
import ( import (
"context"
"errors" "errors"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "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"
"gorm.io/gorm"
) )
type UserGroupService struct { type UserGroupService struct {
@@ -19,8 +21,11 @@ func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserG
return &UserGroupService{db: db, appConfigService: appConfigService} return &UserGroupService{db: db, appConfigService: appConfigService}
} }
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) { func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{}) query := s.db.
WithContext(ctx).
Preload("CustomClaims").
Model(&model.UserGroup{})
if name != "" { if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%") query = query.Where("name LIKE ?", "%"+name+"%")
@@ -42,26 +47,58 @@ func (s *UserGroupService) List(name string, sortedPaginationRequest utils.Sorte
return groups, response, err return groups, response, err
} }
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) { func (s *UserGroupService) Get(ctx context.Context, id string) (group model.UserGroup, err error) {
err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error return s.getInternal(ctx, id, s.db)
}
func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.DB) (group model.UserGroup, err error) {
err = tx.
WithContext(ctx).
Where("id = ?", id).
Preload("CustomClaims").
Preload("Users").
First(&group).
Error
return group, err return group, err
} }
func (s *UserGroupService) Delete(id string) error { func (s *UserGroupService) Delete(ctx context.Context, id string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var group model.UserGroup var group model.UserGroup
if err := s.db.Where("id = ?", id).First(&group).Error; err != nil { err := tx.
WithContext(ctx).
Where("id = ?", id).
First(&group).
Error
if err != nil {
return err return err
} }
// Disallow deleting the group if it is an LDAP group and LDAP is enabled // Disallow deleting the group if it is an LDAP group and LDAP is enabled
if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" { if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return &common.LdapUserGroupUpdateError{} return &common.LdapUserGroupUpdateError{}
} }
return s.db.Delete(&group).Error err = tx.
WithContext(ctx).
Delete(&group).
Error
if err != nil {
return err
}
return tx.Commit().Error
} }
func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.UserGroup, err error) { func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
return s.createInternal(ctx, input, s.db)
}
func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) {
group = model.UserGroup{ group = model.UserGroup{
FriendlyName: input.FriendlyName, FriendlyName: input.FriendlyName,
Name: input.Name, Name: input.Name,
@@ -71,7 +108,12 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
group.LdapID = &input.LdapID group.LdapID = &input.LdapID
} }
if err := s.db.Preload("Users").Create(&group).Error; err != nil { err = tx.
WithContext(ctx).
Preload("Users").
Create(&group).
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
} }
@@ -80,31 +122,73 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
return group, nil return group, nil
} }
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allowLdapUpdate bool) (group model.UserGroup, err error) { func (s *UserGroupService) Update(ctx context.Context, id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
group, err = s.Get(id) tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
group, err = s.updateInternal(ctx, id, input, false, tx)
if err != nil {
return model.UserGroup{}, err
}
err = tx.Commit().Error
if err != nil {
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) updateInternal(ctx context.Context, id string, input dto.UserGroupCreateDto, isLdapSync bool, tx *gorm.DB) (group model.UserGroup, err error) {
group, err = s.getInternal(ctx, id, tx)
if err != nil { if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
// Disallow updating the group if it is an LDAP group and LDAP is enabled // Disallow updating the group if it is an LDAP group and LDAP is enabled
if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" { if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{} return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
} }
group.Name = input.Name group.Name = input.Name
group.FriendlyName = input.FriendlyName group.FriendlyName = input.FriendlyName
if err := s.db.Preload("Users").Save(&group).Error; err != nil { err = tx.
if errors.Is(err, gorm.ErrDuplicatedKey) { WithContext(ctx).
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} Preload("Users").
} Save(&group).
Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
} else if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
return group, nil return group, nil
} }
func (s *UserGroupService) UpdateUsers(id string, userIds []string) (group model.UserGroup, err error) { func (s *UserGroupService) UpdateUsers(ctx context.Context, id string, userIds []string) (group model.UserGroup, err error) {
group, err = s.Get(id) tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
group, err = s.updateUsersInternal(ctx, id, userIds, tx)
if err != nil {
return model.UserGroup{}, err
}
err = tx.Commit().Error
if err != nil {
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, userIds []string, tx *gorm.DB) (group model.UserGroup, err error) {
group, err = s.getInternal(ctx, id, tx)
if err != nil { if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
@@ -112,28 +196,59 @@ func (s *UserGroupService) UpdateUsers(id string, userIds []string) (group model
// Fetch the users based on the userIds // Fetch the users based on the userIds
var users []model.User var users []model.User
if len(userIds) > 0 { if len(userIds) > 0 {
if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil { err := tx.
WithContext(ctx).
Where("id IN (?)", userIds).
Find(&users).
Error
if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
} }
// Replace the current users with the new set of users // Replace the current users with the new set of users
if err := s.db.Model(&group).Association("Users").Replace(users); err != nil { err = tx.
WithContext(ctx).
Model(&group).
Association("Users").
Replace(users)
if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
// Save the updated group // Save the updated group
if err := s.db.Save(&group).Error; err != nil { err = tx.
WithContext(ctx).
Save(&group).
Error
if err != nil {
return model.UserGroup{}, err return model.UserGroup{}, err
} }
return group, nil return group, nil
} }
func (s *UserGroupService) GetUserCountOfGroup(id string) (int64, error) { func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) (int64, error) {
// We only perform select queries here, so we can rollback in all cases
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var group model.UserGroup var group model.UserGroup
if err := s.db.Preload("Users").Where("id = ?", id).First(&group).Error; err != nil { err := tx.
WithContext(ctx).
Preload("Users").
Where("id = ?", id).
First(&group).
Error
if err != nil {
return 0, err return 0, err
} }
return s.db.Model(&group).Association("Users").Count(), nil count := tx.
WithContext(ctx).
Model(&group).
Association("Users").
Count()
return count, nil
} }

View File

@@ -1,6 +1,8 @@
package service package service
import ( import (
"bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -11,7 +13,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/dto"
@@ -19,7 +21,7 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" 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"
"github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
) )
type UserService struct { type UserService struct {
@@ -34,9 +36,9 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService} return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
} }
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User var users []model.User
query := s.db.Model(&model.User{}) query := s.db.WithContext(ctx).Model(&model.User{})
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
@@ -47,46 +49,92 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
return users, pagination, err return users, pagination, err
} }
func (s *UserService) GetUser(userID string) (model.User, error) { func (s *UserService) GetUser(ctx context.Context, userID string) (model.User, error) {
return s.getUserInternal(ctx, userID, s.db)
}
func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *gorm.DB) (model.User, error) {
var user model.User var user model.User
err := s.db.Preload("UserGroups").Preload("CustomClaims").Where("id = ?", userID).First(&user).Error err := tx.
WithContext(ctx).
Preload("UserGroups").
Preload("CustomClaims").
Where("id = ?", userID).
First(&user).
Error
return user, err return user, err
} }
func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) { func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.ReadCloser, int64, error) {
// Validate the user ID to prevent directory traversal // Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil { if err := uuid.Validate(userID); err != nil {
return nil, 0, &common.InvalidUUIDError{} return nil, 0, &common.InvalidUUIDError{}
} }
// First check for a custom uploaded profile picture (userID.png)
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png" profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
file, err := os.Open(profilePicturePath) file, err := os.Open(profilePicturePath)
if err == nil { if err == nil {
// Get the file size // Get the file size
fileInfo, err := file.Stat() fileInfo, err := file.Stat()
if err != nil { if err != nil {
file.Close()
return nil, 0, err return nil, 0, err
} }
return file, fileInfo.Size(), nil return file, fileInfo.Size(), nil
} }
// If the file does not exist, return the default profile picture // If no custom picture exists, get the user's data for creating initials
user, err := s.GetUser(userID) user, err := s.GetUser(ctx, userID)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName) // Check if we have a cached default picture for these initials
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png"
file, err = os.Open(defaultPicturePath)
if err == nil {
fileInfo, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, err
}
return file, fileInfo.Size(), nil
}
// If no cached default picture exists, create one and save it for future use
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
return defaultPicture, int64(defaultPicture.Len()), nil // Save the default picture for future use (in a goroutine to avoid blocking)
defaultPictureBytes := defaultPicture.Bytes()
go func() {
// Ensure the directory exists
err = os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
if err != nil {
log.Printf("Failed to create directory for default profile picture: %v", err)
return
}
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
log.Printf("Failed to cache default profile picture for initials %s: %v", user.Initials(), err)
}
}()
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(defaultPicture.Len()), nil
} }
func (s *UserService) GetUserGroups(userID string) ([]model.UserGroup, error) { func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
var user model.User var user model.User
if err := s.db.Preload("UserGroups").Where("id = ?", userID).First(&user).Error; err != nil { err := s.db.
WithContext(ctx).
Preload("UserGroups").
Where("id = ?", userID).
First(&user).
Error
if err != nil {
return nil, err return nil, err
} }
return user.UserGroups, nil return user.UserGroups, nil
@@ -121,27 +169,64 @@ func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error
return nil return nil
} }
func (s *UserService) DeleteUser(userID string) error { func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error {
return s.db.Transaction(func(tx *gorm.DB) error {
return s.deleteUserInternal(ctx, userID, allowLdapDelete, tx)
})
}
func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error {
var user model.User var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return err err := tx.
WithContext(ctx).
Where("id = ?", userID).
First(&user).
Error
if err != nil {
return fmt.Errorf("failed to load user to delete: %w", err)
} }
// Disallow deleting the user if it is an LDAP user and LDAP is enabled // Disallow deleting the user if it is an LDAP user and LDAP is enabled
if user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() { if !allowLdapDelete && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{} return &common.LdapUserUpdateError{}
} }
// Delete the profile picture // Delete the profile picture
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png" profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
if err := os.Remove(profilePicturePath); err != nil && !os.IsNotExist(err) { err = os.Remove(profilePicturePath)
if err != nil && !os.IsNotExist(err) {
return err return err
} }
return s.db.Delete(&user).Error err = tx.WithContext(ctx).Delete(&user).Error
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
return nil
} }
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) { func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (model.User, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.createUserInternal(ctx, input, false, tx)
if err != nil {
return model.User{}, err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
user := model.User{ user := model.User{
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
@@ -154,23 +239,56 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
} }
if err := s.db.Create(&user).Error; err != nil { err := tx.WithContext(ctx).Create(&user).Error
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user) // Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
if !isLdapSync {
tx.Rollback()
// If we are here, the transaction is already aborted due to an error, so we pass s.db
err = s.checkDuplicatedFields(ctx, user, s.db)
} else {
err = s.checkDuplicatedFields(ctx, user, tx)
} }
return model.User{}, err
} else if err != nil {
return model.User{}, err return model.User{}, err
} }
return user, nil return user, nil
} }
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) { func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.updateUserInternal(ctx, userID, updatedUser, updateOwnUser, allowLdapUpdate, tx)
if err != nil {
return model.User{}, err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err
}
return user, nil
}
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
var user model.User var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { err := tx.
WithContext(ctx).
Where("id = ?", userID).
First(&user).
Error
if err != nil {
return model.User{}, err return model.User{}, err
} }
// Disallow updating the user if it is an LDAP group and LDAP is enabled // Disallow updating the user if it is an LDAP group and LDAP is enabled
if !allowLdapUpdate && user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() { if !isLdapSync && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return model.User{}, &common.LdapUserUpdateError{} return model.User{}, &common.LdapUserUpdateError{}
} }
@@ -183,24 +301,46 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
user.IsAdmin = updatedUser.IsAdmin user.IsAdmin = updatedUser.IsAdmin
} }
if err := s.db.Save(&user).Error; err != nil { err = tx.
if errors.Is(err, gorm.ErrDuplicatedKey) { WithContext(ctx).
return user, s.checkDuplicatedFields(user) Save(&user).
Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
// Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here
if !isLdapSync {
tx.Rollback()
// If we are here, the transaction is already aborted due to an error, so we pass s.db
err = s.checkDuplicatedFields(ctx, user, s.db)
} else {
err = s.checkDuplicatedFields(ctx, user, tx)
} }
return user, err
} else if err != nil {
return user, err return user, err
} }
return user, nil return user, nil
} }
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error { func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddress, redirectPath string) error {
isDisabled := !s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.IsTrue() tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessEnabled.IsTrue()
if isDisabled { if isDisabled {
return &common.OneTimeAccessDisabledError{} return &common.OneTimeAccessDisabledError{}
} }
var user model.User var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil { err := tx.
WithContext(ctx).
Where("email = ?", emailAddress).
First(&user).
Error
if err != nil {
// Do not return error if user not found to prevent email enumeration // Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil return nil
@@ -209,22 +349,31 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
} }
} }
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(15*time.Minute)) oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, time.Now().Add(15*time.Minute), tx)
if err != nil { if err != nil {
return err return err
} }
link := fmt.Sprintf("%s/lc", common.EnvConfig.AppURL) err = tx.Commit().Error
linkWithCode := fmt.Sprintf("%s/%s", link, oneTimeAccessToken) if err != nil {
return err
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
} }
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() { go func() {
err := SendEmail(s.emailService, email.Address{ innerCtx := context.Background()
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, email.Address{
Name: user.Username, Name: user.Username,
Email: user.Email, Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
@@ -232,19 +381,22 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
LoginLink: link, LoginLink: link,
LoginLinkWithCode: linkWithCode, LoginLinkWithCode: linkWithCode,
}) })
if err != nil { if errInternal != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err) log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
} }
}() }()
return nil return nil
} }
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) { func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, expiresAt time.Time) (string, error) {
tokenLength := 16 return s.createOneTimeAccessTokenInternal(ctx, userID, expiresAt, s.db)
}
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) {
// If expires at is less than 15 minutes, use an 6 character token instead of 16 // If expires at is less than 15 minutes, use an 6 character token instead of 16
if expiresAt.Sub(time.Now()) <= 15*time.Minute { tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6 tokenLength = 6
} }
@@ -259,16 +411,26 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
Token: randomString, Token: randomString,
} }
if err := s.db.Create(&oneTimeAccessToken).Error; err != nil { if err := tx.WithContext(ctx).Create(&oneTimeAccessToken).Error; err != nil {
return "", err return "", err
} }
return oneTimeAccessToken.Token, nil return oneTimeAccessToken.Token, nil
} }
func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) { func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token string, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil { err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{} return model.User{}, "", &common.TokenInvalidOrExpiredError{}
} }
@@ -279,19 +441,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
return model.User{}, "", err return model.User{}, "", err
} }
if err := s.db.Delete(&oneTimeAccessToken).Error; err != nil { err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err return model.User{}, "", err
} }
if ipAddress != "" && userAgent != "" { if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}) s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
} }
return oneTimeAccessToken.User, accessToken, nil return oneTimeAccessToken.User, accessToken, nil
} }
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) { func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
user, err = s.GetUser(id) tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err = s.getUserInternal(ctx, id, tx)
if err != nil { if err != nil {
return model.User{}, err return model.User{}, err
} }
@@ -299,27 +475,48 @@ func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user m
// Fetch the groups based on userGroupIds // Fetch the groups based on userGroupIds
var groups []model.UserGroup var groups []model.UserGroup
if len(userGroupIds) > 0 { if len(userGroupIds) > 0 {
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil { err = tx.
WithContext(ctx).
Where("id IN (?)", userGroupIds).
Find(&groups).
Error
if err != nil {
return model.User{}, err return model.User{}, err
} }
} }
// Replace the current groups with the new set of groups // Replace the current groups with the new set of groups
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil { err = tx.
WithContext(ctx).
Model(&user).
Association("UserGroups").
Replace(groups)
if err != nil {
return model.User{}, err return model.User{}, err
} }
// Save the updated user // Save the updated user
if err := s.db.Save(&user).Error; err != nil { err = tx.WithContext(ctx).Save(&user).Error
if err != nil {
return model.User{}, err
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err return model.User{}, err
} }
return user, nil return user, nil
} }
func (s *UserService) SetupInitialAdmin() (model.User, string, error) { func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var userCount int64 var userCount int64
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil { if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err return model.User{}, "", err
} }
if userCount > 1 { if userCount > 1 {
@@ -334,7 +531,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
IsAdmin: true, IsAdmin: true,
} }
if err := s.db.Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil { if err := tx.WithContext(ctx).Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
return model.User{}, "", err return model.User{}, "", err
} }
@@ -347,16 +544,39 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
return model.User{}, "", err return model.User{}, "", err
} }
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, token, nil return user, token, nil
} }
func (s *UserService) checkDuplicatedFields(user model.User) error { func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
var existingUser model.User var result struct {
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil { Found bool
}
err := tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND email = ?) AS found`, user.ID, user.Email).
First(&result).
Error
if err != nil {
return err
}
if result.Found {
return &common.AlreadyInUseError{Property: "email"} return &common.AlreadyInUseError{Property: "email"}
} }
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil { err = tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND username = ?) AS found`, user.ID, user.Username).
First(&result).
Error
if err != nil {
return err
}
if result.Found {
return &common.AlreadyInUseError{Property: "username"} return &common.AlreadyInUseError{Property: "username"}
} }

View File

@@ -1,16 +1,19 @@
package service package service
import ( import (
"context"
"fmt"
"net/http" "net/http"
"time" "time"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" 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"
"gorm.io/gorm"
) )
type WebAuthnService struct { type WebAuthnService struct {
@@ -23,7 +26,7 @@ type WebAuthnService struct {
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService { func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{ webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value, RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL), RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL}, RPOrigins: []string{common.EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{ Timeouts: webauthn.TimeoutsConfig{
@@ -40,18 +43,39 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
}, },
} }
wa, _ := webauthn.New(webauthnConfig) wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService, appConfigService: appConfigService} return &WebAuthnService{
db: db,
webAuthn: wa,
jwtService: jwtService,
auditLogService: auditLogService,
appConfigService: appConfigService,
}
} }
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) { func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) (*model.PublicKeyCredentialCreationOptions, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
s.updateWebAuthnConfig() s.updateWebAuthnConfig()
var user model.User var user model.User
if err := s.db.Preload("Credentials").Find(&user, "id = ?", userID).Error; err != nil { err := tx.
WithContext(ctx).
Preload("Credentials").
Find(&user, "id = ?", userID).
Error
if err != nil {
tx.Rollback()
return nil, err return nil, err
} }
options, session, err := s.webAuthn.BeginRegistration(&user, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors())) options, session, err := s.webAuthn.BeginRegistration(
&user,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -62,7 +86,16 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
UserVerification: string(session.UserVerification), UserVerification: string(session.UserVerification),
} }
if err := s.db.Create(&sessionToStore).Error; err != nil { err = tx.
WithContext(ctx).
Create(&sessionToStore).
Error
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err return nil, err
} }
@@ -73,9 +106,18 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
}, nil }, nil
} }
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) { func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil { err := tx.
WithContext(ctx).
First(&storedSession, "id = ?", sessionID).
Error
if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, err
} }
@@ -86,7 +128,11 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
} }
var user model.User var user model.User
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil { err = tx.
WithContext(ctx).
Find(&user, "id = ?", userID).
Error
if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, err
} }
@@ -108,7 +154,16 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
BackupEligible: credential.Flags.BackupEligible, BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState, BackupState: credential.Flags.BackupState,
} }
if err := s.db.Create(&credentialToStore).Error; err != nil { err = tx.
WithContext(ctx).
Create(&credentialToStore).
Error
if err != nil {
return model.WebauthnCredential{}, err
}
err = tx.Commit().Error
if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, err
} }
@@ -125,7 +180,7 @@ func (s *WebAuthnService) determinePasskeyName(aaguid []byte) string {
return "New Passkey" // Default fallback return "New Passkey" // Default fallback
} }
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) { func (s *WebAuthnService) BeginLogin(ctx context.Context) (*model.PublicKeyCredentialRequestOptions, error) {
options, session, err := s.webAuthn.BeginDiscoverableLogin() options, session, err := s.webAuthn.BeginDiscoverableLogin()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -137,7 +192,11 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
UserVerification: string(session.UserVerification), UserVerification: string(session.UserVerification),
} }
if err := s.db.Create(&sessionToStore).Error; err != nil { err = s.db.
WithContext(ctx).
Create(&sessionToStore).
Error
if err != nil {
return nil, err return nil, err
} }
@@ -148,9 +207,18 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil }, nil
} }
func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil { err := tx.
WithContext(ctx).
First(&storedSession, "id = ?", sessionID).
Error
if err != nil {
return model.User{}, "", err return model.User{}, "", err
} }
@@ -160,9 +228,14 @@ func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData
} }
var user *model.User var user *model.User
_, err := s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) { _, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
if err := s.db.Preload("Credentials").First(&user, "id = ?", string(userHandle)).Error; err != nil { innerErr := tx.
return nil, err WithContext(ctx).
Preload("Credentials").
First(&user, "id = ?", string(userHandle)).
Error
if innerErr != nil {
return nil, innerErr
} }
return user, nil return user, nil
}, session, credentialAssertionData) }, session, credentialAssertionData)
@@ -176,41 +249,69 @@ func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData
return model.User{}, "", err return model.User{}, "", err
} }
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID) s.auditLogService.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return *user, token, nil return *user, token, nil
} }
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) { func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([]model.WebauthnCredential, error) {
var credentials []model.WebauthnCredential var credentials []model.WebauthnCredential
if err := s.db.Find(&credentials, "user_id = ?", userID).Error; err != nil { err := s.db.
WithContext(ctx).
Find(&credentials, "user_id = ?", userID).
Error
if err != nil {
return nil, err return nil, err
} }
return credentials, nil return credentials, nil
} }
func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error { func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID, credentialID string) error {
var credential model.WebauthnCredential err := s.db.
if err := s.db.First(&credential, "id = ? AND user_id = ?", credentialID, userID).Error; err != nil { WithContext(ctx).
return err Where("id = ? AND user_id = ?", credentialID, userID).
} Delete(&model.WebauthnCredential{}).
Error
if err := s.db.Delete(&credential).Error; err != nil { if err != nil {
return err return fmt.Errorf("failed to delete record: %w", err)
} }
return nil return nil
} }
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) { func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credentialID, name string) (model.WebauthnCredential, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var credential model.WebauthnCredential var credential model.WebauthnCredential
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil { err := tx.
WithContext(ctx).
Where("id = ? AND user_id = ?", credentialID, userID).
First(&credential).
Error
if err != nil {
return credential, err return credential, err
} }
credential.Name = name credential.Name = name
if err := s.db.Save(&credential).Error; err != nil { err = tx.
WithContext(ctx).
Save(&credential).
Error
if err != nil {
return credential, err
}
err = tx.Commit().Error
if err != nil {
return credential, err return credential, err
} }
@@ -219,5 +320,5 @@ func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (m
// updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime // updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime
func (s *WebAuthnService) updateWebAuthnConfig() { func (s *WebAuthnService) updateWebAuthnConfig() {
s.webAuthn.Config.RPDisplayName = s.appConfigService.DbConfig.AppName.Value s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
} }

View File

@@ -12,9 +12,13 @@ import (
var ( var (
aaguidMap map[string]string aaguidMap map[string]string
aaguidMapOnce sync.Once aaguidMapOnce *sync.Once
) )
func init() {
aaguidMapOnce = &sync.Once{}
}
// FormatAAGUID converts an AAGUID byte slice to UUID string format // FormatAAGUID converts an AAGUID byte slice to UUID string format
func FormatAAGUID(aaguid []byte) string { func FormatAAGUID(aaguid []byte) string {
if len(aaguid) == 0 { if len(aaguid) == 0 {

View File

@@ -58,7 +58,7 @@ func TestGetAuthenticatorName(t *testing.T) {
"adce0002-35bc-c60a-648b-0b25f1f05503": "Test Authenticator", "adce0002-35bc-c60a-648b-0b25f1f05503": "Test Authenticator",
"00000000-0000-0000-0000-000000000000": "Zero Authenticator", "00000000-0000-0000-0000-000000000000": "Zero Authenticator",
} }
aaguidMapOnce = sync.Once{} aaguidMapOnce = &sync.Once{}
aaguidMapOnce.Do(func() {}) // Mark as done to avoid loading from file aaguidMapOnce.Do(func() {}) // Mark as done to avoid loading from file
tests := []struct { tests := []struct {
@@ -101,7 +101,7 @@ func TestGetAuthenticatorName(t *testing.T) {
func TestLoadAAGUIDsFromFile(t *testing.T) { func TestLoadAAGUIDsFromFile(t *testing.T) {
// Reset the map and once flag for clean testing // Reset the map and once flag for clean testing
aaguidMap = nil aaguidMap = nil
aaguidMapOnce = sync.Once{} aaguidMapOnce = &sync.Once{}
// Trigger loading of AAGUIDs by calling GetAuthenticatorName // Trigger loading of AAGUIDs by calling GetAuthenticatorName
GetAuthenticatorName([]byte{0x01, 0x02, 0x03, 0x04}) GetAuthenticatorName([]byte{0x01, 0x02, 0x03, 0x04})

View File

@@ -170,15 +170,13 @@ func (c *Composer) String() string {
func convertRunes(str string) []string { func convertRunes(str string) []string {
var enc = make([]string, 0, len(str)) var enc = make([]string, 0, len(str))
for _, r := range []rune(str) { for _, r := range str {
if r == ' ' { switch {
case r == ' ':
enc = append(enc, "_") enc = append(enc, "_")
} else if isPrintableASCIIRune(r) && case isPrintableASCIIRune(r) && r != '=' && r != '?' && r != '_':
r != '=' &&
r != '?' &&
r != '_' {
enc = append(enc, string(r)) enc = append(enc, string(r))
} else { default:
enc = append(enc, string(toHex([]byte(string(r))))) enc = append(enc, string(toHex([]byte(string(r)))))
} }
} }
@@ -204,7 +202,7 @@ func hex(n byte) byte {
} }
func isPrintableASCII(str string) bool { func isPrintableASCII(str string) bool {
for _, r := range []rune(str) { for _, r := range str {
if !unicode.IsPrint(r) || r >= unicode.MaxASCII { if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
return false return false
} }

View File

@@ -3,14 +3,12 @@ package utils
import ( import (
"errors" "errors"
"fmt" "fmt"
"hash/crc64"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
@@ -80,22 +78,7 @@ func SaveFile(file *multipart.FileHeader, dst string) error {
// SaveFileStream saves a stream to a file. // SaveFileStream saves a stream to a file.
func SaveFileStream(r io.Reader, dstFileName string) error { func SaveFileStream(r io.Reader, dstFileName string) error {
// Our strategy is to save to a separate file and then rename it to override the original file // Our strategy is to save to a separate file and then rename it to override the original file
// First, get a temp file name that doesn't exist already tmpFileName := dstFileName + "." + uuid.NewString() + "-tmp"
var tmpFileName string
var i int64
for {
seed := strconv.FormatInt(time.Now().UnixNano()+i, 10)
suffix := crc64.Checksum([]byte(dstFileName+seed), crc64.MakeTable(crc64.ISO))
tmpFileName = dstFileName + "." + strconv.FormatUint(suffix, 10)
exists, err := FileExists(tmpFileName)
if err != nil {
return fmt.Errorf("failed to check if file '%s' exists: %w", tmpFileName, err)
}
if !exists {
break
}
i++
}
// Write to the temporary file // Write to the temporary file
tmpFile, err := os.Create(tmpFileName) tmpFile, err := os.Create(tmpFileName)

View File

@@ -6,7 +6,6 @@ import (
"image" "image"
"image/color" "image/color"
"io" "io"
"strings"
"github.com/disintegration/imageorient" "github.com/disintegration/imageorient"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
@@ -32,7 +31,7 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
go func() { go func() {
err = imaging.Encode(pw, img, imaging.PNG) err = imaging.Encode(pw, img, imaging.PNG)
if err != nil { if err != nil {
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %v", err)) _ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", err))
return return
} }
pw.Close() pw.Close()
@@ -42,17 +41,7 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
} }
// CreateDefaultProfilePicture creates a profile picture with the initials // CreateDefaultProfilePicture creates a profile picture with the initials
func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) { func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
// Get the initials
initials := ""
if len(firstName) > 0 {
initials += string(firstName[0])
}
if len(lastName) > 0 {
initials += string(lastName[0])
}
initials = strings.ToUpper(initials)
// Create a blank image with a white background // Create a blank image with a white background
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255}) img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})

View File

@@ -1,8 +1,11 @@
package utils package utils
import ( import (
"gorm.io/gorm"
"reflect" "reflect"
"strconv"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type PaginationResponse struct { type PaginationResponse struct {
@@ -30,15 +33,19 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column) capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn) sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable := sortField.Tag.Get("sortable") == "true" isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc" isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder { if sortFieldFound && isSortable && isValidSortOrder {
query = query.Order(CamelCaseToSnakeCase(sort.Column) + " " + sort.Direction) columnName := CamelCaseToSnakeCase(sort.Column)
query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{
{Column: clause.Column{Name: columnName}, Desc: sort.Direction == "desc"},
},
})
} }
return Paginate(pagination.Page, pagination.Limit, query, result) return Paginate(pagination.Page, pagination.Limit, query, result)
} }
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) { func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {

View File

@@ -0,0 +1,5 @@
package utils
func Ptr[T any](v T) *T {
return &v
}

View File

@@ -102,3 +102,16 @@ func CamelCaseToScreamingSnakeCase(s string) string {
// Convert to uppercase // Convert to uppercase
return strings.ToUpper(snake) return strings.ToUpper(snake)
} }
// GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode
func GetFirstCharacter(str string) string {
for _, c := range str {
if unicode.IsSpace(c) {
continue
}
return string(c)
}
// Empty string case
return ""
}

View File

@@ -103,3 +103,28 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
}) })
} }
} }
func TestGetFirstCharacter(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"single character", "a", "a"},
{"multiple characters", "hello", "h"},
{"unicode character", "étoile", "é"},
{"special character", "!test", "!"},
{"number as first character", "123abc", "1"},
{"whitespace as first character", " hello", "h"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetFirstCharacter(tt.input)
if result != tt.expected {
t.Errorf("GetFirstCharacter(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_audit_logs_event;
DROP INDEX IF EXISTS idx_audit_logs_user_id;
DROP INDEX IF EXISTS idx_audit_logs_client_name;

View File

@@ -0,0 +1,3 @@
CREATE INDEX idx_audit_logs_event ON audit_logs(event);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_client_name ON audit_logs(("data"->>'clientName'));

View File

@@ -0,0 +1,4 @@
ALTER TABLE app_config_variables ADD type VARCHAR(20) NOT NULL,;
ALTER TABLE app_config_variables ADD is_public BOOLEAN DEFAULT FALSE NOT NULL,;
ALTER TABLE app_config_variables ADD is_internal BOOLEAN DEFAULT FALSE NOT NULL,;
ALTER TABLE app_config_variables ADD default_value TEXT;

View File

@@ -0,0 +1,4 @@
ALTER TABLE app_config_variables DROP type;
ALTER TABLE app_config_variables DROP is_public;
ALTER TABLE app_config_variables DROP is_internal;
ALTER TABLE app_config_variables DROP default_value;

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_audit_logs_event;
DROP INDEX IF EXISTS idx_audit_logs_user_id;
DROP INDEX IF EXISTS idx_audit_logs_client_name;

View File

@@ -0,0 +1,3 @@
CREATE INDEX idx_audit_logs_event ON audit_logs(event);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));

View File

@@ -0,0 +1,4 @@
ALTER TABLE app_config_variables ADD type VARCHAR(20) NOT NULL,;
ALTER TABLE app_config_variables ADD is_public BOOLEAN DEFAULT FALSE NOT NULL,;
ALTER TABLE app_config_variables ADD is_internal BOOLEAN DEFAULT FALSE NOT NULL,;
ALTER TABLE app_config_variables ADD default_value TEXT;

View File

@@ -0,0 +1,4 @@
ALTER TABLE app_config_variables DROP type;
ALTER TABLE app_config_variables DROP is_public;
ALTER TABLE app_config_variables DROP is_internal;
ALTER TABLE app_config_variables DROP default_value;

View File

@@ -26,7 +26,7 @@
"login_background": "Pozadí přihlašovací stránky", "login_background": "Pozadí přihlašovací stránky",
"logo": "Logo", "logo": "Logo",
"login_code": "Přihlašovací kód", "login_code": "Přihlašovací kód",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Vytvořte přihlašovací kód, která může uživatel jedenktrát použít pro přihlášení bez přístupového klíče.", "create_a_login_code_to_sign_in_without_a_passkey_once": "Vytvořte přihlašovací kód, který může uživatel jednorázově použít pro přihlášení bez přístupového klíče.",
"one_hour": "1 hodina", "one_hour": "1 hodina",
"twelve_hours": "12 hodin", "twelve_hours": "12 hodin",
"one_day": "1 den", "one_day": "1 den",
@@ -35,10 +35,10 @@
"expiration": "Expirace", "expiration": "Expirace",
"generate_code": "Vygenerovat kód", "generate_code": "Vygenerovat kód",
"name": "Jméno", "name": "Jméno",
"browser_unsupported": "Prohlížeče nepodporován", "browser_unsupported": "Prohlížeč nepodporován",
"this_browser_does_not_support_passkeys": "Tento prohlížeč nepodporuje přístupové klíče. Použijte prosím alternativní metodu přihlášení. přihlášení", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "Došlo k neznámé chybě", "an_unknown_error_occurred": "Došlo k neznámé chybě",
"authentication_process_was_aborted": "Proces přihlašování byl přerušen", "authentication_process_was_aborted": "Proces přihlášení byl přerušen",
"error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem", "error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem",
"authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje zobrazitelné přihlašovací údaje", "authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje zobrazitelné přihlašovací údaje",
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče.", "authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče.",
@@ -55,7 +55,7 @@
"profile": "Profil", "profile": "Profil",
"view_your_profile_information": "Zobrazit informace o Vašem profilu", "view_your_profile_information": "Zobrazit informace o Vašem profilu",
"groups": "Skupiny", "groups": "Skupiny",
"view_the_groups_you_are_a_member_of": "Zobrazit skupiny, které jste členem", "view_the_groups_you_are_a_member_of": "Zobrazit skupiny, jejichž jste členem",
"cancel": "Zrušit", "cancel": "Zrušit",
"sign_in": "Přihlásit se", "sign_in": "Přihlásit se",
"try_again": "Zkusit znovu", "try_again": "Zkusit znovu",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Chystáte se přihlásit k počátečnímu účtu správce. Kdokoli s tímto odkazem může přistupovat k účtu, dokud nebude přidán přístupový účet. Prosím nastavte přístupový klíč co nejdříve, abyste zabránili neoprávněnému přístupu.", "you_are_about_to_sign_in_to_the_initial_admin_account": "Chystáte se přihlásit k počátečnímu účtu správce. Kdokoli s tímto odkazem může přistupovat k účtu, dokud nebude přidán přístupový účet. Prosím nastavte přístupový klíč co nejdříve, abyste zabránili neoprávněnému přístupu.",
"continue": "Pokračovat", "continue": "Pokračovat",
"alternative_sign_in": "Alternativní přihlášení", "alternative_sign_in": "Alternativní přihlášení",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Namísto toho použít svůj přístupový klíč?", "use_your_passkey_instead": "Namísto toho použít svůj přístupový klíč?",
"email_login": "Přihlášení e-mailem", "email_login": "Přihlášení e-mailem",
"enter_a_login_code_to_sign_in": "Pro přihlášení zadejte přihlašovací kód.", "enter_a_login_code_to_sign_in": "Pro přihlášení zadejte přihlašovací kód.",
@@ -84,7 +84,7 @@
"submit": "Potvrdit", "submit": "Potvrdit",
"enter_the_code_you_received_to_sign_in": "Zadejte kód, který jste obdrželi k přihlášení.", "enter_the_code_you_received_to_sign_in": "Zadejte kód, který jste obdrželi k přihlášení.",
"code": "Kód", "code": "Kód",
"invalid_redirect_url": "Neplatná URL přesměrování", "invalid_redirect_url": "Neplatné URL přesměrování",
"audit_log": "Protokol auditu", "audit_log": "Protokol auditu",
"users": "Uživatelé", "users": "Uživatelé",
"user_groups": "Uživatelské skupiny", "user_groups": "Uživatelské skupiny",
@@ -111,7 +111,7 @@
"it_is_recommended_to_add_more_than_one_passkey": "Doporučujeme přidat více než jeden přístupový klíč, aby nedošlo ke ztrátě přístupu k Vašemu účtu.", "it_is_recommended_to_add_more_than_one_passkey": "Doporučujeme přidat více než jeden přístupový klíč, aby nedošlo ke ztrátě přístupu k Vašemu účtu.",
"account_details": "Podrobnosti účtu", "account_details": "Podrobnosti účtu",
"passkeys": "Přístupové klíče", "passkeys": "Přístupové klíče",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupový klíč, které můžete použít pro ověření.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupové klíče, které můžete použít pro ověření.",
"add_passkey": "Přidat přístupový klíč", "add_passkey": "Přidat přístupový klíč",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.", "create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.",
"create": "Vytvořit", "create": "Vytvořit",
@@ -268,7 +268,7 @@
"add_oidc_client": "Přidat OIDC klienta", "add_oidc_client": "Přidat OIDC klienta",
"manage_oidc_clients": "Spravovat OIDC klienty", "manage_oidc_clients": "Spravovat OIDC klienty",
"one_time_link": "Jednorázový odkaz", "one_time_link": "Jednorázový odkaz",
"use_this_link_to_sign_in_once": "Pomocí tohoto odkazu se přihlásíte jednou. Toto je to nutné pro uživatele, kteří ještě nepřidali přístupový klíč nebo jej ztratili.", "use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Přidat", "add": "Přidat",
"callback_urls": "URL zpětného volání", "callback_urls": "URL zpětného volání",
"logout_callback_urls": "URL zpětného volání při odhlášení", "logout_callback_urls": "URL zpětného volání při odhlášení",
@@ -312,5 +312,15 @@
"reset": "Obnovit", "reset": "Obnovit",
"reset_to_default": "Obnovit výchozí", "reset_to_default": "Obnovit výchozí",
"profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.", "profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.",
"select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Některé jazyky nemusí být plně přeloženy." "select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Některé jazyky nemusí být plně přeloženy.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization"
} }

View File

@@ -26,17 +26,17 @@
"login_background": "Login Hintergrund", "login_background": "Login Hintergrund",
"logo": "Logo", "logo": "Logo",
"login_code": "Anmeldecode", "login_code": "Anmeldecode",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Erstelle einen Anmeldecode, mit dem sich der Benutzer einmalig ohne Passkey anmelden kann.", "create_a_login_code_to_sign_in_without_a_passkey_once": "Erzeuge einen Anmeldecode, mit dem sich der Benutzer einmalig ohne Passkey anmelden kann.",
"one_hour": "1 Stunde", "one_hour": "1 Stunde",
"twelve_hours": "12 Stunden", "twelve_hours": "12 Stunden",
"one_day": "1 Tag", "one_day": "1 Tag",
"one_week": "1 Woche", "one_week": "1 Woche",
"one_month": "1 Monat", "one_month": "1 Monat",
"expiration": "Ablaufdatum", "expiration": "Ablaufdatum",
"generate_code": "Code generieren", "generate_code": "Code erzeugen",
"name": "Name", "name": "Name",
"browser_unsupported": "Browser nicht unterstützt", "browser_unsupported": "Browser nicht unterstützt",
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten", "an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten",
"authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen", "authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen",
"error_occurred_with_authenticator": "Beim Authentifikator ist ein Fehler aufgetreten", "error_occurred_with_authenticator": "Beim Authentifikator ist ein Fehler aufgetreten",
@@ -44,7 +44,7 @@
"authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel", "authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel",
"passkey_was_previously_registered": "Dieser Passkey wurde bereits registriert", "passkey_was_previously_registered": "Dieser Passkey wurde bereits registriert",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Der Authentifikator unterstützt keinen der angeforderten Algorithmen", "authenticator_does_not_support_any_of_the_requested_algorithms": "Der Authentifikator unterstützt keinen der angeforderten Algorithmen",
"authenticator_timed_out": "Timeout für den Authentifikator", "authenticator_timed_out": "Der Authentifikator hat eine Zeitüberschreitung",
"critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.", "critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.",
"sign_in_to": "Bei {name} anmelden", "sign_in_to": "Bei {name} anmelden",
"client_not_found": "Client nicht gefunden", "client_not_found": "Client nicht gefunden",
@@ -71,10 +71,10 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.", "you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.",
"continue": "Fortsetzen", "continue": "Fortsetzen",
"alternative_sign_in": "Alternative Anmeldemethoden", "alternative_sign_in": "Alternative Anmeldemethoden",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinen Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Deinen Passkey stattdessen verwenden?", "use_your_passkey_instead": "Deinen Passkey stattdessen verwenden?",
"email_login": "E-Mail Anmeldung", "email_login": "E-Mail Anmeldung",
"enter_a_login_code_to_sign_in": "Gebe einen Anmeldecode zum Anmelden ein.", "enter_a_login_code_to_sign_in": "Gib einen Anmeldecode zum Anmelden ein.",
"request_a_login_code_via_email": "Login-Code per E-Mail anfordern.", "request_a_login_code_via_email": "Login-Code per E-Mail anfordern.",
"go_back": "Zurück", "go_back": "Zurück",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Eine E-Mail wurde an die angegebene E-Mail gesendet, sofern sie im System vorhanden ist.", "an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Eine E-Mail wurde an die angegebene E-Mail gesendet, sofern sie im System vorhanden ist.",
@@ -94,7 +94,7 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"update_pocket_id": "Pocket ID aktualisieren", "update_pocket_id": "Pocket ID aktualisieren",
"powered_by": "Powered by", "powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "Sehe dir deine Kontoaktivitäten der letzten drei Monate an.", "see_your_account_activities_from_the_last_3_months": "Sieh dir deine Kontoaktivitäten der letzten drei Monate an.",
"time": "Zeit", "time": "Zeit",
"event": "Ereignis", "event": "Ereignis",
"approximate_location": "Ungefährer Standort", "approximate_location": "Ungefährer Standort",
@@ -113,7 +113,7 @@
"passkeys": "Passkeys", "passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.",
"add_passkey": "Passkey hinzufügen", "add_passkey": "Passkey hinzufügen",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Erstelle einen einmaligen Anmeldecode, um dich ohne Passkey von einem anderen Gerät aus anzumelden.", "create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Erzeuge einen einmaligen Anmeldecode, um dich ohne Passkey von einem anderen Gerät aus anzumelden.",
"create": "Erzeugen", "create": "Erzeugen",
"first_name": "Vorname", "first_name": "Vorname",
"last_name": "Nachname", "last_name": "Nachname",
@@ -121,7 +121,7 @@
"save": "Speichern", "save": "Speichern",
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten", "username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.", "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.",
"or_visit": "oder besuchen", "or_visit": "oder besuche",
"added_on": "Hinzugefügt am", "added_on": "Hinzugefügt am",
"rename": "Umbenennen", "rename": "Umbenennen",
"delete": "Löschen", "delete": "Löschen",
@@ -268,7 +268,7 @@
"add_oidc_client": "OIDC Client hinzufügen", "add_oidc_client": "OIDC Client hinzufügen",
"manage_oidc_clients": "OIDC Clients verwalten", "manage_oidc_clients": "OIDC Clients verwalten",
"one_time_link": "Einmallink", "one_time_link": "Einmallink",
"use_this_link_to_sign_in_once": "Benutze diesen Link, um dich einmal anzumelden. Dieser wird für Benutzer benötigt, die noch keinem Passkey hinzugefügt haben oder diesen verloren haben.", "use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Hinzufügen", "add": "Hinzufügen",
"callback_urls": "Callback URLs", "callback_urls": "Callback URLs",
"logout_callback_urls": "Abmelde Callback URLs", "logout_callback_urls": "Abmelde Callback URLs",
@@ -312,5 +312,15 @@
"reset": "Zurücksetzen", "reset": "Zurücksetzen",
"reset_to_default": "Auf Standard zurücksetzen", "reset_to_default": "Auf Standard zurücksetzen",
"profile_picture_has_been_reset": "Das Profilbild wurde zurückgesetzt. Es kann einige Minuten dauern, bis es aktualisiert wird.", "profile_picture_has_been_reset": "Das Profilbild wurde zurückgesetzt. Es kann einige Minuten dauern, bis es aktualisiert wird.",
"select_the_language_you_want_to_use": "Wähle die Sprache aus, die du verwenden möchtest. Einige Sprachen sind möglicherweise nicht vollständig übersetzt." "select_the_language_you_want_to_use": "Wähle die Sprache aus, die du verwenden möchtest. Einige Sprachen sind möglicherweise nicht vollständig übersetzt.",
"personal": "Persönlich",
"global": "Global",
"all_users": "Alle Benutzer",
"all_events": "Alle Ereignisse",
"all_clients": "Alle Clients",
"global_audit_log": "Globaler Aktivitäts-Log",
"see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.",
"token_sign_in": "Token-Anmeldung",
"client_authorization": "Client-Autorisierung",
"new_client_authorization": "Neue Client-Autorisierung"
} }

View File

@@ -36,7 +36,7 @@
"generate_code": "Generate Code", "generate_code": "Generate Code",
"name": "Name", "name": "Name",
"browser_unsupported": "Browser unsupported", "browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please or use a alternative sign in method.", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred", "an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted", "authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator", "error_occurred_with_authenticator": "An error occurred with the authenticator",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.", "you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue", "continue": "Continue",
"alternative_sign_in": "Alternative Sign In", "alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you dont't have access to your passkey, you can sign in using one of the following methods.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?", "use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login", "email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.", "enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
@@ -268,7 +268,7 @@
"add_oidc_client": "Add OIDC Client", "add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients", "manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link", "one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or\n\t\t\t\thave lost it.", "use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Add", "add": "Add",
"callback_urls": "Callback URLs", "callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs", "logout_callback_urls": "Logout Callback URLs",
@@ -312,5 +312,15 @@
"reset": "Reset", "reset": "Reset",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated." "select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization"
} }

View File

@@ -36,7 +36,7 @@
"generate_code": "Generate Code", "generate_code": "Generate Code",
"name": "Name", "name": "Name",
"browser_unsupported": "Browser unsupported", "browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please or use a alternative sign in method.", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred", "an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted", "authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator", "error_occurred_with_authenticator": "An error occurred with the authenticator",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.", "you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue", "continue": "Continue",
"alternative_sign_in": "Alternative Sign In", "alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you dont't have access to your passkey, you can sign in using one of the following methods.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?", "use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login", "email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.", "enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
@@ -268,7 +268,7 @@
"add_oidc_client": "Add OIDC Client", "add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients", "manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link", "one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or\n\t\t\t\thave lost it.", "use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Add", "add": "Add",
"callback_urls": "Callback URLs", "callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs", "logout_callback_urls": "Logout Callback URLs",
@@ -312,5 +312,15 @@
"reset": "Reset", "reset": "Reset",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated." "select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization"
} }

View File

@@ -17,7 +17,7 @@
"items_per_page": "Éléments par page", "items_per_page": "Éléments par page",
"no_items_found": "Aucune donnée trouvée", "no_items_found": "Aucune donnée trouvée",
"search": "Rechercher...", "search": "Rechercher...",
"expand_card": "Expand card", "expand_card": "Carte d'expansion",
"copied": "Copié", "copied": "Copié",
"click_to_copy": "Cliquer pour copier", "click_to_copy": "Cliquer pour copier",
"something_went_wrong": "Quelque chose n'a pas fonctionné", "something_went_wrong": "Quelque chose n'a pas fonctionné",
@@ -36,7 +36,7 @@
"generate_code": "Générer un code", "generate_code": "Générer un code",
"name": "Nom", "name": "Nom",
"browser_unsupported": "Navigateur non pris en charge", "browser_unsupported": "Navigateur non pris en charge",
"this_browser_does_not_support_passkeys": "Ce navigateur ne supporte pas les clés d'accès. Veuillez ou utilisez une autre méthode de connexion.", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "Une erreur inconnue est survenue", "an_unknown_error_occurred": "Une erreur inconnue est survenue",
"authentication_process_was_aborted": "Le processus d'authentification a été interrompu", "authentication_process_was_aborted": "Le processus d'authentification a été interrompu",
"error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification", "error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Vous êtes sur le point de vous connecter au compte administrateur initial. N'importe qui avec ce lien peut accéder au compte jusqu'à ce qu'une clé d'accès soit ajouté. Veuillez configurer une clé d'accès dès que possible pour éviter tout accès non autorisé.", "you_are_about_to_sign_in_to_the_initial_admin_account": "Vous êtes sur le point de vous connecter au compte administrateur initial. N'importe qui avec ce lien peut accéder au compte jusqu'à ce qu'une clé d'accès soit ajouté. Veuillez configurer une clé d'accès dès que possible pour éviter tout accès non autorisé.",
"continue": "Continuer", "continue": "Continuer",
"alternative_sign_in": "Connexion alternative", "alternative_sign_in": "Connexion alternative",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si vous n'avez pas accès à votre clé d'accès, vous pouvez vous connecter en utilisant l'une des méthodes suivantes.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?", "use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?",
"email_login": "Connexion par e-mail", "email_login": "Connexion par e-mail",
"enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.", "enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.",
@@ -196,7 +196,7 @@
"client_configuration": "Configuration du client", "client_configuration": "Configuration du client",
"ldap_url": "URL du serveur LDAP", "ldap_url": "URL du serveur LDAP",
"ldap_bind_dn": "LDAP Bind DN", "ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password", "ldap_bind_password": "Attribuer un mot de passe LDAP",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtre de recherche utilisateur", "user_search_filter": "Filtre de recherche utilisateur",
"the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.", "the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.",
@@ -243,7 +243,7 @@
"back": "Retour", "back": "Retour",
"user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}", "user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.", "manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.",
"custom_claims": "Custom Claims", "custom_claims": "Claim personnaliser",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Les revendications personnalisées sont des paires clé-valeur qui permettent de stocker des informations supplémentaires sur un utilisateur. Elles seront incluses dans le jeton d'identité (ID token) si la portée 'profile' est demandée.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Les revendications personnalisées sont des paires clé-valeur qui permettent de stocker des informations supplémentaires sur un utilisateur. Elles seront incluses dans le jeton d'identité (ID token) si la portée 'profile' est demandée.",
"user_group_created_successfully": "Groupe d'utilisateurs créé avec succès", "user_group_created_successfully": "Groupe d'utilisateurs créé avec succès",
"create_user_group": "Créer un groupe d'utilisateurs", "create_user_group": "Créer un groupe d'utilisateurs",
@@ -252,7 +252,7 @@
"manage_user_groups": "Gérer les groupes d'utilisateurs", "manage_user_groups": "Gérer les groupes d'utilisateurs",
"friendly_name": "Nom d'affichage", "friendly_name": "Nom d'affichage",
"name_that_will_be_displayed_in_the_ui": "Nom qui sera affiché dans l'interface utilisateur", "name_that_will_be_displayed_in_the_ui": "Nom qui sera affiché dans l'interface utilisateur",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim", "name_that_will_be_in_the_groups_claim": "Nommez ce qui sera dans le \"groupe\" claim",
"delete_name": "Supprimer {name}", "delete_name": "Supprimer {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Êtes-vous sûr de vouloir supprimer ce groupe d'utilisateurs?", "are_you_sure_you_want_to_delete_this_user_group": "Êtes-vous sûr de vouloir supprimer ce groupe d'utilisateurs?",
"user_group_deleted_successfully": "Groupe d'utilisateurs supprimé avec succès", "user_group_deleted_successfully": "Groupe d'utilisateurs supprimé avec succès",
@@ -268,7 +268,7 @@
"add_oidc_client": "Ajouter un client OIDC", "add_oidc_client": "Ajouter un client OIDC",
"manage_oidc_clients": "Gérer les clients OIDC", "manage_oidc_clients": "Gérer les clients OIDC",
"one_time_link": "Lien de connexion unique", "one_time_link": "Lien de connexion unique",
"use_this_link_to_sign_in_once": "Utilisez ce lien pour vous connecter. Ceci est nécessaire pour les utilisateurs qui n'ont pas encore ajouté de clé d'accès ou l'ont perdu.", "use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Ajouter", "add": "Ajouter",
"callback_urls": "URL de callback", "callback_urls": "URL de callback",
"logout_callback_urls": "URL de callback de déconnexion", "logout_callback_urls": "URL de callback de déconnexion",
@@ -312,5 +312,15 @@
"reset": "Réinitialiser", "reset": "Réinitialiser",
"reset_to_default": "Valeurs par défaut", "reset_to_default": "Valeurs par défaut",
"profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.", "profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
"select_the_language_you_want_to_use": "Sélectionnez la langue que vous souhaitez utiliser. Certaines langues peuvent ne pas être entièrement traduites." "select_the_language_you_want_to_use": "Sélectionnez la langue que vous souhaitez utiliser. Certaines langues peuvent ne pas être entièrement traduites.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization"
} }

View File

@@ -22,7 +22,7 @@
"click_to_copy": "Klik om te kopiëren", "click_to_copy": "Klik om te kopiëren",
"something_went_wrong": "Er is iets misgegaan", "something_went_wrong": "Er is iets misgegaan",
"go_back_to_home": "Ga terug naar huis", "go_back_to_home": "Ga terug naar huis",
"dont_have_access_to_your_passkey": "Hebt u geen toegang tot uw toegangscode?", "dont_have_access_to_your_passkey": "Heeft u geen toegang tot uw toegangscode?",
"login_background": "Inlogachtergrond", "login_background": "Inlogachtergrond",
"logo": "Logo", "logo": "Logo",
"login_code": "Inlogcode", "login_code": "Inlogcode",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.", "you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.",
"continue": "Doorgaan", "continue": "Doorgaan",
"alternative_sign_in": "Alternatieve aanmelding", "alternative_sign_in": "Alternatieve aanmelding",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw toegangscode, kunt u zich op een van de volgende manieren aanmelden.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.",
"use_your_passkey_instead": "Wilt u in plaats daarvan uw toegangscode gebruiken?", "use_your_passkey_instead": "Wilt u in plaats daarvan uw toegangscode gebruiken?",
"email_login": "E-mail inloggen", "email_login": "E-mail inloggen",
"enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.", "enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.",
@@ -268,7 +268,7 @@
"add_oidc_client": "OIDC-client toevoegen", "add_oidc_client": "OIDC-client toevoegen",
"manage_oidc_clients": "OIDC-clients beheren", "manage_oidc_clients": "OIDC-clients beheren",
"one_time_link": "Eenmalige link", "one_time_link": "Eenmalige link",
"use_this_link_to_sign_in_once": "Gebruik deze link om u eenmalig aan te melden. Dit is nodig voor gebruikers die nog geen passkey hebben toegevoegd of\nben het kwijt.", "use_this_link_to_sign_in_once": "Gebruik deze link om u eenmalig aan te melden. Dit is nodig voor gebruikers die nog geen passkey hebben toegevoegd of deze kwijt zijn.",
"add": "Toevoegen", "add": "Toevoegen",
"callback_urls": "Callback-URL's", "callback_urls": "Callback-URL's",
"logout_callback_urls": "Callback-URL's voor afmelden", "logout_callback_urls": "Callback-URL's voor afmelden",
@@ -312,5 +312,15 @@
"reset": "Reset", "reset": "Reset",
"reset_to_default": "Standaardinstellingen herstellen", "reset_to_default": "Standaardinstellingen herstellen",
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.", "profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
"select_the_language_you_want_to_use": "Selecteer de taal die u wilt gebruiken. Sommige talen zijn mogelijk niet volledig vertaald." "select_the_language_you_want_to_use": "Selecteer de taal die u wilt gebruiken. Sommige talen zijn mogelijk niet volledig vertaald.",
"personal": "Persoonlijk",
"global": "Globaal",
"all_users": "Alle gebruikers",
"all_events": "Alle activiteiten",
"all_clients": "Alle clients",
"global_audit_log": "Algemeen audit logboek",
"see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie"
} }

View File

@@ -0,0 +1,326 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Minha Conta",
"logout": "Sair",
"confirm": "Confirmar",
"key": "Chave",
"value": "Valor",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Adicionar outro",
"select_a_date": "Selecione a 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.",
"click_profile_picture_to_upload_custom": "Clique na foto de perfil para enviar uma imagem personalizada dos seus arquivos.",
"image_should_be_in_format": "A imagem deve estar no formato PNG ou JPEG.",
"items_per_page": "Itens por página",
"no_items_found": "Nenhum item encontrado",
"search": "Pesquisar...",
"expand_card": "Expandir cartão",
"copied": "Copiado",
"click_to_copy": "Clique para copiar",
"something_went_wrong": "Algo deu errado",
"go_back_to_home": "Voltar para o início",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Código de Login:",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
"one_hour": "1 hora",
"twelve_hours": "12 horas",
"one_day": "1 dia",
"one_week": "1 semana",
"one_month": "1 mês",
"expiration": "Expiração",
"generate_code": "Gerar Código",
"name": "Nome",
"browser_unsupported": "Navegador não suportado",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "Ocorreu um erro desconhecido",
"authentication_process_was_aborted": "O processo de autenticação foi abortado",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "O autenticador não suporta credenciais detectáveis",
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
"passkey_was_previously_registered": "This passkey was previously registered",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
"authenticator_timed_out": "Tempo limite do autenticador atingido",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Entrar em {name}",
"client_not_found": "Cliente não encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Você quer entrar em <b>{client}</b> com a sua conta <b>{appName}</b>?",
"email": "E-mail",
"view_your_email_address": "Ver seu endereço de e-mail",
"profile": "Profile",
"view_your_profile_information": "View your profile information",
"groups": "Grupos",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
"cancel": "Cancelar",
"sign_in": "Sign in",
"try_again": "Tentar novamente",
"client_logo": "Logo do Cliente",
"sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?",
"sign_in_to_appname": "Entrar em {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
"authenticate": "Autenticar",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continuar",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
"request_a_login_code_via_email": "Request a login code via email.",
"go_back": "Voltar",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
"enter_code": "Enter code",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
"your_email": "Seu e-mail",
"submit": "Submit",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
"code": "Código",
"invalid_redirect_url": "Invalid redirect URL",
"audit_log": "Registro de Auditoria",
"users": "Usuários",
"user_groups": "Grupo de Usuários",
"oidc_clients": "Clientes OIDC",
"api_keys": "Chave de API",
"application_configuration": "Configuração da Aplicação",
"settings": "Configurações",
"update_pocket_id": "Atualizar Pocket ID",
"powered_by": "Fornecido por",
"see_your_account_activities_from_the_last_3_months": "Veja suas atividades de conta dos últimos 3 meses.",
"time": "Time",
"event": "Evento",
"approximate_location": "Localização Aproximada",
"ip_address": "Endereço de IP",
"device": "Dispositivo",
"client": "Cliente",
"unknown": "Desconhecido",
"account_details_updated_successfully": "Detalhes da conta atualizados com sucesso",
"profile_picture_updated_successfully": "Foto do perfil atualizada com sucesso. Pode demorar alguns minutos para atualizar.",
"account_settings": "Configurações de Conta",
"passkey_missing": "Passkey missing",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
"single_passkey_configured": "Single Passkey Configured",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
"account_details": "Detalhes da Conta",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Criar",
"first_name": "Primeiro nome",
"last_name": "Último nome",
"username": "Nome de usuário",
"save": "Salvar",
"username_can_only_contain": "O nome de usuário só pode conter letras minúsculas, números, underscores, pontos, hífens e símbolos '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Faça o login usando o código a seguir. O código irá expirar em 15 minutos.",
"or_visit": "ou visite",
"added_on": "Adicionado em",
"rename": "Renomear",
"delete": "Apagar",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully",
"delete_passkey_name": "Delete {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully",
"name_passkey": "Name Passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
"create_api_key": "Create API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
"add_api_key": "Add API Key",
"manage_api_keys": "Manage API Keys",
"api_key_created": "API Key Created",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
"description": "Descrição",
"api_key": "API Key",
"close": "Fechar",
"name_to_identify_this_api_key": "Name to identify this API key.",
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Nunca",
"revoke": "Revogar",
"api_key_revoked_successfully": "API key revoked successfully",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used",
"actions": "Ações",
"images_updated_successfully": "Imagens atualizadas com sucesso",
"general": "Geral",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Imagens",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Salvar alterações?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
"save_and_send": "Salvar e enviar",
"test_email_sent_successfully": "Test email sent successfully to your email address.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
"smtp_configuration": "SMTP Configuration",
"smtp_host": "SMTP Host",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP User",
"smtp_password": "SMTP Password",
"smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
"session_duration": "Session Duration",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
"enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
"emails_verified": "Emails Verified",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished",
"client_configuration": "Client Configuration",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
"attribute_mapping": "Attribute Mapping",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
"username_attribute": "Username Attribute",
"user_mail_attribute": "User Mail Attribute",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
"disable": "Desativar",
"sync_now": "Sincronizar agora",
"enable": "Enable",
"user_created_successfully": "User created successfully",
"create_user": "Criar Usuário",
"add_a_new_user_to_appname": "Adicionar um novo usuário para {appName}",
"add_user": "Adicionar Usuário",
"manage_users": "Gerenciar Usuários",
"admin_privileges": "Admin Privileges",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
"delete_firstname_lastname": "Delete {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
"user_deleted_successfully": "User deleted successfully",
"role": "Role",
"source": "Source",
"admin": "Admin",
"user": "User",
"local": "Local",
"toggle_menu": "Toggle menu",
"edit": "Editar",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Voltar",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
"add_group": "Adicionar Grupo",
"manage_user_groups": "Manage User Groups",
"friendly_name": "Nome Amigável",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "Delete {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
"user_group_deleted_successfully": "User group deleted successfully",
"user_count": "User Count",
"user_group_updated_successfully": "User group updated successfully",
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
"add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Adicionar",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"enabled": "Habilitado",
"disabled": "Disabled",
"oidc_client_updated_successfully": "OIDC client updated successfully",
"create_new_client_secret": "Create new client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
"generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image",
"language": "Idioma",
"reset_profile_picture_question": "Reset profile picture?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
"reset": "Redefinir",
"reset_to_default": "Redefinir para o padrão",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization"
}

View File

@@ -36,7 +36,7 @@
"generate_code": "Generate Code", "generate_code": "Generate Code",
"name": "Name", "name": "Name",
"browser_unsupported": "Browser unsupported", "browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please or use a alternative sign in method.", "this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred", "an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted", "authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator", "error_occurred_with_authenticator": "An error occurred with the authenticator",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.", "you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue", "continue": "Continue",
"alternative_sign_in": "Alternative Sign In", "alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you dont't have access to your passkey, you can sign in using one of the following methods.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?", "use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login", "email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.", "enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
@@ -268,7 +268,7 @@
"add_oidc_client": "Add OIDC Client", "add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients", "manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link", "one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or\n\t\t\t\thave lost it.", "use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Add", "add": "Add",
"callback_urls": "Callback URLs", "callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs", "logout_callback_urls": "Logout Callback URLs",
@@ -312,5 +312,15 @@
"reset": "Reset", "reset": "Reset",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated." "select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization"
} }

View File

@@ -36,7 +36,7 @@
"generate_code": "Сгенерировать код", "generate_code": "Сгенерировать код",
"name": "Имя", "name": "Имя",
"browser_unsupported": "Браузер не поддерживается", "browser_unsupported": "Браузер не поддерживается",
"this_browser_does_not_support_passkeys": "Этот браузер не поддерживает passkeys. Пожалуйста, воспользуйтесь альтернативным способом входа.", "this_browser_does_not_support_passkeys": "Этот браузер не поддерживает passkey. Пожалуйста, воспользуйтесь альтернативным способом входа.",
"an_unknown_error_occurred": "Произошла неизвестная ошибка", "an_unknown_error_occurred": "Произошла неизвестная ошибка",
"authentication_process_was_aborted": "Процесс аутентификации был прерван", "authentication_process_was_aborted": "Процесс аутентификации был прерван",
"error_occurred_with_authenticator": "С аутентификатором произошла ошибка", "error_occurred_with_authenticator": "С аутентификатором произошла ошибка",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Вы собираетесь впервые войти в учетную запись администратора. Любой пользователь с этой ссылкой может получить доступ к учетной записи до тех пор, пока не будет добавлен passkey. Пожалуйста, настройте passkey как можно скорее для предотвращения несанкционированного доступа.", "you_are_about_to_sign_in_to_the_initial_admin_account": "Вы собираетесь впервые войти в учетную запись администратора. Любой пользователь с этой ссылкой может получить доступ к учетной записи до тех пор, пока не будет добавлен passkey. Пожалуйста, настройте passkey как можно скорее для предотвращения несанкционированного доступа.",
"continue": "Продолжить", "continue": "Продолжить",
"alternative_sign_in": "Альтернативный вход", "alternative_sign_in": "Альтернативный вход",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Если у вас нет доступа к passkey, вы можете войти одним из следующих способов.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Если у вас нет доступа к вашему passkey, вы можете войти одним из следующих способов.",
"use_your_passkey_instead": "Воспользоваться passkey вместо этого?", "use_your_passkey_instead": "Воспользоваться passkey вместо этого?",
"email_login": "Вход через электронную почту", "email_login": "Вход через электронную почту",
"enter_a_login_code_to_sign_in": "Введите предварительно созданный код входа.", "enter_a_login_code_to_sign_in": "Введите предварительно созданный код входа.",
@@ -312,5 +312,15 @@
"reset": "Сбросить", "reset": "Сбросить",
"reset_to_default": "Сбросить по умолчанию", "reset_to_default": "Сбросить по умолчанию",
"profile_picture_has_been_reset": "Изображение профиля было сброшено. Обновление может занять несколько минут.", "profile_picture_has_been_reset": "Изображение профиля было сброшено. Обновление может занять несколько минут.",
"select_the_language_you_want_to_use": "Выберите язык, который вы хотите использовать. Некоторые языки могут быть переведены не полностью." "select_the_language_you_want_to_use": "Выберите язык, который вы хотите использовать. Некоторые языки могут быть переведены не полностью.",
"personal": "Персональный",
"global": "Глобальный",
"all_users": "Все пользователи",
"all_events": "Все события",
"all_clients": "Все клиенты",
"global_audit_log": "Глобальный журнал аудита",
"see_all_account_activities_from_the_last_3_months": "Смотрите всю активность пользователей за последние 3 месяца.",
"token_sign_in": "Вход с помощью токена",
"client_authorization": "Авторизация в клиенте",
"new_client_authorization": "Новая авторизация в клиенте"
} }

Some files were not shown because too many files have changed in this diff Show More