mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-23 17:25:22 +03:00
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b2d981b7 | ||
|
|
8f146188d5 | ||
|
|
a0f93bda49 | ||
|
|
0423d354f5 | ||
|
|
9245851126 | ||
|
|
39b7f6678c | ||
|
|
e45d9e970d | ||
|
|
8ead0be8cd | ||
|
|
9f28503d6c | ||
|
|
26e05947fe | ||
|
|
348192b9d7 | ||
|
|
b483e2e92f | ||
|
|
42f55e6e54 | ||
|
|
a4bfd08a0f | ||
|
|
7b654c6bd1 | ||
|
|
8c1c04db1d | ||
|
|
ec4b41a1d2 | ||
|
|
d27a121985 | ||
|
|
d8952c0d62 | ||
|
|
f65997e85b | ||
|
|
90f8068053 | ||
|
|
9ef2ddf796 | ||
|
|
d1b9f3a44e | ||
|
|
62915d863a | ||
|
|
74ba8390f4 | ||
|
|
31198feec2 | ||
|
|
e5ec264bfd | ||
|
|
c822192124 | ||
|
|
f2d61e964c | ||
|
|
f1256322b6 | ||
|
|
7885ae011c | ||
|
|
6a8dd84ca9 | ||
|
|
eb1426ed26 | ||
|
|
a9713cf6a1 | ||
|
|
8e344f1151 | ||
|
|
04efc36115 | ||
|
|
2ee0bad2c0 | ||
|
|
d0da532240 | ||
|
|
8d55c7c393 | ||
|
|
0f14a93e1d | ||
|
|
37b24bed91 | ||
|
|
66090f36a8 | ||
|
|
ff34e3b925 | ||
|
|
91f254c7bb | ||
|
|
85db96b0ef | ||
|
|
12d60fea23 | ||
|
|
2d733fc79f | ||
|
|
a421d01e0c | ||
|
|
1026ee4f5b | ||
|
|
cddfe8fa4c | ||
|
|
ef25f6b6b8 | ||
|
|
1652cc65f3 | ||
|
|
4bafee4f58 | ||
|
|
e46471cc2d | ||
|
|
fde951b543 | ||
|
|
01a9de0b04 | ||
|
|
a1131bca9a | ||
|
|
9a167d4076 | ||
|
|
887c5e462a | ||
|
|
20eba1378e | ||
|
|
a6ae7ae287 | ||
|
|
840a672fc3 | ||
|
|
7446f853fc | ||
|
|
652ee6ad5d | ||
|
|
dca9e7a11a | ||
|
|
816c198a42 | ||
|
|
339837bec4 | ||
|
|
39b46e99a9 | ||
|
|
dc9e64de3d | ||
|
|
6207e10279 | ||
|
|
7550333fe2 | ||
|
|
3de1301fa8 | ||
|
|
c3980d3d28 | ||
|
|
4d0fff821e | ||
|
|
2e66211b7f | ||
|
|
2071d002fc | ||
|
|
0d071694cd | ||
|
|
39e403d00f | ||
|
|
4e858420e9 | ||
|
|
2d78349b38 | ||
|
|
9ed2adb0f8 | ||
|
|
43790dc1be | ||
|
|
7fbc356d8d | ||
|
|
9b77e8b7c1 | ||
|
|
bea115866f | ||
|
|
626f87d592 | ||
|
|
0751540d7d | ||
|
|
7c04bda5b7 | ||
|
|
98add37390 | ||
|
|
3dda2e16e9 | ||
|
|
3a6fce5c4b | ||
|
|
07ee087c3d | ||
|
|
d66cf70d50 | ||
|
|
fb8cc0bb22 | ||
|
|
0bae7e4f53 |
32
.devcontainer/devcontainer.json
Normal file
32
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||||
|
{
|
||||||
|
"name": "pocket-id",
|
||||||
|
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/go:1": {},
|
||||||
|
"ghcr.io/devcontainers-extra/features/caddy:1": {}
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"golang.go",
|
||||||
|
"svelte.svelte-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
// Install npm dependencies for the frontend.
|
||||||
|
"postCreateCommand": "npm install --prefix frontend"
|
||||||
|
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {},
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// "forwardPorts": [],
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
// "customizations": {},
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -49,7 +49,7 @@ body:
|
|||||||
required: false
|
required: false
|
||||||
attributes:
|
attributes:
|
||||||
label: "Log Output"
|
label: "Log Output"
|
||||||
description: "Output of log files when the issue occured to help us diagnose the issue."
|
description: "Output of log files when the issue occurred to help us diagnose the issue."
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 💬 Discord
|
||||||
|
url: https://discord.gg/8wudU9KaxM
|
||||||
|
about: For help and chatting with the community
|
||||||
|
|||||||
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for more information:
|
||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
# https://containers.dev/guide/dependabot
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "devcontainers"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -6,7 +6,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -17,7 +20,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
${{ github.repository }}
|
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}},prefix=v
|
type=semver,pattern={{version}},prefix=v
|
||||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
@@ -28,11 +30,6 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: 'Login to GitHub Container Registry'
|
- name: 'Login to GitHub Container Registry'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
51
.github/workflows/deploy-docs.yml
vendored
51
.github/workflows/deploy-docs.yml
vendored
@@ -1,51 +0,0 @@
|
|||||||
name: Deploy Docs
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "docs/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build Docusaurus
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: docs/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
working-directory: ./docs
|
|
||||||
|
|
||||||
- name: Build website
|
|
||||||
run: npm run build
|
|
||||||
working-directory: ./docs
|
|
||||||
|
|
||||||
- name: Upload Build Artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: docs/build
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy to GitHub Pages
|
|
||||||
needs: build
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -138,7 +138,7 @@ jobs:
|
|||||||
-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 POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
|
||||||
stonith404/pocket-id:test
|
pocket-id/pocket-id:test
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|||||||
34
.github/workflows/unit-tests.yml
vendored
Normal file
34
.github/workflows/unit-tests.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Unit Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: 'backend/go.mod'
|
||||||
|
cache-dependency-path: 'backend/go.sum'
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
go get ./...
|
||||||
|
- name: Run backend unit tests
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
go test -v ./... | tee /tmp/TestResults.log
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: backend-unit-tests
|
||||||
|
path: /tmp/TestResults.log
|
||||||
|
retention-days: 15
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -38,11 +38,6 @@ data
|
|||||||
pocket-id-backend
|
pocket-id-backend
|
||||||
/backend/GeoLite2-City.mmdb
|
/backend/GeoLite2-City.mmdb
|
||||||
|
|
||||||
# Generated files
|
|
||||||
docs/build
|
|
||||||
docs/.docusaurus
|
|
||||||
docs/.cache-loader
|
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env.local
|
.env.local
|
||||||
@@ -53,3 +48,6 @@ docs/.cache-loader
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
#Debug
|
||||||
|
backend/cmd/__debug_*
|
||||||
|
|||||||
42
.vscode/launch.json
vendored
Normal file
42
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Backend",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"envFile": "${workspaceFolder}/backend/.env.example",
|
||||||
|
"env": {
|
||||||
|
"APP_ENV": "development"
|
||||||
|
},
|
||||||
|
"mode": "debug",
|
||||||
|
"program": "${workspaceFolder}/backend/cmd/main.go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frontend",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"envFile": "${workspaceFolder}/frontend/.env.example",
|
||||||
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"dev"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Development",
|
||||||
|
"configurations": [
|
||||||
|
"Backend",
|
||||||
|
"Frontend"
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"hidden": false,
|
||||||
|
"group": "",
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
37
.vscode/tasks.json
vendored
Normal file
37
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Run Caddy",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "caddy run --config reverse-proxy/Caddyfile",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "custom",
|
||||||
|
"pattern": [
|
||||||
|
{
|
||||||
|
"regexp": ".",
|
||||||
|
"file": 1,
|
||||||
|
"location": 2,
|
||||||
|
"message": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": ".*",
|
||||||
|
"endsPattern": "Caddyfile.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "new"
|
||||||
|
},
|
||||||
|
"runOptions": {
|
||||||
|
"runOn": "folderOpen",
|
||||||
|
"instanceLimit": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
199
CHANGELOG.md
199
CHANGELOG.md
@@ -1,3 +1,202 @@
|
|||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.1...v) (2025-03-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **profile-picture:** allow reset of profile picture ([#355](https://github.com/pocket-id/pocket-id/issues/355)) ([8f14618](https://github.com/pocket-id/pocket-id/commit/8f146188d57b5c08a4c6204674c15379232280d8))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* own avatar not loading ([#351](https://github.com/pocket-id/pocket-id/issues/351)) ([0423d35](https://github.com/pocket-id/pocket-id/commit/0423d354f533d2ff4fd431859af3eea7d4d7044f))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* API keys not working if sqlite is used ([8ead0be](https://github.com/pocket-id/pocket-id/commit/8ead0be8cd0cfb542fe488b7251cfd5274975ae1))
|
||||||
|
* caching for own profile picture ([e45d9e9](https://github.com/pocket-id/pocket-id/commit/e45d9e970d327a5120ff9fb0c8d42df8af69bb38))
|
||||||
|
* email logo icon displaying too big ([#336](https://github.com/pocket-id/pocket-id/issues/336)) ([b483e2e](https://github.com/pocket-id/pocket-id/commit/b483e2e92fdb528e7de026350a727d6970227426))
|
||||||
|
* emails are considered as medium spam by rspamd ([#337](https://github.com/pocket-id/pocket-id/issues/337)) ([39b7f66](https://github.com/pocket-id/pocket-id/commit/39b7f6678c98cadcdc3abfbcb447d8eb0daa9eb0))
|
||||||
|
* Fixes and performance improvements in utils package ([#331](https://github.com/pocket-id/pocket-id/issues/331)) ([348192b](https://github.com/pocket-id/pocket-id/commit/348192b9d7e2698add97810f8fba53d13d0df018))
|
||||||
|
* remove custom claim key restrictions ([9f28503](https://github.com/pocket-id/pocket-id/commit/9f28503d6c73d3521d1309bee055704a0507e9b5))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* allow setting path where keys are stored ([#327](https://github.com/pocket-id/pocket-id/issues/327)) ([7b654c6](https://github.com/pocket-id/pocket-id/commit/7b654c6bd111ddcddd5e3450cbf326d9cf1777b6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **docker:** missing write permissions on scripts ([ec4b41a](https://github.com/pocket-id/pocket-id/commit/ec4b41a1d26ea00bb4a95f654ac4cc745b2ce2e8))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.38.0...v) (2025-03-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* api key authentication ([#291](https://github.com/pocket-id/pocket-id/issues/291)) ([62915d8](https://github.com/pocket-id/pocket-id/commit/62915d863a4adc09cf467b75c414a045be43c2bb))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* alternative login method link on mobile ([9ef2ddf](https://github.com/pocket-id/pocket-id/commit/9ef2ddf7963c6959992f3a5d6816840534e926e9))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.37.0...v) (2025-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add env variable to disable update check ([31198fe](https://github.com/pocket-id/pocket-id/commit/31198feec2ae77dd6673c42b42002871ddd02d37))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* redirection not correctly if signing in with email code ([e5ec264](https://github.com/pocket-id/pocket-id/commit/e5ec264bfd535752565bcc107099a9df5cb8aba7))
|
||||||
|
* typo in account settings ([#307](https://github.com/pocket-id/pocket-id/issues/307)) ([c822192](https://github.com/pocket-id/pocket-id/commit/c8221921245deb3008f655740d1a9460dcdab2fc))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.36.0...v) (2025-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** add ability to sign in with login code ([#271](https://github.com/pocket-id/pocket-id/issues/271)) ([eb1426e](https://github.com/pocket-id/pocket-id/commit/eb1426ed2684b5ddd185db247a8e082b28dfd014))
|
||||||
|
* increase default item count per page ([a9713cf](https://github.com/pocket-id/pocket-id/commit/a9713cf6a1e3c879dc773889b7983e51bbe3c45b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add back setup page ([6a8dd84](https://github.com/pocket-id/pocket-id/commit/6a8dd84ca9396ff3369385af22f7e1f081bec2b2))
|
||||||
|
* add timeout to update check ([04efc36](https://github.com/pocket-id/pocket-id/commit/04efc3611568a0b0127b542b8cc252d9e783af46))
|
||||||
|
* make sorting consistent around tables ([8e344f1](https://github.com/pocket-id/pocket-id/commit/8e344f1151628581b637692a1de0e48e7235a22d))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.6...v) (2025-03-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* display groups on the account page ([#296](https://github.com/pocket-id/pocket-id/issues/296)) ([0f14a93](https://github.com/pocket-id/pocket-id/commit/0f14a93e1d6a723b0994ba475b04702646f04464))
|
||||||
|
* enable sd_notify support ([#277](https://github.com/pocket-id/pocket-id/issues/277)) ([91f254c](https://github.com/pocket-id/pocket-id/commit/91f254c7bb067646c42424c5c62ebcd90a0c8792))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* default sorting on tables ([#299](https://github.com/pocket-id/pocket-id/issues/299)) ([ff34e3b](https://github.com/pocket-id/pocket-id/commit/ff34e3b925321c80e9d7d42d0fd50e397d198435))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.5...v) (2025-03-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* support `LOGIN` authentication method for SMTP ([#292](https://github.com/pocket-id/pocket-id/issues/292)) ([2d733fc](https://github.com/pocket-id/pocket-id/commit/2d733fc79faefca23d54b22768029c3ba3427410))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.4...v) (2025-03-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* profile picture orientation if image is rotated with EXIF ([1026ee4](https://github.com/pocket-id/pocket-id/commit/1026ee4f5b5c7fda78b65c94a5d0f899525defd1))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.3...v) (2025-03-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add `groups` scope and claim to well known endpoint ([4bafee4](https://github.com/pocket-id/pocket-id/commit/4bafee4f58f5a76898cf66d6192916d405eea389))
|
||||||
|
* profile picture of other user can't be updated ([#273](https://github.com/pocket-id/pocket-id/issues/273)) ([ef25f6b](https://github.com/pocket-id/pocket-id/commit/ef25f6b6b84b52f1310d366d40aa3769a6fe9bef))
|
||||||
|
* support POST for OIDC userinfo endpoint ([1652cc6](https://github.com/pocket-id/pocket-id/commit/1652cc65f3f966d018d81a1ae22abb5ff1b4c47b))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.2...v) (2025-02-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add option to manually select SMTP TLS method ([#268](https://github.com/pocket-id/pocket-id/issues/268)) ([01a9de0](https://github.com/pocket-id/pocket-id/commit/01a9de0b04512c62d0f223de33d711f93c49b9cc))
|
||||||
|
* **ldap:** sync error if LDAP user collides with an existing user ([fde951b](https://github.com/pocket-id/pocket-id/commit/fde951b543281fedf9f602abae26b50881e3d157))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.1...v) (2025-02-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* delete profile picture if user gets deleted ([9a167d4](https://github.com/pocket-id/pocket-id/commit/9a167d4076872e5e3e5d78d2a66ef7203ca5261b))
|
||||||
|
* updating profile picture of other user updates own profile picture ([887c5e4](https://github.com/pocket-id/pocket-id/commit/887c5e462a50c8fb579ca6804f1a643d8af78fe8))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add validation that `PUBLIC_APP_URL` can't contain a path ([a6ae7ae](https://github.com/pocket-id/pocket-id/commit/a6ae7ae28713f7fc8018ae2aa7572986df3e1a5b))
|
||||||
|
* binary profile picture can't be imported from LDAP ([840a672](https://github.com/pocket-id/pocket-id/commit/840a672fc35ca8476caf86d7efaba9d54bce86aa))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.34.0...v) (2025-02-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to upload a profile picture ([#244](https://github.com/pocket-id/pocket-id/issues/244)) ([652ee6a](https://github.com/pocket-id/pocket-id/commit/652ee6ad5d6c46f0d35c955ff7bb9bdac6240ca6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* app config strings starting with a number are parsed incorrectly ([816c198](https://github.com/pocket-id/pocket-id/commit/816c198a42c189cb1f2d94885d2e3623e47e2848))
|
||||||
|
* emails do not get rendered correctly in Gmail ([dca9e7a](https://github.com/pocket-id/pocket-id/commit/dca9e7a11a3ba5d3b43a937f11cb9d16abad2db5))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
|
||||||
|
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
|
||||||
|
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to override the UI configuration with environment variables ([4e85842](https://github.com/pocket-id/pocket-id/commit/4e858420e9d9713e19f3b35c45c882403717f72f))
|
||||||
|
* add warning for only having one passkey configured ([#220](https://github.com/pocket-id/pocket-id/issues/220)) ([39e403d](https://github.com/pocket-id/pocket-id/commit/39e403d00f3870f9e960427653a1d9697da27a6f))
|
||||||
|
* display source in user and group table ([#225](https://github.com/pocket-id/pocket-id/issues/225)) ([9ed2adb](https://github.com/pocket-id/pocket-id/commit/9ed2adb0f8da13725fd9a4ef6a7798c377d13513))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* user linking in ldap group sync ([#222](https://github.com/pocket-id/pocket-id/issues/222)) ([2d78349](https://github.com/pocket-id/pocket-id/commit/2d78349b381d7ca10f47d3c03cef685a576b1b49))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.29.0...v) (2025-02-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add custom ldap search filters ([#216](https://github.com/pocket-id/pocket-id/issues/216)) ([626f87d](https://github.com/pocket-id/pocket-id/commit/626f87d59211f4129098b91dc1d020edb4aca692))
|
||||||
|
* update host configuration to allow external access ([#218](https://github.com/pocket-id/pocket-id/issues/218)) ([bea1158](https://github.com/pocket-id/pocket-id/commit/bea115866fd8e4b15d3281c422d2fb72312758b1))
|
||||||
|
|
||||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.28.1...v) (2025-02-05)
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.28.1...v) (2025-02-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ Before you submit the pull request for review please ensure that
|
|||||||
- You run `npm run format` to format the code
|
- You run `npm run format` to format the code
|
||||||
|
|
||||||
## Setup project
|
## Setup project
|
||||||
|
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
|
||||||
|
|
||||||
Pocket ID consists of a frontend, backend and a reverse proxy.
|
## 1. Using DevContainers
|
||||||
|
1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed
|
||||||
|
2. Clone and open the repo in VS Code
|
||||||
|
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
|
||||||
|
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
|
||||||
|
|
||||||
|
## 2. Manual
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
@@ -63,6 +70,10 @@ Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
|||||||
|
|
||||||
You're all set!
|
You're all set!
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json))
|
||||||
|
2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json))
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
|||||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||||
|
|
||||||
COPY ./scripts ./scripts
|
COPY ./scripts ./scripts
|
||||||
RUN chmod +x ./scripts/*.sh
|
RUN chmod +x ./scripts/**/*.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ SQLITE_DB_PATH=data/pocket-id.db
|
|||||||
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
||||||
UPLOAD_PATH=data/uploads
|
UPLOAD_PATH=data/uploads
|
||||||
PORT=8080
|
PORT=8080
|
||||||
HOST=localhost
|
HOST=0.0.0.0
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ go 1.23.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||||
|
github.com/emersion/go-smtp v0.21.3
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
github.com/fxamacker/cbor/v2 v2.7.0
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0
|
github.com/go-co-op/gocron/v2 v2.15.0
|
||||||
@@ -16,7 +20,8 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
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
|
||||||
golang.org/x/crypto v0.32.0
|
golang.org/x/crypto v0.35.0
|
||||||
|
golang.org/x/image v0.24.0
|
||||||
golang.org/x/time v0.9.0
|
golang.org/x/time v0.9.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/driver/sqlite v1.5.7
|
gorm.io/driver/sqlite v1.5.7
|
||||||
@@ -28,6 +33,7 @@ require (
|
|||||||
github.com/bytedance/sonic v1.12.8 // indirect
|
github.com/bytedance/sonic v1.12.8 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/disintegration/gift v1.1.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||||
@@ -63,10 +69,10 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.13.0 // indirect
|
golang.org/x/arch v0.13.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/net v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.4 // indirect
|
google.golang.org/protobuf v1.36.4 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||||
|
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||||
|
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||||
|
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||||
|
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||||
@@ -30,6 +36,10 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
|||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
|
||||||
|
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
@@ -207,10 +217,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -227,16 +240,17 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -249,8 +263,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -268,8 +282,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||||
"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/systemd"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -41,9 +43,10 @@ func initRouter(db *gorm.DB, appConfigService *service.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)
|
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)
|
||||||
|
|
||||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
||||||
|
|
||||||
@@ -51,24 +54,24 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
r.Use(middleware.NewCorsMiddleware().Add())
|
r.Use(middleware.NewCorsMiddleware().Add())
|
||||||
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))
|
||||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
|
||||||
|
|
||||||
job.RegisterLdapJobs(ldapService, appConfigService)
|
job.RegisterLdapJobs(ldapService, appConfigService)
|
||||||
job.RegisterDbCleanupJobs(db)
|
job.RegisterDbCleanupJobs(db)
|
||||||
|
|
||||||
// Initialize middleware for specific routes
|
// Initialize middleware for specific routes
|
||||||
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
|
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
||||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||||
|
|
||||||
// Set up API routes
|
// Set up API routes
|
||||||
apiGroup := r.Group("/api")
|
apiGroup := r.Group("/api")
|
||||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService)
|
||||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
||||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
|
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService)
|
||||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware)
|
||||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService)
|
||||||
|
controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
@@ -79,8 +82,20 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
baseGroup := r.Group("/")
|
baseGroup := r.Group("/")
|
||||||
controller.NewWellKnownController(baseGroup, jwtService)
|
controller.NewWellKnownController(baseGroup, jwtService)
|
||||||
|
|
||||||
// Run the server
|
// Get the listener
|
||||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
l, err := net.Listen("tcp", common.EnvConfig.Host+":"+common.EnvConfig.Port)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify systemd that we are ready
|
||||||
|
if err := systemd.SdNotifyReady(); err != nil {
|
||||||
|
log.Println("Unable to notify systemd that the service is ready: ", err)
|
||||||
|
// continue to serve anyway since it's not that important
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve requests
|
||||||
|
if err := r.RunListener(l); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
_ "github.com/joho/godotenv/autoload"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
@@ -12,6 +13,7 @@ type DbProvider string
|
|||||||
const (
|
const (
|
||||||
DbProviderSqlite DbProvider = "sqlite"
|
DbProviderSqlite DbProvider = "sqlite"
|
||||||
DbProviderPostgres DbProvider = "postgres"
|
DbProviderPostgres DbProvider = "postgres"
|
||||||
|
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
@@ -21,10 +23,13 @@ type EnvConfigSchema struct {
|
|||||||
SqliteDBPath string `env:"SQLITE_DB_PATH"`
|
SqliteDBPath string `env:"SQLITE_DB_PATH"`
|
||||||
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
|
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
|
KeysPath string `env:"KEYS_PATH"`
|
||||||
Port string `env:"BACKEND_PORT"`
|
Port string `env:"BACKEND_PORT"`
|
||||||
Host string `env:"HOST"`
|
Host string `env:"HOST"`
|
||||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = &EnvConfigSchema{
|
||||||
@@ -33,27 +38,40 @@ var EnvConfig = &EnvConfigSchema{
|
|||||||
SqliteDBPath: "data/pocket-id.db",
|
SqliteDBPath: "data/pocket-id.db",
|
||||||
PostgresConnectionString: "",
|
PostgresConnectionString: "",
|
||||||
UploadPath: "data/uploads",
|
UploadPath: "data/uploads",
|
||||||
|
KeysPath: "data/keys",
|
||||||
AppURL: "http://localhost",
|
AppURL: "http://localhost",
|
||||||
Port: "8080",
|
Port: "8080",
|
||||||
Host: "localhost",
|
Host: "0.0.0.0",
|
||||||
MaxMindLicenseKey: "",
|
MaxMindLicenseKey: "",
|
||||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||||
|
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||||
|
UiConfigDisabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the environment variables
|
// Validate the environment variables
|
||||||
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
|
switch EnvConfig.DbProvider {
|
||||||
|
case DbProviderSqlite:
|
||||||
|
if EnvConfig.SqliteDBPath == "" {
|
||||||
|
log.Fatal("Missing SQLITE_DB_PATH environment variable")
|
||||||
|
}
|
||||||
|
case DbProviderPostgres:
|
||||||
|
if EnvConfig.PostgresConnectionString == "" {
|
||||||
|
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
|
||||||
|
}
|
||||||
|
default:
|
||||||
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||||
}
|
}
|
||||||
|
|
||||||
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
|
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||||
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
|
if err != nil {
|
||||||
|
log.Fatal("PUBLIC_APP_URL is not a valid URL")
|
||||||
}
|
}
|
||||||
|
if parsedAppUrl.Path != "" {
|
||||||
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
|
log.Fatal("PUBLIC_APP_URL must not contain a path")
|
||||||
log.Fatal("Missing SQLITE_DB_PATH environment variable")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ type TokenInvalidOrExpiredError struct{}
|
|||||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type TokenInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *TokenInvalidError) Error() string {
|
||||||
|
return "Token is invalid"
|
||||||
|
}
|
||||||
|
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type OidcMissingAuthorizationError struct{}
|
type OidcMissingAuthorizationError struct{}
|
||||||
|
|
||||||
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||||
@@ -87,6 +94,11 @@ type NotSignedInError struct{}
|
|||||||
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
||||||
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
|
type MissingAccessToken struct{}
|
||||||
|
|
||||||
|
func (e *MissingAccessToken) Error() string { return "Missing access token" }
|
||||||
|
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type MissingPermissionError struct{}
|
type MissingPermissionError struct{}
|
||||||
|
|
||||||
func (e *MissingPermissionError) Error() string {
|
func (e *MissingPermissionError) Error() string {
|
||||||
@@ -182,5 +194,64 @@ type OidcAccessDeniedError struct{}
|
|||||||
func (e *OidcAccessDeniedError) Error() string {
|
func (e *OidcAccessDeniedError) Error() string {
|
||||||
return "You're not allowed to access this service"
|
return "You're not allowed to access this service"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcClientIdNotMatchingError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientIdNotMatchingError) Error() string {
|
||||||
|
return "Client id in request doesn't match client id in token"
|
||||||
|
}
|
||||||
|
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcNoCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcNoCallbackURLError) Error() string {
|
||||||
|
return "No callback URL provided"
|
||||||
|
}
|
||||||
|
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type UiConfigDisabledError struct{}
|
||||||
|
|
||||||
|
func (e *UiConfigDisabledError) Error() string {
|
||||||
|
return "The configuration can't be changed since the UI configuration is disabled"
|
||||||
|
}
|
||||||
|
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type InvalidUUIDError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidUUIDError) Error() string {
|
||||||
|
return "Invalid UUID"
|
||||||
|
}
|
||||||
|
|
||||||
|
type InvalidEmailError struct{}
|
||||||
|
|
||||||
|
type OneTimeAccessDisabledError struct{}
|
||||||
|
|
||||||
|
func (e *OneTimeAccessDisabledError) Error() string {
|
||||||
|
return "One-time access is disabled"
|
||||||
|
}
|
||||||
|
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type InvalidAPIKeyError struct{}
|
||||||
|
|
||||||
|
func (e *InvalidAPIKeyError) Error() string {
|
||||||
|
return "Invalid Api Key"
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoAPIKeyProvidedError struct{}
|
||||||
|
|
||||||
|
func (e *NoAPIKeyProvidedError) Error() string {
|
||||||
|
return "No API Key Provided"
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyNotFoundError struct{}
|
||||||
|
|
||||||
|
func (e *APIKeyNotFoundError) Error() string {
|
||||||
|
return "API Key Not Found"
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyExpirationDateError struct{}
|
||||||
|
|
||||||
|
func (e *APIKeyExpirationDateError) Error() string {
|
||||||
|
return "API Key expiration time must be in the future"
|
||||||
|
}
|
||||||
|
|||||||
125
backend/internal/controller/api_key_controller.go
Normal file
125
backend/internal/controller/api_key_controller.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
|
||||||
|
|
||||||
|
// ApiKeyController manages API keys for authenticated users
|
||||||
|
type ApiKeyController struct {
|
||||||
|
apiKeyService *service.ApiKeyService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApiKeyController creates a new controller for API key management
|
||||||
|
// @Summary API key management controller
|
||||||
|
// @Description Initializes API endpoints for managing API keys
|
||||||
|
// @Tags API Keys
|
||||||
|
func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
|
||||||
|
uc := &ApiKeyController{apiKeyService: apiKeyService}
|
||||||
|
|
||||||
|
apiKeyGroup := group.Group("/api-keys")
|
||||||
|
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
|
||||||
|
{
|
||||||
|
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||||
|
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||||
|
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listApiKeysHandler godoc
|
||||||
|
// @Summary List API keys
|
||||||
|
// @Description Get a paginated list of API keys belonging to the current user
|
||||||
|
// @Tags API Keys
|
||||||
|
// @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")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||||
|
// @Router /api-keys [get]
|
||||||
|
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||||
|
userID := ctx.GetString("userID")
|
||||||
|
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKeysDto []dto.ApiKeyDto
|
||||||
|
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
|
||||||
|
Data: apiKeysDto,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createApiKeyHandler godoc
|
||||||
|
// @Summary Create API key
|
||||||
|
// @Description Create a new API key for the current user
|
||||||
|
// @Tags API Keys
|
||||||
|
// @Param api_key body dto.ApiKeyCreateDto true "API key information"
|
||||||
|
// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
|
||||||
|
// @Router /api-keys [post]
|
||||||
|
func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
||||||
|
userID := ctx.GetString("userID")
|
||||||
|
|
||||||
|
var input dto.ApiKeyCreateDto
|
||||||
|
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKeyDto dto.ApiKeyDto
|
||||||
|
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{
|
||||||
|
ApiKey: apiKeyDto,
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeApiKeyHandler godoc
|
||||||
|
// @Summary Revoke API key
|
||||||
|
// @Description Revoke (delete) an existing API key by ID
|
||||||
|
// @Tags API Keys
|
||||||
|
// @Param id path string true "API Key ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api-keys/{id} [delete]
|
||||||
|
func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
|
||||||
|
userID := ctx.GetString("userID")
|
||||||
|
apiKeyID := ctx.Param("id")
|
||||||
|
|
||||||
|
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
|
||||||
|
ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -12,9 +12,13 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewAppConfigController creates a new controller for application configuration endpoints
|
||||||
|
// @Summary Create a new application configuration controller
|
||||||
|
// @Description Initialize routes for application configuration
|
||||||
|
// @Tags Application Configuration
|
||||||
func NewAppConfigController(
|
func NewAppConfigController(
|
||||||
group *gin.RouterGroup,
|
group *gin.RouterGroup,
|
||||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
authMiddleware *middleware.AuthMiddleware,
|
||||||
appConfigService *service.AppConfigService,
|
appConfigService *service.AppConfigService,
|
||||||
emailService *service.EmailService,
|
emailService *service.EmailService,
|
||||||
ldapService *service.LdapService,
|
ldapService *service.LdapService,
|
||||||
@@ -26,18 +30,18 @@ func NewAppConfigController(
|
|||||||
ldapService: ldapService,
|
ldapService: ldapService,
|
||||||
}
|
}
|
||||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||||
group.PUT("/application-configuration", acc.updateAppConfigHandler)
|
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||||
|
|
||||||
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
||||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
||||||
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
||||||
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
|
||||||
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
|
||||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
|
||||||
|
|
||||||
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
|
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||||
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
|
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfigController struct {
|
type AppConfigController struct {
|
||||||
@@ -46,6 +50,15 @@ type AppConfigController struct {
|
|||||||
ldapService *service.LdapService
|
ldapService *service.LdapService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listAppConfigHandler godoc
|
||||||
|
// @Summary List public application configurations
|
||||||
|
// @Description Get all public application configurations
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||||
|
// @Failure 500 {object} object "{"error": "error message"}"
|
||||||
|
// @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, err := acc.appConfigService.ListAppConfig(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,6 +75,15 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
|||||||
c.JSON(200, configVariablesDto)
|
c.JSON(200, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listAllAppConfigHandler godoc
|
||||||
|
// @Summary List all application configurations
|
||||||
|
// @Description Get all application configurations including private ones
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} dto.AppConfigVariableDto
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @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, err := acc.appConfigService.ListAppConfig(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,6 +100,16 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
|||||||
c.JSON(200, configVariablesDto)
|
c.JSON(200, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateAppConfigHandler godoc
|
||||||
|
// @Summary Update application configurations
|
||||||
|
// @Description Update application configuration settings
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||||
|
// @Success 200 {array} dto.AppConfigVariableDto
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /application-configuration [put]
|
||||||
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 {
|
||||||
@@ -100,6 +132,16 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, configVariablesDto)
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getLogoHandler godoc
|
||||||
|
// @Summary Get logo image
|
||||||
|
// @Description Get the logo image for the application
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Produce image/svg+xml
|
||||||
|
// @Success 200 {file} binary "Logo image"
|
||||||
|
// @Router /application-configuration/logo [get]
|
||||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||||
|
|
||||||
@@ -117,15 +159,42 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
|||||||
acc.getImage(c, imageName, imageType)
|
acc.getImage(c, imageName, imageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getFaviconHandler godoc
|
||||||
|
// @Summary Get favicon
|
||||||
|
// @Description Get the favicon for the application
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Produce image/x-icon
|
||||||
|
// @Success 200 {file} binary "Favicon image"
|
||||||
|
// @Failure 404 {object} object "{"error": "File not found"}"
|
||||||
|
// @Router /application-configuration/favicon [get]
|
||||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||||
acc.getImage(c, "favicon", "ico")
|
acc.getImage(c, "favicon", "ico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBackgroundImageHandler godoc
|
||||||
|
// @Summary Get background image
|
||||||
|
// @Description Get the background image for the application
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Success 200 {file} binary "Background image"
|
||||||
|
// @Failure 404 {object} object "{"error": "File not found"}"
|
||||||
|
// @Router /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.DbConfig.BackgroundImageType.Value
|
||||||
acc.getImage(c, "background", imageType)
|
acc.getImage(c, "background", imageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateLogoHandler godoc
|
||||||
|
// @Summary Update logo
|
||||||
|
// @Description Update the application logo
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Param file formData file true "Logo image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /application-configuration/logo [put]
|
||||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||||
|
|
||||||
@@ -143,6 +212,15 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|||||||
acc.updateImage(c, imageName, imageType)
|
acc.updateImage(c, imageName, imageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateFaviconHandler godoc
|
||||||
|
// @Summary Update favicon
|
||||||
|
// @Description Update the application favicon
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Favicon file (.ico)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /application-configuration/favicon [put]
|
||||||
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 {
|
||||||
@@ -158,11 +236,21 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
|||||||
acc.updateImage(c, "favicon", "ico")
|
acc.updateImage(c, "favicon", "ico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateBackgroundImageHandler godoc
|
||||||
|
// @Summary Update background image
|
||||||
|
// @Description Update the application background image
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Background image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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.DbConfig.BackgroundImageType.Value
|
||||||
acc.updateImage(c, "background", imageType)
|
acc.updateImage(c, "background", imageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
||||||
mimeType := utils.GetImageMimeType(imageType)
|
mimeType := utils.GetImageMimeType(imageType)
|
||||||
@@ -171,6 +259,7 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
|||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateImage is a helper function to update image files
|
||||||
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 {
|
||||||
@@ -187,6 +276,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncLdapHandler godoc
|
||||||
|
// @Summary Synchronize LDAP
|
||||||
|
// @Description Manually trigger LDAP synchronization
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,6 +292,14 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testEmailHandler godoc
|
||||||
|
// @Summary Send test email
|
||||||
|
// @Description Send a test email to verify email configuration
|
||||||
|
// @Tags Application Configuration
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /application-configuration/test-email [post]
|
||||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,32 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
|
// NewAuditLogController creates a new controller for audit log management
|
||||||
|
// @Summary Audit log controller
|
||||||
|
// @Description Initializes API endpoints for accessing audit logs
|
||||||
|
// @Tags Audit Logs
|
||||||
|
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
|
||||||
alc := AuditLogController{
|
alc := AuditLogController{
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
|
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditLogController struct {
|
type AuditLogController struct {
|
||||||
auditLogService *service.AuditLogService
|
auditLogService *service.AuditLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listAuditLogsForUserHandler godoc
|
||||||
|
// @Summary List audit logs
|
||||||
|
// @Description Get a paginated list of audit logs for the current user
|
||||||
|
// @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")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
|
// @Router /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 {
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
@@ -53,8 +67,8 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
logsDtos[i] = logsDto
|
logsDtos[i] = logsDto
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||||
"data": logsDtos,
|
Data: logsDtos,
|
||||||
"pagination": pagination,
|
Pagination: pagination,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,37 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
|
// NewCustomClaimController creates a new controller for custom claim management
|
||||||
|
// @Summary Custom claim management controller
|
||||||
|
// @Description Initializes all custom claim-related API endpoints
|
||||||
|
// @Tags Custom Claims
|
||||||
|
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
|
||||||
wkc := &CustomClaimController{customClaimService: customClaimService}
|
wkc := &CustomClaimController{customClaimService: customClaimService}
|
||||||
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
|
||||||
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
customClaimsGroup := group.Group("/custom-claims")
|
||||||
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
customClaimsGroup.Use(authMiddleware.Add())
|
||||||
|
{
|
||||||
|
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
|
||||||
|
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
|
||||||
|
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomClaimController struct {
|
type CustomClaimController struct {
|
||||||
customClaimService *service.CustomClaimService
|
customClaimService *service.CustomClaimService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSuggestionsHandler godoc
|
||||||
|
// @Summary Get custom claim suggestions
|
||||||
|
// @Description Get a list of suggested custom claim names
|
||||||
|
// @Tags Custom Claims
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string "List of suggested custom claim names"
|
||||||
|
// @Failure 401 {object} object "Unauthorized"
|
||||||
|
// @Failure 403 {object} object "Forbidden"
|
||||||
|
// @Failure 500 {object} object "Internal server error"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,6 +50,16 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, claims)
|
c.JSON(http.StatusOK, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUserHandler godoc
|
||||||
|
// @Summary Update custom claims for a user
|
||||||
|
// @Description Update or create custom claims for a specific user
|
||||||
|
// @Tags Custom Claims
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param userId path string true "User ID"
|
||||||
|
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
|
||||||
|
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||||
|
// @Router /custom-claims/user/{userId} [put]
|
||||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||||
var input []dto.CustomClaimCreateDto
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
@@ -54,6 +84,17 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
|||||||
c.JSON(http.StatusOK, customClaimsDto)
|
c.JSON(http.StatusOK, customClaimsDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCustomClaimsForUserGroupHandler godoc
|
||||||
|
// @Summary Update custom claims for a user group
|
||||||
|
// @Description Update or create custom claims for a specific user group
|
||||||
|
// @Tags Custom Claims
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param userGroupId path string true "User Group ID"
|
||||||
|
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||||
|
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /custom-claims/user-group/{userGroupId} [put]
|
||||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||||
var input []dto.CustomClaimCreateDto
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
@@ -62,8 +103,8 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.C
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userId := c.Param("userGroupId")
|
userGroupId := c.Param("userGroupId")
|
||||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"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/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"
|
||||||
@@ -11,27 +16,35 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
|
// NewOidcController creates a new controller for OIDC related endpoints
|
||||||
|
// @Summary OIDC controller
|
||||||
|
// @Description Initializes all OIDC-related API endpoints for authentication and client management
|
||||||
|
// @Tags OIDC
|
||||||
|
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
|
||||||
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
|
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
|
||||||
|
|
||||||
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
|
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
|
||||||
group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler)
|
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
|
||||||
|
|
||||||
group.POST("/oidc/token", oc.createTokensHandler)
|
group.POST("/oidc/token", oc.createTokensHandler)
|
||||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||||
|
group.POST("/oidc/userinfo", oc.userInfoHandler)
|
||||||
|
group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||||
|
group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||||
|
|
||||||
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
|
||||||
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
|
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
|
||||||
group.GET("/oidc/clients/:id", oc.getClientHandler)
|
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
|
||||||
group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
|
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
|
||||||
group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
|
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
|
||||||
|
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
|
||||||
|
|
||||||
group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler)
|
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
|
||||||
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
|
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
|
||||||
|
|
||||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||||
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||||
group.POST("/oidc/clients/:id/logo", jwtAuthMiddleware.Add(true), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcController struct {
|
type OidcController struct {
|
||||||
@@ -39,6 +52,16 @@ type OidcController struct {
|
|||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authorizeHandler godoc
|
||||||
|
// @Summary Authorize OIDC client
|
||||||
|
// @Description Start the OIDC authorization process for a client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
||||||
|
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/authorize [post]
|
||||||
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 {
|
||||||
@@ -60,6 +83,16 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authorizationConfirmationRequiredHandler godoc
|
||||||
|
// @Summary Check if authorization confirmation is required
|
||||||
|
// @Description Check if the user needs to confirm authorization for the client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
||||||
|
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/authorization-required [post]
|
||||||
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 {
|
||||||
@@ -76,6 +109,19 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
|||||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createTokensHandler godoc
|
||||||
|
// @Summary Create OIDC tokens
|
||||||
|
// @Description Exchange authorization code for ID and access tokens
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept application/x-www-form-urlencoded
|
||||||
|
// @Produce json
|
||||||
|
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
||||||
|
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
|
||||||
|
// @Param code formData string true "Authorization code"
|
||||||
|
// @Param grant_type formData string true "Grant type (must be 'authorization_code')"
|
||||||
|
// @Param code_verifier formData string false "PKCE code verifier"
|
||||||
|
// @Success 200 {object} object "{ \"id_token\": \"string\", \"access_token\": \"string\", \"token_type\": \"Bearer\" }"
|
||||||
|
// @Router /oidc/token [post]
|
||||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
// Disable cors for this endpoint
|
// Disable cors for this endpoint
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
@@ -104,8 +150,24 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
|
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userInfoHandler godoc
|
||||||
|
// @Summary Get user information
|
||||||
|
// @Description Get user information based on the access token
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} object "User claims based on requested scopes"
|
||||||
|
// @Security OAuth2AccessToken
|
||||||
|
// @Router /oidc/userinfo [get]
|
||||||
func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||||
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
|
authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
||||||
|
if len(authHeaderSplit) != 2 {
|
||||||
|
c.Error(&common.MissingAccessToken{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := authHeaderSplit[1]
|
||||||
|
|
||||||
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -122,6 +184,118 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
|||||||
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 /oidc/userinfo [post]
|
||||||
|
func (oc *OidcController) userInfoHandlerPost(c *gin.Context) {
|
||||||
|
// Implementation is the same as GET
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndSessionHandler godoc
|
||||||
|
// @Summary End OIDC session
|
||||||
|
// @Description End user session and handle OIDC logout
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept application/x-www-form-urlencoded
|
||||||
|
// @Produce html
|
||||||
|
// @Param id_token_hint query string false "ID token"
|
||||||
|
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
||||||
|
// @Param state query string false "State parameter to include in the redirect"
|
||||||
|
// @Success 302 "Redirect to post-logout URL or application logout page"
|
||||||
|
// @Router /oidc/end-session [get]
|
||||||
|
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcLogoutDto
|
||||||
|
|
||||||
|
// Bind query parameters to the struct
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
if err := c.ShouldBindQuery(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if c.Request.Method == http.MethodPost {
|
||||||
|
// Bind form parameters to the struct
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
// 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)
|
||||||
|
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
|
||||||
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
|
|
||||||
|
logoutCallbackURL, _ := url.Parse(callbackURL)
|
||||||
|
if input.State != "" {
|
||||||
|
q := logoutCallbackURL.Query()
|
||||||
|
q.Set("state", input.State)
|
||||||
|
logoutCallbackURL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, logoutCallbackURL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndSessionHandler godoc (POST method)
|
||||||
|
// @Summary End OIDC session (POST method)
|
||||||
|
// @Description End user session and handle OIDC logout using POST
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept application/x-www-form-urlencoded
|
||||||
|
// @Produce html
|
||||||
|
// @Param id_token_hint formData string false "ID token"
|
||||||
|
// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
|
||||||
|
// @Param state formData string false "State parameter to include in the redirect"
|
||||||
|
// @Success 302 "Redirect to post-logout URL or application logout page"
|
||||||
|
// @Router /oidc/end-session [post]
|
||||||
|
func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
|
||||||
|
// Implementation is the same as GET
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientMetaDataHandler godoc
|
||||||
|
// @Summary Get client metadata
|
||||||
|
// @Description Get OIDC client metadata for discovery and configuration
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
|
||||||
|
// @Router /oidc/clients/{id}/meta [get]
|
||||||
|
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||||
|
clientId := c.Param("id")
|
||||||
|
client, err := oc.oidcService.GetClient(clientId)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientDto := dto.OidcClientMetaDataDto{}
|
||||||
|
err = dto.MapStruct(client, &clientDto)
|
||||||
|
if err == nil {
|
||||||
|
c.JSON(http.StatusOK, clientDto)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientHandler godoc
|
||||||
|
// @Summary Get OIDC client
|
||||||
|
// @Description Get detailed information about an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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(clientId)
|
||||||
@@ -130,26 +304,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a different DTO based on the user's role
|
|
||||||
if c.GetBool("userIsAdmin") {
|
|
||||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||||
err = dto.MapStruct(client, &clientDto)
|
err = dto.MapStruct(client, &clientDto)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.JSON(http.StatusOK, clientDto)
|
c.JSON(http.StatusOK, clientDto)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
clientDto := dto.PublicOidcClientDto{}
|
|
||||||
err = dto.MapStruct(client, &clientDto)
|
|
||||||
if err == nil {
|
|
||||||
c.JSON(http.StatusOK, clientDto)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listClientsHandler godoc
|
||||||
|
// @Summary List OIDC clients
|
||||||
|
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param search query string false "Search term to filter clients by name"
|
||||||
|
// @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("name")
|
||||||
|
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/clients [get]
|
||||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
@@ -170,12 +346,22 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
|
||||||
"data": clientsDto,
|
Data: clientsDto,
|
||||||
"pagination": pagination,
|
Pagination: pagination,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createClientHandler godoc
|
||||||
|
// @Summary Create OIDC client
|
||||||
|
// @Description Create a new OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||||
|
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/clients [post]
|
||||||
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 {
|
||||||
@@ -198,6 +384,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, clientDto)
|
c.JSON(http.StatusCreated, clientDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteClientHandler godoc
|
||||||
|
// @Summary Delete OIDC client
|
||||||
|
// @Description Delete an OIDC client by ID
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,6 +402,17 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateClientHandler godoc
|
||||||
|
// @Summary Update OIDC client
|
||||||
|
// @Description Update an existing OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||||
|
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/clients/{id} [put]
|
||||||
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 {
|
||||||
@@ -230,6 +435,15 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, clientDto)
|
c.JSON(http.StatusOK, clientDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createClientSecretHandler godoc
|
||||||
|
// @Summary Create client secret
|
||||||
|
// @Description Generate a new secret for an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,6 +454,16 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientLogoHandler godoc
|
||||||
|
// @Summary Get client logo
|
||||||
|
// @Description Get the logo image for an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Produce image/svg+xml
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 200 {file} binary "Logo image"
|
||||||
|
// @Router /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.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -251,6 +475,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateClientLogoHandler godoc
|
||||||
|
// @Summary Update client logo
|
||||||
|
// @Description Upload or update the logo for an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/clients/{id}/logo [post]
|
||||||
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 {
|
||||||
@@ -267,6 +501,14 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteClientLogoHandler godoc
|
||||||
|
// @Summary Delete client logo
|
||||||
|
// @Description Delete the logo for an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,6 +519,17 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateAllowedUserGroupsHandler godoc
|
||||||
|
// @Summary Update allowed user groups
|
||||||
|
// @Description Update the user groups allowed to access an OIDC client
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||||
|
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /oidc/clients/{id}/allowed-user-groups [put]
|
||||||
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 {
|
||||||
|
|||||||
@@ -38,5 +38,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tc.TestService.SetJWTKeys()
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,24 +16,40 @@ import (
|
|||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
// NewUserController creates a new controller for user management endpoints
|
||||||
|
// @Summary User management controller
|
||||||
|
// @Description Initializes all user-related API endpoints
|
||||||
|
// @Tags Users
|
||||||
|
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
|
||||||
uc := UserController{
|
uc := UserController{
|
||||||
userService: userService,
|
userService: userService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
|
||||||
group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler)
|
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler)
|
||||||
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
|
group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler)
|
||||||
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
|
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
|
||||||
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
|
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
|
||||||
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
|
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
|
||||||
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
|
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
|
||||||
|
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
|
||||||
|
|
||||||
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
|
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
||||||
|
|
||||||
|
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
|
||||||
|
|
||||||
|
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
|
||||||
|
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
|
||||||
|
|
||||||
|
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
|
||||||
|
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||||
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
||||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
|
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
|
||||||
|
|
||||||
|
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||||
|
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
@@ -41,6 +57,41 @@ type UserController struct {
|
|||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getUserGroupsHandler godoc
|
||||||
|
// @Summary Get user groups
|
||||||
|
// @Description Retrieve all groups a specific user belongs to
|
||||||
|
// @Tags Users,User Groups
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {array} dto.UserGroupDtoWithUsers
|
||||||
|
// @Router /users/{id}/groups [get]
|
||||||
|
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
groups, err := uc.userService.GetUserGroups(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupsDto []dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStructList(groups, &groupsDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupsDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUsersHandler godoc
|
||||||
|
// @Summary List users
|
||||||
|
// @Description Get a paginated list of users with optional search and sorting
|
||||||
|
// @Tags Users
|
||||||
|
// @Param search query string false "Search term to filter users"
|
||||||
|
// @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")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
||||||
|
// @Router /users [get]
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
@@ -61,12 +112,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
|
||||||
"data": usersDto,
|
Data: usersDto,
|
||||||
"pagination": pagination,
|
Pagination: pagination,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getUserHandler godoc
|
||||||
|
// @Summary Get user by ID
|
||||||
|
// @Description Retrieve detailed information about a specific user
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /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.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,6 +141,12 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCurrentUserHandler godoc
|
||||||
|
// @Summary Get current user
|
||||||
|
// @Description Retrieve information about the currently authenticated user
|
||||||
|
// @Tags Users
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /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.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,6 +163,13 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteUserHandler godoc
|
||||||
|
// @Summary Delete user
|
||||||
|
// @Description Delete a specific user by ID
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /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.Param("id")); err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -108,6 +179,13 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createUserHandler godoc
|
||||||
|
// @Summary Create user
|
||||||
|
// @Description Create a new user
|
||||||
|
// @Tags Users
|
||||||
|
// @Param user body dto.UserCreateDto true "User information"
|
||||||
|
// @Success 201 {object} dto.UserDto
|
||||||
|
// @Router /users [post]
|
||||||
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 {
|
||||||
@@ -130,10 +208,25 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, userDto)
|
c.JSON(http.StatusCreated, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateUserHandler godoc
|
||||||
|
// @Summary Update user
|
||||||
|
// @Description Update an existing user by ID
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param user body dto.UserCreateDto true "User information"
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /users/{id} [put]
|
||||||
func (uc *UserController) updateUserHandler(c *gin.Context) {
|
func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||||
uc.updateUser(c, false)
|
uc.updateUser(c, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateCurrentUserHandler godoc
|
||||||
|
// @Summary Update current user
|
||||||
|
// @Description Update the currently authenticated user's information
|
||||||
|
// @Tags Users
|
||||||
|
// @Param user body dto.UserCreateDto true "User information"
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /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.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
||||||
c.Error(&common.AccountEditNotAllowedError{})
|
c.Error(&common.AccountEditNotAllowedError{})
|
||||||
@@ -142,13 +235,101 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
|||||||
uc.updateUser(c, true)
|
uc.updateUser(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
// getUserProfilePictureHandler godoc
|
||||||
|
// @Summary Get user profile picture
|
||||||
|
// @Description Retrieve a specific user's profile picture
|
||||||
|
// @Tags Users
|
||||||
|
// @Produce image/png
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 200 {file} binary "PNG image"
|
||||||
|
// @Router /users/{id}/profile-picture.png [get]
|
||||||
|
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
picture, size, err := uc.userService.GetProfilePicture(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Cache-Control", "public, max-age=300")
|
||||||
|
|
||||||
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUserProfilePictureHandler godoc
|
||||||
|
// @Summary Update user profile picture
|
||||||
|
// @Description Update a specific user's profile picture
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /users/{id}/profile-picture [put]
|
||||||
|
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCurrentUserProfilePictureHandler godoc
|
||||||
|
// @Summary Update current user's profile picture
|
||||||
|
// @Description Update the currently authenticated user's profile picture
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Produce json
|
||||||
|
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /users/me/profile-picture [put]
|
||||||
|
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
input.UserID = c.GetString("userID")
|
||||||
|
}
|
||||||
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -158,6 +339,22 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"token": token})
|
c.JSON(http.StatusCreated, gin.H{"token": token})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createOwnOneTimeAccessTokenHandler godoc
|
||||||
|
// @Summary Create one-time access token for current user
|
||||||
|
// @Description Generate a one-time access token for the currently authenticated user
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
|
||||||
|
// @Success 201 {object} object "{ \"token\": \"string\" }"
|
||||||
|
// @Router /users/{id}/one-time-access-token [post]
|
||||||
|
func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
|
uc.createOneTimeAccessTokenHandler(c, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
|
uc.createOneTimeAccessTokenHandler(c, false)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -174,6 +371,13 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exchangeOneTimeAccessTokenHandler godoc
|
||||||
|
// @Summary Exchange one-time access token
|
||||||
|
// @Description Exchange a one-time access token for a session token
|
||||||
|
// @Tags Users
|
||||||
|
// @Param token path string true "One-time access token"
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /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.Param("token"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,6 +398,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSetupAccessTokenHandler godoc
|
||||||
|
// @Summary Setup initial admin
|
||||||
|
// @Description Generate setup access token for initial admin user configuration
|
||||||
|
// @Tags Users
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /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()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,6 +424,37 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateUserGroups godoc
|
||||||
|
// @Summary Update user groups
|
||||||
|
// @Description Update the groups a specific user belongs to
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs"
|
||||||
|
// @Success 200 {object} dto.UserDto
|
||||||
|
// @Router /users/{id}/user-groups [put]
|
||||||
|
func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||||
|
var input dto.UserUpdateUserGroupDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||||
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 {
|
||||||
@@ -242,3 +483,40 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetUserProfilePictureHandler godoc
|
||||||
|
// @Summary Reset user profile picture
|
||||||
|
// @Description Reset a specific user's profile picture to the default
|
||||||
|
// @Tags Users
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /users/{id}/profile-picture [delete]
|
||||||
|
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
if err := uc.userService.ResetProfilePicture(userID); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetCurrentUserProfilePictureHandler godoc
|
||||||
|
// @Summary Reset current user's profile picture
|
||||||
|
// @Description Reset the currently authenticated user's profile picture to the default
|
||||||
|
// @Tags Users
|
||||||
|
// @Produce json
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /users/me/profile-picture [delete]
|
||||||
|
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
if err := uc.userService.ResetProfilePicture(userID); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,23 +10,42 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
// NewUserGroupController creates a new controller for user group management
|
||||||
|
// @Summary User group management controller
|
||||||
|
// @Description Initializes all user group-related API endpoints
|
||||||
|
// @Tags User Groups
|
||||||
|
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
|
||||||
ugc := UserGroupController{
|
ugc := UserGroupController{
|
||||||
UserGroupService: userGroupService,
|
UserGroupService: userGroupService,
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
|
userGroupsGroup := group.Group("/user-groups")
|
||||||
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
|
userGroupsGroup.Use(authMiddleware.Add())
|
||||||
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
|
{
|
||||||
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
|
userGroupsGroup.GET("", ugc.list)
|
||||||
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
|
userGroupsGroup.GET("/:id", ugc.get)
|
||||||
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
|
userGroupsGroup.POST("", ugc.create)
|
||||||
|
userGroupsGroup.PUT("/:id", ugc.update)
|
||||||
|
userGroupsGroup.DELETE("/:id", ugc.delete)
|
||||||
|
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupController struct {
|
type UserGroupController struct {
|
||||||
UserGroupService *service.UserGroupService
|
UserGroupService *service.UserGroupService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// list godoc
|
||||||
|
// @Summary List user groups
|
||||||
|
// @Description Get a paginated list of user groups with optional search and sorting
|
||||||
|
// @Tags User Groups
|
||||||
|
// @Param search query string false "Search term to filter user groups by name"
|
||||||
|
// @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("name")
|
||||||
|
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
||||||
|
// @Router /user-groups [get]
|
||||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
@@ -41,7 +60,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
|
// Map the user groups to DTOs
|
||||||
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
|
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
|
||||||
for i, group := range groups {
|
for i, group := range groups {
|
||||||
var groupDto dto.UserGroupDtoWithUserCount
|
var groupDto dto.UserGroupDtoWithUserCount
|
||||||
@@ -57,12 +76,22 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
groupsDto[i] = groupDto
|
groupsDto[i] = groupDto
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
|
||||||
"data": groupsDto,
|
Data: groupsDto,
|
||||||
"pagination": pagination,
|
Pagination: pagination,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get godoc
|
||||||
|
// @Summary Get user group by ID
|
||||||
|
// @Description Retrieve detailed information about a specific user group including its users
|
||||||
|
// @Tags User Groups
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User Group ID"
|
||||||
|
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,6 +108,16 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, groupDto)
|
c.JSON(http.StatusOK, groupDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create godoc
|
||||||
|
// @Summary Create user group
|
||||||
|
// @Description Create a new user group
|
||||||
|
// @Tags User Groups
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||||
|
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /user-groups [post]
|
||||||
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 {
|
||||||
@@ -101,6 +140,17 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, groupDto)
|
c.JSON(http.StatusCreated, groupDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update godoc
|
||||||
|
// @Summary Update user group
|
||||||
|
// @Description Update an existing user group by ID
|
||||||
|
// @Tags User Groups
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User Group ID"
|
||||||
|
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||||
|
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /user-groups/{id} [put]
|
||||||
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 {
|
||||||
@@ -123,6 +173,16 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, groupDto)
|
c.JSON(http.StatusOK, groupDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delete godoc
|
||||||
|
// @Summary Delete user group
|
||||||
|
// @Description Delete a specific user group by ID
|
||||||
|
// @Tags User Groups
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User Group ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /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.Param("id")); err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -132,6 +192,17 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateUsers godoc
|
||||||
|
// @Summary Update users in a group
|
||||||
|
// @Description Update the list of users belonging to a specific user group
|
||||||
|
// @Tags User Groups
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User Group ID"
|
||||||
|
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||||
|
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /user-groups/{id}/users [put]
|
||||||
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 {
|
||||||
@@ -139,7 +210,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ import (
|
|||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
|
func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
|
||||||
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
|
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
|
||||||
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
|
group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
|
||||||
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
|
group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler)
|
||||||
|
|
||||||
group.GET("/webauthn/login/start", wc.beginLoginHandler)
|
group.GET("/webauthn/login/start", wc.beginLoginHandler)
|
||||||
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
|
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
|
||||||
|
|
||||||
group.POST("/webauthn/logout", jwtAuthMiddleware.Add(false), wc.logoutHandler)
|
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
||||||
|
|
||||||
group.GET("/webauthn/credentials", jwtAuthMiddleware.Add(false), wc.listCredentialsHandler)
|
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||||
group.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler)
|
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||||
group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler)
|
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebauthnController struct {
|
type WebauthnController struct {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewWellKnownController creates a new controller for OIDC discovery endpoints
|
||||||
|
// @Summary OIDC Discovery controller
|
||||||
|
// @Description Initializes OIDC discovery and JWKS endpoints
|
||||||
|
// @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}
|
||||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||||
@@ -18,6 +22,13 @@ type WellKnownController struct {
|
|||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jwksHandler godoc
|
||||||
|
// @Summary Get JSON Web Key Set (JWKS)
|
||||||
|
// @Description Returns the JSON Web Key Set used for token verification
|
||||||
|
// @Tags Well Known
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} object "{ \"keys\": []interface{} }"
|
||||||
|
// @Router /.well-known/jwks.json [get]
|
||||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||||
jwk, err := wkc.jwtService.GetJWK()
|
jwk, err := wkc.jwtService.GetJWK()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,6 +39,12 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openIDConfigurationHandler godoc
|
||||||
|
// @Summary Get OpenID Connect discovery configuration
|
||||||
|
// @Description Returns the OpenID Connect discovery document with endpoints and capabilities
|
||||||
|
// @Tags Well Known
|
||||||
|
// @Success 200 {object} object "OpenID Connect configuration"
|
||||||
|
// @Router /.well-known/openid-configuration [get]
|
||||||
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||||
appUrl := common.EnvConfig.AppURL
|
appUrl := common.EnvConfig.AppURL
|
||||||
config := map[string]interface{}{
|
config := map[string]interface{}{
|
||||||
@@ -35,9 +52,10 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
|||||||
"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",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
"scopes_supported": []string{"openid", "profile", "email"},
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
"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{"RS256"},
|
||||||
|
|||||||
25
backend/internal/dto/api_key_dto.go
Normal file
25
backend/internal/dto/api_key_dto.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKeyCreateDto struct {
|
||||||
|
Name string `json:"name" binding:"required,min=3,max=50"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiKeyDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiKeyResponseDto struct {
|
||||||
|
ApiKey ApiKeyDto `json:"apiKey"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
@@ -21,19 +21,23 @@ type AppConfigUpdateDto struct {
|
|||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
SmtpUser string `json:"smtpUser"`
|
SmtpUser string `json:"smtpUser"`
|
||||||
SmtpPassword string `json:"smtpPassword"`
|
SmtpPassword string `json:"smtpPassword"`
|
||||||
SmtpTls string `json:"smtpTls"`
|
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
||||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||||
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
||||||
LdapUrl string `json:"ldapUrl"`
|
LdapUrl string `json:"ldapUrl"`
|
||||||
LdapBindDn string `json:"ldapBindDn"`
|
LdapBindDn string `json:"ldapBindDn"`
|
||||||
LdapBindPassword string `json:"ldapBindPassword"`
|
LdapBindPassword string `json:"ldapBindPassword"`
|
||||||
LdapBase string `json:"ldapBase"`
|
LdapBase string `json:"ldapBase"`
|
||||||
|
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
||||||
|
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
||||||
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
||||||
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
||||||
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
||||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||||
|
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||||
|
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CustomClaimCreateDto struct {
|
type CustomClaimCreateDto struct {
|
||||||
Key string `json:"key" binding:"required,claimKey"`
|
Key string `json:"key" binding:"required"`
|
||||||
Value string `json:"value" binding:"required"`
|
Value string `json:"value" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type PublicOidcClientDto struct {
|
type OidcClientMetaDataDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
PublicOidcClientDto
|
OidcClientMetaDataDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
IsPublic bool `json:"isPublic"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientWithAllowedUserGroupsDto struct {
|
type OidcClientWithAllowedUserGroupsDto struct {
|
||||||
PublicOidcClientDto
|
OidcClientDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
|
||||||
IsPublic bool `json:"isPublic"`
|
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
|
||||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||||
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
IsPublic bool `json:"isPublic"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
}
|
}
|
||||||
@@ -58,3 +57,10 @@ type OidcCreateTokensDto struct {
|
|||||||
type OidcUpdateAllowedUserGroupsDto struct {
|
type OidcUpdateAllowedUserGroupsDto struct {
|
||||||
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcLogoutDto struct {
|
||||||
|
IdTokenHint string `form:"id_token_hint"`
|
||||||
|
ClientId string `form:"client_id"`
|
||||||
|
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
|
||||||
|
State string `form:"state"`
|
||||||
|
}
|
||||||
|
|||||||
10
backend/internal/dto/pagination_dto.go
Normal file
10
backend/internal/dto/pagination_dto.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
|
type Pagination = utils.PaginationResponse
|
||||||
|
|
||||||
|
type Paginated[T any] struct {
|
||||||
|
Data []T `json:"data"`
|
||||||
|
Pagination Pagination `json:"pagination"`
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type UserDto struct {
|
|||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
|
UserGroups []UserGroupDto `json:"userGroups"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ type UserCreateDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTokenCreateDto struct {
|
type OneTimeAccessTokenCreateDto struct {
|
||||||
UserID string `json:"userId" binding:"required"`
|
UserID string `json:"userId"`
|
||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,3 +32,7 @@ type OneTimeAccessEmailDto struct {
|
|||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
RedirectPath string `json:"redirectPath"`
|
RedirectPath string `json:"redirectPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserUpdateUserGroupDto struct {
|
||||||
|
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import (
|
|||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UserGroupDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FriendlyName string `json:"friendlyName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
|
LdapID *string `json:"ldapId"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserGroupDtoWithUsers struct {
|
type UserGroupDtoWithUsers struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FriendlyName string `json:"friendlyName"`
|
FriendlyName string `json:"friendlyName"`
|
||||||
|
|||||||
@@ -16,22 +16,10 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
|
|
||||||
// The string can only contain letters and numbers
|
|
||||||
regex := "^[A-Za-z0-9]*$"
|
|
||||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
|
||||||
return matched
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
|
||||||
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
|
||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
50
backend/internal/middleware/api_key_auth.go
Normal file
50
backend/internal/middleware/api_key_auth.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKeyAuthMiddleware struct {
|
||||||
|
apiKeyService *service.ApiKeyService
|
||||||
|
jwtService *service.JwtService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware {
|
||||||
|
return &ApiKeyAuthMiddleware{
|
||||||
|
apiKeyService: apiKeyService,
|
||||||
|
jwtService: jwtService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID, isAdmin, err := m.Verify(c, adminRequired)
|
||||||
|
if err != nil {
|
||||||
|
c.Abort()
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("userID", userID)
|
||||||
|
c.Set("userIsAdmin", isAdmin)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
|
||||||
|
apiKey := c.GetHeader("X-API-KEY")
|
||||||
|
|
||||||
|
user, err := m.apiKeyService.ValidateApiKey(apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, &common.NotSignedInError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user is an admin
|
||||||
|
if adminRequired && !user.IsAdmin {
|
||||||
|
return "", false, &common.MissingPermissionError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.ID, user.IsAdmin, nil
|
||||||
|
}
|
||||||
89
backend/internal/middleware/auth_middleware.go
Normal file
89
backend/internal/middleware/auth_middleware.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication
|
||||||
|
type AuthMiddleware struct {
|
||||||
|
apiKeyMiddleware *ApiKeyAuthMiddleware
|
||||||
|
jwtMiddleware *JwtAuthMiddleware
|
||||||
|
options AuthOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthOptions struct {
|
||||||
|
AdminRequired bool
|
||||||
|
SuccessOptional bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthMiddleware(
|
||||||
|
apiKeyService *service.ApiKeyService,
|
||||||
|
jwtService *service.JwtService,
|
||||||
|
) *AuthMiddleware {
|
||||||
|
return &AuthMiddleware{
|
||||||
|
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
|
||||||
|
jwtMiddleware: NewJwtAuthMiddleware(jwtService),
|
||||||
|
options: AuthOptions{
|
||||||
|
AdminRequired: true,
|
||||||
|
SuccessOptional: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAdminNotRequired allows the middleware to continue with the request even if the user is not an admin
|
||||||
|
func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware {
|
||||||
|
// Create a new instance to avoid modifying the original
|
||||||
|
clone := &AuthMiddleware{
|
||||||
|
apiKeyMiddleware: m.apiKeyMiddleware,
|
||||||
|
jwtMiddleware: m.jwtMiddleware,
|
||||||
|
options: m.options,
|
||||||
|
}
|
||||||
|
clone.options.AdminRequired = false
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuccessOptional allows the middleware to continue with the request even if authentication fails
|
||||||
|
func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
|
||||||
|
// Create a new instance to avoid modifying the original
|
||||||
|
clone := &AuthMiddleware{
|
||||||
|
apiKeyMiddleware: m.apiKeyMiddleware,
|
||||||
|
jwtMiddleware: m.jwtMiddleware,
|
||||||
|
options: m.options,
|
||||||
|
}
|
||||||
|
clone.options.SuccessOptional = true
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// First try JWT auth
|
||||||
|
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||||
|
if err == nil {
|
||||||
|
// JWT auth succeeded, continue with the request
|
||||||
|
c.Set("userID", userID)
|
||||||
|
c.Set("userIsAdmin", isAdmin)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT auth failed, try API key auth
|
||||||
|
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
||||||
|
if err == nil {
|
||||||
|
// API key auth succeeded, continue with the request
|
||||||
|
c.Set("userID", userID)
|
||||||
|
c.Set("userIsAdmin", isAdmin)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.options.SuccessOptional {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both JWT and API key auth failed
|
||||||
|
c.Abort()
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,50 +11,49 @@ import (
|
|||||||
|
|
||||||
type JwtAuthMiddleware struct {
|
type JwtAuthMiddleware struct {
|
||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
ignoreUnauthenticated bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
|
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
|
||||||
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
|
return &JwtAuthMiddleware{jwtService: jwtService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
|
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Extract the token from the cookie or the Authorization header
|
|
||||||
token, err := c.Cookie(cookie.AccessTokenCookieName)
|
userID, isAdmin, err := m.Verify(c, adminRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
|
|
||||||
if len(authorizationHeaderSplitted) == 2 {
|
|
||||||
token = authorizationHeaderSplitted[1]
|
|
||||||
} else if m.ignoreUnauthenticated {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
c.Error(&common.NotSignedInError{})
|
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Set("userID", userID)
|
||||||
|
c.Set("userIsAdmin", isAdmin)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
|
||||||
|
// Extract the token from the cookie
|
||||||
|
token, err := c.Cookie(cookie.AccessTokenCookieName)
|
||||||
|
if err != nil {
|
||||||
|
// Try to extract the token from the Authorization header if it's not in the cookie
|
||||||
|
authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
||||||
|
if len(authorizationHeaderSplit) != 2 {
|
||||||
|
return "", false, &common.NotSignedInError{}
|
||||||
|
}
|
||||||
|
token = authorizationHeaderSplit[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := m.jwtService.VerifyAccessToken(token)
|
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||||
if err != nil && m.ignoreUnauthenticated {
|
if err != nil {
|
||||||
c.Next()
|
return "", false, &common.NotSignedInError{}
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
c.Error(&common.NotSignedInError{})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user is an admin
|
// Check if the user is an admin
|
||||||
if adminOnly && !claims.IsAdmin {
|
if adminRequired && !claims.IsAdmin {
|
||||||
c.Error(&common.MissingPermissionError{})
|
return "", false, &common.MissingPermissionError{}
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("userID", claims.Subject)
|
return claims.Subject, claims.IsAdmin, nil
|
||||||
c.Set("userIsAdmin", claims.IsAdmin)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/internal/model/api_key.go
Normal file
18
backend/internal/model/api_key.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKey struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Name string `sortable:"true"`
|
||||||
|
Key string
|
||||||
|
Description *string
|
||||||
|
ExpiresAt datatype.DateTime `sortable:"true"`
|
||||||
|
LastUsedAt *datatype.DateTime `sortable:"true"`
|
||||||
|
|
||||||
|
UserID string
|
||||||
|
User User
|
||||||
|
}
|
||||||
@@ -35,12 +35,16 @@ type AppConfig struct {
|
|||||||
LdapBindDn AppConfigVariable
|
LdapBindDn AppConfigVariable
|
||||||
LdapBindPassword AppConfigVariable
|
LdapBindPassword AppConfigVariable
|
||||||
LdapBase AppConfigVariable
|
LdapBase AppConfigVariable
|
||||||
|
LdapUserSearchFilter AppConfigVariable
|
||||||
|
LdapUserGroupSearchFilter AppConfigVariable
|
||||||
LdapSkipCertVerify AppConfigVariable
|
LdapSkipCertVerify AppConfigVariable
|
||||||
LdapAttributeUserUniqueIdentifier AppConfigVariable
|
LdapAttributeUserUniqueIdentifier AppConfigVariable
|
||||||
LdapAttributeUserUsername AppConfigVariable
|
LdapAttributeUserUsername AppConfigVariable
|
||||||
LdapAttributeUserEmail AppConfigVariable
|
LdapAttributeUserEmail AppConfigVariable
|
||||||
LdapAttributeUserFirstName AppConfigVariable
|
LdapAttributeUserFirstName AppConfigVariable
|
||||||
LdapAttributeUserLastName AppConfigVariable
|
LdapAttributeUserLastName AppConfigVariable
|
||||||
|
LdapAttributeUserProfilePicture AppConfigVariable
|
||||||
|
LdapAttributeGroupMember AppConfigVariable
|
||||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
||||||
LdapAttributeGroupName AppConfigVariable
|
LdapAttributeGroupName AppConfigVariable
|
||||||
LdapAttributeAdminGroup AppConfigVariable
|
LdapAttributeAdminGroup AppConfigVariable
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
model "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
"github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Base contains common columns for all tables.
|
// Base contains common columns for all tables.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
ID string `gorm:"primaryKey;not null"`
|
ID string `gorm:"primaryKey;not null"`
|
||||||
CreatedAt model.DateTime `sortable:"true"`
|
CreatedAt datatype.DateTime `sortable:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||||
if b.ID == "" {
|
if b.ID == "" {
|
||||||
b.ID = uuid.New().String()
|
b.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
b.CreatedAt = model.DateTime(time.Now())
|
b.CreatedAt = datatype.DateTime(time.Now())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ type OidcClient struct {
|
|||||||
|
|
||||||
Name string `sortable:"true"`
|
Name string `sortable:"true"`
|
||||||
Secret string
|
Secret string
|
||||||
CallbackURLs CallbackURLs
|
CallbackURLs UrlList
|
||||||
|
LogoutCallbackURLs UrlList
|
||||||
ImageType *string
|
ImageType *string
|
||||||
HasLogo bool `gorm:"-"`
|
HasLogo bool `gorm:"-"`
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
@@ -56,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallbackURLs []string
|
type UrlList []string
|
||||||
|
|
||||||
func (cu *CallbackURLs) Scan(value interface{}) error {
|
func (cu *UrlList) Scan(value interface{}) error {
|
||||||
if v, ok := value.([]byte); ok {
|
if v, ok := value.([]byte); ok {
|
||||||
return json.Unmarshal(v, cu)
|
return json.Unmarshal(v, cu)
|
||||||
} else {
|
} else {
|
||||||
@@ -66,6 +67,6 @@ func (cu *CallbackURLs) Scan(value interface{}) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cu CallbackURLs) Value() (driver.Value, error) {
|
func (cu UrlList) Value() (driver.Value, error) {
|
||||||
return json.Marshal(cu)
|
return json.Marshal(cu)
|
||||||
}
|
}
|
||||||
|
|||||||
102
backend/internal/service/api_key_service.go
Normal file
102
backend/internal/service/api_key_service.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKeyService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApiKeyService(db *gorm.DB) *ApiKeyService {
|
||||||
|
return &ApiKeyService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) ListApiKeys(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
|
||||||
|
query := s.db.Where("user_id = ?", userID).Model(&model.ApiKey{})
|
||||||
|
|
||||||
|
var apiKeys []model.ApiKey
|
||||||
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, utils.PaginationResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKeys, pagination, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) CreateApiKey(userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) {
|
||||||
|
// Check if expiration is in the future
|
||||||
|
if !input.ExpiresAt.ToTime().After(time.Now()) {
|
||||||
|
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a secure random API key
|
||||||
|
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
|
if err != nil {
|
||||||
|
return model.ApiKey{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := model.ApiKey{
|
||||||
|
Name: input.Name,
|
||||||
|
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
||||||
|
Description: &input.Description,
|
||||||
|
ExpiresAt: datatype.DateTime(input.ExpiresAt),
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&apiKey).Error; err != nil {
|
||||||
|
return model.ApiKey{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the raw token only once - it cannot be retrieved later
|
||||||
|
return apiKey, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) RevokeApiKey(userID, apiKeyID string) error {
|
||||||
|
var apiKey model.ApiKey
|
||||||
|
if err := s.db.Where("id = ? AND user_id = ?", apiKeyID, userID).First(&apiKey).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return &common.APIKeyNotFoundError{}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Delete(&apiKey).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return model.User{}, &common.NoAPIKeyProvidedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var key model.ApiKey
|
||||||
|
hashedKey := utils.CreateSha256Hash(apiKey)
|
||||||
|
|
||||||
|
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
|
||||||
|
hashedKey, datatype.DateTime(time.Now())).Preload("User").First(&key).Error; err != nil {
|
||||||
|
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return model.User{}, &common.InvalidAPIKeyError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
|||||||
if err := service.InitDbConfig(); err != nil {
|
if err := service.InitDbConfig(); 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +97,8 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
},
|
},
|
||||||
SmtpTls: model.AppConfigVariable{
|
SmtpTls: model.AppConfigVariable{
|
||||||
Key: "smtpTls",
|
Key: "smtpTls",
|
||||||
Type: "bool",
|
Type: "string",
|
||||||
DefaultValue: "true",
|
DefaultValue: "none",
|
||||||
},
|
},
|
||||||
SmtpSkipCertVerify: model.AppConfigVariable{
|
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||||
Key: "smtpSkipCertVerify",
|
Key: "smtpSkipCertVerify",
|
||||||
@@ -138,6 +139,16 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
Key: "ldapBase",
|
Key: "ldapBase",
|
||||||
Type: "string",
|
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{
|
LdapSkipCertVerify: model.AppConfigVariable{
|
||||||
Key: "ldapSkipCertVerify",
|
Key: "ldapSkipCertVerify",
|
||||||
Type: "bool",
|
Type: "bool",
|
||||||
@@ -163,6 +174,15 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
Key: "ldapAttributeUserLastName",
|
Key: "ldapAttributeUserLastName",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
},
|
},
|
||||||
|
LdapAttributeUserProfilePicture: model.AppConfigVariable{
|
||||||
|
Key: "ldapAttributeUserProfilePicture",
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
LdapAttributeGroupMember: model.AppConfigVariable{
|
||||||
|
Key: "ldapAttributeGroupMember",
|
||||||
|
Type: "string",
|
||||||
|
DefaultValue: "member",
|
||||||
|
},
|
||||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
|
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
|
||||||
Key: "ldapAttributeGroupUniqueIdentifier",
|
Key: "ldapAttributeGroupUniqueIdentifier",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -178,12 +198,15 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||||
var savedConfigVariables []model.AppConfigVariable
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
return nil, &common.UiConfigDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
rt := reflect.ValueOf(input).Type()
|
rt := reflect.ValueOf(input).Type()
|
||||||
rv := reflect.ValueOf(input)
|
rv := reflect.ValueOf(input)
|
||||||
|
|
||||||
|
var savedConfigVariables []model.AppConfigVariable
|
||||||
for i := 0; i < rt.NumField(); i++ {
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
field := rt.Field(i)
|
field := rt.Field(i)
|
||||||
key := field.Tag.Get("json")
|
key := field.Tag.Get("json")
|
||||||
@@ -244,9 +267,13 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the value to the default value if it is empty
|
|
||||||
for i := range configuration {
|
for i := range configuration {
|
||||||
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
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 != "" {
|
||||||
|
// Set the value to the default value if it is empty
|
||||||
configuration[i].Value = configuration[i].DefaultValue
|
configuration[i].Value = configuration[i].DefaultValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,12 +372,25 @@ func (s *AppConfigService) LoadDbConfigFromDb() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
storedConfigVar.Value = s.getConfigVariableFromEnvironmentVariable(currentConfigVar.Key, storedConfigVar.DefaultValue)
|
||||||
|
} else if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||||
storedConfigVar.Value = storedConfigVar.DefaultValue
|
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,27 +3,25 @@ package service
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
htemplate "html/template"
|
"github.com/emersion/go-sasl"
|
||||||
"mime/multipart"
|
"github.com/emersion/go-smtp"
|
||||||
"mime/quotedprintable"
|
|
||||||
"net"
|
|
||||||
"net/smtp"
|
|
||||||
"net/textproto"
|
|
||||||
"os"
|
|
||||||
ttemplate "text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
htemplate "html/template"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
ttemplate "text/template"
|
||||||
|
"time"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var netDialer = &net.Dialer{
|
|
||||||
Timeout: 3 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -88,6 +86,29 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
|||||||
c.AddHeaderRaw("Content-Type",
|
c.AddHeaderRaw("Content-Type",
|
||||||
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
c.AddHeader("MIME-Version", "1.0")
|
||||||
|
c.AddHeader("Date", time.Now().Format(time.RFC1123Z))
|
||||||
|
|
||||||
|
// to create a message-id, we need the FQDN of the sending server, but that may be a docker hostname or localhost
|
||||||
|
// 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
|
||||||
|
|
||||||
|
from_address := srv.appConfigService.DbConfig.SmtpFrom.Value
|
||||||
|
domain := ""
|
||||||
|
if strings.Contains(from_address, "@") {
|
||||||
|
domain = strings.Split(from_address, "@")[1]
|
||||||
|
} else {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
// can that happen? we just give up
|
||||||
|
return fmt.Errorf("failed to get own hostname: %w", err)
|
||||||
|
} else {
|
||||||
|
domain = hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.AddHeader("Message-ID", "<" + uuid.New().String() + "@" + domain + ">")
|
||||||
|
|
||||||
c.Body(body)
|
c.Body(body)
|
||||||
|
|
||||||
// Connect to the SMTP server
|
// Connect to the SMTP server
|
||||||
@@ -114,105 +135,57 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
|
|||||||
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the SMTP server
|
// Connect to the SMTP server based on TLS setting
|
||||||
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
|
switch srv.appConfigService.DbConfig.SmtpTls.Value {
|
||||||
client, err = srv.connectToSmtpServer(smtpAddress)
|
case "none":
|
||||||
} else if port == "465" {
|
client, err = smtp.Dial(smtpAddress)
|
||||||
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
case "tls":
|
||||||
smtpAddress,
|
client, err = smtp.DialTLS(smtpAddress, tlsConfig)
|
||||||
tlsConfig,
|
case "starttls":
|
||||||
)
|
client, err = smtp.DialStartTLS(
|
||||||
} else {
|
|
||||||
client, err = srv.connectToSmtpServerUsingStartTLS(
|
|
||||||
smtpAddress,
|
smtpAddress,
|
||||||
tlsConfig,
|
tlsConfig,
|
||||||
)
|
)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", srv.appConfigService.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.CommandTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Send the HELO command
|
||||||
|
if err := srv.sendHelloCommand(client); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send HELO command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 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 := srv.appConfigService.DbConfig.SmtpUser.Value
|
||||||
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
|
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
|
||||||
|
|
||||||
if smtpUser != "" || smtpPassword != "" {
|
if smtpUser != "" || smtpPassword != "" {
|
||||||
auth := smtp.PlainAuth("",
|
// Authenticate with plain auth
|
||||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
auth := sasl.NewPlainClient("", smtpUser, smtpPassword)
|
||||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
|
||||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
|
||||||
)
|
|
||||||
if err := client.Auth(auth); err != nil {
|
if err := client.Auth(auth); err != nil {
|
||||||
return nil, fmt.Errorf("failed to authenticate SMTP client: %w", err)
|
// If the server does not support plain auth, try login auth
|
||||||
|
var smtpErr *smtp.SMTPError
|
||||||
|
ok := errors.As(err, &smtpErr)
|
||||||
|
if ok && smtpErr.Code == smtp.ErrAuthUnknownMechanism.Code {
|
||||||
|
auth = sasl.NewLoginClient(smtpUser, smtpPassword)
|
||||||
|
err = client.Auth(auth)
|
||||||
|
}
|
||||||
|
// Both plain and login auth failed
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return client, err
|
return client, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *EmailService) connectToSmtpServer(serverAddr string) (*smtp.Client, error) {
|
|
||||||
conn, err := netDialer.Dial("tcp", serverAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := srv.sendHelloCommand(client); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
|
||||||
tlsDialer := &tls.Dialer{
|
|
||||||
NetDialer: netDialer,
|
|
||||||
Config: tlsConfig,
|
|
||||||
}
|
|
||||||
conn, err := tlsDialer.Dial("tcp", serverAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := srv.sendHelloCommand(client); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
|
||||||
conn, err := netDialer.Dial("tcp", serverAddr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := srv.sendHelloCommand(client); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to say hello to SMTP server: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := client.StartTLS(tlsConfig); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
|
func (srv *EmailService) sendHelloCommand(client *smtp.Client) error {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -224,23 +197,33 @@ 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 {
|
||||||
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
|
// Set the sender
|
||||||
|
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value, nil); err != nil {
|
||||||
return fmt.Errorf("failed to set sender: %w", err)
|
return fmt.Errorf("failed to set sender: %w", err)
|
||||||
}
|
}
|
||||||
if err := client.Rcpt(toEmail.Email); err != nil {
|
|
||||||
|
// Set the recipient
|
||||||
|
if err := client.Rcpt(toEmail.Email, nil); err != nil {
|
||||||
return fmt.Errorf("failed to set recipient: %w", err)
|
return fmt.Errorf("failed to set recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get a writer to write the email data
|
||||||
w, err := client.Data()
|
w, err := client.Data()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start data: %w", err)
|
return fmt.Errorf("failed to start data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write the email content
|
||||||
_, err = w.Write([]byte(c.String()))
|
_, err = w.Write([]byte(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close the writer
|
||||||
if err := w.Close(); err != nil {
|
if err := w.Close(); err != nil {
|
||||||
return fmt.Errorf("failed to close data writer: %w", err)
|
return fmt.Errorf("failed to close data writer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
|||||||
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
||||||
Path: "one-time-access",
|
Path: "one-time-access",
|
||||||
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
||||||
return "One time access"
|
return "Login Code"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,9 @@ type NewLoginTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTemplateData = struct {
|
type OneTimeAccessTemplateData = struct {
|
||||||
Link string
|
Code string
|
||||||
|
LoginLink string
|
||||||
|
LoginLinkWithCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is list of all template paths used for preloading templates
|
// this is list of all template paths used for preloading templates
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GeoLiteService struct {
|
type GeoLiteService struct {
|
||||||
|
disableUpdater bool
|
||||||
mutex sync.Mutex
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ var tailscaleIPNets = []*net.IPNet{
|
|||||||
func NewGeoLiteService() *GeoLiteService {
|
func NewGeoLiteService() *GeoLiteService {
|
||||||
service := &GeoLiteService{}
|
service := &GeoLiteService{}
|
||||||
|
|
||||||
|
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
|
||||||
|
// Warn the user, and disable the updater.
|
||||||
|
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
|
||||||
|
service.disableUpdater = true
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := service.updateDatabase(); err != nil {
|
if err := service.updateDatabase(); err != nil {
|
||||||
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
||||||
@@ -104,18 +111,19 @@ 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() error {
|
||||||
|
if s.disableUpdater {
|
||||||
|
// Avoid updating the GeoLite2 City database.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if s.isDatabaseUpToDate() {
|
if s.isDatabaseUpToDate() {
|
||||||
log.Println("GeoLite2 City database is up-to-date.")
|
log.Println("GeoLite2 City database is up-to-date.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Updating GeoLite2 City database...")
|
log.Println("Updating GeoLite2 City database...")
|
||||||
|
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||||
|
|
||||||
// Download and extract the database
|
|
||||||
downloadUrl := fmt.Sprintf(
|
|
||||||
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
|
|
||||||
common.EnvConfig.MaxMindLicenseKey,
|
|
||||||
)
|
|
||||||
// Download the database tar.gz file
|
// Download the database tar.gz file
|
||||||
resp, err := http.Get(downloadUrl)
|
resp, err := http.Get(downloadUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,13 +23,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
privateKeyPath = "data/keys/jwt_private_key.pem"
|
privateKeyFile = "jwt_private_key.pem"
|
||||||
publicKeyPath = "data/keys/jwt_public_key.pem"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type JwtService struct {
|
type JwtService struct {
|
||||||
publicKey *rsa.PublicKey
|
|
||||||
privateKey *rsa.PrivateKey
|
privateKey *rsa.PrivateKey
|
||||||
|
keyId string
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ func NewJwtService(appConfigService *AppConfigService) *JwtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure keys are generated or loaded
|
// Ensure keys are generated or loaded
|
||||||
if err := service.loadOrGenerateKeys(); err != nil {
|
if err := service.loadOrGenerateKey(common.EnvConfig.KeysPath); err != nil {
|
||||||
log.Fatalf("Failed to initialize jwt service: %v", err)
|
log.Fatalf("Failed to initialize jwt service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,30 +59,39 @@ type JWK struct {
|
|||||||
E string `json:"e"`
|
E string `json:"e"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOrGenerateKeys loads RSA keys from the given paths or generates them if they do not exist.
|
// loadOrGenerateKey loads RSA keys from the given paths or generates them if they do not exist.
|
||||||
func (s *JwtService) loadOrGenerateKeys() error {
|
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
|
||||||
|
privateKeyPath := filepath.Join(keysPath, privateKeyFile)
|
||||||
|
|
||||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
||||||
if err := s.generateKeys(); err != nil {
|
if err := s.generateKey(keysPath); err != nil {
|
||||||
return err
|
return fmt.Errorf("can't generate key: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't read jwt private key: " + err.Error())
|
return fmt.Errorf("can't read jwt private key: %w", err)
|
||||||
}
|
}
|
||||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't parse jwt private key: " + err.Error())
|
return fmt.Errorf("can't parse jwt private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
err = s.SetKey(privateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't read jwt public key: " + err.Error())
|
return fmt.Errorf("failed to set private key: %w", err)
|
||||||
}
|
}
|
||||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) SetKey(privateKey *rsa.PrivateKey) (err error) {
|
||||||
|
s.privateKey = privateKey
|
||||||
|
|
||||||
|
s.keyId, err = s.generateKeyID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't parse jwt public key: " + err.Error())
|
return fmt.Errorf("can't generate key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -101,20 +109,15 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
token.Header["kid"] = kid
|
token.Header["kid"] = s.keyId
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
|
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return s.publicKey, nil
|
return &s.privateKey.PublicKey, nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
return nil, errors.New("couldn't handle this token")
|
return nil, errors.New("couldn't handle this token")
|
||||||
@@ -147,13 +150,8 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
|||||||
claims["nonce"] = nonce
|
claims["nonce"] = nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
token.Header["kid"] = kid
|
token.Header["kid"] = s.keyId
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
@@ -167,20 +165,15 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
Issuer: common.EnvConfig.AppURL,
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
token.Header["kid"] = kid
|
token.Header["kid"] = s.keyId
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return s.publicKey, nil
|
return &s.privateKey.PublicKey, nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
return nil, errors.New("couldn't handle this token")
|
return nil, errors.New("couldn't handle this token")
|
||||||
@@ -194,34 +187,46 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return &s.privateKey.PublicKey, nil
|
||||||
|
}, jwt.WithIssuer(common.EnvConfig.AppURL))
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, errors.New("couldn't handle this token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
|
||||||
|
if !isValid {
|
||||||
|
return nil, errors.New("can't parse claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
||||||
func (s *JwtService) GetJWK() (JWK, error) {
|
func (s *JwtService) GetJWK() (JWK, error) {
|
||||||
if s.publicKey == nil {
|
if s.privateKey == nil {
|
||||||
return JWK{}, errors.New("public key is not initialized")
|
return JWK{}, errors.New("public key is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
|
||||||
if err != nil {
|
|
||||||
return JWK{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
jwk := JWK{
|
jwk := JWK{
|
||||||
Kid: kid,
|
Kid: s.keyId,
|
||||||
Kty: "RSA",
|
Kty: "RSA",
|
||||||
Use: "sig",
|
Use: "sig",
|
||||||
Alg: "RS256",
|
Alg: "RS256",
|
||||||
N: base64.RawURLEncoding.EncodeToString(s.publicKey.N.Bytes()),
|
N: base64.RawURLEncoding.EncodeToString(s.privateKey.N.Bytes()),
|
||||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.publicKey.E)).Bytes()),
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.privateKey.E)).Bytes()),
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwk, nil
|
return jwk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
|
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
|
||||||
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
|
func (s *JwtService) generateKeyID() (string, error) {
|
||||||
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
|
pubASN1, err := x509.MarshalPKIXPublicKey(&s.privateKey.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("failed to marshal public key: " + err.Error())
|
return "", fmt.Errorf("failed to marshal public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute SHA-256 hash of the public key
|
// Compute SHA-256 hash of the public key
|
||||||
@@ -236,29 +241,22 @@ func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
|
|||||||
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
// generateKey generates a new RSA key and saves it to the specified path.
|
||||||
func (s *JwtService) generateKeys() error {
|
func (s *JwtService) generateKey(keysPath string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
if err := os.MkdirAll(keysPath, 0700); err != nil {
|
||||||
return errors.New("failed to create directories for keys: " + err.Error())
|
return fmt.Errorf("failed to create directories for keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("failed to generate private key: " + err.Error())
|
return fmt.Errorf("failed to generate private key: %w", err)
|
||||||
}
|
}
|
||||||
s.privateKey = privateKey
|
|
||||||
|
|
||||||
|
privateKeyPath := filepath.Join(keysPath, privateKeyFile)
|
||||||
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKey := &privateKey.PublicKey
|
|
||||||
s.publicKey = publicKey
|
|
||||||
|
|
||||||
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +264,7 @@ func (s *JwtService) generateKeys() error {
|
|||||||
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
|
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
|
||||||
keyFile, err := os.Create(path)
|
keyFile, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("failed to create key file: " + err.Error())
|
return fmt.Errorf("failed to create key file: %w", err)
|
||||||
}
|
}
|
||||||
defer keyFile.Close()
|
defer keyFile.Close()
|
||||||
|
|
||||||
@@ -276,36 +274,7 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
|
|||||||
})
|
})
|
||||||
|
|
||||||
if _, err := keyFile.Write(keyPEM); err != nil {
|
if _, err := keyFile.Write(keyPEM); err != nil {
|
||||||
return errors.New("failed to write key file: " + err.Error())
|
return fmt.Errorf("failed to write key file: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadKeys loads RSA keys from the given paths.
|
|
||||||
func (s *JwtService) loadKeys() error {
|
|
||||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
|
||||||
if err := s.generateKeys(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't read jwt private key: %w", err)
|
|
||||||
}
|
|
||||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't parse jwt private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't read jwt public key: %w", err)
|
|
||||||
}
|
|
||||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't parse jwt public key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-ldap/ldap/v3"
|
"github.com/go-ldap/ldap/v3"
|
||||||
@@ -70,12 +76,13 @@ func (s *LdapService) SyncGroups() error {
|
|||||||
baseDN := s.appConfigService.DbConfig.LdapBase.Value
|
baseDN := s.appConfigService.DbConfig.LdapBase.Value
|
||||||
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
|
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
|
||||||
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
|
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
|
||||||
filter := "(objectClass=groupOfUniqueNames)"
|
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
|
||||||
|
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
|
||||||
|
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
nameAttribute,
|
nameAttribute,
|
||||||
uniqueIdentifierAttribute,
|
uniqueIdentifierAttribute,
|
||||||
"member",
|
groupMemberOfAttribute,
|
||||||
}
|
}
|
||||||
|
|
||||||
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
|
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
|
||||||
@@ -88,7 +95,6 @@ func (s *LdapService) SyncGroups() error {
|
|||||||
ldapGroupIDs := make(map[string]bool)
|
ldapGroupIDs := make(map[string]bool)
|
||||||
|
|
||||||
for _, value := range result.Entries {
|
for _, value := range result.Entries {
|
||||||
var usersToAddDto dto.UserGroupUpdateUsersDto
|
|
||||||
var membersUserId []string
|
var membersUserId []string
|
||||||
|
|
||||||
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
|
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
|
||||||
@@ -99,14 +105,24 @@ func (s *LdapService) SyncGroups() error {
|
|||||||
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
|
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
|
||||||
|
|
||||||
// Get group members and add to the correct Group
|
// Get group members and add to the correct Group
|
||||||
groupMembers := value.GetAttributeValues("member")
|
groupMembers := value.GetAttributeValues(groupMemberOfAttribute)
|
||||||
for _, member := range groupMembers {
|
for _, member := range groupMembers {
|
||||||
// Normal output of this would be CN=username,ou=people,dc=example,dc=com
|
// Normal output of this would be CN=username,ou=people,dc=example,dc=com
|
||||||
// Splitting at the "=" and "," then just grabbing the username for that string
|
// Splitting at the "=" and "," then just grabbing the username for that string
|
||||||
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
|
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
|
||||||
|
|
||||||
var databaseUser model.User
|
var databaseUser model.User
|
||||||
s.db.Where("username = ?", singleMember).First(&databaseUser)
|
err := s.db.Where("username = ? AND ldap_id IS NOT NULL", singleMember).First(&databaseUser).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// The user collides with a non-LDAP user, so we skip it
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
membersUserId = append(membersUserId, databaseUser.ID)
|
membersUserId = append(membersUserId, databaseUser.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
|
|||||||
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
|
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
|
||||||
}
|
}
|
||||||
|
|
||||||
usersToAddDto = dto.UserGroupUpdateUsersDto{
|
|
||||||
UserIDs: membersUserId,
|
|
||||||
}
|
|
||||||
|
|
||||||
if databaseGroup.ID == "" {
|
if databaseGroup.ID == "" {
|
||||||
newGroup, err := s.groupService.Create(syncGroup)
|
newGroup, err := s.groupService.Create(syncGroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||||
} else {
|
} else {
|
||||||
if _, err = s.groupService.UpdateUsers(newGroup.ID, usersToAddDto); err != nil {
|
if _, err = s.groupService.UpdateUsers(newGroup.ID, membersUserId); err != nil {
|
||||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
|
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
|
||||||
_, err = s.groupService.UpdateUsers(databaseGroup.ID, usersToAddDto)
|
_, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||||
return err
|
return err
|
||||||
@@ -175,9 +187,9 @@ func (s *LdapService) SyncUsers() error {
|
|||||||
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
|
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
|
||||||
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
|
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
|
||||||
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
|
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
|
||||||
|
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
|
||||||
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
|
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
|
||||||
|
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
|
||||||
filter := "(objectClass=person)"
|
|
||||||
|
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
"memberOf",
|
"memberOf",
|
||||||
@@ -188,6 +200,7 @@ func (s *LdapService) SyncUsers() error {
|
|||||||
emailAttribute,
|
emailAttribute,
|
||||||
firstNameAttribute,
|
firstNameAttribute,
|
||||||
lastNameAttribute,
|
lastNameAttribute,
|
||||||
|
profilePictureAttribute,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters must start and finish with ()!
|
// Filters must start and finish with ()!
|
||||||
@@ -236,9 +249,14 @@ func (s *LdapService) SyncUsers() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error syncing user %s: %s", newUser.Username, err)
|
log.Printf("Error syncing user %s: %s", newUser.Username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save profile picture
|
||||||
|
if pictureString := value.GetAttributeValue(profilePictureAttribute); pictureString != "" {
|
||||||
|
if err := s.SaveProfilePicture(databaseUser.ID, pictureString); err != nil {
|
||||||
|
log.Printf("Error saving profile picture for user %s: %s", newUser.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all LDAP users from the database
|
// Get all LDAP users from the database
|
||||||
@@ -250,7 +268,7 @@ func (s *LdapService) SyncUsers() error {
|
|||||||
// 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.db.Delete(&model.User{}, "ldap_id = ?", user.LdapID).Error; err != nil {
|
if err := s.userService.DeleteUser(user.ID); err != nil {
|
||||||
log.Printf("Failed to delete user %s with: %v", user.Username, err)
|
log.Printf("Failed to delete user %s with: %v", user.Username, err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Deleted user %s", user.Username)
|
log.Printf("Deleted user %s", user.Username)
|
||||||
@@ -259,3 +277,33 @@ func (s *LdapService) SyncUsers() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LdapService) SaveProfilePicture(userId string, pictureString string) error {
|
||||||
|
var reader io.Reader
|
||||||
|
|
||||||
|
if _, err := url.ParseRequestURI(pictureString); err == nil {
|
||||||
|
// If the photo is a URL, download it
|
||||||
|
response, err := http.Get(pictureString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download profile picture: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
reader = response.Body
|
||||||
|
|
||||||
|
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
|
||||||
|
// If the photo is a base64 encoded string, decode it
|
||||||
|
reader = bytes.NewReader(decodedPhoto)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// If the photo is a string, we assume that it's a binary string
|
||||||
|
reader = bytes.NewReader([]byte(pictureString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the profile picture
|
||||||
|
if err := s.userService.UpdateProfilePicture(userId, reader); err != nil {
|
||||||
|
return fmt.Errorf("failed to update profile picture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
||||||
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -230,6 +230,7 @@ func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string)
|
|||||||
client := model.OidcClient{
|
client := model.OidcClient{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
CallbackURLs: input.CallbackURLs,
|
CallbackURLs: input.CallbackURLs,
|
||||||
|
LogoutCallbackURLs: input.LogoutCallbackURLs,
|
||||||
CreatedByID: userID,
|
CreatedByID: userID,
|
||||||
IsPublic: input.IsPublic,
|
IsPublic: input.IsPublic,
|
||||||
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||||
@@ -250,6 +251,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
|||||||
|
|
||||||
client.Name = input.Name
|
client.Name = input.Name
|
||||||
client.CallbackURLs = input.CallbackURLs
|
client.CallbackURLs = input.CallbackURLs
|
||||||
|
client.LogoutCallbackURLs = input.LogoutCallbackURLs
|
||||||
client.IsPublic = input.IsPublic
|
client.IsPublic = input.IsPublic
|
||||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||||
|
|
||||||
@@ -399,6 +401,7 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(scope, "profile") {
|
if strings.Contains(scope, "profile") {
|
||||||
@@ -460,6 +463,46 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// If no ID token hint is provided, return an error
|
||||||
|
if input.IdTokenHint == "" {
|
||||||
|
return "", &common.TokenInvalidError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the ID token hint is provided, verify the ID token
|
||||||
|
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
|
||||||
|
if err != nil {
|
||||||
|
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 input.ClientId != "" && claims.Audience[0] != input.ClientId {
|
||||||
|
return "", &common.OidcClientIdNotMatchingError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId := claims.Audience[0]
|
||||||
|
|
||||||
|
// Check if the user has authorized the client before
|
||||||
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
|
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
|
||||||
|
return "", &common.OidcMissingAuthorizationError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client has no logout callback URLs, return an error
|
||||||
|
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
|
||||||
|
return "", &common.OidcNoCallbackURLError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbackURL, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,12 +549,12 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
|
|||||||
return encodedVerifierHash == codeChallenge
|
return encodedVerifierHash == codeChallenge
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
if inputCallbackURL == "" {
|
if inputCallbackURL == "" {
|
||||||
return client.CallbackURLs[0], nil
|
return urls[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, callbackPattern := range client.CallbackURLs {
|
for _, callbackPattern := range urls {
|
||||||
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
||||||
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,8 +12,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
|
||||||
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/resources"
|
||||||
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -23,11 +24,12 @@ import (
|
|||||||
|
|
||||||
type TestService struct {
|
type TestService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
jwtService *JwtService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService {
|
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
|
||||||
return &TestService{db: db, appConfigService: appConfigService}
|
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TestService) SeedDatabase() error {
|
func (s *TestService) SeedDatabase() error {
|
||||||
@@ -114,7 +116,8 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
},
|
},
|
||||||
Name: "Nextcloud",
|
Name: "Nextcloud",
|
||||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||||
|
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||||
ImageType: utils.StringPointer("png"),
|
ImageType: utils.StringPointer("png"),
|
||||||
CreatedByID: users[0].ID,
|
CreatedByID: users[0].ID,
|
||||||
},
|
},
|
||||||
@@ -124,7 +127,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
},
|
},
|
||||||
Name: "Immich",
|
Name: "Immich",
|
||||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||||
CreatedByID: users[1].ID,
|
CreatedByID: users[1].ID,
|
||||||
AllowedUserGroups: []model.UserGroup{
|
AllowedUserGroups: []model.UserGroup{
|
||||||
userGroups[1],
|
userGroups[1],
|
||||||
@@ -209,6 +212,18 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiKey := model.ApiKey{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
||||||
|
},
|
||||||
|
Name: "Test API Key",
|
||||||
|
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
|
||||||
|
UserID: users[0].ID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&apiKey).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -288,6 +303,42 @@ func (s *TestService) ResetAppConfig() error {
|
|||||||
return s.appConfigService.LoadDbConfigFromDb()
|
return s.appConfigService.LoadDbConfigFromDb()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TestService) SetJWTKeys() {
|
||||||
|
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
|
||||||
|
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
|
||||||
|
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
|
||||||
|
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
|
||||||
|
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
|
||||||
|
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
|
||||||
|
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
|
||||||
|
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
|
||||||
|
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
|
||||||
|
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
|
||||||
|
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
|
||||||
|
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
|
||||||
|
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
|
||||||
|
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
|
||||||
|
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
|
||||||
|
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
|
||||||
|
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
|
||||||
|
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
|
||||||
|
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
|
||||||
|
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
|
||||||
|
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
|
||||||
|
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
|
||||||
|
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
|
||||||
|
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
|
||||||
|
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
`
|
||||||
|
|
||||||
|
block, _ := pem.Decode([]byte(privateKeyString))
|
||||||
|
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
|
||||||
|
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
|
||||||
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||||
|
|||||||
@@ -103,16 +103,16 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
|
|||||||
return group, nil
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) {
|
func (s *UserGroupService) UpdateUsers(id string, userIds []string) (group model.UserGroup, err error) {
|
||||||
group, err = s.Get(id)
|
group, err = s.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the users based on UserIDs in input
|
// Fetch the users based on the userIds
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if len(input.UserIDs) > 0 {
|
if len(userIds) > 0 {
|
||||||
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
|
if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil {
|
||||||
return model.UserGroup{}, err
|
return model.UserGroup{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
|
||||||
|
|
||||||
"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"
|
||||||
|
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"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,10 +49,83 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
|
|||||||
|
|
||||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
|
err := s.db.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) {
|
||||||
|
// Validate the user ID to prevent directory traversal
|
||||||
|
if err := uuid.Validate(userID); err != nil {
|
||||||
|
return nil, 0, &common.InvalidUUIDError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
|
||||||
|
file, err := os.Open(profilePicturePath)
|
||||||
|
if err == nil {
|
||||||
|
// Get the file size
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return file, fileInfo.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file does not exist, return the default profile picture
|
||||||
|
user, err := s.GetUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultPicture, int64(defaultPicture.Len()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUserGroups(userID string) ([]model.UserGroup, error) {
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Preload("UserGroups").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user.UserGroups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error {
|
||||||
|
// Validate the user ID to prevent directory traversal
|
||||||
|
if err := uuid.Validate(userID); err != nil {
|
||||||
|
return &common.InvalidUUIDError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the image to a smaller square image
|
||||||
|
profilePicture, err := profilepicture.CreateProfilePicture(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
profilePictureDir := fmt.Sprintf("%s/profile-pictures", common.EnvConfig.UploadPath)
|
||||||
|
if err := os.MkdirAll(profilePictureDir, os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the profile picture file
|
||||||
|
createdProfilePicture, err := os.Create(fmt.Sprintf("%s/%s.png", profilePictureDir, userID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer createdProfilePicture.Close()
|
||||||
|
|
||||||
|
// Copy the image to the file
|
||||||
|
_, err = io.Copy(createdProfilePicture, profilePicture)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserService) DeleteUser(userID string) error {
|
func (s *UserService) DeleteUser(userID string) error {
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||||
@@ -59,6 +137,12 @@ func (s *UserService) DeleteUser(userID string) error {
|
|||||||
return &common.LdapUserUpdateError{}
|
return &common.LdapUserUpdateError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete the profile picture
|
||||||
|
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
|
||||||
|
if err := os.Remove(profilePicturePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return s.db.Delete(&user).Error
|
return s.db.Delete(&user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +197,11 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
||||||
|
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
|
||||||
|
if isDisabled {
|
||||||
|
return &common.OneTimeAccessDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
|
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; 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
|
||||||
@@ -123,17 +212,18 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
|
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(15*time.Minute))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
|
link := fmt.Sprintf("%s/lc", common.EnvConfig.AppURL)
|
||||||
|
linkWithCode := fmt.Sprintf("%s/%s", link, oneTimeAccessToken)
|
||||||
|
|
||||||
// Add redirect path to the link
|
// Add redirect path to the link
|
||||||
if strings.HasPrefix(redirectPath, "/") {
|
if strings.HasPrefix(redirectPath, "/") {
|
||||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||||
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
|
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -141,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
|||||||
Name: user.Username,
|
Name: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||||
Link: link,
|
Code: oneTimeAccessToken,
|
||||||
|
LoginLink: link,
|
||||||
|
LoginLinkWithCode: linkWithCode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != 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, err)
|
||||||
@@ -152,7 +244,14 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
|
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
tokenLength := 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 = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -194,6 +293,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
|
|||||||
return oneTimeAccessToken.User, accessToken, nil
|
return oneTimeAccessToken.User, accessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateUserGroups(id string, userGroupIds []string) (user model.User, err error) {
|
||||||
|
user, err = s.GetUser(id)
|
||||||
|
if err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the groups based on userGroupIds
|
||||||
|
var groups []model.UserGroup
|
||||||
|
if len(userGroupIds) > 0 {
|
||||||
|
if err := s.db.Where("id IN (?)", userGroupIds).Find(&groups).Error; err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the current groups with the new set of groups
|
||||||
|
if err := s.db.Model(&user).Association("UserGroups").Replace(groups); err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated user
|
||||||
|
if err := s.db.Save(&user).Error; err != nil {
|
||||||
|
return model.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
|
||||||
var userCount int64
|
var userCount int64
|
||||||
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||||
@@ -239,3 +365,27 @@ func (s *UserService) checkDuplicatedFields(user model.User) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetProfilePicture deletes a user's custom profile picture
|
||||||
|
func (s *UserService) ResetProfilePicture(userID string) error {
|
||||||
|
// Validate the user ID to prevent directory traversal
|
||||||
|
if err := uuid.Validate(userID); err != nil {
|
||||||
|
return &common.InvalidUUIDError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build path to profile picture
|
||||||
|
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
|
||||||
|
|
||||||
|
// Check if file exists and delete it
|
||||||
|
if _, err := os.Stat(profilePicturePath); err == nil {
|
||||||
|
if err := os.Remove(profilePicturePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete profile picture: %w", err)
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
// If any error other than "file not exists"
|
||||||
|
return fmt.Errorf("failed to check if profile picture exists: %w", err)
|
||||||
|
}
|
||||||
|
// It's okay if the file doesn't exist - just means there's no custom picture to delete
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ func genAddressHeader(name string, addresses []Address, maxLength int) string {
|
|||||||
} else {
|
} else {
|
||||||
email = fmt.Sprintf("<%s>", addr.Email)
|
email = fmt.Sprintf("<%s>", addr.Email)
|
||||||
}
|
}
|
||||||
|
if isPrintableASCII(addr.Name) {
|
||||||
|
writeHeaderAtom(hl, addr.Name)
|
||||||
|
} else {
|
||||||
writeHeaderQ(hl, addr.Name)
|
writeHeaderQ(hl, addr.Name)
|
||||||
|
}
|
||||||
writeHeaderAtom(hl, " ")
|
writeHeaderAtom(hl, " ")
|
||||||
writeHeaderAtom(hl, email)
|
writeHeaderAtom(hl, email)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
|
|||||||
return templateMap[template.Path]
|
return templateMap[template.Path]
|
||||||
}
|
}
|
||||||
|
|
||||||
type clonable[V pareseable[V]] interface {
|
type cloneable[V pareseable[V]] interface {
|
||||||
Clone() (V, error)
|
Clone() (V, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ type pareseable[V any] interface {
|
|||||||
ParseFS(fs.FS, ...string) (V, error)
|
ParseFS(fs.FS, ...string) (V, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
|
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
|
||||||
tmpl, err := rootTemplate.Clone()
|
tmpl, err := rootTemplate.Clone()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return *new(V), fmt.Errorf("clone root template: %w", err)
|
return *new(V), fmt.Errorf("clone root template: %w", err)
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetFileExtension(filename string) string {
|
func GetFileExtension(filename string) string {
|
||||||
splitted := strings.Split(filename, ".")
|
ext := filepath.Ext(filename)
|
||||||
return splitted[len(splitted)-1]
|
if len(ext) > 0 && ext[0] == '.' {
|
||||||
|
return ext[1:]
|
||||||
|
}
|
||||||
|
return filename
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImageMimeType(ext string) string {
|
func GetImageMimeType(ext string) string {
|
||||||
|
|||||||
73
backend/internal/utils/file_util_test.go
Normal file
73
backend/internal/utils/file_util_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetFileExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Simple file with extension",
|
||||||
|
filename: "document.pdf",
|
||||||
|
want: "pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File with path",
|
||||||
|
filename: "/path/to/document.txt",
|
||||||
|
want: "txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File with path (Windows style)",
|
||||||
|
filename: "C:\\path\\to\\document.jpg",
|
||||||
|
want: "jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple extensions",
|
||||||
|
filename: "archive.tar.gz",
|
||||||
|
want: "gz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hidden file with extension",
|
||||||
|
filename: ".config.json",
|
||||||
|
want: "json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Filename with dots",
|
||||||
|
filename: "version.1.2.3.txt",
|
||||||
|
want: "txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File with uppercase extension",
|
||||||
|
filename: "image.JPG",
|
||||||
|
want: "JPG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "File without extension",
|
||||||
|
filename: "README",
|
||||||
|
want: "README",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hidden file without extension",
|
||||||
|
filename: ".gitignore",
|
||||||
|
want: "gitignore",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty filename",
|
||||||
|
filename: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := GetFileExtension(tt.filename)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GetFileExtension(%q) = %q, want %q", tt.filename, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/internal/utils/hash_util.go
Normal file
11
backend/internal/utils/hash_util.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateSha256Hash(input string) string {
|
||||||
|
hash := sha256.Sum256([]byte(input))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
97
backend/internal/utils/image/profile_picture.go
Normal file
97
backend/internal/utils/image/profile_picture.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package profilepicture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"github.com/disintegration/imageorient"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const profilePictureSize = 300
|
||||||
|
|
||||||
|
// CreateProfilePicture resizes the profile picture to a square
|
||||||
|
func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) {
|
||||||
|
img, _, err := imageorient.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = imaging.Encode(&buf, img, imaging.PNG)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDefaultProfilePicture creates a profile picture with the initials
|
||||||
|
func CreateDefaultProfilePicture(firstName, lastName 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
|
||||||
|
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||||
|
|
||||||
|
// Load the font
|
||||||
|
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read font file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the font
|
||||||
|
fontFace, err := opentype.Parse(fontBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse font: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a font.Face with a specific size
|
||||||
|
fontSize := 160.0
|
||||||
|
face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{
|
||||||
|
Size: fontSize,
|
||||||
|
DPI: 72,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create font face: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a drawer for the image
|
||||||
|
drawer := &font.Drawer{
|
||||||
|
Dst: img,
|
||||||
|
Src: image.NewUniform(color.RGBA{R: 0, G: 0, B: 0, A: 255}), // Black text color
|
||||||
|
Face: face,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the initials
|
||||||
|
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
|
||||||
|
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
|
||||||
|
drawer.Dot = fixed.P(x, y)
|
||||||
|
|
||||||
|
// Draw the initials
|
||||||
|
drawer.DrawString(initials)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = imaging.Encode(&buf, img, imaging.PNG)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &buf, nil
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if pageSize < 1 {
|
if pageSize < 1 {
|
||||||
pageSize = 10
|
pageSize = 20
|
||||||
} else if pageSize > 100 {
|
} else if pageSize > 100 {
|
||||||
pageSize = 100
|
pageSize = 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,53 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
||||||
func GenerateRandomAlphanumericString(length int) (string, error) {
|
func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
const charsetLength = int64(len(charset))
|
|
||||||
|
|
||||||
if length <= 0 {
|
if length <= 0 {
|
||||||
return "", fmt.Errorf("length must be a positive integer")
|
return "", errors.New("length must be a positive integer")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]byte, length)
|
// The algorithm below is adapted from https://stackoverflow.com/a/35615565
|
||||||
|
const (
|
||||||
|
letterIdxBits = 6 // 6 bits to represent a letter index
|
||||||
|
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||||
|
)
|
||||||
|
|
||||||
for i := range result {
|
result := strings.Builder{}
|
||||||
num, err := rand.Int(rand.Reader, big.NewInt(charsetLength))
|
result.Grow(length)
|
||||||
|
// Because we discard a bunch of bytes, we read more in the buffer to minimize the changes of performing additional IO
|
||||||
|
bufferSize := int(float64(length) * 1.3)
|
||||||
|
randomBytes := make([]byte, bufferSize)
|
||||||
|
for i, j := 0, 0; i < length; j++ {
|
||||||
|
// Fill the buffer if needed
|
||||||
|
if j%bufferSize == 0 {
|
||||||
|
_, err := io.ReadFull(rand.Reader, randomBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
}
|
}
|
||||||
result[i] = charset[num.Int64()]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(result), nil
|
// Discard bytes that are outside of the range
|
||||||
|
// This allows making sure that we maintain uniform distribution
|
||||||
|
idx := int(randomBytes[j%length] & letterIdxMask)
|
||||||
|
if idx < len(charset) {
|
||||||
|
result.WriteByte(charset[idx])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostnameFromURL(rawURL string) string {
|
func GetHostnameFromURL(rawURL string) string {
|
||||||
@@ -43,22 +64,41 @@ func StringPointer(s string) *string {
|
|||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
func CapitalizeFirstLetter(s string) string {
|
func CapitalizeFirstLetter(str string) string {
|
||||||
if s == "" {
|
if str == "" {
|
||||||
return s
|
return ""
|
||||||
}
|
}
|
||||||
runes := []rune(s)
|
|
||||||
runes[0] = unicode.ToUpper(runes[0])
|
result := strings.Builder{}
|
||||||
return string(runes)
|
result.Grow(len(str))
|
||||||
|
for i, r := range str {
|
||||||
|
if i == 0 {
|
||||||
|
result.WriteRune(unicode.ToUpper(r))
|
||||||
|
} else {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func CamelCaseToSnakeCase(s string) string {
|
func CamelCaseToSnakeCase(str string) string {
|
||||||
var result []rune
|
result := strings.Builder{}
|
||||||
for i, r := range s {
|
result.Grow(int(float32(len(str)) * 1.1))
|
||||||
|
for i, r := range str {
|
||||||
if unicode.IsUpper(r) && i > 0 {
|
if unicode.IsUpper(r) && i > 0 {
|
||||||
result = append(result, '_')
|
result.WriteByte('_')
|
||||||
}
|
}
|
||||||
result = append(result, unicode.ToLower(r))
|
result.WriteRune(unicode.ToLower(r))
|
||||||
}
|
}
|
||||||
return string(result)
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||||
|
|
||||||
|
func CamelCaseToScreamingSnakeCase(s string) string {
|
||||||
|
// Insert underscores before uppercase letters (except the first one)
|
||||||
|
snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
|
||||||
|
|
||||||
|
// Convert to uppercase
|
||||||
|
return strings.ToUpper(snake)
|
||||||
}
|
}
|
||||||
|
|||||||
105
backend/internal/utils/string_util_test.go
Normal file
105
backend/internal/utils/string_util_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateRandomAlphanumericString(t *testing.T) {
|
||||||
|
t.Run("valid length returns correct string", func(t *testing.T) {
|
||||||
|
const length = 10
|
||||||
|
str, err := GenerateRandomAlphanumericString(length)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(str) != length {
|
||||||
|
t.Errorf("Expected length %d, got %d", length, len(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, err := regexp.MatchString(`^[a-zA-Z0-9]+$`, str)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Regex match failed: %v", err)
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
t.Errorf("String contains non-alphanumeric characters: %s", str)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero length returns error", func(t *testing.T) {
|
||||||
|
_, err := GenerateRandomAlphanumericString(0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for zero length, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("negative length returns error", func(t *testing.T) {
|
||||||
|
_, err := GenerateRandomAlphanumericString(-1)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for negative length, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("generates different strings", func(t *testing.T) {
|
||||||
|
str1, _ := GenerateRandomAlphanumericString(10)
|
||||||
|
str2, _ := GenerateRandomAlphanumericString(10)
|
||||||
|
if str1 == str2 {
|
||||||
|
t.Error("Generated strings should be different")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapitalizeFirstLetter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"lowercase first letter", "hello", "Hello"},
|
||||||
|
{"already capitalized", "Hello", "Hello"},
|
||||||
|
{"single lowercase letter", "h", "H"},
|
||||||
|
{"single uppercase letter", "H", "H"},
|
||||||
|
{"starts with number", "123abc", "123abc"},
|
||||||
|
{"unicode character", "étoile", "Étoile"},
|
||||||
|
{"special character", "_test", "_test"},
|
||||||
|
{"multi-word", "hello world", "Hello world"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := CapitalizeFirstLetter(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("CapitalizeFirstLetter(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCamelCaseToSnakeCase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"simple camelCase", "camelCase", "camel_case"},
|
||||||
|
{"PascalCase", "PascalCase", "pascal_case"},
|
||||||
|
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
|
||||||
|
{"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"},
|
||||||
|
{"single lowercase word", "word", "word"},
|
||||||
|
{"single uppercase word", "WORD", "w_o_r_d"},
|
||||||
|
{"with numbers", "camel123Case", "camel123_case"},
|
||||||
|
{"with numbers in middle", "model2Name", "model2_name"},
|
||||||
|
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := CamelCaseToSnakeCase(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("CamelCaseToSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/internal/utils/systemd/sdnotify.go
Normal file
33
backend/internal/utils/systemd/sdnotify.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package systemd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SdNotifyReady sends a message to the systemd daemon to notify that service is ready to operate.
|
||||||
|
// It is common to ignore the error.
|
||||||
|
func SdNotifyReady() error {
|
||||||
|
socketAddr := &net.UnixAddr{
|
||||||
|
Name: os.Getenv("NOTIFY_SOCKET"),
|
||||||
|
Net: "unixgram",
|
||||||
|
}
|
||||||
|
|
||||||
|
if socketAddr.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err = conn.Write([]byte("READY=1")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,95 +1,92 @@
|
|||||||
{{ define "style" }}
|
{{ define "style" }}
|
||||||
<style>
|
<style>
|
||||||
body {
|
/* Reset styles for email clients */
|
||||||
font-family: Arial, sans-serif;
|
body, table, td, p, a {
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
border: 0;
|
||||||
.container {
|
font-size: 100%;
|
||||||
background-color: #fff;
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #f0f0f0;
|
||||||
color: #333;
|
color: #333;
|
||||||
padding: 32px;
|
}
|
||||||
border-radius: 10px;
|
.container {
|
||||||
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
padding: 32px;
|
||||||
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
.header .logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.header .logo img {
|
.header .logo img {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
object-fit: cover;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
.warning {
|
.warning {
|
||||||
background-color: #ffd966;
|
background-color: #ffd966;
|
||||||
color: #7f6000;
|
color: #7f6000;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
margin: auto 0 auto auto;
|
||||||
|
}
|
||||||
.content {
|
.content {
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
color: #333;
|
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.content h2 {
|
.content h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
width: 100%;
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.grid div {
|
.grid td {
|
||||||
display: flex;
|
width: 50%;
|
||||||
flex-direction: column;
|
padding-bottom: 8px;
|
||||||
}
|
vertical-align: top;
|
||||||
.grid p {
|
}
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.label {
|
.label {
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-bottom: 4px;
|
}
|
||||||
}
|
|
||||||
.message {
|
.message {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
margin-top: 16px;
|
||||||
|
}
|
||||||
.button {
|
.button {
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 0.7rem 1.5rem;
|
padding: 0.7rem 1.5rem;
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
.button-container {
|
.button-container {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 24px;
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
{{ define "base" }}
|
{{ define "base" }}
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||||
<h1>{{ .AppName }}</h1>
|
<h1>{{ .AppName }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="warning">Warning</div>
|
<div class="warning">Warning</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>New Sign-In Detected</h2>
|
<h2>New Sign-In Detected</h2>
|
||||||
<div class="grid">
|
<table class="grid">
|
||||||
|
<tr>
|
||||||
{{ if and .Data.City .Data.Country }}
|
{{ if and .Data.City .Data.Country }}
|
||||||
<div>
|
<td>
|
||||||
<p class="label">Approximate Location</p>
|
<p class="label">Approximate Location</p>
|
||||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||||
</div>
|
</td>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div>
|
<td>
|
||||||
<p class="label">IP Address</p>
|
<p class="label">IP Address</p>
|
||||||
<p>{{ .Data.IPAddress }}</p>
|
<p>{{ .Data.IPAddress }}</p>
|
||||||
</div>
|
</td>
|
||||||
<div>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
<p class="label">Device</p>
|
<p class="label">Device</p>
|
||||||
<p>{{ .Data.Device }}</p>
|
<p>{{ .Data.Device }}</p>
|
||||||
</div>
|
</td>
|
||||||
<div>
|
<td>
|
||||||
<p class="label">Sign-In Time</p>
|
<p class="label">Sign-In Time</p>
|
||||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
|
</table>
|
||||||
<p class="message">
|
<p class="message">
|
||||||
This sign-in was detected from a new device or location. If you recognize this activity, you can
|
This sign-in was detected from a new device or location. If you recognize this activity, you can
|
||||||
safely ignore this message. If not, please review your account and security settings.
|
safely ignore this message. If not, please review your account and security settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{ end -}}
|
{{ end -}}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{{ define "base" }}
|
{{ define "base" }}
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||||
<h1>{{ .AppName }}</h1>
|
<h1>{{ .AppName }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>One-Time Access</h2>
|
<h2>Login Code</h2>
|
||||||
<p class="message">
|
<p class="message">
|
||||||
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
|
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in 15 minutes.
|
||||||
</p>
|
</p>
|
||||||
<div class="button-container">
|
<div class="button-container">
|
||||||
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
|
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end -}}
|
{{ end -}}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
{{ define "base" -}}
|
{{ define "base" -}}
|
||||||
One-Time Access
|
Login Code
|
||||||
====================
|
====================
|
||||||
|
|
||||||
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
|
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes.
|
||||||
|
|
||||||
{{ .Data.Link }}
|
{{ .Data.LoginLinkWithCode }}
|
||||||
|
|
||||||
|
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}".
|
||||||
{{ end -}}
|
{{ end -}}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import "embed"
|
|||||||
|
|
||||||
// Embedded file systems for the project
|
// Embedded file systems for the project
|
||||||
|
|
||||||
//go:embed email-templates images migrations
|
//go:embed email-templates images migrations fonts
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|||||||
BIN
backend/resources/fonts/PlayfairDisplay-Bold.ttf
Normal file
BIN
backend/resources/fonts/PlayfairDisplay-Bold.ttf
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls JSONB;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE app_config_variables SET value = 'true' WHERE key = 'smtpTls';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
UPDATE app_config_variables AS target
|
||||||
|
SET value = CASE
|
||||||
|
WHEN target.value = 'true' AND (SELECT value FROM app_config_variables WHERE key = 'smtpPort' LIMIT 1) = '587' THEN 'starttls'
|
||||||
|
WHEN target.value = 'true' THEN 'tls'
|
||||||
|
ELSE 'none'
|
||||||
|
END
|
||||||
|
WHERE target.key = 'smtpTls';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_api_keys_key;
|
||||||
|
DROP TABLE IF EXISTS api_keys;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
user_id UUID REFERENCES users ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_api_keys_key ON api_keys(key);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls BLOB;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE app_config_variables SET value = 'true' WHERE key = 'smtpTls';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
UPDATE app_config_variables
|
||||||
|
SET value = CASE
|
||||||
|
WHEN value = 'true' AND (SELECT value FROM app_config_variables WHERE key = 'smtpPort' LIMIT 1) = '587' THEN 'starttls'
|
||||||
|
WHEN value = 'true' THEN 'tls'
|
||||||
|
ELSE 'none'
|
||||||
|
END
|
||||||
|
WHERE key = 'smtpTls';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_api_keys_key;
|
||||||
|
DROP TABLE IF EXISTS api_keys;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
last_used_at DATETIME,
|
||||||
|
created_at DATETIME,
|
||||||
|
user_id TEXT REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_api_keys_key ON api_keys(key);
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
id: cloudflare-zero-trust
|
|
||||||
---
|
|
||||||
# Cloudflare Zero Trust
|
|
||||||
|
|
||||||
**Note: Cloudflare will need to be able to reach your Pocket ID instance and vice versa for this to work correctly**
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
|
|
||||||
1. In Pocket-ID create a new OIDC Client, name it i.e. `Cloudflare Zero Trust`.
|
|
||||||
2. Set a logo for this OIDC Client if you would like too.
|
|
||||||
3. Set the callback URL to: `https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback`.
|
|
||||||
4. Copy the Client ID, Client Secret, Authorization URL, Token URL, and Certificate URL for the next steps.
|
|
||||||
|
|
||||||
## Cloudflare Zero Trust Setup
|
|
||||||
|
|
||||||
1. Login to Cloudflare Zero Trust [Dashboard](https://one.dash.cloudflare.com/).
|
|
||||||
2. Navigate to Settings > Authentication > Login Methods.
|
|
||||||
3. Click `Add New` under login methods.
|
|
||||||
4. Create a name for the new login method.
|
|
||||||
5. Paste in the `Client ID` from Pocket ID into the `App ID` field.
|
|
||||||
6. Paste the `Client Secret` from Pocket ID into the `Client Secret` field.
|
|
||||||
7. Paste the `Authorization URL` from Pocket ID into the `Auth URL` field.
|
|
||||||
8. Paste the `Token URL` from Pocket ID into the `Token URL` field.
|
|
||||||
9. Paste the `Certificate URL` from Pocket ID into the `Certificate URL` field.
|
|
||||||
10. Save the new login method and test to make sure it works with cloudflare.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
id: freshrss
|
|
||||||
---
|
|
||||||
|
|
||||||
# FreshRSS
|
|
||||||
|
|
||||||
The following example variables are used, and should be replaced with your actual URLs.
|
|
||||||
|
|
||||||
- `freshrss.example.com` (The URL of your Proxmox instance.)
|
|
||||||
- `id.example.com` (The URL of your Pocket ID instance.)
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
|
|
||||||
1. In Pocket ID create a new OIDC Client, name it, for example, `FreshRSS`.
|
|
||||||
2. Set a logo for this OIDC Client if you would like to.
|
|
||||||
3. Set the callback URL to: `https://freshrss.example.com`.
|
|
||||||
4. Copy the `Client ID`, `Client Secret`, and `OIDC Discovery URL` for use in the next steps.
|
|
||||||
|
|
||||||
## FreshRSS Setup
|
|
||||||
|
|
||||||
See [FreshRSS’ OpenID Connect documentation](16_OpenID-Connect.md) for general OIDC settings.
|
|
||||||
|
|
||||||
This is an example docker-compose file for FreshRSS with OIDC enabled.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
freshrss:
|
|
||||||
image: freshrss/freshrss:1.25.0
|
|
||||||
container_name: freshrss
|
|
||||||
ports:
|
|
||||||
- 8080:80
|
|
||||||
volumes:
|
|
||||||
- /freshrss_data:/var/www/FreshRSS/data
|
|
||||||
- /freshrss_extensions:/var/www/FreshRSS/extensions
|
|
||||||
environment:
|
|
||||||
CRON_MIN: 1,31
|
|
||||||
TZ: Etc/UTC
|
|
||||||
OIDC_ENABLED: 1
|
|
||||||
OIDC_CLIENT_ID: <POCKET_ID_CLIENT_ID>
|
|
||||||
OIDC_CLIENT_SECRET: <POCKET_ID_SECRET>
|
|
||||||
OIDC_PROVIDER_METADATA_URL: https://id.example.com/.well-known/openid-configuration
|
|
||||||
OIDC_SCOPES: openid email profile
|
|
||||||
OIDC_X_FORWARDED_HEADERS: X-Forwarded-Proto X-Forwarded-Host
|
|
||||||
OIDC_REMOTE_USER_CLAIM: preferred_username
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- freshrss
|
|
||||||
networks:
|
|
||||||
freshrss:
|
|
||||||
name: freshrss
|
|
||||||
```
|
|
||||||
|
|
||||||
:::important
|
|
||||||
The Username used in Pocket ID must match the Username used in FreshRSS **exactly**. This also applies to case sensitivity. As of version `0.24` of Pocket ID all Usernames are required to be entirely lowercase. FreshRSS allows for uppercase. If a Pocket ID Username is `amanda` and your FreshRSS Username is `Amanda`, you will get a 403 error in FreshRSS and be unable to login. As of version `1.25` of FreshRSS, you are unable to change your username in the GUI. To change your FreshRSS username to lowercase or to match your Pocket ID username, you must nagivate to your FreshRSS volume location. Go to `data/users/` and change the folder for your user to the matching username in Pocket ID, then restart the FreshRSS container to apply the changes.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Complete OIDC Setup
|
|
||||||
|
|
||||||
If you are setting up a new instance of FreshRSS, simply start the container with the OIDC variables and navigate to your FreshRSS URL.
|
|
||||||
|
|
||||||
If you are adding OIDC to an existing FreshRSS instance, recreate the container with the docker-compose file with the OIDC variables in it and navigate to your FreshRSS URL. Go to `Settings > Authentication` and change the Authentication method to **HTTP** and hit Submit. Logout to test your OIDC connection.
|
|
||||||
|
|
||||||
If you have an error with Pocket ID or are unable to login to your FreshRSS account, you can revert to password login by editing your `config.php` file for FreshRSS. Find the value for `auth_type` and change from `http_auth` to `form`. Restart the FreshRSS container to revert to password login.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
id: gitea
|
|
||||||
---
|
|
||||||
|
|
||||||
# Gitea
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
|
|
||||||
1. In Pocket ID, create a new OIDC client named `Gitea` (or any name you prefer).
|
|
||||||
2. (Optional) Set a logo for the OIDC client.
|
|
||||||
3. Set the callback URL to: `https://<Gitea Host>/user/oauth2/PocketID/callback`
|
|
||||||
4. Copy the `Client ID`, `Client Secret`, and `OIDC Discovery URL` for the next steps.
|
|
||||||
|
|
||||||
## Gitea Setup
|
|
||||||
|
|
||||||
1. Log in to Gitea as an admin.
|
|
||||||
2. Go to **Site Administration → Identity & Access → Authentication Sources**.
|
|
||||||
3. Click **Add Authentication Source**.
|
|
||||||
4. Set **Authentication Type** to `OAuth2`.
|
|
||||||
5. Set **Authentication Name** to `PocketID`.
|
|
||||||
:::important
|
|
||||||
If you change this name, update the callback URL in Pocket ID to match.
|
|
||||||
:::
|
|
||||||
6. Set **OAuth2 Provider** to `OpenID Connect`.
|
|
||||||
7. Enter the `Client ID` into the **Client ID (Key)** field.
|
|
||||||
8. Enter the `Client Secret` into the **Client Secret** field.
|
|
||||||
9. Enter the `OIDC Discovery URL` into the **OpenID Connect Auto Discovery URL** field.
|
|
||||||
10. Enable **Skip local 2FA**.
|
|
||||||
11. Set **Additional Scopes** to `openid email profile`.
|
|
||||||
12. Save the settings and test the OAuth login.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
id: grist
|
|
||||||
---
|
|
||||||
|
|
||||||
# Grist
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
1. In Pocket-ID create a new OIDC Client, name it i.e. `Grist`
|
|
||||||
2. Set the callback url to: `https://<Grist Host>/oauth2/callback`
|
|
||||||
3. In Grist (Docker/Docker Compose/etc), set these environment variables:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
GRIST_OIDC_IDP_ISSUER="https://<Pocket ID Host>/.well-known/openid-configuration"
|
|
||||||
GRIST_OIDC_IDP_CLIENT_ID="<Client ID from the OIDC Client created in Pocket ID>"
|
|
||||||
GRIST_OIDC_IDP_CLIENT_SECRET="<Client Secret from the OIDC Client created in Pocket ID>"
|
|
||||||
GRIST_OIDC_SP_HOST="https://<Grist Host>"
|
|
||||||
GRIST_OIDC_IDP_SCOPES="openid email profile" # Default
|
|
||||||
GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true # Default=false, needs to be true for Pocket Id b/c end_session_endpoint is not implemented
|
|
||||||
GRIST_OIDC_IDP_END_SESSION_ENDPOINT="https://<Pocket ID Host>/api/webauthn/logout" # Only set this if GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=false and you need to define a custom endpoint
|
|
||||||
```
|
|
||||||
4. Also ensure that the `GRIST_DEFAULT_EMAIL` env variable is set to the same email address as your user profile within Pocket ID
|
|
||||||
5. Start/Restart Grist
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
id: headscale
|
|
||||||
---
|
|
||||||
# Headscale
|
|
||||||
|
|
||||||
## Create OIDC Client in Pocket ID
|
|
||||||
1. Create a new OIDC Client in Pocket ID (e.g., `Headscale`).
|
|
||||||
2. Set the callback URL: `https://<HEADSCALE-DOMAIN>/oidc/callback`
|
|
||||||
3. Enable `PKCE`.
|
|
||||||
4. Copy the **Client ID** and **Client Secret**.
|
|
||||||
|
|
||||||
## Configure Headscale
|
|
||||||
> Refer to the example [`config.yaml`](https://github.com/juanfont/headscale/blob/main/config-example.yaml) for full OIDC configuration options.
|
|
||||||
|
|
||||||
Add the following to `config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
oidc:
|
|
||||||
issuer: "https://<POCKET-ID-DOMAIN>"
|
|
||||||
client_id: "<CLIENT-ID>"
|
|
||||||
client_secret: "<CLIENT-SECRET>"
|
|
||||||
pkce:
|
|
||||||
enabled: true
|
|
||||||
method: S256
|
|
||||||
```
|
|
||||||
|
|
||||||
### (Optional) Restrict Access to Certain Groups
|
|
||||||
To allow only specific groups, add:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
scope: ["openid", "profile", "email", "groups"]
|
|
||||||
allowed_groups:
|
|
||||||
- <POCKET-ID-GROUP-NAME> #example: headscale
|
|
||||||
```
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
id: hoarder
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hoarder
|
|
||||||
|
|
||||||
1. In Pocket-ID create a new OIDC Client, name it i.e. `Hoarder`
|
|
||||||
2. Set the callback url to: `https://<your-hoarder-subdomain>.<your-domain>/api/auth/callback/custom`
|
|
||||||
3. Open your `.env` file from your Hoarder compose and add these lines:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
OAUTH_WELLKNOWN_URL = https://<your-pocket-id-subdomain>.<your-domain>/.well-known/openid-configuration
|
|
||||||
OAUTH_CLIENT_SECRET = <client secret from the created OIDC client>
|
|
||||||
OAUTH_CLIENT_ID = <client id from the created OIDC client>
|
|
||||||
OAUTH_PROVIDER_NAME = Pocket-Id
|
|
||||||
NEXTAUTH_URL = https:///<your-hoarder-subdomain>.<your-domain>
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Optional: If you like to disable password authentication and link your existing hoarder account with your pocket-id identity
|
|
||||||
|
|
||||||
```ini
|
|
||||||
DISABLE_PASSWORD_AUTH = true
|
|
||||||
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING = true
|
|
||||||
```
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
id: immich
|
|
||||||
---
|
|
||||||
# Immich
|
|
||||||
|
|
||||||
## Create OIDC Client in Pocket ID
|
|
||||||
1. Create a new OIDC Client in Pocket ID (e.g., `immich`).
|
|
||||||
2. Set the callback URLs:
|
|
||||||
```
|
|
||||||
https://<IMMICH-DOMAIN>/auth/login
|
|
||||||
https://<IMMICH-DOMAIN>/user-settings
|
|
||||||
app.immich:///oauth-callback
|
|
||||||
```
|
|
||||||
4. Copy the **Client ID**, **Client Secret**, and **OIDC Discovery URL**.
|
|
||||||
|
|
||||||
## Configure Immich
|
|
||||||
1. Open Immich and navigate to:
|
|
||||||
**`Administration > Settings > Authentication Settings > OAuth`**
|
|
||||||
2. Enable **Login with OAuth**.
|
|
||||||
3. Fill in the required fields:
|
|
||||||
- **Issuer URL**: Paste the `Authorization URL` from Pocket ID.
|
|
||||||
- **Client ID**: Paste the `Client ID` from Pocket ID.
|
|
||||||
- **Client Secret**: Paste the `Client Secret` from Pocket ID.
|
|
||||||
4. *(Optional)* Change `Button Text` to `Login with Pocket ID`.
|
|
||||||
5. Save the settings.
|
|
||||||
6. Test the OAuth login to ensure it works.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
---
|
|
||||||
id: jellyfin
|
|
||||||
---
|
|
||||||
|
|
||||||
# Jellyfin
|
|
||||||
|
|
||||||
> Due to the current limitations of the Jellyfin SSO plugin, this integration will only work in a browser. When tested, the Jellyfin app did not work and displayed an error, even when custom menu buttons were created.
|
|
||||||
|
|
||||||
> To view the original references and a full list of capabilities, please visit the [Jellyfin SSO OpenID Section](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#openid).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- [Jellyfin Server](https://jellyfin.org/downloads/server)
|
|
||||||
- [Jellyfin SSO Plugin](https://github.com/9p4/jellyfin-plugin-sso)
|
|
||||||
- HTTPS connection to your Jellyfin server
|
|
||||||
|
|
||||||
## OIDC - Pocket ID Setup
|
|
||||||
|
|
||||||
To start, we need to create a new SSO resource in our Jellyfin application.
|
|
||||||
|
|
||||||
> Replace the `JELLYFINDOMAIN` and `PROVIDER` elements in the URL.
|
|
||||||
|
|
||||||
1. Log into the admin panel, and go to OIDC Clients -> Add OIDC Client.
|
|
||||||
2. **Name**: Jellyfin (or any name you prefer)
|
|
||||||
3. **Callback URL**: `https://JELLYFINDOMAIN.com/sso/OID/redirect/PROVIDER`
|
|
||||||
4. For this example, we’ll be using the provider named "test_resource."
|
|
||||||
5. Click **Save**. Keep the page open, as we will need the OID client ID and OID secret.
|
|
||||||
|
|
||||||
## OIDC Client - Jellyfin SSO Resource
|
|
||||||
|
|
||||||
1. Visit the plugin page (<i>Administration Dashboard -> My Plugins -> SSO-Auth</i>).
|
|
||||||
2. Enter the <i>OID Provider Name (we used "test_resource" as our name in the callback URL), Open ID, OID Secret, and mark it as enabled.</i>
|
|
||||||
3. The following steps are optional based on your needs. In this guide, we’ll be managing only regular users, not admins.
|
|
||||||
|
|
||||||

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

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

|
|
||||||
|
|
||||||
7. Skip the remaining fields until you reach **Scheme Override**. Enter `https` here. If omitted, it will attempt to use HTTP first, which will break as WebAuthn requires an HTTPS connection.
|
|
||||||
8. Click **Save** and restart Jellyfin.
|
|
||||||
|
|
||||||
## Optional Step - Custom Home Button
|
|
||||||
|
|
||||||
Follow the [guide to create a login button on the login page](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#creating-a-login-button-on-the-main-page) to add a custom button on your sign-in page. This step is optional, as you could also provide the sign-in URL via a bookmark or other means.
|
|
||||||
|
|
||||||
## Signing into Your Jellyfin Instance
|
|
||||||
|
|
||||||
Done! You have successfully set up SSO for your Jellyfin instance using Pocket ID.
|
|
||||||
|
|
||||||
> **Note:** Sometimes there may be a brief delay when using the custom menu option. This is related to the Jellyfin plugin and not Pocket ID.
|
|
||||||
|
|
||||||
If your users already have accounts, as long as their Pocket ID username matches their Jellyfin ID, they will be logged in automatically. Otherwise, a new user will be created with access to all of your folders. Of course, you can modify this in your configuration as desired.
|
|
||||||
|
|
||||||
This setup will only work if sign-in is performed using the `https://jellyfin.example.com/sso/OID/start/PROVIDER` URL. This URL initiates the SSO plugin and applies all the configurations we completed above.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
id: memos
|
|
||||||
---
|
|
||||||
|
|
||||||
# Memos
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
|
|
||||||
1. In Pocket ID, create a new OIDC client named `Memos` (or any name you prefer).
|
|
||||||
2. (Optional) Set a logo for the OIDC client.
|
|
||||||
3. Set the callback URL to: `https://< Memos Host >/auth/callback`
|
|
||||||
4. Copy the `Client ID`, `Client Secret`, `Authorization endpoint`, `Token endpoint`, and `User endpoint` for the next steps.
|
|
||||||
|
|
||||||
## Gitea Setup
|
|
||||||
|
|
||||||
1. Log in to Memos as an admin.
|
|
||||||
2. Go to **Settings → SSO → Create**.
|
|
||||||
3. Set **Template** to `Custom`.
|
|
||||||
4. Enter the `Client ID` into the **Client ID** field.
|
|
||||||
5. Enter the `Client Secret` into the **Client secret** field.
|
|
||||||
6. Enter the `Authorization URL` into the **Authorization endpoint** field.
|
|
||||||
7. Enter the `Token URL` into the **Token endpoint** field.
|
|
||||||
8. Enter the `Userinfo URL` into the **User endpoint** field.
|
|
||||||
11. Set **Scopes** to `openid email profile`.
|
|
||||||
12. Set **Identifier** to `preferred_username`
|
|
||||||
13. Set **Display Name** to `profile`.
|
|
||||||
14. Set **Email** to `email`.
|
|
||||||
15. Save the settings and test the OAuth login.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
id: netbox
|
|
||||||
---
|
|
||||||
|
|
||||||
# Netbox
|
|
||||||
|
|
||||||
**This guide does not currently show how to map groups in netbox from OIDC claims**
|
|
||||||
|
|
||||||
The following example variables are used, and should be replaced with your actual URLS.
|
|
||||||
|
|
||||||
- netbox.example.com (The url of your netbox instance.)
|
|
||||||
- id.example.com (The url of your Pocket ID instance.)
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
|
|
||||||
1. In Pocket-ID create a new OIDC Client, name it i.e. `Netbox`.
|
|
||||||
2. Set a logo for this OIDC Client if you would like too.
|
|
||||||
3. Set the callback URL to: `https://netbox.example.com/oauth/complete/oidc/`.
|
|
||||||
4. Copy the `Client ID`, and the `Client Secret` for use in the next steps.
|
|
||||||
|
|
||||||
## Netbox Setup
|
|
||||||
|
|
||||||
This guide assumes you are using the git based install of netbox.
|
|
||||||
|
|
||||||
1. On your netbox server navigate to `/opt/netbox/netbox/netbox`
|
|
||||||
2. Add the following to your `configuration.py` file:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Remote authentication support
|
|
||||||
REMOTE_AUTH_ENABLED = True
|
|
||||||
REMOTE_AUTH_BACKEND = 'social_core.backends.open_id_connect.OpenIdConnectAuth'
|
|
||||||
REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER'
|
|
||||||
REMOTE_AUTH_USER_FIRST_NAME = 'HTTP_REMOTE_USER_FIRST_NAME'
|
|
||||||
REMOTE_AUTH_USER_LAST_NAME = 'HTTP_REMOTE_USER_LAST_NAME'
|
|
||||||
REMOTE_AUTH_USER_EMAIL = 'HTTP_REMOTE_USER_EMAIL'
|
|
||||||
REMOTE_AUTH_AUTO_CREATE_USER = True
|
|
||||||
REMOTE_AUTH_DEFAULT_GROUPS = []
|
|
||||||
REMOTE_AUTH_DEFAULT_PERMISSIONS = {}
|
|
||||||
|
|
||||||
SOCIAL_AUTH_OIDC_ENDPOINT = 'https://id.example.com'
|
|
||||||
SOCIAL_AUTH_OIDC_KEY = '<client id from the first part of this guide>'
|
|
||||||
SOCIAL_AUTH_OIDC_SECRET = '<client id from the first part of this guide>'
|
|
||||||
LOGOUT_REDIRECT_URL = 'https://netbox.example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Save the file and restart netbox: `sudo systemctl start netbox netbox-rq`
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
id: open-webui
|
|
||||||
---
|
|
||||||
|
|
||||||
# Open WebUI
|
|
||||||
|
|
||||||
1. In Pocket-ID, create a new OIDC Client, name it i.e. `Open WebUI`.
|
|
||||||
2. Set the callback URL to: `https://openwebui.domain/oauth/oidc/callback`
|
|
||||||
3. Add the following to your docker `.env` file for Open WebUI:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
ENABLE_OAUTH_SIGNUP=true
|
|
||||||
OAUTH_CLIENT_ID=<client id from pocket ID>
|
|
||||||
OAUTH_CLIENT_SECRET=<client secret from pocket ID>
|
|
||||||
OAUTH_PROVIDER_NAME=Pocket ID
|
|
||||||
OPENID_PROVIDER_URL=https://<your pocket id url>/.well-known/openid-configuration
|
|
||||||
```
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
id: pgadmin
|
|
||||||
---
|
|
||||||
|
|
||||||
# pgAdmin
|
|
||||||
|
|
||||||
The following example variables are used, and should be replaced with your actual URLS.
|
|
||||||
|
|
||||||
- pgadmin.example.com (The url of your pgAdmin instance.)
|
|
||||||
- id.example.com (The url of your Pocket ID instance.)
|
|
||||||
|
|
||||||
## Pocket ID Setup
|
|
||||||
|
|
||||||
1. In Pocket-ID create a new OIDC Client, name it i.e. `pgAdmin`.
|
|
||||||
2. Set a logo for this OIDC Client if you would like too.
|
|
||||||
3. Set the callback URL to: `https://pgadmin.example.com/oauth2/authorize`.
|
|
||||||
4. Copy the `Client ID`, `Client Secret`, `Authorization URL`, `Userinfo URL`, `Token URL`, and `OIDC Discovery URL` for use in the next steps.
|
|
||||||
|
|
||||||
# pgAdmin Setup
|
|
||||||
|
|
||||||
1. Add the following to the `config_local.py` file for pgAdmin:
|
|
||||||
|
|
||||||
**Make sure to replace https://id.example.com with your actual Pocket ID URL**
|
|
||||||
|
|
||||||
```python
|
|
||||||
AUTHENTICATION_SOURCES = ['oauth2', 'internal'] # This keeps internal authentication enabled as well as oauth2
|
|
||||||
OAUTH2_AUTO_CREATE_USER = True
|
|
||||||
OAUTH2_CONFIG = [{
|
|
||||||
'OAUTH2_NAME' : 'pocketid',
|
|
||||||
'OAUTH2_DISPLAY_NAME' : 'Pocket ID',
|
|
||||||
'OAUTH2_CLIENT_ID' : '<client id from the earlier step>',
|
|
||||||
'OAUTH2_CLIENT_SECRET' : '<client secret from the earlier step>',
|
|
||||||
'OAUTH2_TOKEN_URL' : 'https://id.example.com/api/oidc/token',
|
|
||||||
'OAUTH2_AUTHORIZATION_URL' : 'https://id.example/authorize',
|
|
||||||
'OAUTH2_API_BASE_URL' : 'https://id.example.com',
|
|
||||||
'OAUTH2_USERINFO_ENDPOINT' : 'https://id.example.com/api/oidc/userinfo',
|
|
||||||
'OAUTH2_SERVER_METADATA_URL' : 'https://id.example.com/.well-known/openid-configuration',
|
|
||||||
'OAUTH2_SCOPE' : 'openid email profile',
|
|
||||||
'OAUTH2_ICON' : 'fa-openid',
|
|
||||||
'OAUTH2_BUTTON_COLOR' : '#fd4b2d' # Can select any color you would like here.
|
|
||||||
}]
|
|
||||||
```
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user