mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-06 13:22:57 +03:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e486dbd771 | ||
|
|
f7e36a422e | ||
|
|
f74c7bf95d | ||
|
|
a7c9741802 | ||
|
|
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 | ||
|
|
974b7b3c34 | ||
|
|
15cde6ac66 | ||
|
|
e864d5dcbf | ||
|
|
c6ab2b252c | ||
|
|
7350e3486d | ||
|
|
96303ded2b | ||
|
|
d06257ec9b | ||
|
|
19ef4833e9 | ||
|
|
e2c38138be | ||
|
|
13b02a072f | ||
|
|
430421e98b | ||
|
|
61e71ad43b | ||
|
|
4db44e4818 | ||
|
|
9ab178712a | ||
|
|
ecd74b794f | ||
|
|
5afd651434 | ||
|
|
2d3cba6308 | ||
|
|
e607fe424a | ||
|
|
8ae446322a | ||
|
|
37a835b44e | ||
|
|
75f531fbc6 | ||
|
|
28346da731 | ||
|
|
a1b20f0e74 | ||
|
|
7497f4ad40 | ||
|
|
b530d646ac | ||
|
|
77985800ae |
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"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# See the README for more information: https://github.com/stonith404/pocket-id?tab=readme-ov-file#environment-variables
|
||||
# See the README for more information: https://github.com/pocket-id/pocket-id?tab=readme-ov-file#environment-variables
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
TRUST_PROXY=false
|
||||
MAXMIND_LICENSE_KEY=
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -49,7 +49,7 @@ body:
|
||||
required: false
|
||||
attributes:
|
||||
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
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.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:
|
||||
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:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
@@ -17,7 +20,6 @@ jobs:
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
@@ -28,11 +30,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
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'
|
||||
uses: docker/login-action@v3
|
||||
|
||||
15
.github/workflows/e2e-tests.yml
vendored
15
.github/workflows/e2e-tests.yml
vendored
@@ -2,8 +2,17 @@ name: E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 20
|
||||
@@ -15,7 +24,7 @@ jobs:
|
||||
- name: Build and export
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: stonith404/pocket-id:test
|
||||
tags: pocket-id/pocket-id:test
|
||||
outputs: type=docker,dest=/tmp/docker-image.tar
|
||||
|
||||
- name: Upload Docker image artifact
|
||||
@@ -56,7 +65,7 @@ jobs:
|
||||
docker run -d --name pocket-id-sqlite \
|
||||
-p 80:80 \
|
||||
-e APP_ENV=test \
|
||||
stonith404/pocket-id:test
|
||||
pocket-id/pocket-id:test
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
@@ -129,7 +138,7 @@ jobs:
|
||||
-e APP_ENV=test \
|
||||
-e DB_PROVIDER=postgres \
|
||||
-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
|
||||
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
|
||||
/backend/GeoLite2-City.mmdb
|
||||
|
||||
# Generated files
|
||||
docs/build
|
||||
docs/.docusaurus
|
||||
docs/.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
@@ -53,3 +48,6 @@ docs/.cache-loader
|
||||
npm-debug.log*
|
||||
yarn-debug.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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
244
CHANGELOG.md
244
CHANGELOG.md
@@ -1,3 +1,247 @@
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.0...v) (2025-03-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* kid not added to JWTs ([f7e36a4](https://github.com/pocket-id/pocket-id/commit/f7e36a422ea6b5327360c9a13308ae408ff7fffe))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.41.0...v) (2025-03-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* store keys as JWK on disk ([#339](https://github.com/pocket-id/pocket-id/issues/339)) ([a7c9741](https://github.com/pocket-id/pocket-id/commit/a7c9741802667811c530ef4e6313b71615ec6a9b))
|
||||
|
||||
## [](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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add JSON support in custom claims ([15cde6a](https://github.com/pocket-id/pocket-id/commit/15cde6ac66bc857ac28df545a37c1f4341977595))
|
||||
* add option to disable Caddy in the Docker container ([e864d5d](https://github.com/pocket-id/pocket-id/commit/e864d5dcbff1ef28dc6bf120e4503093a308c5c8))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.28.0...v) (2025-02-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't return error page if version info fetching failed ([d06257e](https://github.com/stonith404/pocket-id/commit/d06257ec9b5e46e25e40c174b4bef02dca0a1ea3))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.27.2...v) (2025-02-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow LDAP users and groups to be deleted if LDAP gets disabled ([9ab1787](https://github.com/stonith404/pocket-id/commit/9ab178712aa3cc71546a89226e67b7ba91245251))
|
||||
* map allowed groups to OIDC clients ([#202](https://github.com/stonith404/pocket-id/issues/202)) ([13b02a0](https://github.com/stonith404/pocket-id/commit/13b02a072f20ce10e12fd8b897cbf42a908f3291))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **caddy:** trusted_proxies for IPv6 enabled hosts ([#189](https://github.com/stonith404/pocket-id/issues/189)) ([37a835b](https://github.com/stonith404/pocket-id/commit/37a835b44e308622f6862de494738dd2bfb58ef0))
|
||||
* missing user service dependency ([61e71ad](https://github.com/stonith404/pocket-id/commit/61e71ad43b8f0f498133d3eb2381382e7bc642b9))
|
||||
* non LDAP user group can't be updated after update ([ecd74b7](https://github.com/stonith404/pocket-id/commit/ecd74b794f1ffb7da05bce0046fb8d096b039409))
|
||||
* use cursor pointer on clickable elements ([7798580](https://github.com/stonith404/pocket-id/commit/77985800ae9628104e03e7f2e803b7ed9eaaf4e0))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.27.1...v) (2025-01-27)
|
||||
|
||||
|
||||
|
||||
@@ -31,8 +31,15 @@ Before you submit the pull request for review please ensure that
|
||||
- You run `npm run format` to format the code
|
||||
|
||||
## 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
|
||||
|
||||
@@ -63,6 +70,10 @@ Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
||||
|
||||
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
|
||||
|
||||
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 ./scripts ./scripts
|
||||
RUN chmod +x ./scripts/*.sh
|
||||
RUN chmod +x ./scripts/**/*.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENV APP_ENV=production
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||
|
||||
→ Try out the [Demo](https://pocket-id.eliasschneider.com)
|
||||
→ Try out the [Demo](https://demo.pocket-id.org)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
|
||||
|
||||
@@ -14,7 +14,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
|
||||
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.
|
||||
|
||||
Visit the [documentation](https://stonith404.github.io/pocket-id) for the setup guide and more information.
|
||||
Visit the [documentation](https://docs.pocket-id.org) for the setup guide and more information.
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ SQLITE_DB_PATH=data/pocket-id.db
|
||||
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
||||
UPLOAD_PATH=data/uploads
|
||||
PORT=8080
|
||||
HOST=localhost
|
||||
HOST=0.0.0.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
module github.com/stonith404/pocket-id/backend
|
||||
module github.com/pocket-id/pocket-id/backend
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
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/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.15.0
|
||||
@@ -14,9 +18,12 @@ require (
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||
golang.org/x/crypto v0.32.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/time v0.9.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
@@ -28,6 +35,9 @@ require (
|
||||
github.com/bytedance/sonic v1.12.8 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/disintegration/gift v1.1.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
@@ -49,6 +59,10 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
@@ -56,17 +70,19 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.4 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -20,8 +20,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||
@@ -30,6 +38,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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
@@ -127,6 +139,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 h1:HHT8iW+UcPBgBr5A3soZQQsL5cBor/u6BkLB+wzY/R0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -166,12 +188,15 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -207,10 +232,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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
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.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
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/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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -227,16 +255,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.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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
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-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.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.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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -249,8 +278,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.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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -268,8 +297,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.14.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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/resources"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||
|
||||
@@ -2,7 +2,7 @@ package bootstrap
|
||||
|
||||
import (
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func Bootstrap() {
|
||||
@@ -11,5 +11,7 @@ func Bootstrap() {
|
||||
db := newDatabase()
|
||||
appConfigService := service.NewAppConfigService(db)
|
||||
|
||||
migrateKey()
|
||||
|
||||
initRouter(db, appConfigService)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,21 @@ package bootstrap
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/resources"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newDatabase() (db *gorm.DB) {
|
||||
|
||||
133
backend/internal/bootstrap/jwk_migration.go
Normal file
133
backend/internal/bootstrap/jwk_migration.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyFilePem = "jwt_private_key.pem"
|
||||
)
|
||||
|
||||
func migrateKey() {
|
||||
err := migrateKeyInternal(common.EnvConfig.KeysPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to perform migration of keys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateKeyInternal(basePath string) error {
|
||||
// First, check if there's already a JWK stored
|
||||
jwkPath := filepath.Join(basePath, service.PrivateKeyFile)
|
||||
ok, err := utils.FileExists(jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
if ok {
|
||||
// There's already a key as JWK, so we don't do anything else here
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's a PEM file
|
||||
pemPath := filepath.Join(basePath, privateKeyFilePem)
|
||||
ok, err = utils.FileExists(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (PEM) exists at path '%s': %w", pemPath, err)
|
||||
}
|
||||
if !ok {
|
||||
// No file to migrate, return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load and validate the key
|
||||
key, err := loadKeyPEM(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load private key file (PEM) at path '%s': %w", pemPath, err)
|
||||
}
|
||||
err = service.ValidateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key object is invalid: %w", err)
|
||||
}
|
||||
|
||||
// Save the key as JWK
|
||||
err = service.SaveKeyJWK(key, jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
// Finally, delete the PEM file
|
||||
err = os.Remove(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove migrated key at path '%s': %w", pemPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadKeyPEM(path string) (jwk.Key, error) {
|
||||
// Load the key from disk and parse it
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key data: %w", err)
|
||||
}
|
||||
|
||||
key, err := jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
// Populate the key ID using the "legacy" algorithm
|
||||
keyId, err := generateKeyID(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
||||
}
|
||||
key.Set(jwk.KeyIDKey, keyId)
|
||||
|
||||
// Populate other required fields
|
||||
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)
|
||||
service.EnsureAlgInKey(key)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// generateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key's PKIX-serialized structure.
|
||||
// This is used for legacy keys, imported from PEM.
|
||||
func generateKeyID(key jwk.Key) (string, error) {
|
||||
// Export the public key and serialize it to PKIX (not in a PEM block)
|
||||
// This is for backwards-compatibility with the algorithm used before the switch to JWK
|
||||
pubKey, err := key.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
var pubKeyRaw any
|
||||
err = jwk.Export(pubKey, &pubKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to export public key: %w", err)
|
||||
}
|
||||
pubASN1, err := x509.MarshalPKIXPublicKey(pubKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the public key
|
||||
hash := sha256.New()
|
||||
hash.Write(pubASN1)
|
||||
hashed := hash.Sum(nil)
|
||||
|
||||
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||
shortHash := hashed[:8]
|
||||
|
||||
// Return Base64 encoded truncated hash as Key ID
|
||||
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||
}
|
||||
190
backend/internal/bootstrap/jwk_migration_test.go
Normal file
190
backend/internal/bootstrap/jwk_migration_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestMigrateKey(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("no keys exist", func(t *testing.T) {
|
||||
// Test when no keys exist
|
||||
err := migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("jwk already exists", func(t *testing.T) {
|
||||
// Create a JWK file
|
||||
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
|
||||
key, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
err = service.SaveKeyJWK(key, jwkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run migration - should do nothing
|
||||
err = migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check the file still exists
|
||||
exists, err := utils.FileExists(jwkPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Delete for next test
|
||||
err = os.Remove(jwkPath)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("migrate pem to jwk", func(t *testing.T) {
|
||||
// Create a PEM file
|
||||
pemPath := filepath.Join(tempDir, privateKeyFilePem)
|
||||
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
|
||||
|
||||
// Generate RSA key and save as PEM
|
||||
createRSAPrivateKeyPEM(t, pemPath)
|
||||
|
||||
// Run migration
|
||||
err := migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check PEM file is gone
|
||||
exists, err := utils.FileExists(pemPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
|
||||
// Check JWK file exists
|
||||
exists, err = utils.FileExists(jwkPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify the JWK can be loaded
|
||||
data, err := os.ReadFile(jwkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = jwk.ParseKey(data)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadKeyPEM(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("successfully load PEM key", func(t *testing.T) {
|
||||
pemPath := filepath.Join(tempDir, "test_key.pem")
|
||||
|
||||
// Generate RSA key and save as PEM
|
||||
createRSAPrivateKeyPEM(t, pemPath)
|
||||
|
||||
// Load the key
|
||||
key, err := loadKeyPEM(pemPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key properties
|
||||
assert.NotEmpty(t, key)
|
||||
|
||||
// Check key ID is set
|
||||
var keyID string
|
||||
err = key.Get(jwk.KeyIDKey, &keyID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
// Check algorithm is set
|
||||
var alg jwa.SignatureAlgorithm
|
||||
err = key.Get(jwk.AlgorithmKey, &alg)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, alg)
|
||||
|
||||
// Check key usage is set
|
||||
var keyUsage string
|
||||
err = key.Get(jwk.KeyUsageKey, &keyUsage)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, service.KeyUsageSigning, keyUsage)
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
})
|
||||
|
||||
t.Run("invalid file content", func(t *testing.T) {
|
||||
invalidPath := filepath.Join(tempDir, "invalid.pem")
|
||||
err := os.WriteFile(invalidPath, []byte("not a valid PEM"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := loadKeyPEM(invalidPath)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateKeyID(t *testing.T) {
|
||||
key, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID, err := generateKeyID(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Key ID should be non-empty
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
// Generate another key ID to prove it depends on the key
|
||||
key2, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID2, err := generateKeyID(key2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The two key IDs should be different
|
||||
assert.NotEqual(t, keyID, keyID2)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestRSAKey() (jwk.Key, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := jwk.Import(privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// createRSAPrivateKeyPEM generates an RSA private key and returns its PEM-encoded form
|
||||
func createRSAPrivateKeyPEM(t *testing.T, pemPath string) ([]byte, *rsa.PrivateKey) {
|
||||
// Generate RSA key
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encode to PEM format
|
||||
pemData := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
|
||||
err = os.WriteFile(pemPath, pemData, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return pemData, privKey
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/controller"
|
||||
"github.com/stonith404/pocket-id/backend/internal/job"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||
"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/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -38,12 +40,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
||||
customClaimService := service.NewCustomClaimService(db)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
userGroupService := service.NewUserGroupService(db)
|
||||
testService := service.NewTestService(db, appConfigService, jwtService)
|
||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
||||
apiKeyService := service.NewApiKeyService(db)
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
||||
|
||||
@@ -51,24 +54,24 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
|
||||
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
|
||||
|
||||
job.RegisterLdapJobs(ldapService, appConfigService)
|
||||
job.RegisterDbCleanupJobs(db)
|
||||
|
||||
// Initialize middleware for specific routes
|
||||
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
|
||||
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
|
||||
// Set up API routes
|
||||
apiGroup := r.Group("/api")
|
||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||
controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService)
|
||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
@@ -79,8 +82,20 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
baseGroup := r.Group("/")
|
||||
controller.NewWellKnownController(baseGroup, jwtService)
|
||||
|
||||
// Run the server
|
||||
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
|
||||
// Get the listener
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
@@ -10,8 +11,9 @@ import (
|
||||
type DbProvider string
|
||||
|
||||
const (
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
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 {
|
||||
@@ -21,10 +23,13 @@ type EnvConfigSchema struct {
|
||||
SqliteDBPath string `env:"SQLITE_DB_PATH"`
|
||||
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
KeysPath string `env:"KEYS_PATH"`
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||
}
|
||||
|
||||
var EnvConfig = &EnvConfigSchema{
|
||||
@@ -33,27 +38,40 @@ var EnvConfig = &EnvConfigSchema{
|
||||
SqliteDBPath: "data/pocket-id.db",
|
||||
PostgresConnectionString: "",
|
||||
UploadPath: "data/uploads",
|
||||
KeysPath: "data/keys",
|
||||
AppURL: "http://localhost",
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
Host: "0.0.0.0",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
UiConfigDisabled: false,
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 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'")
|
||||
}
|
||||
|
||||
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
|
||||
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
|
||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||
if err != nil {
|
||||
log.Fatal("PUBLIC_APP_URL is not a valid URL")
|
||||
}
|
||||
|
||||
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
|
||||
log.Fatal("Missing SQLITE_DB_PATH environment variable")
|
||||
if parsedAppUrl.Path != "" {
|
||||
log.Fatal("PUBLIC_APP_URL must not contain a path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ type TokenInvalidOrExpiredError struct{}
|
||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
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{}
|
||||
|
||||
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) 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{}
|
||||
|
||||
func (e *MissingPermissionError) Error() string {
|
||||
@@ -176,3 +188,70 @@ func (e *LdapUserGroupUpdateError) Error() string {
|
||||
return "LDAP user groups can't be updated"
|
||||
}
|
||||
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcAccessDeniedError struct{}
|
||||
|
||||
func (e *OidcAccessDeniedError) Error() string {
|
||||
return "You're not allowed to access this service"
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -2,18 +2,23 @@ package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"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(
|
||||
group *gin.RouterGroup,
|
||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||
authMiddleware *middleware.AuthMiddleware,
|
||||
appConfigService *service.AppConfigService,
|
||||
emailService *service.EmailService,
|
||||
ldapService *service.LdapService,
|
||||
@@ -25,18 +30,18 @@ func NewAppConfigController(
|
||||
ldapService: ldapService,
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
||||
group.PUT("/application-configuration", acc.updateAppConfigHandler)
|
||||
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||
|
||||
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
||||
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
||||
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
||||
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
||||
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
|
||||
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
|
||||
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/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
|
||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
@@ -45,6 +50,15 @@ type AppConfigController struct {
|
||||
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) {
|
||||
configuration, err := acc.appConfigService.ListAppConfig(false)
|
||||
if err != nil {
|
||||
@@ -61,6 +75,15 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
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) {
|
||||
configuration, err := acc.appConfigService.ListAppConfig(true)
|
||||
if err != nil {
|
||||
@@ -77,6 +100,16 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -99,6 +132,16 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
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) {
|
||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||
|
||||
@@ -116,15 +159,42 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||
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) {
|
||||
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) {
|
||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||
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) {
|
||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||
|
||||
@@ -142,6 +212,15 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
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) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -157,11 +236,21 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||
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) {
|
||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
||||
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) {
|
||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
||||
mimeType := utils.GetImageMimeType(imageType)
|
||||
@@ -170,6 +259,7 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
// updateImage is a helper function to update image files
|
||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -186,6 +276,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
||||
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) {
|
||||
err := acc.ldapService.SyncAll()
|
||||
if err != nil {
|
||||
@@ -195,6 +292,14 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
|
||||
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) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
|
||||
"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/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/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{
|
||||
auditLogService: auditLogService,
|
||||
}
|
||||
|
||||
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
|
||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||
}
|
||||
|
||||
type AuditLogController struct {
|
||||
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) {
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
@@ -52,8 +67,8 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": logsDtos,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
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}
|
||||
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
|
||||
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
|
||||
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
|
||||
|
||||
customClaimsGroup := group.Group("/custom-claims")
|
||||
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 {
|
||||
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) {
|
||||
claims, err := ccc.customClaimService.GetSuggestions()
|
||||
if err != nil {
|
||||
@@ -29,6 +50,16 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
@@ -53,6 +84,17 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
||||
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) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
@@ -61,8 +103,8 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.C
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
|
||||
userGroupId := c.Param("userGroupId")
|
||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
|
||||
@@ -1,34 +1,50 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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/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"
|
||||
"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}
|
||||
|
||||
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
|
||||
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
|
||||
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
|
||||
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
|
||||
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
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.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
|
||||
group.GET("/oidc/clients/:id", oc.getClientHandler)
|
||||
group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
|
||||
group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
|
||||
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
|
||||
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
|
||||
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
|
||||
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
|
||||
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
|
||||
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
|
||||
|
||||
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
|
||||
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
|
||||
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
|
||||
|
||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||
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 {
|
||||
@@ -36,6 +52,16 @@ type OidcController struct {
|
||||
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) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -57,27 +83,45 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
// 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) {
|
||||
var input dto.AuthorizationRequiredDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.AuthorizeOidcClientResponseDto{
|
||||
Code: code,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
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) {
|
||||
// Disable cors for this endpoint
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@@ -106,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"})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
@@ -124,6 +184,118 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||
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) {
|
||||
clientId := c.Param("id")
|
||||
client, err := oc.oidcService.GetClient(clientId)
|
||||
@@ -132,26 +304,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return a different DTO based on the user's role
|
||||
if c.GetBool("userIsAdmin") {
|
||||
clientDto := dto.OidcClientDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
clientDto := dto.PublicOidcClientDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
searchTerm := c.Query("search")
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
@@ -172,12 +346,22 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": clientsDto,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
|
||||
Data: clientsDto,
|
||||
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) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -191,7 +375,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
@@ -200,6 +384,14 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
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) {
|
||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -210,6 +402,17 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.OidcClientCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -223,7 +426,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
@@ -232,6 +435,15 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
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) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -242,6 +454,16 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
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) {
|
||||
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -253,6 +475,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
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) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -269,6 +501,14 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
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) {
|
||||
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -278,3 +518,36 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
|
||||
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) {
|
||||
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var oidcClientDto dto.OidcClientDto
|
||||
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, oidcClientDto)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||
@@ -37,5 +38,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tc.TestService.SetJWTKeys()
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/cookie"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"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{
|
||||
userService: userService,
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
|
||||
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
|
||||
group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler)
|
||||
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
|
||||
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
|
||||
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
|
||||
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
|
||||
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
|
||||
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
|
||||
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler)
|
||||
group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler)
|
||||
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
|
||||
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
|
||||
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
|
||||
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/setup", uc.getSetupAccessTokenHandler)
|
||||
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 {
|
||||
@@ -40,6 +57,41 @@ type UserController struct {
|
||||
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) {
|
||||
searchTerm := c.Query("search")
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
@@ -60,12 +112,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": usersDto,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
|
||||
Data: usersDto,
|
||||
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) {
|
||||
user, err := uc.userService.GetUser(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -82,6 +141,12 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||
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) {
|
||||
user, err := uc.userService.GetUser(c.GetString("userID"))
|
||||
if err != nil {
|
||||
@@ -98,6 +163,13 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||
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) {
|
||||
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
|
||||
c.Error(err)
|
||||
@@ -107,6 +179,13 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -129,10 +208,25 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
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) {
|
||||
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) {
|
||||
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
||||
c.Error(&common.AccountEditNotAllowedError{})
|
||||
@@ -141,13 +235,101 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
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
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
}
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
@@ -157,6 +339,22 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.OneTimeAccessEmailDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -173,6 +371,13 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
||||
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) {
|
||||
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
@@ -193,6 +398,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
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) {
|
||||
user, token, err := uc.userService.SetupInitialAdmin()
|
||||
if err != nil {
|
||||
@@ -213,6 +424,37 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -241,3 +483,40 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
"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{
|
||||
UserGroupService: userGroupService,
|
||||
}
|
||||
|
||||
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
|
||||
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
|
||||
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
|
||||
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
|
||||
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
|
||||
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
|
||||
userGroupsGroup := group.Group("/user-groups")
|
||||
userGroupsGroup.Use(authMiddleware.Add())
|
||||
{
|
||||
userGroupsGroup.GET("", ugc.list)
|
||||
userGroupsGroup.GET("/:id", ugc.get)
|
||||
userGroupsGroup.POST("", ugc.create)
|
||||
userGroupsGroup.PUT("/:id", ugc.update)
|
||||
userGroupsGroup.DELETE("/:id", ugc.delete)
|
||||
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
|
||||
}
|
||||
}
|
||||
|
||||
type UserGroupController struct {
|
||||
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) {
|
||||
searchTerm := c.Query("search")
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
@@ -40,7 +60,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
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))
|
||||
for i, group := range groups {
|
||||
var groupDto dto.UserGroupDtoWithUserCount
|
||||
@@ -56,12 +76,22 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
groupsDto[i] = groupDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": groupsDto,
|
||||
"pagination": pagination,
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
|
||||
Data: groupsDto,
|
||||
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) {
|
||||
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -78,6 +108,16 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -100,6 +140,17 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -122,6 +173,16 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
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) {
|
||||
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||
c.Error(err)
|
||||
@@ -131,6 +192,17 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
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) {
|
||||
var input dto.UserGroupUpdateUsersDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -138,7 +210,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/cookie"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"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}
|
||||
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
|
||||
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
|
||||
group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
|
||||
group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler)
|
||||
|
||||
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/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.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler)
|
||||
group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler)
|
||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||
}
|
||||
|
||||
type WebauthnController struct {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"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) {
|
||||
wkc := &WellKnownController{jwtService: jwtService}
|
||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||
@@ -17,16 +22,29 @@ type WellKnownController struct {
|
||||
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) {
|
||||
jwk, err := wkc.jwtService.GetJWK()
|
||||
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
appUrl := common.EnvConfig.AppURL
|
||||
config := map[string]interface{}{
|
||||
@@ -34,9 +52,10 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": appUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"scopes_supported": []string{"openid", "profile", "email"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"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"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
SmtpTls string `json:"smtpTls"`
|
||||
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
||||
LdapUrl string `json:"ldapUrl"`
|
||||
LdapBindDn string `json:"ldapBindDn"`
|
||||
LdapBindPassword string `json:"ldapBindPassword"`
|
||||
LdapBase string `json:"ldapBase"`
|
||||
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
||||
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type AuditLogDto struct {
|
||||
|
||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
||||
}
|
||||
|
||||
type CustomClaimCreateDto struct {
|
||||
Key string `json:"key" binding:"required,claimKey"`
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// MapStructList maps a list of source structs to a list of destination structs
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
package dto
|
||||
|
||||
type PublicOidcClientDto struct {
|
||||
type OidcClientMetaDataDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
}
|
||||
|
||||
type OidcClientDto struct {
|
||||
PublicOidcClientDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
CreatedBy UserDto `json:"createdBy"`
|
||||
OidcClientMetaDataDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
OidcClientDto
|
||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
@@ -35,6 +41,11 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
type AuthorizationRequiredDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
}
|
||||
|
||||
type OidcCreateTokensDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
@@ -42,3 +53,14 @@ type OidcCreateTokensDto struct {
|
||||
ClientSecret string `form:"client_secret"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
}
|
||||
|
||||
type OidcUpdateAllowedUserGroupsDto struct {
|
||||
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"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
UserGroups []UserGroupDto `json:"userGroups"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
}
|
||||
|
||||
@@ -23,7 +24,7 @@ type UserCreateDto struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -31,3 +32,7 @@ type OneTimeAccessEmailDto struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
RedirectPath string `json:"redirectPath"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
datatype "github.com/stonith404/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 {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
@@ -33,7 +42,3 @@ type UserGroupCreateDto struct {
|
||||
type UserGroupUpdateUsersDto struct {
|
||||
UserIDs []string `json:"userIds" binding:"required"`
|
||||
}
|
||||
|
||||
type AssignUserToGroupDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -16,22 +16,10 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
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() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type WebauthnCredentialDto struct {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterDbCleanupJobs(db *gorm.DB) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
type LdapJobs struct {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type CorsMiddleware struct{}
|
||||
|
||||
@@ -3,13 +3,14 @@ package middleware
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrorHandlerMiddleware struct{}
|
||||
|
||||
@@ -2,9 +2,10 @@ package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type FileSizeLimitMiddleware struct{}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/cookie"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
type JwtAuthMiddleware struct {
|
||||
jwtService *service.JwtService
|
||||
ignoreUnauthenticated bool
|
||||
jwtService *service.JwtService
|
||||
}
|
||||
|
||||
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
|
||||
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
|
||||
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
|
||||
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) {
|
||||
// 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 {
|
||||
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()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
claims, err := m.jwtService.VerifyAccessToken(token)
|
||||
if err != nil && m.ignoreUnauthenticated {
|
||||
c.Next()
|
||||
return
|
||||
} else if err != nil {
|
||||
c.Error(&common.NotSignedInError{})
|
||||
c.Abort()
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminOnly && !claims.IsAdmin {
|
||||
c.Error(&common.MissingPermissionError{})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", claims.Subject)
|
||||
c.Set("userIsAdmin", claims.IsAdmin)
|
||||
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)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
}
|
||||
|
||||
// Check if the user is an admin
|
||||
if adminRequired && !claims.IsAdmin {
|
||||
return "", false, &common.MissingPermissionError{}
|
||||
}
|
||||
|
||||
return claims.Subject, claims.IsAdmin, nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
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
|
||||
LdapBindPassword AppConfigVariable
|
||||
LdapBase AppConfigVariable
|
||||
LdapUserSearchFilter AppConfigVariable
|
||||
LdapUserGroupSearchFilter AppConfigVariable
|
||||
LdapSkipCertVerify AppConfigVariable
|
||||
LdapAttributeUserUniqueIdentifier AppConfigVariable
|
||||
LdapAttributeUserUsername AppConfigVariable
|
||||
LdapAttributeUserEmail AppConfigVariable
|
||||
LdapAttributeUserFirstName AppConfigVariable
|
||||
LdapAttributeUserLastName AppConfigVariable
|
||||
LdapAttributeUserProfilePicture AppConfigVariable
|
||||
LdapAttributeGroupMember AppConfigVariable
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
||||
LdapAttributeGroupName AppConfigVariable
|
||||
LdapAttributeAdminGroup AppConfigVariable
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
model "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Base contains common columns for all tables.
|
||||
type Base struct {
|
||||
ID string `gorm:"primaryKey;not null"`
|
||||
CreatedAt model.DateTime `sortable:"true"`
|
||||
ID string `gorm:"primaryKey;not null"`
|
||||
CreatedAt datatype.DateTime `sortable:"true"`
|
||||
}
|
||||
|
||||
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.New().String()
|
||||
}
|
||||
b.CreatedAt = model.DateTime(time.Now())
|
||||
b.CreatedAt = datatype.DateTime(time.Now())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -36,16 +37,18 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
@@ -54,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
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 {
|
||||
return json.Unmarshal(v, cu)
|
||||
} else {
|
||||
@@ -64,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)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package datatype
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
|
||||
|
||||
@@ -3,7 +3,7 @@ package model
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
|
||||
@@ -4,9 +4,10 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type WebauthnSession struct {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
|
||||
if err := service.InitDbConfig(); err != nil {
|
||||
log.Fatalf("Failed to initialize app config service: %v", err)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -96,8 +97,8 @@ var defaultDbConfig = model.AppConfig{
|
||||
},
|
||||
SmtpTls: model.AppConfigVariable{
|
||||
Key: "smtpTls",
|
||||
Type: "bool",
|
||||
DefaultValue: "true",
|
||||
Type: "string",
|
||||
DefaultValue: "none",
|
||||
},
|
||||
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||
Key: "smtpSkipCertVerify",
|
||||
@@ -119,6 +120,7 @@ var defaultDbConfig = model.AppConfig{
|
||||
LdapEnabled: model.AppConfigVariable{
|
||||
Key: "ldapEnabled",
|
||||
Type: "bool",
|
||||
IsPublic: true,
|
||||
DefaultValue: "false",
|
||||
},
|
||||
LdapUrl: model.AppConfigVariable{
|
||||
@@ -137,6 +139,16 @@ var defaultDbConfig = model.AppConfig{
|
||||
Key: "ldapBase",
|
||||
Type: "string",
|
||||
},
|
||||
LdapUserSearchFilter: model.AppConfigVariable{
|
||||
Key: "ldapUserSearchFilter",
|
||||
Type: "string",
|
||||
DefaultValue: "(objectClass=person)",
|
||||
},
|
||||
LdapUserGroupSearchFilter: model.AppConfigVariable{
|
||||
Key: "ldapUserGroupSearchFilter",
|
||||
Type: "string",
|
||||
DefaultValue: "(objectClass=groupOfNames)",
|
||||
},
|
||||
LdapSkipCertVerify: model.AppConfigVariable{
|
||||
Key: "ldapSkipCertVerify",
|
||||
Type: "bool",
|
||||
@@ -162,6 +174,15 @@ var defaultDbConfig = model.AppConfig{
|
||||
Key: "ldapAttributeUserLastName",
|
||||
Type: "string",
|
||||
},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{
|
||||
Key: "ldapAttributeUserProfilePicture",
|
||||
Type: "string",
|
||||
},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{
|
||||
Key: "ldapAttributeGroupMember",
|
||||
Type: "string",
|
||||
DefaultValue: "member",
|
||||
},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
|
||||
Key: "ldapAttributeGroupUniqueIdentifier",
|
||||
Type: "string",
|
||||
@@ -177,12 +198,15 @@ var defaultDbConfig = model.AppConfig{
|
||||
}
|
||||
|
||||
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()
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
|
||||
var savedConfigVariables []model.AppConfigVariable
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
key := field.Tag.Get("json")
|
||||
@@ -243,9 +267,13 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the value to the default value if it is empty
|
||||
for i := range configuration {
|
||||
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -344,12 +372,25 @@ func (s *AppConfigService) LoadDbConfigFromDb() error {
|
||||
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
|
||||
}
|
||||
|
||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) getConfigVariableFromEnvironmentVariable(key, fallbackValue string) string {
|
||||
environmentVariableName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||
|
||||
if value, exists := os.LookupEnv(environmentVariableName); exists {
|
||||
return value
|
||||
}
|
||||
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuditLogService struct {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,27 +3,25 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
htemplate "html/template"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
ttemplate "text/template"
|
||||
"time"
|
||||
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"github.com/google/uuid"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var netDialer = &net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
type EmailService struct {
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
@@ -88,6 +86,29 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
||||
c.AddHeaderRaw("Content-Type",
|
||||
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)
|
||||
|
||||
// Connect to the SMTP server
|
||||
@@ -114,105 +135,57 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
|
||||
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
}
|
||||
|
||||
// Connect to the SMTP server
|
||||
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
|
||||
client, err = srv.connectToSmtpServer(smtpAddress)
|
||||
} else if port == "465" {
|
||||
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
} else {
|
||||
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||
// Connect to the SMTP server based on TLS setting
|
||||
switch srv.appConfigService.DbConfig.SmtpTls.Value {
|
||||
case "none":
|
||||
client, err = smtp.Dial(smtpAddress)
|
||||
case "tls":
|
||||
client, err = smtp.DialTLS(smtpAddress, tlsConfig)
|
||||
case "starttls":
|
||||
client, err = smtp.DialStartTLS(
|
||||
smtpAddress,
|
||||
tlsConfig,
|
||||
)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid SMTP TLS setting: %s", srv.appConfigService.DbConfig.SmtpTls.Value)
|
||||
}
|
||||
if err != nil {
|
||||
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
|
||||
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
|
||||
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
|
||||
|
||||
if smtpUser != "" || smtpPassword != "" {
|
||||
auth := smtp.PlainAuth("",
|
||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
)
|
||||
// Authenticate with plain auth
|
||||
auth := sasl.NewPlainClient("", smtpUser, smtpPassword)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
hostname, err := os.Hostname()
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// Get a writer to write the email data
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start data: %w", err)
|
||||
}
|
||||
|
||||
// Write the email content
|
||||
_, err = w.Write([]byte(c.String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write email data: %w", err)
|
||||
}
|
||||
|
||||
// Close the writer
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
||||
Path: "one-time-access",
|
||||
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
||||
return "One time access"
|
||||
return "Login Code"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -50,7 +51,9 @@ type NewLoginTemplateData struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTemplateData = struct {
|
||||
Link string
|
||||
Code string
|
||||
LoginLink string
|
||||
LoginLinkWithCode string
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
|
||||
@@ -17,11 +17,12 @@ import (
|
||||
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type GeoLiteService struct {
|
||||
mutex sync.Mutex
|
||||
disableUpdater bool
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
var localhostIPNets = []*net.IPNet{
|
||||
@@ -43,6 +44,12 @@ var tailscaleIPNets = []*net.IPNet{
|
||||
func NewGeoLiteService() *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() {
|
||||
if err := service.updateDatabase(); err != nil {
|
||||
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.
|
||||
func (s *GeoLiteService) updateDatabase() error {
|
||||
if s.disableUpdater {
|
||||
// Avoid updating the GeoLite2 City database.
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.isDatabaseUpToDate() {
|
||||
log.Println("GeoLite2 City database is up-to-date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
resp, err := http.Get(downloadUrl)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,86 +3,167 @@ package service
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyPath = "data/keys/jwt_private_key.pem"
|
||||
publicKeyPath = "data/keys/jwt_public_key.pem"
|
||||
// PrivateKeyFile is the path in the data/keys folder where the key is stored
|
||||
// This is a JSON file containing a key encoded as JWK
|
||||
PrivateKeyFile = "jwt_private_key.json"
|
||||
|
||||
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found
|
||||
RsaKeySize = 2048
|
||||
|
||||
// KeyUsageSigning is the usage for the private keys, for the "use" property
|
||||
KeyUsageSigning = "sig"
|
||||
)
|
||||
|
||||
type JwtService struct {
|
||||
publicKey *rsa.PublicKey
|
||||
privateKey *rsa.PrivateKey
|
||||
privateKey jwk.Key
|
||||
keyId string
|
||||
appConfigService *AppConfigService
|
||||
jwksEncoded []byte
|
||||
}
|
||||
|
||||
func NewJwtService(appConfigService *AppConfigService) *JwtService {
|
||||
service := &JwtService{
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
service := &JwtService{}
|
||||
|
||||
// Ensure keys are generated or loaded
|
||||
if err := service.loadOrGenerateKeys(); err != nil {
|
||||
if err := service.init(appConfigService, common.EnvConfig.KeysPath); err != nil {
|
||||
log.Fatalf("Failed to initialize jwt service: %v", err)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) error {
|
||||
s.appConfigService = appConfigService
|
||||
|
||||
// Ensure keys are generated or loaded
|
||||
return s.loadOrGenerateKey(keysPath)
|
||||
}
|
||||
|
||||
type AccessTokenJWTClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
IsAdmin bool `json:"isAdmin,omitempty"`
|
||||
}
|
||||
|
||||
type JWK struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
// loadOrGenerateKey loads the private key from the given path or generates it if not existing.
|
||||
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
|
||||
var key jwk.Key
|
||||
|
||||
// First, check if we have a JWK file
|
||||
// If we do, then we just load that
|
||||
jwkPath := filepath.Join(keysPath, PrivateKeyFile)
|
||||
ok, err := utils.FileExists(jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
if ok {
|
||||
key, err = s.loadKeyJWK(jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load private key file (JWK) at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
// Set the key, and we are done
|
||||
err = s.SetKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set private key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we are here, we need to generate a new key
|
||||
key, err = s.generateNewRSAKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate new private key: %w", err)
|
||||
}
|
||||
|
||||
// Set the key in the object, which also validates it
|
||||
err = s.SetKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set private key: %w", err)
|
||||
}
|
||||
|
||||
// Save the key as JWK
|
||||
err = SaveKeyJWK(s.privateKey, jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadOrGenerateKeys loads RSA keys from the given paths or generates them if they do not exist.
|
||||
func (s *JwtService) loadOrGenerateKeys() error {
|
||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
||||
if err := s.generateKeys(); err != nil {
|
||||
return err
|
||||
}
|
||||
func ValidateKey(privateKey jwk.Key) error {
|
||||
// Validate the loaded key
|
||||
err := privateKey.Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("key object is invalid: %w", err)
|
||||
}
|
||||
keyID, ok := privateKey.KeyID()
|
||||
if !ok || keyID == "" {
|
||||
return errors.New("key object does not contain a key ID")
|
||||
}
|
||||
usage, ok := privateKey.KeyUsage()
|
||||
if !ok || usage != KeyUsageSigning {
|
||||
return errors.New("key object is not valid for signing")
|
||||
}
|
||||
ok, err = jwk.IsPrivateKey(privateKey)
|
||||
if err != nil || !ok {
|
||||
return errors.New("key object is not a private key")
|
||||
}
|
||||
|
||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JwtService) SetKey(privateKey jwk.Key) error {
|
||||
// Validate the loaded key
|
||||
err := ValidateKey(privateKey)
|
||||
if err != nil {
|
||||
return errors.New("can't read jwt private key: " + err.Error())
|
||||
}
|
||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
if err != nil {
|
||||
return errors.New("can't parse jwt private key: " + err.Error())
|
||||
return fmt.Errorf("private key is not valid: %w", err)
|
||||
}
|
||||
|
||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
||||
if err != nil {
|
||||
return errors.New("can't read jwt public key: " + err.Error())
|
||||
// Set the private key and key id in the object
|
||||
s.privateKey = privateKey
|
||||
|
||||
keyId, ok := privateKey.KeyID()
|
||||
if !ok {
|
||||
return errors.New("key object does not contain a key ID")
|
||||
}
|
||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
s.keyId = keyId
|
||||
|
||||
// Create and encode a JWKS containing the public key
|
||||
publicKey, err := s.GetPublicJWK()
|
||||
if err != nil {
|
||||
return errors.New("can't parse jwt public key: " + err.Error())
|
||||
return fmt.Errorf("failed to get public JWK: %w", err)
|
||||
}
|
||||
jwks := jwk.NewSet()
|
||||
err = jwks.AddKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add public key to JWKS: %w", err)
|
||||
}
|
||||
s.jwksEncoded, err = json.Marshal(jwks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode JWKS to JSON: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -100,20 +181,26 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
IsAdmin: user.IsAdmin,
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
token.Header["kid"] = s.keyId
|
||||
|
||||
var privateKeyRaw any
|
||||
err := jwk.Export(s.privateKey, &privateKeyRaw)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||
return "", fmt.Errorf("failed to export private key object: %w", err)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
token.Header["kid"] = kid
|
||||
signed, err := token.SignedString(privateKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return s.publicKey, nil
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (any, error) {
|
||||
return s.getPublicKeyRaw()
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("couldn't handle this token")
|
||||
@@ -131,12 +218,12 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
|
||||
}
|
||||
|
||||
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"aud": clientID,
|
||||
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
"iat": jwt.NewNumericDate(time.Now()),
|
||||
"iss": common.EnvConfig.AppURL,
|
||||
}
|
||||
// Initialize with capacity for userClaims, + 4 fixed claims, + 2 claims which may be set in some cases, to avoid re-allocations
|
||||
claims := make(jwt.MapClaims, len(userClaims)+6)
|
||||
claims["aud"] = clientID
|
||||
claims["exp"] = jwt.NewNumericDate(time.Now().Add(1 * time.Hour))
|
||||
claims["iat"] = jwt.NewNumericDate(time.Now())
|
||||
claims["iss"] = common.EnvConfig.AppURL
|
||||
|
||||
for k, v := range userClaims {
|
||||
claims[k] = v
|
||||
@@ -146,15 +233,33 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
||||
claims["nonce"] = nonce
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = s.keyId
|
||||
|
||||
var privateKeyRaw any
|
||||
err := jwk.Export(s.privateKey, &privateKeyRaw)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||
return "", fmt.Errorf("failed to export private key object: %w", err)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = kid
|
||||
return token.SignedString(privateKeyRaw)
|
||||
}
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
|
||||
return s.getPublicKeyRaw()
|
||||
}, 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
|
||||
}
|
||||
|
||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||
@@ -166,20 +271,21 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
||||
Issuer: common.EnvConfig.AppURL,
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
token.Header["kid"] = s.keyId
|
||||
|
||||
var privateKeyRaw any
|
||||
err := jwk.Export(s.privateKey, &privateKeyRaw)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||
return "", fmt.Errorf("failed to export private key object: %w", err)
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||
token.Header["kid"] = kid
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
return token.SignedString(privateKeyRaw)
|
||||
}
|
||||
|
||||
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return s.publicKey, nil
|
||||
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
|
||||
return s.getPublicKeyRaw()
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("couldn't handle this token")
|
||||
@@ -193,119 +299,142 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
||||
func (s *JwtService) GetJWK() (JWK, error) {
|
||||
if s.publicKey == nil {
|
||||
return JWK{}, errors.New("public key is not initialized")
|
||||
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
|
||||
func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
|
||||
if s.privateKey == nil {
|
||||
return nil, errors.New("key is not initialized")
|
||||
}
|
||||
|
||||
kid, err := s.generateKeyID(s.publicKey)
|
||||
pubKey, err := s.privateKey.PublicKey()
|
||||
if err != nil {
|
||||
return JWK{}, err
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
jwk := JWK{
|
||||
Kid: kid,
|
||||
Kty: "RSA",
|
||||
Use: "sig",
|
||||
Alg: "RS256",
|
||||
N: base64.RawURLEncoding.EncodeToString(s.publicKey.N.Bytes()),
|
||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.publicKey.E)).Bytes()),
|
||||
}
|
||||
EnsureAlgInKey(pubKey)
|
||||
|
||||
return jwk, nil
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to marshal public key: " + err.Error())
|
||||
// GetPublicJWKSAsJSON returns the JSON Web Key Set (JWKS) for the public key, encoded as JSON.
|
||||
// The value is cached since the key is static.
|
||||
func (s *JwtService) GetPublicJWKSAsJSON() ([]byte, error) {
|
||||
if len(s.jwksEncoded) == 0 {
|
||||
return nil, errors.New("key is not initialized")
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the public key
|
||||
hash := sha256.New()
|
||||
hash.Write(pubASN1)
|
||||
hashed := hash.Sum(nil)
|
||||
|
||||
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||
shortHash := hashed[:8]
|
||||
|
||||
// Return Base64 encoded truncated hash as Key ID
|
||||
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||
return s.jwksEncoded, nil
|
||||
}
|
||||
|
||||
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
||||
func (s *JwtService) generateKeys() error {
|
||||
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
||||
return errors.New("failed to create directories for keys: " + err.Error())
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
func (s *JwtService) getPublicKeyRaw() (any, error) {
|
||||
pubKey, err := s.privateKey.PublicKey()
|
||||
if err != nil {
|
||||
return errors.New("failed to generate private key: " + err.Error())
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
s.privateKey = privateKey
|
||||
|
||||
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
||||
return err
|
||||
var pubKeyRaw any
|
||||
err = jwk.Export(pubKey, &pubKeyRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to export raw public key: %w", 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 pubKeyRaw, nil
|
||||
}
|
||||
|
||||
// savePEMKey saves a PEM encoded key to a file.
|
||||
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
|
||||
keyFile, err := os.Create(path)
|
||||
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return errors.New("failed to create key file: " + err.Error())
|
||||
return nil, fmt.Errorf("failed to read key data: %w", err)
|
||||
}
|
||||
|
||||
key, err := jwk.ParseKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// EnsureAlgInKey ensures that the key contains an "alg" parameter, set depending on the key type
|
||||
func EnsureAlgInKey(key jwk.Key) {
|
||||
_, ok := key.Algorithm()
|
||||
if ok {
|
||||
// Algorithm is already set
|
||||
return
|
||||
}
|
||||
|
||||
switch key.KeyType() {
|
||||
case jwa.RSA():
|
||||
// Default to RS256 for RSA keys
|
||||
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
|
||||
case jwa.EC():
|
||||
// Default to ES256 for ECDSA keys
|
||||
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
|
||||
case jwa.OKP():
|
||||
// Default to EdDSA for OKP keys
|
||||
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
|
||||
// We generate RSA keys only
|
||||
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
|
||||
}
|
||||
|
||||
// Import the raw key
|
||||
return importRawKey(rawKey)
|
||||
}
|
||||
|
||||
func importRawKey(rawKey any) (jwk.Key, error) {
|
||||
key, err := jwk.Import(rawKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to import generated private key: %w", err)
|
||||
}
|
||||
|
||||
// Generate the key ID
|
||||
kid, err := generateRandomKeyID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
||||
}
|
||||
_ = key.Set(jwk.KeyIDKey, kid)
|
||||
|
||||
// Set other required fields
|
||||
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
|
||||
EnsureAlgInKey(key)
|
||||
|
||||
return key, err
|
||||
}
|
||||
|
||||
// SaveKeyJWK saves a JWK to a file
|
||||
func SaveKeyJWK(key jwk.Key, path string) error {
|
||||
dir := filepath.Dir(path)
|
||||
err := os.MkdirAll(dir, 0700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory '%s' for key file: %w", dir, err)
|
||||
}
|
||||
|
||||
keyFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create key file: %w", err)
|
||||
}
|
||||
defer keyFile.Close()
|
||||
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: keyType,
|
||||
Bytes: keyBytes,
|
||||
})
|
||||
|
||||
if _, err := keyFile.Write(keyPEM); err != nil {
|
||||
return errors.New("failed to write key file: " + err.Error())
|
||||
// Write the JSON file to disk
|
||||
enc := json.NewEncoder(keyFile)
|
||||
enc.SetEscapeHTML(false)
|
||||
err = enc.Encode(key)
|
||||
if err != nil {
|
||||
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)
|
||||
// generateRandomKeyID generates a random key ID.
|
||||
func generateRandomKeyID() (string, error) {
|
||||
buf := make([]byte, 8)
|
||||
_, err := io.ReadFull(rand.Reader, buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't read jwt private key: %w", err)
|
||||
return "", fmt.Errorf("failed to read random bytes: %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 base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
546
backend/internal/service/jwt_service_test.go
Normal file
546
backend/internal/service/jwt_service_test.go
Normal file
@@ -0,0 +1,546 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestJwtService_Init(t *testing.T) {
|
||||
t.Run("should generate new key when none exists", func(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a mock AppConfigService
|
||||
appConfigService := &AppConfigService{}
|
||||
|
||||
// Initialize the JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(appConfigService, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Verify the private key was set
|
||||
require.NotNil(t, service.privateKey, "Private key should be set")
|
||||
|
||||
// Verify the key has been saved to disk as JWK
|
||||
jwkPath := filepath.Join(tempDir, PrivateKeyFile)
|
||||
_, err = os.Stat(jwkPath)
|
||||
assert.NoError(t, err, "JWK file should exist")
|
||||
|
||||
// Verify the generated key is valid
|
||||
keyData, err := os.ReadFile(jwkPath)
|
||||
require.NoError(t, err)
|
||||
key, err := jwk.ParseKey(keyData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Key should have required properties
|
||||
keyID, ok := key.KeyID()
|
||||
assert.True(t, ok, "Key should have a key ID")
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
keyUsage, ok := key.KeyUsage()
|
||||
assert.True(t, ok, "Key should have a key usage")
|
||||
assert.Equal(t, "sig", keyUsage)
|
||||
})
|
||||
|
||||
t.Run("should load existing JWK key", func(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// First create a service to generate a key
|
||||
firstService := &JwtService{}
|
||||
err := firstService.init(&AppConfigService{}, tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the key ID of the first service
|
||||
origKeyID, ok := firstService.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
|
||||
// Now create a new service that should load the existing key
|
||||
secondService := &JwtService{}
|
||||
err = secondService.init(&AppConfigService{}, tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the loaded key has the same ID as the original
|
||||
loadedKeyID, ok := secondService.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
})
|
||||
|
||||
t.Run("should load existing JWK for EC keys", func(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a new JWK and save it to disk
|
||||
origKeyID := createECKeyJWK(t, tempDir)
|
||||
|
||||
// Now create a new service that should load the existing key
|
||||
svc := &JwtService{}
|
||||
err := svc.init(&AppConfigService{}, tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the loaded key has the same ID as the original
|
||||
loadedKeyID, ok := svc.privateKey.KeyID()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
})
|
||||
}
|
||||
|
||||
func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
t.Run("returns public key when private key is initialized", func(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a JWT service with initialized key
|
||||
service := &JwtService{}
|
||||
err := service.init(&AppConfigService{}, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Get the JWK (public key)
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err, "GetPublicJWK should not return an error when private key is initialized")
|
||||
|
||||
// Verify the returned key is valid
|
||||
require.NotNil(t, publicKey, "Public key should not be nil")
|
||||
|
||||
// Validate it's actually a public key
|
||||
isPrivate, err := jwk.IsPrivateKey(publicKey)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isPrivate, "Returned key should be a public key")
|
||||
|
||||
// Check that key has required properties
|
||||
keyID, ok := publicKey.KeyID()
|
||||
require.True(t, ok, "Public key should have a key ID")
|
||||
assert.NotEmpty(t, keyID, "Key ID should not be empty")
|
||||
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok, "Public key should have an algorithm")
|
||||
assert.Equal(t, "RS256", alg.String(), "Algorithm should be RS256")
|
||||
})
|
||||
|
||||
t.Run("returns public key when ECDSA private key is initialized", func(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create an ECDSA key and save it as JWK
|
||||
originalKeyID := createECKeyJWK(t, tempDir)
|
||||
|
||||
// Create a JWT service that loads the ECDSA key
|
||||
service := &JwtService{}
|
||||
err := service.init(&AppConfigService{}, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Get the JWK (public key)
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err, "GetPublicJWK should not return an error when private key is initialized")
|
||||
|
||||
// Verify the returned key is valid
|
||||
require.NotNil(t, publicKey, "Public key should not be nil")
|
||||
|
||||
// Validate it's actually a public key
|
||||
isPrivate, err := jwk.IsPrivateKey(publicKey)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isPrivate, "Returned key should be a public key")
|
||||
|
||||
// Check that key has required properties
|
||||
keyID, ok := publicKey.KeyID()
|
||||
require.True(t, ok, "Public key should have a key ID")
|
||||
assert.Equal(t, originalKeyID, keyID, "Key ID should match the original key ID")
|
||||
|
||||
// Check that the key type is EC
|
||||
assert.Equal(t, "EC", publicKey.KeyType().String(), "Key type should be EC")
|
||||
|
||||
// Check that the algorithm is ES256
|
||||
alg, ok := publicKey.Algorithm()
|
||||
require.True(t, ok, "Public key should have an algorithm")
|
||||
assert.Equal(t, "ES256", alg.String(), "Algorithm should be ES256")
|
||||
})
|
||||
|
||||
t.Run("returns error when private key is not initialized", func(t *testing.T) {
|
||||
// Create a service with nil private key
|
||||
service := &JwtService{
|
||||
privateKey: nil,
|
||||
}
|
||||
|
||||
// Try to get the JWK
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
|
||||
// Verify it returns an error
|
||||
require.Error(t, err, "GetPublicJWK should return an error when private key is nil")
|
||||
assert.Contains(t, err.Error(), "key is not initialized", "Error message should indicate key is not initialized")
|
||||
assert.Nil(t, publicKey, "Public key should be nil when there's an error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Initialize the JWT service with a mock AppConfigService
|
||||
mockConfig := &AppConfigService{
|
||||
DbConfig: &model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
},
|
||||
}
|
||||
|
||||
// Setup the environment variable required by the token verification
|
||||
originalAppURL := common.EnvConfig.AppURL
|
||||
common.EnvConfig.AppURL = "https://test.example.com"
|
||||
defer func() {
|
||||
common.EnvConfig.AppURL = originalAppURL
|
||||
}()
|
||||
|
||||
t.Run("generates token for regular user", func(t *testing.T) {
|
||||
// Create a JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create a test user
|
||||
user := model.User{
|
||||
Base: model.Base{
|
||||
ID: "user123",
|
||||
},
|
||||
Email: "user@example.com",
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
// Generate a token
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
// Verify the token
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check the claims
|
||||
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID")
|
||||
assert.Equal(t, false, claims.IsAdmin, "IsAdmin should be false")
|
||||
assert.Contains(t, claims.Audience, "https://test.example.com", "Audience should contain the app URL")
|
||||
|
||||
// Check token expiration time is approximately 60 minutes from now
|
||||
expectedExp := time.Now().Add(60 * time.Minute)
|
||||
tokenExp := claims.ExpiresAt.Time
|
||||
timeDiff := expectedExp.Sub(tokenExp).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 60 minutes")
|
||||
})
|
||||
|
||||
t.Run("generates token for admin user", func(t *testing.T) {
|
||||
// Create a JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create a test admin user
|
||||
adminUser := model.User{
|
||||
Base: model.Base{
|
||||
ID: "admin123",
|
||||
},
|
||||
Email: "admin@example.com",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
// Generate a token
|
||||
tokenString, err := service.GenerateAccessToken(adminUser)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
// Verify the token
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check the IsAdmin claim is true
|
||||
assert.Equal(t, true, claims.IsAdmin, "IsAdmin should be true for admin users")
|
||||
assert.Equal(t, adminUser.ID, claims.Subject, "Token subject should match admin ID")
|
||||
})
|
||||
|
||||
t.Run("uses session duration from config", func(t *testing.T) {
|
||||
// Create a JWT service with a different session duration
|
||||
customMockConfig := &AppConfigService{
|
||||
DbConfig: &model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
|
||||
},
|
||||
}
|
||||
|
||||
service := &JwtService{}
|
||||
err := service.init(customMockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create a test user
|
||||
user := model.User{
|
||||
Base: model.Base{
|
||||
ID: "user456",
|
||||
},
|
||||
}
|
||||
|
||||
// Generate a token
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
// Verify the token
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check token expiration time is approximately 30 minutes from now
|
||||
expectedExp := time.Now().Add(30 * time.Minute)
|
||||
tokenExp := claims.ExpiresAt.Time
|
||||
timeDiff := expectedExp.Sub(tokenExp).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Initialize the JWT service with a mock AppConfigService
|
||||
mockConfig := &AppConfigService{
|
||||
DbConfig: &model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
},
|
||||
}
|
||||
|
||||
// Setup the environment variable required by the token verification
|
||||
originalAppURL := common.EnvConfig.AppURL
|
||||
common.EnvConfig.AppURL = "https://test.example.com"
|
||||
defer func() {
|
||||
common.EnvConfig.AppURL = originalAppURL
|
||||
}()
|
||||
|
||||
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
||||
// Create a JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create test claims
|
||||
userClaims := map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"name": "Test User",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
// Generate a token
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate ID token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
// Verify the token
|
||||
claims, err := service.VerifyIdToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated ID token")
|
||||
|
||||
// Check the claims
|
||||
assert.Equal(t, "user123", claims.Subject, "Token subject should match user ID")
|
||||
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID")
|
||||
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL")
|
||||
|
||||
// Check token expiration time is approximately 1 hour from now
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
tokenExp := claims.ExpiresAt.Time
|
||||
timeDiff := expectedExp.Sub(tokenExp).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
|
||||
})
|
||||
|
||||
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
|
||||
// Create a JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create test claims with nonce
|
||||
userClaims := map[string]interface{}{
|
||||
"sub": "user456",
|
||||
"name": "Another User",
|
||||
}
|
||||
const clientID = "test-client-456"
|
||||
nonce := "random-nonce-value"
|
||||
|
||||
// Generate a token with nonce
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce)
|
||||
require.NoError(t, err, "Failed to generate ID token with nonce")
|
||||
|
||||
// Parse the token manually to check nonce
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
require.NoError(t, err, "Failed to get public key")
|
||||
token, err := jwt.Parse([]byte(tokenString), jwt.WithKey(jwa.RS256(), publicKey))
|
||||
require.NoError(t, err, "Failed to parse token")
|
||||
|
||||
var tokenNonce string
|
||||
err = token.Get("nonce", &tokenNonce)
|
||||
require.NoError(t, err, "Failed to get claims")
|
||||
|
||||
assert.Equal(t, nonce, tokenNonce, "Token should contain the correct nonce")
|
||||
})
|
||||
|
||||
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
|
||||
// Create a JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Generate a token with standard claims
|
||||
userClaims := map[string]interface{}{
|
||||
"sub": "user789",
|
||||
}
|
||||
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
|
||||
require.NoError(t, err, "Failed to generate ID token")
|
||||
|
||||
// Temporarily change the app URL to simulate wrong issuer
|
||||
common.EnvConfig.AppURL = "https://wrong-issuer.com"
|
||||
|
||||
// Verify should fail due to issuer mismatch
|
||||
_, err = service.VerifyIdToken(tokenString)
|
||||
assert.Error(t, err, "Verification should fail with incorrect issuer")
|
||||
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
||||
// Create a temporary directory for the test
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Initialize the JWT service with a mock AppConfigService
|
||||
mockConfig := &AppConfigService{
|
||||
DbConfig: &model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
},
|
||||
}
|
||||
|
||||
// Setup the environment variable required by the token verification
|
||||
originalAppURL := common.EnvConfig.AppURL
|
||||
common.EnvConfig.AppURL = "https://test.example.com"
|
||||
defer func() {
|
||||
common.EnvConfig.AppURL = originalAppURL
|
||||
}()
|
||||
|
||||
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
|
||||
// Create a JWT service
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create a test user
|
||||
user := model.User{
|
||||
Base: model.Base{
|
||||
ID: "user123",
|
||||
},
|
||||
Email: "user@example.com",
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
// Generate a token
|
||||
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
// Verify the token
|
||||
claims, err := service.VerifyOauthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token")
|
||||
|
||||
// Check the claims
|
||||
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID")
|
||||
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID")
|
||||
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL")
|
||||
|
||||
// Check token expiration time is approximately 1 hour from now
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
tokenExp := claims.ExpiresAt.Time
|
||||
timeDiff := expectedExp.Sub(tokenExp).Minutes()
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
|
||||
})
|
||||
|
||||
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||
// Create a JWT service with a mock function to generate an expired token
|
||||
service := &JwtService{}
|
||||
err := service.init(mockConfig, tempDir)
|
||||
require.NoError(t, err, "Failed to initialize JWT service")
|
||||
|
||||
// Create a test user
|
||||
user := model.User{
|
||||
Base: model.Base{
|
||||
ID: "user456",
|
||||
},
|
||||
}
|
||||
const clientID = "test-client-456"
|
||||
|
||||
// Generate a token using JWT directly to create an expired token
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||
Audience([]string{clientID}).
|
||||
Issuer(common.EnvConfig.AppURL).
|
||||
Build()
|
||||
require.NoError(t, err, "Failed to build token")
|
||||
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
|
||||
require.NoError(t, err, "Failed to sign token")
|
||||
|
||||
// Verify should fail due to expiration
|
||||
_, err = service.VerifyOauthAccessToken(string(signed))
|
||||
assert.Error(t, err, "Verification should fail with expired token")
|
||||
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
|
||||
})
|
||||
|
||||
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||
// Create two JWT services with different keys
|
||||
service1 := &JwtService{}
|
||||
err := service1.init(mockConfig, t.TempDir()) // Use a different temp dir
|
||||
require.NoError(t, err, "Failed to initialize first JWT service")
|
||||
|
||||
service2 := &JwtService{}
|
||||
err = service2.init(mockConfig, t.TempDir()) // Use a different temp dir
|
||||
require.NoError(t, err, "Failed to initialize second JWT service")
|
||||
|
||||
// Create a test user
|
||||
user := model.User{
|
||||
Base: model.Base{
|
||||
ID: "user789",
|
||||
},
|
||||
}
|
||||
const clientID = "test-client-789"
|
||||
|
||||
// Generate a token with the first service
|
||||
tokenString, err := service1.GenerateOauthAccessToken(user, clientID)
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
|
||||
// Verify with the second service should fail due to different keys
|
||||
_, err = service2.VerifyOauthAccessToken(tokenString)
|
||||
assert.Error(t, err, "Verification should fail with invalid signature")
|
||||
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
|
||||
})
|
||||
}
|
||||
|
||||
func createECKeyJWK(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
// Generate a new P-256 ECDSA key
|
||||
privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err, "Failed to generate ECDSA key")
|
||||
|
||||
// Import as JWK and save to disk
|
||||
privateKey, err := importRawKey(privateKeyRaw)
|
||||
require.NoError(t, err, "Failed to import private key")
|
||||
|
||||
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))
|
||||
require.NoError(t, err, "Failed to save key")
|
||||
|
||||
kid, _ := privateKey.KeyID()
|
||||
require.NotEmpty(t, kid, "Key ID must be set")
|
||||
|
||||
return kid
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -70,12 +76,13 @@ func (s *LdapService) SyncGroups() error {
|
||||
baseDN := s.appConfigService.DbConfig.LdapBase.Value
|
||||
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
|
||||
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
|
||||
filter := "(objectClass=groupOfUniqueNames)"
|
||||
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
|
||||
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
|
||||
|
||||
searchAttrs := []string{
|
||||
nameAttribute,
|
||||
uniqueIdentifierAttribute,
|
||||
"member",
|
||||
groupMemberOfAttribute,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
for _, value := range result.Entries {
|
||||
var usersToAddDto dto.UserGroupUpdateUsersDto
|
||||
var membersUserId []string
|
||||
|
||||
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
|
||||
@@ -99,14 +105,24 @@ func (s *LdapService) SyncGroups() error {
|
||||
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
|
||||
|
||||
// Get group members and add to the correct Group
|
||||
groupMembers := value.GetAttributeValues("member")
|
||||
groupMembers := value.GetAttributeValues(groupMemberOfAttribute)
|
||||
for _, member := range groupMembers {
|
||||
// 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
|
||||
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -116,22 +132,18 @@ func (s *LdapService) SyncGroups() error {
|
||||
LdapID: value.GetAttributeValue(uniqueIdentifierAttribute),
|
||||
}
|
||||
|
||||
usersToAddDto = dto.UserGroupUpdateUsersDto{
|
||||
UserIDs: membersUserId,
|
||||
}
|
||||
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.Create(syncGroup)
|
||||
if err != nil {
|
||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, 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 {
|
||||
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
|
||||
return err
|
||||
@@ -175,9 +187,9 @@ func (s *LdapService) SyncUsers() error {
|
||||
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
|
||||
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
|
||||
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
|
||||
profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value
|
||||
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
|
||||
|
||||
filter := "(objectClass=person)"
|
||||
filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value
|
||||
|
||||
searchAttrs := []string{
|
||||
"memberOf",
|
||||
@@ -188,6 +200,7 @@ func (s *LdapService) SyncUsers() error {
|
||||
emailAttribute,
|
||||
firstNameAttribute,
|
||||
lastNameAttribute,
|
||||
profilePictureAttribute,
|
||||
}
|
||||
|
||||
// Filters must start and finish with ()!
|
||||
@@ -236,9 +249,14 @@ func (s *LdapService) SyncUsers() error {
|
||||
if err != nil {
|
||||
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
|
||||
@@ -250,7 +268,7 @@ func (s *LdapService) SyncUsers() error {
|
||||
// Delete users that no longer exist in LDAP
|
||||
for _, user := range ldapUsersInDb {
|
||||
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)
|
||||
} else {
|
||||
log.Printf("Deleted user %s", user.Username)
|
||||
@@ -259,3 +277,33 @@ func (s *LdapService) SyncUsers() error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,20 +3,22 @@ package service
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"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"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OidcService struct {
|
||||
@@ -38,71 +40,111 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppCo
|
||||
}
|
||||
|
||||
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
|
||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||
|
||||
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
}
|
||||
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", &common.OidcMissingAuthorizationError{}
|
||||
}
|
||||
|
||||
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
|
||||
|
||||
return code, callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||
if err := s.db.Preload("AllowedUserGroups").First(&client, "id = ?", input.ClientID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// If the client is not public, the code challenge must be provided
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
}
|
||||
|
||||
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
||||
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
||||
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: input.ClientID,
|
||||
Scope: input.Scope,
|
||||
// Check if the user group is allowed to authorize the client
|
||||
var user model.User
|
||||
if err := s.db.Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
|
||||
} else {
|
||||
return "", "", err
|
||||
if !s.IsUserGroupAllowedToAuthorize(user, client) {
|
||||
return "", "", &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
// Check if the user has already authorized the client with the given scope
|
||||
hasAuthorizedClient, err := s.HasAuthorizedClient(input.ClientID, userID, input.Scope)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// If the user has not authorized the client, create a new authorization in the database
|
||||
if !hasAuthorizedClient {
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: input.ClientID,
|
||||
Scope: input.Scope,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
// The client has already been authorized but with a different scope so we need to update the scope
|
||||
if err := s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
} else {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the authorization code
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||
// Log the authorization event
|
||||
if hasAuthorizedClient {
|
||||
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||
} else {
|
||||
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||
|
||||
}
|
||||
|
||||
return code, callbackURL, nil
|
||||
}
|
||||
|
||||
// HasAuthorizedClient checks if the user has already authorized the client with the given scope
|
||||
func (s *OidcService) HasAuthorizedClient(clientID, userID, scope string) (bool, error) {
|
||||
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
|
||||
if err := s.db.First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if userAuthorizedOidcClient.Scope != scope {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
|
||||
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
|
||||
if len(client.AllowedUserGroups) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
isAllowedToAuthorize := false
|
||||
for _, userGroup := range client.AllowedUserGroups {
|
||||
for _, userGroupUser := range user.UserGroups {
|
||||
if userGroup.ID == userGroupUser.ID {
|
||||
isAllowedToAuthorize = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isAllowedToAuthorize
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
|
||||
if grantType != "authorization_code" {
|
||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||
@@ -161,7 +203,7 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, code
|
||||
|
||||
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
|
||||
if err := s.db.Preload("CreatedBy").Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
return client, nil
|
||||
@@ -186,11 +228,12 @@ func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest uti
|
||||
|
||||
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||
client := model.OidcClient{
|
||||
Name: input.Name,
|
||||
CallbackURLs: input.CallbackURLs,
|
||||
CreatedByID: userID,
|
||||
IsPublic: input.IsPublic,
|
||||
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||
Name: input.Name,
|
||||
CallbackURLs: input.CallbackURLs,
|
||||
LogoutCallbackURLs: input.LogoutCallbackURLs,
|
||||
CreatedByID: userID,
|
||||
IsPublic: input.IsPublic,
|
||||
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&client).Error; err != nil {
|
||||
@@ -208,6 +251,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
||||
|
||||
client.Name = input.Name
|
||||
client.CallbackURLs = input.CallbackURLs
|
||||
client.LogoutCallbackURLs = input.LogoutCallbackURLs
|
||||
client.IsPublic = input.IsPublic
|
||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||
|
||||
@@ -357,6 +401,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
"family_name": user.LastName,
|
||||
"name": user.FullName(),
|
||||
"preferred_username": user.Username,
|
||||
"picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID),
|
||||
}
|
||||
|
||||
if strings.Contains(scope, "profile") {
|
||||
@@ -372,7 +417,16 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
}
|
||||
|
||||
for _, customClaim := range customClaims {
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
// The value of the custom claim can be a JSON object or a string
|
||||
var jsonValue interface{}
|
||||
json.Unmarshal([]byte(customClaim.Value), &jsonValue)
|
||||
if jsonValue != nil {
|
||||
// It's JSON so we store it as an object
|
||||
claims[customClaim.Key] = jsonValue
|
||||
} else {
|
||||
// Marshalling failed, so we store it as a string
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(scope, "email") {
|
||||
@@ -382,6 +436,73 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
|
||||
client, err = s.GetClient(id)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
// Fetch the user groups based on UserGroupIDs in input
|
||||
var groups []model.UserGroup
|
||||
if len(input.UserGroupIDs) > 0 {
|
||||
if err := s.db.Where("id IN (?)", input.UserGroupIDs).Find(&groups).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the current user groups with the new set of user groups
|
||||
if err := s.db.Model(&client).Association("AllowedUserGroups").Replace(groups); err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
// Save the updated client
|
||||
if err := s.db.Save(&client).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
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) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
@@ -428,12 +549,12 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
|
||||
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 == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
for _, callbackPattern := range client.CallbackURLs {
|
||||
for _, callbackPattern := range urls {
|
||||
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
||||
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,28 +5,31 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/stonith404/pocket-id/backend/resources"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService {
|
||||
return &TestService{db: db, appConfigService: appConfigService}
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
|
||||
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
|
||||
}
|
||||
|
||||
func (s *TestService) SeedDatabase() error {
|
||||
@@ -111,11 +114,12 @@ func (s *TestService) SeedDatabase() error {
|
||||
Base: model.Base{
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
Name: "Nextcloud",
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -123,8 +127,11 @@ func (s *TestService) SeedDatabase() error {
|
||||
},
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
||||
CreatedByID: users[0].ID,
|
||||
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||
CreatedByID: users[1].ID,
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[1],
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
@@ -163,27 +170,31 @@ func (s *TestService) SeedDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||
// To generate a new key pair, run the following command:
|
||||
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
|
||||
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
|
||||
|
||||
publicKeyPasskey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKeyPasskey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
webauthnCredentials := []model.WebauthnCredential{
|
||||
{
|
||||
Name: "Passkey 1",
|
||||
CredentialID: []byte("test-credential-1"),
|
||||
PublicKey: publicKey1,
|
||||
CredentialID: []byte("test-credential-tim"),
|
||||
PublicKey: publicKeyPasskey1,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Name: "Passkey 2",
|
||||
CredentialID: []byte("test-credential-2"),
|
||||
PublicKey: publicKey2,
|
||||
CredentialID: []byte("test-credential-craig"),
|
||||
PublicKey: publicKeyPasskey2,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
UserID: users[1].ID,
|
||||
},
|
||||
}
|
||||
for _, credential := range webauthnCredentials {
|
||||
@@ -201,6 +212,18 @@ func (s *TestService) SeedDatabase() error {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -280,6 +303,13 @@ func (s *TestService) ResetAppConfig() error {
|
||||
return s.appConfigService.LoadDbConfigFromDb()
|
||||
}
|
||||
|
||||
func (s *TestService) SetJWTKeys() {
|
||||
const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`
|
||||
|
||||
privateKey, _ := jwk.ParseKey([]byte(privateKeyString))
|
||||
s.jwtService.SetKey(privateKey)
|
||||
}
|
||||
|
||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||
|
||||
@@ -2,19 +2,21 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
|
||||
"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 UserGroupService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
||||
return &UserGroupService{db: db}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
@@ -51,7 +53,8 @@ func (s *UserGroupService) Delete(id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.LdapID != nil {
|
||||
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
|
||||
if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
@@ -83,13 +86,13 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if group.LdapID != nil && !allowLdapUpdate {
|
||||
// Disallow updating the group if it is an LDAP group and LDAP is enabled
|
||||
if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
group.Name = input.Name
|
||||
group.FriendlyName = input.FriendlyName
|
||||
group.LdapID = &input.LdapID
|
||||
|
||||
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
@@ -100,16 +103,16 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
|
||||
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)
|
||||
if err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
// Fetch the users based on UserIDs in input
|
||||
// Fetch the users based on the userIds
|
||||
var users []model.User
|
||||
if len(input.UserIDs) > 0 {
|
||||
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
|
||||
if len(userIds) > 0 {
|
||||
if err := s.db.Where("id IN (?)", userIds).Find(&users).Error; err != nil {
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,35 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"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/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
|
||||
@@ -42,20 +49,100 @@ func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils
|
||||
|
||||
func (s *UserService) GetUser(userID string) (model.User, error) {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.LdapID != nil {
|
||||
// Disallow deleting the user if it is an LDAP user and LDAP is enabled
|
||||
if user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -86,7 +173,8 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
if user.LdapID != nil && !allowLdapUpdate {
|
||||
// Disallow updating the user if it is an LDAP group and LDAP is enabled
|
||||
if !allowLdapUpdate && user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return model.User{}, &common.LdapUserUpdateError{}
|
||||
}
|
||||
|
||||
@@ -109,6 +197,11 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
}
|
||||
|
||||
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
||||
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
|
||||
if isDisabled {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
|
||||
// Do not return error if user not found to prevent email enumeration
|
||||
@@ -119,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 {
|
||||
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
|
||||
if strings.HasPrefix(redirectPath, "/") {
|
||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
|
||||
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -137,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
||||
Name: user.Username,
|
||||
Email: user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Link: link,
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
|
||||
@@ -148,7 +244,14 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
||||
}
|
||||
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -190,6 +293,33 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAg
|
||||
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) {
|
||||
var userCount int64
|
||||
if err := s.db.Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||
@@ -235,3 +365,27 @@ func (s *UserService) checkDuplicatedFields(user model.User) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebAuthnService struct {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
var AccessTokenCookieName = "__Host-access_token"
|
||||
|
||||
@@ -45,7 +45,11 @@ func genAddressHeader(name string, addresses []Address, maxLength int) string {
|
||||
} else {
|
||||
email = fmt.Sprintf("<%s>", addr.Email)
|
||||
}
|
||||
writeHeaderQ(hl, addr.Name)
|
||||
if isPrintableASCII(addr.Name) {
|
||||
writeHeaderAtom(hl, addr.Name)
|
||||
} else {
|
||||
writeHeaderQ(hl, addr.Name)
|
||||
}
|
||||
writeHeaderAtom(hl, " ")
|
||||
writeHeaderAtom(hl, email)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/resources"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"path"
|
||||
ttemplate "text/template"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
type Template[V any] struct {
|
||||
@@ -26,7 +27,7 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
|
||||
return templateMap[template.Path]
|
||||
}
|
||||
|
||||
type clonable[V pareseable[V]] interface {
|
||||
type cloneable[V pareseable[V]] interface {
|
||||
Clone() (V, error)
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ type pareseable[V any] interface {
|
||||
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()
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("clone root template: %w", err)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/resources"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func GetFileExtension(filename string) string {
|
||||
splitted := strings.Split(filename, ".")
|
||||
return splitted[len(splitted)-1]
|
||||
ext := filepath.Ext(filename)
|
||||
if len(ext) > 0 && ext[0] == '.' {
|
||||
return ext[1:]
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func GetImageMimeType(ext string) string {
|
||||
@@ -75,3 +78,15 @@ func SaveFile(file *multipart.FileHeader, dst string) error {
|
||||
_, err = io.Copy(out, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// FileExists returns true if a file exists on disk and is a regular file
|
||||
func FileExists(path string) (bool, error) {
|
||||
s, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return !s.IsDir(), nil
|
||||
}
|
||||
|
||||
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 {
|
||||
pageSize = 10
|
||||
pageSize = 20
|
||||
} else if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
@@ -2,32 +2,53 @@ package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
|
||||
func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const charsetLength = int64(len(charset))
|
||||
|
||||
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 {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(charsetLength))
|
||||
if err != nil {
|
||||
return "", err
|
||||
result := strings.Builder{}
|
||||
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 {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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++
|
||||
}
|
||||
result[i] = charset[num.Int64()]
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func GetHostnameFromURL(rawURL string) string {
|
||||
@@ -43,22 +64,41 @@ func StringPointer(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func CapitalizeFirstLetter(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
func CapitalizeFirstLetter(str string) string {
|
||||
if str == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
|
||||
result := strings.Builder{}
|
||||
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 {
|
||||
var result []rune
|
||||
for i, r := range s {
|
||||
func CamelCaseToSnakeCase(str string) string {
|
||||
result := strings.Builder{}
|
||||
result.Grow(int(float32(len(str)) * 1.1))
|
||||
for i, r := range str {
|
||||
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" }}
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 32px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid p {
|
||||
margin: 0;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.button {
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
padding: 0.7rem 1.5rem;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
/* Reset styles for email clients */
|
||||
body, table, td, p, a {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
body {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: 32px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.warning {
|
||||
background-color: #ffd966;
|
||||
color: #7f6000;
|
||||
padding: 4px 12px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
margin: auto 0 auto auto;
|
||||
}
|
||||
.content {
|
||||
background-color: #fafafa;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.grid td {
|
||||
width: 50%;
|
||||
padding-bottom: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.message {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.button {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
padding: 0.7rem 1.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<div class="grid">
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<div>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="message">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>New Sign-In Detected</h2>
|
||||
<table class="grid">
|
||||
<tr>
|
||||
{{ if and .Data.City .Data.Country }}
|
||||
<td>
|
||||
<p class="label">Approximate Location</p>
|
||||
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||
</td>
|
||||
{{ end }}
|
||||
<td>
|
||||
<p class="label">IP Address</p>
|
||||
<p>{{ .Data.IPAddress }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p class="label">Device</p>
|
||||
<p>{{ .Data.Device }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="label">Sign-In Time</p>
|
||||
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="message">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{{ end -}}
|
||||
@@ -1,17 +1,17 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>One-Time Access</h2>
|
||||
<h2>Login Code</h2>
|
||||
<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>
|
||||
<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>
|
||||
{{ end -}}
|
||||
@@ -1,8 +1,10 @@
|
||||
{{ 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 -}}
|
||||
|
||||
@@ -4,5 +4,5 @@ import "embed"
|
||||
|
||||
// Embedded file systems for the project
|
||||
|
||||
//go:embed email-templates images migrations
|
||||
//go:embed email-templates images migrations fonts
|
||||
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 @@
|
||||
DROP TABLE oidc_clients_allowed_user_groups;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user