Compare commits

...

22 Commits

Author SHA1 Message Date
Elias Schneider
6e44b5e367 release: 0.26.0 2025-01-20 11:23:52 +01:00
Elias Schneider
8a1db0cb4a feat: support wildcard callback URLs 2025-01-20 11:19:23 +01:00
Elias Schneider
3f02d08109 fix: non LDAP users get created with a empty LDAP ID string 2025-01-20 10:41:01 +01:00
Elias Schneider
715040ba04 release: 0.25.1 2025-01-19 22:14:29 +01:00
Elias Schneider
a8b9d60a86 fix: disable account details inputs if user is imported from LDAP 2025-01-19 22:14:20 +01:00
Elias Schneider
712ff396f4 release: 0.25.0 2025-01-19 20:18:07 +01:00
Elias Schneider
090eca202d fix: improve spacing of checkboxes on application configuration page 2025-01-19 19:15:11 +01:00
Elias Schneider
d4055af3f4 tests: adapt OIDC tests 2025-01-19 15:56:54 +01:00
Elias Schneider
692ff70c91 refactor: run formatter 2025-01-19 15:41:16 +01:00
Elias Schneider
d5dd118a3f feat: automatically authorize client if signed in 2025-01-19 15:39:55 +01:00
Elias Schneider
06b90eddd6 feat: allow sign in with email (#100) 2025-01-19 15:30:31 +01:00
Elias Schneider
e284e352e2 fix: don't panic if LDAP sync fails on startup 2025-01-19 13:09:16 +01:00
Kyle Mendell
5101b14eec feat: add LDAP sync (#106)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-01-19 13:02:07 +01:00
Elias Schneider
bc8f454ea1 fix: session duration ignored in cookie expiration 2025-01-18 23:27:55 +01:00
Chris Danis
fda08ac1cd fix: always set secure on cookie (#130) 2025-01-18 22:33:41 +01:00
Elias Schneider
05a98ebe87 fix: search input not displayed if response hasn't any items 2025-01-18 22:29:20 +01:00
Elias Schneider
6e3728ddc8 docs: add guide to setup Pocket ID with Caddy 2025-01-15 14:05:44 +01:00
Elias Schneider
5c57beb4d7 release: 0.24.1 2025-01-13 15:14:10 +01:00
Elias Schneider
2a984eeaf1 docs: add account recovery to README 2025-01-13 15:13:56 +01:00
Elias Schneider
be6e25a167 fix: remove restrictive validation for group names 2025-01-13 12:38:02 +01:00
Elias Schneider
888557171d fix: optional arguments not working with create-one-time-access-token.sh 2025-01-13 12:32:22 +01:00
Elias Schneider
4d337a20c5 fix: audit log table overflow if row data is long 2025-01-12 01:21:47 +01:00
205 changed files with 2332 additions and 1049 deletions

View File

@@ -1 +1 @@
0.24.0
0.26.0

View File

@@ -1,3 +1,49 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.25.1...v) (2025-01-20)
### Features
* support wildcard callback URLs ([8a1db0c](https://github.com/stonith404/pocket-id/commit/8a1db0cb4a5d4b32b4fdc19d41fff688a7c71a56))
### Bug Fixes
* non LDAP users get created with a empty LDAP ID string ([3f02d08](https://github.com/stonith404/pocket-id/commit/3f02d081098ad2caaa60a56eea4705639f80d01f))
## [](https://github.com/stonith404/pocket-id/compare/v0.25.0...v) (2025-01-19)
### Bug Fixes
* disable account details inputs if user is imported from LDAP ([a8b9d60](https://github.com/stonith404/pocket-id/commit/a8b9d60a86e80c10d6fba07072b1d32cec400ecb))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.1...v) (2025-01-19)
### Features
* add LDAP sync ([#106](https://github.com/stonith404/pocket-id/issues/106)) ([5101b14](https://github.com/stonith404/pocket-id/commit/5101b14eec68a9507e1730994178d0ebe8185876))
* allow sign in with email ([#100](https://github.com/stonith404/pocket-id/issues/100)) ([06b90ed](https://github.com/stonith404/pocket-id/commit/06b90eddd645cce57813f2536e4a6a8836548f2b))
* automatically authorize client if signed in ([d5dd118](https://github.com/stonith404/pocket-id/commit/d5dd118a3f4ad6eed9ca496c458201bb10f148a0))
### Bug Fixes
* always set secure on cookie ([#130](https://github.com/stonith404/pocket-id/issues/130)) ([fda08ac](https://github.com/stonith404/pocket-id/commit/fda08ac1cd88842e25dc47395ed1288a5cfac4f8))
* don't panic if LDAP sync fails on startup ([e284e35](https://github.com/stonith404/pocket-id/commit/e284e352e2b95fac1d098de3d404e8531de4b869))
* improve spacing of checkboxes on application configuration page ([090eca2](https://github.com/stonith404/pocket-id/commit/090eca202d198852e6fbf4e6bebaf3b5ada13944))
* search input not displayed if response hasn't any items ([05a98eb](https://github.com/stonith404/pocket-id/commit/05a98ebe87d7a88e8b96b144c53250a40d724ec3))
* session duration ignored in cookie expiration ([bc8f454](https://github.com/stonith404/pocket-id/commit/bc8f454ea173ecc60e06450a1d22e24207f76714))
## [](https://github.com/stonith404/pocket-id/compare/v0.24.0...v) (2025-01-13)
### Bug Fixes
* audit log table overflow if row data is long ([4d337a2](https://github.com/stonith404/pocket-id/commit/4d337a20c5cb92ef80bb7402f9b99b08e3ad0b6b))
* optional arguments not working with `create-one-time-access-token.sh` ([8885571](https://github.com/stonith404/pocket-id/commit/888557171d61589211b10f70dce405126216ad61))
* remove restrictive validation for group names ([be6e25a](https://github.com/stonith404/pocket-id/commit/be6e25a167de8bf07075b46f09d9fc1fa6c74426))
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)

View File

@@ -10,9 +10,27 @@ The goal of Pocket ID is to be a simple and easy-to-use. There are other self-ho
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
## Table of Contents
- [ Pocket ID](#-pocket-id)
- [Table of Contents](#table-of-contents)
- [Setup](#setup)
- [Before you start](#before-you-start)
- [Installation with Docker (recommended)](#installation-with-docker-recommended)
- [Unraid](#unraid)
- [Stand-alone Installation](#stand-alone-installation)
- [Nginx Reverse Proxy](#nginx-reverse-proxy)
- [Proxy Services with Pocket ID](#proxy-services-with-pocket-id)
- [Update](#update)
- [Docker](#docker)
- [Stand-alone](#stand-alone)
- [Environment variables](#environment-variables)
- [Account recovery](#account-recovery)
- [Contribute](#contribute)
## Setup
> [!WARNING]
> [!WARNING]
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
### Before you start
@@ -95,7 +113,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
The goal of Pocket ID is to function exclusively as an OIDC provider. As such, we don't have a built-in proxy provider. However, you can use other tools that act as a middleware to protect your services and support OIDC as an authentication provider.
See the [guide](docs/proxy-services.md) for more information.
@@ -157,6 +175,17 @@ docker compose up -d
| `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
## Account recovery
There are two ways to create a one-time access link for a user:
1. **UI**: An admin can create a one-time access link for the user in the admin panel under the "Users" tab by clicking on the three dots next to the user's name and selecting "One-time link".
2. **Terminal**: You can create a one-time access link for a user by running the `scripts/create-one-time-access-token.sh` script. To execute this script with Docker you have to run the following command:
```bash
docker compose exec pocket-id sh "sh scripts/create-one-time-access-token.sh <username or email>"
```
## Contribute
You're very welcome to contribute to Pocket ID! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.

View File

@@ -5,4 +5,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=localhost

3
backend/.gitignore vendored
View File

@@ -13,4 +13,5 @@
# Dependency directories (remove the comment below to include it)
# vendor/
./data
./data
.env

View File

@@ -15,7 +15,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
golang.org/x/crypto v0.27.0
golang.org/x/crypto v0.31.0
golang.org/x/time v0.6.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.6
@@ -23,12 +23,15 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.3 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.5 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-ldap/ldap/v3 v3.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.14 // indirect
@@ -61,10 +64,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.10.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,7 +1,10 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -37,8 +40,12 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -70,11 +77,15 @@ github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -83,6 +94,12 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -146,6 +163,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
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/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.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=
@@ -158,6 +176,7 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -172,27 +191,98 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
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=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.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/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=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,7 +2,6 @@ package bootstrap
import (
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/service"
)
@@ -11,6 +10,5 @@ func Bootstrap() {
appConfigService := service.NewAppConfigService(db)
initApplicationImages()
job.RegisterJobs(db)
initRouter(db, appConfigService)
}

View File

@@ -7,6 +7,7 @@ import (
"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"
"golang.org/x/time/rate"
@@ -37,27 +38,34 @@ 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)
userService := service.NewUserService(db, jwtService, auditLogService, emailService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
// Initialize middleware
job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes
apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
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)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -1,9 +1,10 @@
package common
import (
"log"
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
"log"
)
type DbProvider string

View File

@@ -58,7 +58,9 @@ func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" }
func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{}
@@ -95,7 +97,7 @@ func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbid
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests. Please wait a while before trying again."
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
@@ -160,3 +162,17 @@ func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }

View File

@@ -16,11 +16,13 @@ func NewAppConfigController(
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
acc := &AppConfigController{
appConfigService: appConfigService,
emailService: emailService,
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -34,11 +36,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
}
type AppConfigController struct {
appConfigService *service.AppConfigService
emailService *service.EmailService
ldapService *service.LdapService
}
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -182,6 +186,15 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")

View File

@@ -1,6 +1,9 @@
package controller
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
@@ -8,14 +11,12 @@ import (
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
"net/http"
"time"
)
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{
UserService: userService,
AppConfigService: appConfigService,
userService: userService,
appConfigService: appConfigService,
}
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
@@ -29,11 +30,12 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
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)
}
type UserController struct {
UserService *service.UserService
AppConfigService *service.AppConfigService
userService *service.UserService
appConfigService *service.AppConfigService
}
func (uc *UserController) listUsersHandler(c *gin.Context) {
@@ -44,7 +46,7 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
return
}
users, pagination, err := uc.UserService.ListUsers(searchTerm, sortedPaginationRequest)
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
@@ -63,7 +65,7 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
}
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id"))
user, err := uc.userService.GetUser(c.Param("id"))
if err != nil {
c.Error(err)
return
@@ -79,7 +81,7 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
}
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID"))
user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil {
c.Error(err)
return
@@ -95,7 +97,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
}
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
return
}
@@ -110,7 +112,7 @@ func (uc *UserController) createUserHandler(c *gin.Context) {
return
}
user, err := uc.UserService.CreateUser(input)
user, err := uc.userService.CreateUser(input)
if err != nil {
c.Error(err)
return
@@ -130,7 +132,7 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
}
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
return
}
@@ -144,7 +146,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
return
}
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
c.Error(err)
return
@@ -153,8 +155,24 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"token": token})
}
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return
@@ -166,12 +184,12 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin()
user, token, err := uc.userService.SetupInitialAdmin()
if err != nil {
c.Error(err)
return
@@ -183,7 +201,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
utils.AddAccessTokenCookie(c, uc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
@@ -201,7 +219,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id")
}
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
if err != nil {
c.Error(err)
return

View File

@@ -107,7 +107,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
if err != nil {
c.Error(err)
return

View File

@@ -5,6 +5,7 @@ import (
"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"
"net/http"
"time"
@@ -13,8 +14,8 @@ import (
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
wc := &WebauthnController{webAuthnService: webauthnService}
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, 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)
@@ -29,7 +30,8 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
}
type WebauthnController struct {
webAuthnService *service.WebAuthnService
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
@@ -40,7 +42,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
return
}
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response)
}
@@ -74,7 +76,7 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
return
}
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", false, true)
c.SetCookie("session_id", options.SessionID, int(options.Timeout.Seconds()), "/", "", true, true)
c.JSON(http.StatusOK, options.Response)
}
@@ -103,7 +105,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
utils.AddAccessTokenCookie(c, wc.appConfigService.DbConfig.SessionDuration.Value, token)
c.JSON(http.StatusOK, userDto)
}
@@ -163,6 +165,6 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
c.SetCookie("access_token", "", 0, "/", "", false, true)
utils.AddAccessTokenCookie(c, "0", "")
c.Status(http.StatusNoContent)
}

View File

@@ -12,16 +12,31 @@ type AppConfigVariableDto struct {
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"`
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"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
}

View File

@@ -16,7 +16,7 @@ type OidcClientDto struct {
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
CallbackURLs []string `json:"callbackURLs" binding:"required"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
}

View File

@@ -10,6 +10,7 @@ type UserDto struct {
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
}
type UserCreateDto struct {
@@ -18,9 +19,15 @@ type UserCreateDto struct {
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
LdapID string `json:"-"`
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type OneTimeAccessEmailDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}

View File

@@ -10,6 +10,7 @@ type UserGroupDtoWithUsers struct {
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
@@ -19,12 +20,14 @@ type UserGroupDtoWithUserCount struct {
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"`
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=2,max=255"`
LdapID string `json:"-"`
}
type UserGroupUpdateUsersDto struct {

View File

@@ -4,21 +4,9 @@ import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log"
"net/url"
"regexp"
)
var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
urls := fl.Field().Interface().([]string)
for _, u := range urls {
_, err := url.ParseRequestURI(u)
if err != nil {
return false
}
}
return true
}
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
@@ -28,13 +16,6 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched
}
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain lowercase letters, numbers, and underscores
regex := "^[a-z0-9_]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
@@ -43,23 +24,11 @@ var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
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("userGroupName", validateUserGroupName); 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)

View File

@@ -10,7 +10,7 @@ import (
"time"
)
func RegisterJobs(db *gorm.DB) {
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)

View File

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

View File

@@ -83,8 +83,6 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
case "urlList":
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
}

View File

@@ -16,8 +16,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine
go cleanupClients()
go cleanupClients(&mu, clients)
return func(c *gin.Context) {
ip := c.ClientIP()
@@ -29,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
return
}
limiter := getLimiter(ip, limit, burst)
limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{})
c.Abort()
@@ -45,12 +49,8 @@ type client struct {
lastSeen time.Time
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() {
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for {
time.Sleep(time.Minute)
mu.Lock()
@@ -64,7 +64,7 @@ func cleanupClients() {
}
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter {
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock()
defer mu.Unlock()

View File

@@ -10,16 +10,16 @@ type AppConfigVariable struct {
}
type AppConfig struct {
// General
AppName AppConfigVariable
SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable
// Internal
BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
EmailEnabled AppConfigVariable
// Email
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
@@ -27,4 +27,21 @@ type AppConfig struct {
SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable
LdapBindDn AppConfigVariable
LdapBindPassword AppConfigVariable
LdapBase AppConfigVariable
LdapSkipCertVerify AppConfigVariable
LdapAttributeUserUniqueIdentifier AppConfigVariable
LdapAttributeUserUsername AppConfigVariable
LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable
}

View File

@@ -14,6 +14,7 @@ type User struct {
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
LdapID *string
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`

View File

@@ -3,7 +3,8 @@ package model
type UserGroup struct {
Base
FriendlyName string `sortable:"true"`
Name string `gorm:"unique" sortable:"true"`
Name string `sortable:"true"`
LdapID *string
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
}

View File

@@ -2,15 +2,16 @@ package service
import (
"fmt"
"log"
"mime/multipart"
"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"
"gorm.io/gorm"
"log"
"mime/multipart"
"os"
"reflect"
)
type AppConfigService struct {
@@ -30,6 +31,7 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
}
var defaultDbConfig = model.AppConfig{
// General
AppName: model.AppConfigVariable{
Key: "appName",
Type: "string",
@@ -52,6 +54,7 @@ var defaultDbConfig = model.AppConfig{
IsPublic: true,
DefaultValue: "true",
},
// Internal
BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType",
Type: "string",
@@ -70,11 +73,7 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true,
DefaultValue: "svg",
},
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
DefaultValue: "false",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
@@ -105,6 +104,76 @@ var defaultDbConfig = model.AppConfig{
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
}
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
@@ -119,6 +188,13 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()

View File

@@ -58,8 +58,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
return createdAuditLog
}
// If the user hasn't logged in from the same device before, send an email
if count <= 1 {
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)

View File

@@ -3,7 +3,6 @@ package service
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
@@ -16,8 +15,13 @@ import (
"net/smtp"
"net/textproto"
ttemplate "text/template"
"time"
)
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct {
appConfigService *AppConfigService
db *gorm.DB
@@ -58,11 +62,6 @@ func (srv *EmailService) SendTestEmail(recipientUserId string) error {
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
@@ -112,11 +111,13 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer client.Close()
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
@@ -141,7 +142,11 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
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)
}
@@ -156,7 +161,7 @@ func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string,
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}

View File

@@ -9,7 +9,7 @@ import (
/**
How to add new template:
- pick unique and descriptive template ${name} (for example "login-with-new-device")
- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
- Path *must* be ${name}
- add xxxTemplate.Path to "emailTemplatePaths" at the end
@@ -27,6 +27,13 @@ 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"
},
}
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
@@ -42,5 +49,9 @@ type NewLoginTemplateData struct {
DateTime time.Time
}
type OneTimeAccessTemplateData = struct {
Link string
}
// this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}

View File

@@ -0,0 +1,261 @@
package service
import (
"crypto/tls"
"fmt"
"log"
"strings"
"github.com/go-ldap/ldap/v3"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type LdapService struct {
db *gorm.DB
appConfigService *AppConfigService
userService *UserService
groupService *UserGroupService
}
func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService) *LdapService {
return &LdapService{db: db, appConfigService: appConfigService, userService: userService, groupService: groupService}
}
func (s *LdapService) createClient() (*ldap.Conn, error) {
if s.appConfigService.DbConfig.LdapEnabled.Value != "true" {
return nil, fmt.Errorf("LDAP is not enabled")
}
// Setup LDAP connection
ldapURL := s.appConfigService.DbConfig.LdapUrl.Value
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true"
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify}))
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
}
// Bind as service account
bindDn := s.appConfigService.DbConfig.LdapBindDn.Value
bindPassword := s.appConfigService.DbConfig.LdapBindPassword.Value
err = client.Bind(bindDn, bindPassword)
if err != nil {
return nil, fmt.Errorf("failed to bind to LDAP: %w", err)
}
return client, nil
}
func (s *LdapService) SyncAll() error {
err := s.SyncUsers()
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
err = s.SyncGroups()
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
return nil
}
func (s *LdapService) SyncGroups() error {
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
filter := "(objectClass=groupOfUniqueNames)"
searchAttrs := []string{
nameAttribute,
uniqueIdentifierAttribute,
"member",
}
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
result, err := client.Search(searchReq)
if err != nil {
return fmt.Errorf("failed to query LDAP: %w", err)
}
// Create a mapping for groups that exist
ldapGroupIDs := make(map[string]bool)
for _, value := range result.Entries {
var usersToAddDto dto.UserGroupUpdateUsersDto
var membersUserId []string
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
ldapGroupIDs[ldapId] = true
// Try to find the group in the database
var databaseGroup model.UserGroup
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues("member")
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)
membersUserId = append(membersUserId, databaseUser.ID)
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(nameAttribute),
FriendlyName: value.GetAttributeValue(nameAttribute),
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 {
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)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
return err
}
}
}
// Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup
if err := s.db.Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch groups from database: %v", err))
}
// Delete groups that no longer exist in LDAP
for _, group := range ldapGroupsInDb {
if _, exists := ldapGroupIDs[*group.LdapID]; !exists {
if err := s.db.Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).Error; err != nil {
log.Printf("Failed to delete group %s with: %v", group.Name, err)
} else {
log.Printf("Deleted group %s", group.Name)
}
}
}
return nil
}
func (s *LdapService) SyncUsers() error {
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
baseDN := s.appConfigService.DbConfig.LdapBase.Value
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeUserUniqueIdentifier.Value
usernameAttribute := s.appConfigService.DbConfig.LdapAttributeUserUsername.Value
emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value
firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value
lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value
adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value
filter := "(objectClass=person)"
searchAttrs := []string{
"memberOf",
"sn",
"cn",
uniqueIdentifierAttribute,
usernameAttribute,
emailAttribute,
firstNameAttribute,
lastNameAttribute,
}
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
result, err := client.Search(searchReq)
if err != nil {
fmt.Println(fmt.Errorf("failed to query LDAP: %w", err))
}
// Create a mapping for users that exist
ldapUserIDs := make(map[string]bool)
for _, value := range result.Entries {
ldapId := value.GetAttributeValue(uniqueIdentifierAttribute)
ldapUserIDs[ldapId] = true
// Get the user from the database
var databaseUser model.User
s.db.Where("ldap_id = ?", ldapId).First(&databaseUser)
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if strings.Contains(group, adminGroupAttribute) {
isAdmin = true
}
}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(usernameAttribute),
Email: value.GetAttributeValue(emailAttribute),
FirstName: value.GetAttributeValue(firstNameAttribute),
LastName: value.GetAttributeValue(lastNameAttribute),
IsAdmin: isAdmin,
LdapID: ldapId,
}
if databaseUser.ID == "" {
_, err = s.userService.CreateUser(newUser)
if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err)
}
} else {
_, err = s.userService.UpdateUser(databaseUser.ID, newUser, false, true)
if err != nil {
log.Printf("Error syncing user %s: %s", newUser.Username, err)
}
}
}
// Get all LDAP users from the database
var ldapUsersInDb []model.User
if err := s.db.Find(&ldapUsersInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch users from database: %v", err))
}
// 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 {
log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else {
log.Printf("Deleted user %s", user.Username)
}
}
}
return nil
}

View File

@@ -14,7 +14,7 @@ import (
"gorm.io/gorm"
"mime/multipart"
"os"
"slices"
"regexp"
"strings"
"time"
)
@@ -432,8 +432,16 @@ func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL s
if inputCallbackURL == "" {
return client.CallbackURLs[0], nil
}
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
return inputCallbackURL, nil
for _, callbackPattern := range client.CallbackURLs {
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil {
return "", err
}
if matched {
return inputCallbackURL, nil
}
}
return "", &common.OidcInvalidCallbackURLError{}

View File

@@ -51,6 +51,10 @@ func (s *UserGroupService) Delete(id string) error {
return err
}
if group.LdapID != nil {
return &common.LdapUserGroupUpdateError{}
}
return s.db.Delete(&group).Error
}
@@ -60,6 +64,10 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
Name: input.Name,
}
if input.LdapID != "" {
group.LdapID = &input.LdapID
}
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
@@ -69,14 +77,19 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
return group, nil
}
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allowLdapUpdate bool) (group model.UserGroup, err error) {
group, err = s.Get(id)
if err != nil {
return model.UserGroup{}, err
}
if group.LdapID != nil && !allowLdapUpdate {
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) {

View File

@@ -2,12 +2,17 @@ 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"
"log"
"net/url"
"strings"
"time"
)
@@ -15,10 +20,11 @@ type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
}
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
@@ -46,6 +52,10 @@ func (s *UserService) DeleteUser(userID string) error {
return err
}
if user.LdapID != nil {
return &common.LdapUserUpdateError{}
}
return s.db.Delete(&user).Error
}
@@ -57,6 +67,10 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
Username: input.Username,
IsAdmin: input.IsAdmin,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
}
if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user)
@@ -66,11 +80,16 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
return user, nil
}
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, allowLdapUpdate bool) (model.User, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err
}
if user.LdapID != nil && !allowLdapUpdate {
return model.User{}, &common.LdapUserUpdateError{}
}
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
@@ -89,7 +108,46 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
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
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if err != nil {
return err
}
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
go func() {
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link,
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
return nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", err
@@ -105,12 +163,10 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err
}
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -127,6 +183,10 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
return model.User{}, "", err
}
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
}
return oneTimeAccessToken.User, accessToken, nil
}

View File

@@ -0,0 +1,12 @@
package utils
import (
"github.com/gin-gonic/gin"
"strconv"
)
func AddAccessTokenCookie(c *gin.Context, sessionDurationInMinutes string, token string) {
sessionDurationInMinutesParsed, _ := strconv.Atoi(sessionDurationInMinutes)
maxAge := sessionDurationInMinutesParsed * 60
c.SetCookie("access_token", token, maxAge, "/", "", true, true)
}

View File

@@ -9,8 +9,6 @@ import (
ttemplate "text/template"
)
const templateComponentsDir = "components"
type Template[V any] struct {
Path string
Title func(data *TemplateData[V]) string

View File

@@ -63,8 +63,13 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
return PaginationResponse{}, err
}
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
return PaginationResponse{
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,

View File

@@ -76,5 +76,20 @@
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;
}
</style>
{{ end }}

View File

@@ -1,7 +1,7 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>

View File

@@ -0,0 +1,17 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>One-Time Access</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.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -0,0 +1,8 @@
{{ define "base" -}}
One-Time Access
====================
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
{{ .Data.Link }}
{{ end -}}

View File

@@ -1,7 +1,7 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>

View File

@@ -0,0 +1,5 @@
ALTER TABLE users
DROP COLUMN ldap_id;
ALTER TABLE user_groups
DROP COLUMN ldap_id;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN ldap_id TEXT;
ALTER TABLE user_groups ADD COLUMN ldap_id TEXT;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id);

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = '' WHERE ldap_id IS NULL;
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = null WHERE ldap_id = '';
UPDATE user_groups SET ldap_id = null WHERE ldap_id = '';

View File

@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN ldap_id;
ALTER TABLE user_groups DROP COLUMN ldap_id;

View File

@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN ldap_id TEXT;
ALTER TABLE user_groups ADD COLUMN ldap_id TEXT;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id);

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = '' WHERE ldap_id IS NULL;
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;

View File

@@ -0,0 +1,2 @@
UPDATE users SET ldap_id = null WHERE ldap_id = '';
UPDATE user_groups SET ldap_id = null WHERE ldap_id = '';

View File

@@ -1,8 +1,105 @@
# Proxy Services through Pocket ID
The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID.
The goal of Pocket ID is to function exclusively as an OIDC provider. As such, we don't have a built-in proxy provider. However, you can use other tools that act as a middleware to protect your services and support OIDC as an authentication provider.
## Docker Setup
There are two ways to do this:
- Implement OIDC into your reverse proxy
- Use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
## Reverse Proxy
Almost every reverse proxy somehow supports protecting your services with OIDC. Currently only Caddy is documented but you can search on Google for your reverse proxy and OIDC.
We would really appreciate if you contribute to this documentation by adding your reverse proxy and how to configure it with Pocket ID.
### Caddy
With [caddy-security](https://github.com/greenpau/caddy-security) you can easily protect your services with Pocket ID.
#### 1. Create a new OIDC client in Pocket ID.
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. Now enter `https://<domain-of-proxied-service>/auth/oauth2/generic/authorization-code-callback` as the callback URL. After adding the client, you will obtain the client ID and client secret, which you will need in the next step.
#### 2. Install caddy-security
Run the following command to install caddy-security:
```bash
caddy add-package github.com/greenpau/caddy-security
```
#### 3. Create your Caddyfile
```bash
{
# Port to listen on
http_port 443
# Configure caddy-security.
order authenticate before respond
security {
oauth identity provider generic {
realm generic
driver generic
client_id client-id-from-pocket-id # Replace with your own client ID
client_secret client-secret-from-pocket-id # Replace with your own client secret
scopes openid email profile
base_auth_url http://localhost
metadata_url http://localhost/.well-known/openid-configuration
}
authentication portal myportal {
crypto default token lifetime 3600 # Seconds until you have to re-authenticate
enable identity provider generic
cookie insecure off # Set to "on" if you're not using HTTPS
transform user {
match realm generic
action add role user
}
}
authorization policy mypolicy {
set auth url /auth/oauth2/generic
allow roles user
inject headers with claims
}
}
}
https://<domain-of-your-service> {
@auth {
path /auth/oauth2/generic
path /auth/oauth2/generic/authorization-code-callback
}
route @auth {
authenticate with myportal
}
route /* {
authorize with mypolicy
reverse_proxy http://<service-to-be-proxied>:<port> # Replace with your own service
}
}
```
For additional configuration options, refer to the official [caddy-security documentation](https://docs.authcrunch.com/docs/intro).
#### 4. Start Caddy
```bash
caddy run --config Caddyfile
```
#### 5. Access the service
Your service should now be protected by Pocket ID.
## OAuth2 Proxy
### Docker Installation
#### 1. Add OAuth2 proxy to the service that should be proxied.
@@ -74,7 +171,7 @@ docker compose up -d
You can now access the service through OAuth2 Proxy by visiting `http://localhost:4180`.
## Standalone Installation
### Standalone Installation
Setting up OAuth2 Proxy with Pocket ID without Docker is similar to the Docker setup. As the setup depends on your environment, you have to adjust the steps accordingly but is should be similar to the Docker setup.

View File

@@ -11,4 +11,4 @@
"utils": "$lib/utils/style"
},
"typescript": true
}
}

View File

@@ -32,7 +32,7 @@ export default [
},
{
rules: {
"@typescript-eslint/no-explicit-any": "off"
'@typescript-eslint/no-explicit-any': 'off'
}
}
];

View File

@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
"version": "0.10.0",
"version": "0.24.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.10.0",
"version": "0.24.1",
"dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"axios": "^1.7.7",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.24.0",
"version": "0.26.0",
"private": true,
"scripts": {
"dev": "vite dev --port 3000",

View File

@@ -97,4 +97,4 @@
font-weight: 700;
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
}
}
}

View File

@@ -17,6 +17,7 @@
requestOptions = $bindable(),
selectedIds = $bindable(),
withoutSearch = false,
selectionDisabled = false,
defaultSort,
onRefresh,
columns,
@@ -26,12 +27,15 @@
requestOptions?: SearchPaginationSortRequest;
selectedIds?: string[];
withoutSearch?: boolean;
selectionDisabled?: boolean;
defaultSort?: { column: string; direction: 'asc' | 'desc' };
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
rows: Snippet<[{ item: T }]>;
} = $props();
let searchValue = $state('');
if (!requestOptions) {
requestOptions = {
search: '',
@@ -55,9 +59,10 @@
return true;
});
const onSearch = debounced(async (searchValue: string) => {
requestOptions.search = searchValue;
onRefresh(requestOptions);
const onSearch = debounced(async (search: string) => {
requestOptions.search = search;
await onRefresh(requestOptions);
searchValue = search;
}, 300);
async function onAllCheck(checked: boolean) {
@@ -95,124 +100,129 @@
}
</script>
{#if items.data.length === 0}
{#if !withoutSearch}
<Input
value={searchValue}
class={cn(
'relative z-50 mb-4 max-w-sm',
items.data.length == 0 && searchValue == '' && 'hidden'
)}
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
{#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
<Empty class="h-20 text-muted-foreground" />
<p class="mt-3 text-sm text-muted-foreground">No items found</p>
</div>
{:else}
<div class="w-full">
{#if !withoutSearch}
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head>
{/if}
{#each columns as column}
<Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
{#if column.sortColumn}
<Button
variant="ghost"
class="flex items-center"
on:click={() =>
onSort(
column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
)}
>
{column.label}
{#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown
class={cn(
'ml-2 h-4 w-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)}
/>
{/if}
</Button>
{:else}
<Table.Root class="min-w-full table-auto overflow-x-auto">
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head class="w-12">
<Checkbox
disabled={selectionDisabled}
checked={allChecked}
onCheckedChange={(c) => onAllCheck(c as boolean)}
/>
</Table.Head>
{/if}
{#each columns as column}
<Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
{#if column.sortColumn}
<Button
variant="ghost"
class="flex items-center"
on:click={() =>
onSort(
column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
)}
>
{column.label}
{/if}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown
class={cn(
'ml-2 h-4 w-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)}
/>
{/if}
</Button>
{:else}
{column.label}
{/if}
{@render rows({ item })}
</Table.Row>
</Table.Head>
{/each}
</Table.Body>
</Table.Root>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell class="w-12">
<Checkbox
disabled={selectionDisabled}
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div
class="mt-5 flex flex-col-reverse items-center justify-between gap-3 space-x-2 sm:flex-row"
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis'}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div>
{/if}

View File

@@ -103,7 +103,7 @@
onkeydown={(e) => {
if (e.key === 'Enter') handleSuggestionClick(suggestion);
}}
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
index
? 'bg-accent text-accent-foreground'
: ''}"

View File

@@ -19,7 +19,7 @@
} = $props();
</script>
<div class="items-top mt-5 flex space-x-2">
<div class="items-top flex space-x-2">
<Checkbox
{id}
{disabled}
@@ -31,7 +31,7 @@
{label}
</Label>
{#if description}
<p class="text-muted-foreground text-[0.8rem]">
<p class="text-[0.8rem] text-muted-foreground">
{description}
</p>
{/if}

View File

@@ -6,7 +6,7 @@
</script>
<div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="text-muted-foreground h-12 w-12" />
<LucideXCircle class="h-12 w-12 text-muted-foreground" />
<h1 class="mt-3 text-2xl font-semibold">Something went wrong</h1>
<p class="text-muted-foreground">{message}</p>
{#if showButton}

View File

@@ -9,6 +9,7 @@
input = $bindable(),
label,
description,
placeholder,
disabled = false,
type = 'text',
children,
@@ -18,6 +19,7 @@
input?: FormInput<string | boolean | number>;
label?: string;
description?: string;
placeholder?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
onInput?: (e: FormInputEvent) => void;
@@ -32,13 +34,20 @@
<Label class="mb-0" for={id}>{label}</Label>
{/if}
{#if description}
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
<p class="mt-1 text-xs text-muted-foreground">{description}</p>
{/if}
<div class={label || description ? 'mt-2' : ''}>
{#if children}
{@render children()}
{:else if input}
<Input {id} {type} bind:value={input.value} {disabled} on:input={(e) => onInput?.(e)} />
<Input
{id}
{placeholder}
{type}
bind:value={input.value}
{disabled}
on:input={(e) => onInput?.(e)}
/>
{/if}
{#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p>

View File

@@ -39,7 +39,7 @@
{$userStore?.firstName}
{$userStore?.lastName}
</p>
<p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p>
<p class="text-xs leading-none text-muted-foreground">{$userStore?.email}</p>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />

View File

@@ -2,22 +2,44 @@
import { browser } from '$app/environment';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import type { Snippet } from 'svelte';
import { Button } from './ui/button';
import * as Card from './ui/card';
import WebAuthnUnsupported from './web-authn-unsupported.svelte';
import { page } from '$app/stores';
let {
children
children,
showEmailOneTimeAccessButton = false
}: {
children: Snippet;
showEmailOneTimeAccessButton?: boolean;
} = $props();
</script>
<!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex">
<div class="min-w-[650px] p-16">
<div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
{#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported />
{:else}
{@render children()}
<div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
{@render children()}
</div>
{#if showEmailOneTimeAccessButton}
<div class="mb-4 flex justify-center">
<Button
href="/login/email?redirect={encodeURIComponent(
$page.url.pathname + $page.url.search
)}"
variant="link"
class="text-xs text-muted-foreground"
>
Don't have access to your passkey?
</Button>
</div>
{/if}
</div>
{/if}
</div>
<img
@@ -27,15 +49,31 @@
/>
</div>
<!-- Mobile -->
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
>
<Card.Root class="mx-3">
<Card.CardContent class="px-4 py-10 sm:p-10">
<Card.CardContent
class="px-4 py-10 sm:p-10 {showEmailOneTimeAccessButton ? 'pb-3 sm:pb-3' : ''}"
>
{#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported />
{:else}
{@render children()}
{#if showEmailOneTimeAccessButton}
<div class="mt-5">
<Button
href="/login/email?redirect={encodeURIComponent(
$page.url.pathname + $page.url.search
)}"
variant="link"
class="text-xs text-muted-foreground"
>
Don't have access to your passkey?
</Button>
</div>
{/if}
{/if}
</Card.CardContent>
</Card.Root>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils/style.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.ActionProps;
type $$Events = AlertDialogPrimitive.ActionEvents;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils/style.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.CancelProps;
type $$Events = AlertDialogPrimitive.CancelEvents;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<AlertDialogPrimitive.Cancel
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
class={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...$$restProps}
on:click
on:keydown

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import * as AlertDialog from "./index.js";
import { cn, flyAndScale } from "$lib/utils/style.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import * as AlertDialog from './index.js';
import { cn, flyAndScale } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.ContentProps;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = undefined;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
@@ -18,7 +18,7 @@
{transition}
{transitionConfig}
class={cn(
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
className
)}
{...$$restProps}

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.DescriptionProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<AlertDialogPrimitive.Description
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-sm text-muted-foreground', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...$$restProps}>
<div class={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { fade } from "svelte/transition";
import { cn } from "$lib/utils/style.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { fade } from 'svelte/transition';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.OverlayProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = fade;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 150,
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = fade;
export let transitionConfig: $$Props['transitionConfig'] = {
duration: 150
};
export { className as class };
</script>
@@ -16,6 +16,6 @@
<AlertDialogPrimitive.Overlay
{transition}
{transitionConfig}
class={cn("bg-background/80 fixed inset-0 z-50 backdrop-blur-sm ", className)}
class={cn('fixed inset-0 z-50 bg-background/80 backdrop-blur-sm ', className)}
{...$$restProps}
/>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
type $$Props = AlertDialogPrimitive.PortalProps;
</script>

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
type $$Props = AlertDialogPrimitive.TitleProps;
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h3";
let className: $$Props['class'] = undefined;
export let level: $$Props['level'] = 'h3';
export { className as class };
</script>
<AlertDialogPrimitive.Title class={cn("text-lg font-semibold", className)} {level} {...$$restProps}>
<AlertDialogPrimitive.Title class={cn('text-lg font-semibold', className)} {level} {...$$restProps}>
<slot />
</AlertDialogPrimitive.Title>

View File

@@ -1,14 +1,14 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
import Cancel from './alert-dialog-cancel.svelte';
import Portal from './alert-dialog-portal.svelte';
import Footer from './alert-dialog-footer.svelte';
import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger;
@@ -36,5 +36,5 @@ export {
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Description as AlertDialogDescription
};

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
<div class={cn('text-sm [&_p]:leading-relaxed', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import type { HeadingLevel } from './index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
level?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h5";
let className: $$Props['class'] = undefined;
export let level: $$Props['level'] = 'h5';
export { className as class };
</script>
<svelte:element
this={level}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
class={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type Variant, alertVariants } from "./index.js";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { type Variant, alertVariants } from './index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement> & {
variant?: Variant;
};
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
let className: $$Props['class'] = undefined;
export let variant: $$Props['variant'] = 'default';
export { className as class };
</script>

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
type $$Props = AvatarPrimitive.FallbackProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Fallback
class={cn("bg-muted flex h-full w-full items-center justify-center rounded-full", className)}
class={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
type $$Props = AvatarPrimitive.ImageProps;
let className: $$Props["class"] = undefined;
export let src: $$Props["src"] = undefined;
export let alt: $$Props["alt"] = undefined;
let className: $$Props['class'] = undefined;
export let src: $$Props['src'] = undefined;
export let alt: $$Props['alt'] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Image
{src}
{alt}
class={cn("aspect-square h-full w-full", className)}
class={cn('aspect-square h-full w-full', className)}
{...$$restProps}
/>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
type $$Props = AvatarPrimitive.Props;
let className: $$Props["class"] = undefined;
export let delayMs: $$Props["delayMs"] = undefined;
let className: $$Props['class'] = undefined;
export let delayMs: $$Props['delayMs'] = undefined;
export { className as class };
</script>
<AvatarPrimitive.Root
{delayMs}
class={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,6 +1,6 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
import Root from './avatar.svelte';
import Image from './avatar-image.svelte';
import Fallback from './avatar-fallback.svelte';
export {
Root,
@@ -9,5 +9,5 @@ export {
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
Fallback as AvatarFallback
};

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { type Variant, badgeVariants } from "./index.js";
import { cn } from "$lib/utils/style.js";
import { type Variant, badgeVariants } from './index.js';
import { cn } from '$lib/utils/style.js';
let className: string | undefined | null = undefined;
export let href: string | undefined = undefined;
export let variant: Variant = "default";
export let variant: Variant = 'default';
export { className as class };
</script>
<svelte:element
this={href ? "a" : "span"}
this={href ? 'a' : 'span'}
{href}
class={cn(badgeVariants({ variant, className }))}
{...$$restProps}

View File

@@ -1,21 +1,20 @@
import { type VariantProps, tv } from "tailwind-variants";
export { default as Badge } from "./badge.svelte";
import { type VariantProps, tv } from 'tailwind-variants';
export { default as Badge } from './badge.svelte';
export const badgeVariants = tv({
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap",
base: 'inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap',
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground'
}
},
defaultVariants: {
variant: "default",
},
variant: 'default'
}
});
export type Variant = VariantProps<typeof badgeVariants>["variant"];
export type Variant = VariantProps<typeof badgeVariants>['variant'];

View File

@@ -1,34 +1,33 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
import { type VariantProps, tv } from 'tailwind-variants';
import type { Button as ButtonPrimitive } from 'bits-ui';
import Root from './button.svelte';
const buttonVariants = tv({
base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: "default",
size: "default",
},
variant: 'default',
size: 'default'
}
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Variant = VariantProps<typeof buttonVariants>['variant'];
type Size = VariantProps<typeof buttonVariants>['size'];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
@@ -46,5 +45,5 @@ export {
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
buttonVariants
};

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn("p-6 pt-0", className)} {...$$restProps}>
<div class={cn('p-6 pt-0', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLParagraphElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<p class={cn("text-sm text-muted-foreground", className)} {...$$restProps}>
<p class={cn('text-sm text-muted-foreground', className)} {...$$restProps}>
<slot />
</p>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn("flex items-center p-6 pt-0", className)} {...$$restProps}>
<div class={cn('flex items-center p-6 pt-0', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div class={cn("flex flex-col space-y-1.5 p-6", className)} {...$$restProps}>
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import type { HeadingLevel } from './index.js';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
tag?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let tag: $$Props["tag"] = "h3";
let className: $$Props['class'] = undefined;
export let tag: $$Props['tag'] = 'h3';
export { className as class };
</script>
<svelte:element
this={tag}
class={cn("text-xl font-semibold leading-none tracking-tight", className)}
class={cn('text-xl font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div
class={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
class={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,9 +1,9 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Footer from './card-footer.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
export {
Root,
@@ -18,7 +18,7 @@ export {
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Title as CardTitle
};
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import Check from "lucide-svelte/icons/check";
import Minus from "lucide-svelte/icons/minus";
import { cn } from "$lib/utils/style.js";
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import Check from 'lucide-svelte/icons/check';
import Minus from 'lucide-svelte/icons/minus';
import { cn } from '$lib/utils/style.js';
type $$Props = CheckboxPrimitive.Props;
type $$Events = CheckboxPrimitive.Events;
let className: $$Props["class"] = undefined;
export let checked: $$Props["checked"] = false;
let className: $$Props['class'] = undefined;
export let checked: $$Props['checked'] = false;
export { className as class };
</script>
<CheckboxPrimitive.Root
class={cn(
"peer box-content h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50",
'peer box-content h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[disabled=true]:opacity-50',
className
)}
bind:checked
@@ -22,7 +22,7 @@
on:click
>
<CheckboxPrimitive.Indicator
class={cn("flex h-4 w-4 items-center justify-center text-current")}
class={cn('flex h-4 w-4 items-center justify-center text-current')}
let:isChecked
let:isIndeterminate
>

View File

@@ -1,6 +1,6 @@
import Root from "./checkbox.svelte";
import Root from './checkbox.svelte';
export {
Root,
//
Root as Checkbox,
Root as Checkbox
};

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import X from "lucide-svelte/icons/x";
import * as Dialog from "./index.js";
import { cn, flyAndScale } from "$lib/utils/style.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import * as Dialog from './index.js';
import { cn, flyAndScale } from '$lib/utils/style.js';
type $$Props = DialogPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = {
duration: 200,
let className: $$Props['class'] = undefined;
export let transition: $$Props['transition'] = flyAndScale;
export let transitionConfig: $$Props['transitionConfig'] = {
duration: 200
};
export { className as class };
</script>
@@ -20,14 +20,14 @@
{transition}
{transitionConfig}
class={cn(
"bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full",
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full',
className
)}
{...$$restProps}
>
<slot />
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="h-4 w-4" />
<span class="sr-only">Close</span>

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/style.js';
type $$Props = DialogPrimitive.DescriptionProps;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<DialogPrimitive.Description
class={cn("text-muted-foreground text-sm", className)}
class={cn('text-sm text-muted-foreground', className)}
{...$$restProps}
>
<slot />

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
let className: $$Props['class'] = undefined;
export { className as class };
</script>
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...$$restProps}
>
<slot />

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