Compare commits

..

27 Commits

Author SHA1 Message Date
Elias Schneider
1f0ec08290 release: 0.5.0 2024-09-09 10:30:12 +02:00
Elias Schneider
9121239dd7 feat: add audit log with email notification (#26) 2024-09-09 10:29:41 +02:00
Elias Schneider
4010ee27d6 release: 0.4.1 2024-09-06 09:24:42 +02:00
Elias Schneider
4e7574a297 feat: add name claim to userinfo endpoint and id token 2024-09-06 09:19:13 +02:00
Elias Schneider
8038a111dd fix: show error message if error occurs while authorizing new client 2024-09-06 08:58:23 +02:00
Elias Schneider
c6f83a581a fix: limit width of content on large screens 2024-09-06 08:57:48 +02:00
Elias Schneider
8ad632e6c1 release: 0.4.0 2024-09-03 22:42:22 +02:00
Elias Schneider
903b0b3918 feat: add support for more username formats 2024-09-03 22:35:18 +02:00
Elias Schneider
fd21ce5aac feat: add setup details to oidc client details 2024-09-03 22:24:29 +02:00
Elias Schneider
e7861df95a fix: non pointer passed to create user 2024-08-28 08:43:44 +02:00
Elias Schneider
8e27320649 refactor: rename user service 2024-08-28 08:22:27 +02:00
Elias Schneider
2b9413c757 fix: typo in hasLogo property of oidc dto 2024-08-28 08:21:46 +02:00
Elias Schneider
fd5a881cfb Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-27 23:27:03 +02:00
Elias Schneider
28ed064668 fix: oidc client logo not displayed on authorize page 2024-08-27 23:26:56 +02:00
Elias Schneider
5446b46b65 Merge pull request #17 from stonith404/imgbot
[ImgBot] Optimize images
2024-08-26 13:59:58 +02:00
ImgBotApp
0ce6045657 [ImgBot] Optimize images
*Total -- 4,659.87kb -> 4,503.61kb (3.35%)

/frontend/tests/assets/nextcloud-logo.png -- 163.47kb -> 87.99kb (46.17%)
/frontend/tests/assets/pingvin-share-logo.png -- 85.61kb -> 58.04kb (32.2%)
/backend/images/logo.svg -- 0.68kb -> 0.53kb (22.56%)
/frontend/tests/assets/clouds.jpg -- 577.56kb -> 528.14kb (8.56%)
/backend/images/background.jpg -- 3,832.55kb -> 3,828.92kb (0.09%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-08-26 11:10:18 +00:00
Elias Schneider
3fe24a04de release: 0.3.1 2024-08-24 12:59:23 +02:00
Elias Schneider
6769cc8c10 tests: fix missing host in cleanup request 2024-08-24 12:50:27 +02:00
Elias Schneider
97f7fc4e28 fix: empty lists don't get returned correctly from the api 2024-08-24 12:44:02 +02:00
Elias Schneider
fc47c2a2a4 chore: upgrade dependencies 2024-08-24 12:43:22 +02:00
Elias Schneider
f1a6c8db85 release: 0.3.0 2024-08-24 01:20:18 +02:00
Elias Schneider
552d7ccfa5 fix: db migration for multiple callback urls 2024-08-24 01:12:33 +02:00
Elias Schneider
e45b0b3ed0 Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-24 00:49:11 +02:00
Elias Schneider
8166e2ead7 feat: add support for multiple callback urls 2024-08-24 00:49:08 +02:00
Elias Schneider
ae7aeb0945 refactor: use dtos in controllers 2024-08-23 17:04:19 +02:00
Elias Schneider
16f273ffce docs: compress screenshot in README 2024-08-23 16:39:09 +02:00
Elias Schneider
9f49e5577e docs: add proxy guide 2024-08-20 22:40:28 +02:00
95 changed files with 2436 additions and 916 deletions

View File

@@ -1 +1,2 @@
PUBLIC_APP_URL=http://localhost PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false

View File

@@ -1 +1 @@
0.2.1 0.5.0

View File

@@ -1,3 +1,57 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
### Features
* add audit log with email notification ([#26](https://github.com/stonith404/pocket-id/issues/26)) ([9121239](https://github.com/stonith404/pocket-id/commit/9121239dd7c14a2107a984f9f94f54227489a63a))
## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06)
### Features
* add name claim to userinfo endpoint and id token ([4e7574a](https://github.com/stonith404/pocket-id/commit/4e7574a297307395603267c7a3285d538d4111d8))
### Bug Fixes
* limit width of content on large screens ([c6f83a5](https://github.com/stonith404/pocket-id/commit/c6f83a581ad385391d77fec7eeb385060742f097))
* show error message if error occurs while authorizing new client ([8038a11](https://github.com/stonith404/pocket-id/commit/8038a111dd7fa8f5d421b29c3bc0c11d865dc71b))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.1...v) (2024-09-03)
### Features
* add setup details to oidc client details ([fd21ce5](https://github.com/stonith404/pocket-id/commit/fd21ce5aac1daeba04e4e7399a0720338ea710c2))
* add support for more username formats ([903b0b3](https://github.com/stonith404/pocket-id/commit/903b0b39181c208e9411ee61849d2671e7c56dc5))
### Bug Fixes
* non pointer passed to create user ([e7861df](https://github.com/stonith404/pocket-id/commit/e7861df95a6beecab359d1c56f4383373f74bb73))
* oidc client logo not displayed on authorize page ([28ed064](https://github.com/stonith404/pocket-id/commit/28ed064668afeec8f80adda59ba94f1fc2fbce17))
* typo in hasLogo property of oidc dto ([2b9413c](https://github.com/stonith404/pocket-id/commit/2b9413c7575e1322f8547490a9b02a1836bad549))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.0...v) (2024-08-24)
### Bug Fixes
* empty lists don't get returned correctly from the api ([97f7fc4](https://github.com/stonith404/pocket-id/commit/97f7fc4e288c2bb49210072a7a151b58ef44f5b5))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.1...v) (2024-08-23)
### Features
* add support for multiple callback urls ([8166e2e](https://github.com/stonith404/pocket-id/commit/8166e2ead7fc71a0b7a45950b05c5c65a60833b6))
### Bug Fixes
* db migration for multiple callback urls ([552d7cc](https://github.com/stonith404/pocket-id/commit/552d7ccfa58d7922ecb94bdfe6a86651b4cf2745))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19) ## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19)

View File

@@ -61,7 +61,7 @@ You're all set!
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself. We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
#### Setup #### Setup
Run `caddy run --config Caddyfile` in the root folder. Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
### Testing ### Testing

View File

@@ -8,7 +8,7 @@ RUN npm run build
RUN npm prune --production RUN npm prune --production
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.22-alpine AS backend-builder FROM golang:1.23-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
COPY ./backend/go.mod ./backend/go.sum ./ COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download RUN go mod download
@@ -22,7 +22,7 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
# Stage 3: Production Image # Stage 3: Production Image
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache caddy RUN apk add --no-cache caddy
COPY ./Caddyfile /etc/caddy/Caddyfile COPY ./reverse-proxy /etc/caddy/
WORKDIR /app WORKDIR /app
COPY --from=frontend-builder /app/frontend/build ./frontend/build COPY --from=frontend-builder /app/frontend/build ./frontend/build
@@ -31,6 +31,7 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts COPY ./scripts ./scripts

View File

@@ -2,7 +2,7 @@
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services. Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
<img src="https://github.com/user-attachments/assets/783dc0c1-1580-476b-9bb1-d9ef1077bc1e" width="1200"/> <img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases. The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
@@ -41,7 +41,7 @@ Pocket ID is available as a template on the Community Apps store.
Required tools: Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 20 - [Node.js](https://nodejs.org/en/download/) >= 20
- [Go](https://golang.org/doc/install) >= 1.22 - [Go](https://golang.org/doc/install) >= 1.23
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [PM2](https://pm2.keymetrics.io/) - [PM2](https://pm2.keymetrics.io/)
- [Caddy](https://caddyserver.com/docs/install) (optional) - [Caddy](https://caddyserver.com/docs/install) (optional)
@@ -91,10 +91,17 @@ You may need the following information:
- **Authorization URL**: `https://<your-domain>/authorize` - **Authorization URL**: `https://<your-domain>/authorize`
- **Token URL**: `https://<your-domain>/api/oidc/token` - **Token URL**: `https://<your-domain>/api/oidc/token`
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json` - **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration` - **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
- **PKCE**: `false` as this is not supported yet. - **PKCE**: `false` as this is not supported yet.
### Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information.
### Update ### Update
#### Docker #### Docker
@@ -140,6 +147,7 @@ docker compose up -d
| Variable | Default Value | Recommended to change | Description | | Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- | | ---------------------- | ----------------------- | --------------------- | --------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. | | `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. | | `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. | | `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. | | `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |

View File

@@ -0,0 +1,119 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
color: #333;
margin: 0;
padding: 0;
}
.container {
background-color: #fff;
color: #333;
padding: 32px;
border-radius: 10px;
max-width: 600px;
margin: 40px auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header .logo {
display: flex;
align-items: center;
gap: 8px;
}
.header .logo img {
width: 32px;
height: 32px;
object-fit: cover;
}
.header h1 {
font-size: 1.5rem;
font-weight: bold;
}
.warning {
background-color: #ffd966;
color: #7f6000;
padding: 4px 12px;
border-radius: 50px;
font-size: 0.875rem;
}
.content {
background-color: #fafafa;
color: #333;
padding: 24px;
border-radius: 10px;
}
.content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.grid div {
display: flex;
flex-direction: column;
}
.grid p {
margin: 0;
}
.label {
color: #888;
font-size: 0.875rem;
margin-bottom: 4px;
}
.message {
font-size: 1rem;
line-height: 1.5;
}
</style>
<title>Pocket ID</title>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<img src="{{appUrl}}/api/application-configuration/logo" alt="Pocket ID" />
<h1>{{appName}}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>New Sign-In Detected</h2>
<div class="grid">
<div>
<p class="label">IP Address</p>
<p>{{ipAddress}}</p>
</div>
<div>
<p class="label">Device</p>
<p>{{device}}</p>
</div>
<div>
<p class="label">Sign-In Time</p>
<p>{{dateTimeString}}</p>
</div>
</div>
<p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can safely ignore
this message. If not, please review your account and security settings.
</p>
</div>
</div>
</body>
</html>

View File

@@ -1,19 +1,21 @@
module github.com/stonith404/pocket-id/backend module github.com/stonith404/pocket-id/backend
go 1.22 go 1.23
require ( require (
github.com/caarlos0/env/v11 v11.2.0 github.com/caarlos0/env/v11 v11.2.2
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.11.0 github.com/go-co-op/gocron/v2 v2.11.0
github.com/go-webauthn/webauthn v0.11.0 github.com/go-playground/validator/v10 v10.22.0
github.com/go-webauthn/webauthn v0.11.1
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.17.1 github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.25.0 github.com/mileusna/useragent v1.3.4
golang.org/x/crypto v0.26.0
golang.org/x/time v0.6.0 golang.org/x/time v0.6.0
gorm.io/driver/sqlite v1.5.6 gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11 gorm.io/gorm v1.25.11
@@ -28,7 +30,6 @@ require (
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/go-webauthn/x v0.1.12 // indirect github.com/go-webauthn/x v0.1.12 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-tpm v0.9.1 // indirect github.com/google/go-tpm v0.9.1 // indirect
@@ -57,7 +58,7 @@ require (
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.23.0 // indirect golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.17.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -3,8 +3,8 @@ github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKz
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/caarlos0/env/v11 v11.2.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs= github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo= github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
@@ -33,8 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-webauthn/webauthn v0.11.0 h1:2U0jWuGeoiI+XSZkHPFRtwaYtqmMUsqABtlfSq1rODo= github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
github.com/go-webauthn/webauthn v0.11.0/go.mod h1:57ZrqsZzD/eboQDVtBkvTdfqFYAh/7IwzdPT+sPWqB0= github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A= github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs= github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
@@ -81,6 +81,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -122,8 +124,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
@@ -132,8 +134,8 @@ 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 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/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -1,17 +1 @@
<svg id="a" <svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1015 1015">
<path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z" />
<style>
@media (prefers-color-scheme: dark) {
#a path {
fill: #ffffff;
}
}
@media (prefers-color-scheme: light) {
#a path {
fill: #000000;
}
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 539 B

View File

@@ -27,29 +27,30 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r := gin.Default() r := gin.Default()
r.Use(gin.Logger()) r.Use(gin.Logger())
// Add middleware
r.Use(
middleware.NewCorsMiddleware().Add(),
middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60),
)
// Initialize services // Initialize services
webauthnService := service.NewWebAuthnService(db, appConfigService) emailService := service.NewEmailService(appConfigService)
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService) userService := service.NewUserService(db, jwtService)
oidcService := service.NewOidcService(db, jwtService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService)
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
// Initialize middleware // Initialize middleware
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService) jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes // Set up API routes
apiGroup := r.Group("/api") apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, jwtService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
controller.NewApplicationConfigurationController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
// Add test controller in non-production environments // Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AppEnv != "production" {

View File

@@ -6,13 +6,13 @@ var (
ErrUsernameTaken = errors.New("username is already taken") ErrUsernameTaken = errors.New("username is already taken")
ErrEmailTaken = errors.New("email is already taken") ErrEmailTaken = errors.New("email is already taken")
ErrSetupAlreadyCompleted = errors.New("setup already completed") ErrSetupAlreadyCompleted = errors.New("setup already completed")
ErrInvalidBody = errors.New("invalid request body")
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired") ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
ErrOidcMissingAuthorization = errors.New("missing authorization") ErrOidcMissingAuthorization = errors.New("missing authorization")
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported") ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided") ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
ErrOidcClientSecretInvalid = errors.New("invalid client secret") ErrOidcClientSecretInvalid = errors.New("invalid client secret")
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code") ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
ErrFileTypeNotSupported = errors.New("file type not supported") ErrFileTypeNotSupported = errors.New("file type not supported")
ErrInvalidCredentials = errors.New("no user found with provided credentials") ErrInvalidCredentials = errors.New("no user found with provided credentials")
) )

View File

@@ -5,24 +5,24 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "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/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
) )
func NewApplicationConfigurationController( func NewAppConfigController(
group *gin.RouterGroup, group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware, jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) { appConfigService *service.AppConfigService) {
acc := &ApplicationConfigurationController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
} }
group.GET("/application-configuration", acc.listApplicationConfigurationHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllApplicationConfigurationHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateApplicationConfigurationHandler) group.PUT("/application-configuration", acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler) group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler) group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
@@ -32,86 +32,104 @@ func NewApplicationConfigurationController(
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
} }
type ApplicationConfigurationController struct { type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
} }
func (acc *ApplicationConfigurationController) listApplicationConfigurationHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(false) configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(200, configuration) var configVariablesDto []dto.PublicAppConfigVariableDto
} if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
func (acc *ApplicationConfigurationController) listAllApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
if err != nil {
utils.UnknownHandlerError(c, err)
return return
} }
c.JSON(200, configuration) c.JSON(200, configVariablesDto)
} }
func (acc *ApplicationConfigurationController) updateApplicationConfigurationHandler(c *gin.Context) { func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
var input model.AppConfigUpdateDto configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
utils.ControllerError(c, err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input) savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, savedConfigVariables) var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, configVariablesDto)
} }
func (acc *ApplicationConfigurationController) getLogoHandler(c *gin.Context) { func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.getImage(c, "logo", imageType) acc.getImage(c, "logo", imageType)
} }
func (acc *ApplicationConfigurationController) getFaviconHandler(c *gin.Context) { func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico") acc.getImage(c, "favicon", "ico")
} }
func (acc *ApplicationConfigurationController) getBackgroundImageHandler(c *gin.Context) { func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.getImage(c, "background", imageType) acc.getImage(c, "background", imageType)
} }
func (acc *ApplicationConfigurationController) updateLogoHandler(c *gin.Context) { func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.updateImage(c, "logo", imageType) acc.updateImage(c, "logo", imageType)
} }
func (acc *ApplicationConfigurationController) updateFaviconHandler(c *gin.Context) { func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
fileType := utils.GetFileExtension(file.Filename) fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" { if fileType != "ico" {
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico") utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
return return
} }
acc.updateImage(c, "favicon", "ico") acc.updateImage(c, "favicon", "ico")
} }
func (acc *ApplicationConfigurationController) updateBackgroundImageHandler(c *gin.Context) { func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.updateImage(c, "background", imageType) acc.updateImage(c, "background", imageType)
} }
func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name string, imageType string) { func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType) imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
mimeType := utils.GetImageMimeType(imageType) mimeType := utils.GetImageMimeType(imageType)
@@ -119,19 +137,19 @@ func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name str
c.File(imagePath) c.File(imagePath)
} }
func (acc *ApplicationConfigurationController) updateImage(c *gin.Context, imageName string, oldImageType string) { func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType) err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil { if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) { if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.HandlerError(c, http.StatusBadRequest, err.Error()) utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }

View File

@@ -0,0 +1,56 @@
package controller
import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
)
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
alc := AuditLogController{
auditLogService: auditLogService,
}
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
userID := c.GetString("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
if err != nil {
utils.ControllerError(c, err)
return
}
// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
utils.ControllerError(c, err)
return
}
// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, gin.H{
"data": logsDtos,
"pagination": pagination,
})
}

View File

@@ -4,8 +4,8 @@ import (
"errors" "errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "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/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
@@ -40,71 +40,87 @@ type OidcController struct {
} }
func (oc *OidcController) authorizeHandler(c *gin.Context) { func (oc *OidcController) authorizeHandler(c *gin.Context) {
var parsedBody model.AuthorizeRequest var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&parsedBody); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
code, err := oc.oidcService.Authorize(parsedBody, c.GetString("userID")) code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) { if errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.HandlerError(c, http.StatusForbidden, err.Error()) utils.CustomControllerError(c, http.StatusForbidden, err.Error())
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
c.JSON(http.StatusOK, gin.H{"code": code}) response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
} }
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) { func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
var parsedBody model.AuthorizeNewClientDto var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&parsedBody); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
code, err := oc.oidcService.AuthorizeNewClient(parsedBody, c.GetString("userID")) code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
c.JSON(http.StatusOK, gin.H{"code": code}) response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
} }
func (oc *OidcController) createIDTokenHandler(c *gin.Context) { func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
var body model.OidcIdTokenDto var input dto.OidcIdTokenDto
if err := c.ShouldBind(&body); err != nil { if err := c.ShouldBind(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
clientID := body.ClientID clientID := input.ClientID
clientSecret := body.ClientSecret clientSecret := input.ClientSecret
// Client id and secret can also be passed over the Authorization header // Client id and secret can also be passed over the Authorization header
if clientID == "" || clientSecret == "" { if clientID == "" || clientSecret == "" {
var ok bool var ok bool
clientID, clientSecret, ok = c.Request.BasicAuth() clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok { if !ok {
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided") utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
return return
} }
} }
idToken, accessToken, err := oc.oidcService.CreateTokens(body.Code, body.GrantType, clientID, clientSecret) idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) || if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
errors.Is(err, common.ErrOidcMissingClientCredentials) || errors.Is(err, common.ErrOidcMissingClientCredentials) ||
errors.Is(err, common.ErrOidcClientSecretInvalid) || errors.Is(err, common.ErrOidcClientSecretInvalid) ||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) { errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
utils.HandlerError(c, http.StatusBadRequest, err.Error()) utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
@@ -116,14 +132,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1] token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token) jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error()) utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
return return
} }
userID := jwtClaims.Subject userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0] clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId) claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -134,11 +150,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id") clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId) client, err := oc.oidcService.GetClient(clientId)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, client) // Return a different DTO based on the user's role
if c.GetBool("userIsAdmin") {
clientDto := dto.OidcClientDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
} else {
clientDto := dto.PublicOidcClientDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
}
utils.ControllerError(c, err)
} }
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
@@ -148,36 +181,48 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize) clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": clients, "data": clientsDto,
"pagination": pagination, "pagination": pagination,
}) })
} }
func (oc *OidcController) createClientHandler(c *gin.Context) { func (oc *OidcController) createClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
client, err := oc.oidcService.CreateClient(input, c.GetString("userID")) client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusCreated, client) var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusCreated, clientDto)
} }
func (oc *OidcController) deleteClientHandler(c *gin.Context) { func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id")) err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found") utils.ControllerError(c, err)
return return
} }
@@ -185,25 +230,31 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
} }
func (oc *OidcController) updateClientHandler(c *gin.Context) { func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
client, err := oc.oidcService.UpdateClient(c.Param("id"), input) client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusNoContent, client) var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, clientDto)
} }
func (oc *OidcController) createClientSecretHandler(c *gin.Context) { func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id")) secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -213,7 +264,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) { func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id")) imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -224,16 +275,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file) err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil { if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) { if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.HandlerError(c, http.StatusBadRequest, err.Error()) utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
@@ -244,7 +295,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id")) err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }

View File

@@ -4,6 +4,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
) )
func NewTestController(group *gin.RouterGroup, testService *service.TestService) { func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
@@ -18,19 +19,19 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) { func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil { if err := tc.TestService.ResetDatabase(); err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
if err := tc.TestService.ResetApplicationImages(); err != nil { if err := tc.TestService.ResetApplicationImages(); err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
if err := tc.TestService.SeedDatabase(); err != nil { if err := tc.TestService.SeedDatabase(); err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(200, gin.H{"message": "Database reset and seeded"}) c.Status(http.StatusNoContent)
} }

View File

@@ -4,8 +4,8 @@ import (
"errors" "errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "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/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -43,12 +43,18 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize) users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": users, "data": usersDto,
"pagination": pagination, "pagination": pagination,
}) })
} }
@@ -56,25 +62,38 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
func (uc *UserController) getUserHandler(c *gin.Context) { func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id")) user, err := uc.UserService.GetUser(c.Param("id"))
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, user) var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) getCurrentUserHandler(c *gin.Context) { func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID")) user, err := uc.UserService.GetUser(c.GetString("userID"))
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) deleteUserHandler(c *gin.Context) { func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil { if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -82,22 +101,29 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
} }
func (uc *UserController) createUserHandler(c *gin.Context) { func (uc *UserController) createUserHandler(c *gin.Context) {
var user model.User var input dto.UserCreateDto
if err := c.ShouldBindJSON(&user); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
if err := uc.UserService.CreateUser(&user); err != nil { user, err := uc.UserService.CreateUser(input)
if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) { if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.HandlerError(c, http.StatusConflict, err.Error()) utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
c.JSON(http.StatusCreated, user) var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusCreated, userDto)
} }
func (uc *UserController) updateUserHandler(c *gin.Context) { func (uc *UserController) updateUserHandler(c *gin.Context) {
@@ -109,15 +135,15 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
} }
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input model.OneTimeAccessTokenCreateDto var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -128,9 +154,9 @@ 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"))
if err != nil { if err != nil {
if errors.Is(err, common.ErrTokenInvalidOrExpired) { if errors.Is(err, common.ErrTokenInvalidOrExpired) {
utils.HandlerError(c, http.StatusUnauthorized, err.Error()) utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
@@ -143,21 +169,27 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin() user, token, err := uc.UserService.SetupInitialAdmin()
if err != nil { if err != nil {
if errors.Is(err, common.ErrSetupAlreadyCompleted) { if errors.Is(err, common.ErrSetupAlreadyCompleted) {
utils.HandlerError(c, http.StatusBadRequest, err.Error()) utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true) c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user) c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var updatedUser model.User var input dto.UserCreateDto
if err := c.ShouldBindJSON(&updatedUser); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
@@ -168,15 +200,21 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id") userID = c.Param("id")
} }
user, err := uc.UserService.UpdateUser(userID, updatedUser, updateOwnUser) user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
if err != nil { if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) { if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.HandlerError(c, http.StatusConflict, err.Error()) utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
c.JSON(http.StatusOK, user) var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, userDto)
} }

View File

@@ -3,9 +3,8 @@ package controller
import ( import (
"errors" "errors"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"log"
"net/http" "net/http"
"time" "time"
@@ -16,8 +15,8 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, jwtService *service.JwtService) { func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
wc := &WebauthnController{webAuthnService: webauthnService, jwtService: jwtService} wc := &WebauthnController{webAuthnService: webauthnService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler) group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler) group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
@@ -33,15 +32,13 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
type WebauthnController struct { type WebauthnController struct {
webAuthnService *service.WebAuthnService webAuthnService *service.WebAuthnService
jwtService *service.JwtService
} }
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID) options, err := wc.webAuthnService.BeginRegistration(userID)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
log.Println(err)
return return
} }
@@ -52,24 +49,30 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id") sessionID, err := c.Cookie("session_id")
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing") utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
return return
} }
userID := c.GetString("userID") userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request) credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, credential) var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentialDto)
} }
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) { func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin() options, err := wc.webAuthnService.BeginLogin()
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -80,46 +83,53 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) { func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id") sessionID, err := c.Cookie("session_id")
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing") utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
return return
} }
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil { if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
userID := c.GetString("userID") userID := c.GetString("userID")
user, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData)
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) { if errors.Is(err, common.ErrInvalidCredentials) {
utils.HandlerError(c, http.StatusUnauthorized, err.Error()) utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else { } else {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
} }
return return
} }
token, err := wc.jwtService.GenerateAccessToken(*user) var userDto dto.UserDto
if err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true) c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user) c.JSON(http.StatusOK, userDto)
} }
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) { func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID) credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.JSON(http.StatusOK, credentials) var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentialDtos)
} }
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) { func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
@@ -128,7 +138,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
err := wc.webAuthnService.DeleteCredential(userID, credentialID) err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -139,19 +149,25 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
credentialID := c.Param("id") credentialID := c.Param("id")
var input model.WebauthnCredentialUpdateDto var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error()) utils.ControllerError(c, err)
return return
} }
err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name) credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
c.Status(http.StatusNoContent) var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentialDto)
} }
func (wc *WebauthnController) logoutHandler(c *gin.Context) { func (wc *WebauthnController) logoutHandler(c *gin.Context) {

View File

@@ -21,7 +21,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) { func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK() jwk, err := wkc.jwtService.GetJWK()
if err != nil { if err != nil {
utils.UnknownHandlerError(c, err) utils.ControllerError(c, err)
return return
} }
@@ -37,7 +37,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"}, "scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "preferred_username"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"}, "id_token_signing_alg_values_supported": []string{"RS256"},

View File

@@ -0,0 +1,23 @@
package dto
type PublicAppConfigVariableDto struct {
Key string `json:"key"`
Type string `json:"type"`
Value string `json:"value"`
}
type AppConfigVariableDto struct {
PublicAppConfigVariableDto
IsPublic bool `json:"isPublic"`
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" 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"`
}

View File

@@ -0,0 +1,17 @@
package dto
import (
"github.com/stonith404/pocket-id/backend/internal/model"
"time"
)
type AuditLogDto struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"`
Device string `json:"device"`
UserID string `json:"userID"`
Data model.AuditLogData `json:"data"`
}

View File

@@ -0,0 +1,81 @@
package dto
import (
"errors"
"reflect"
)
// MapStructList maps a list of source structs to a list of destination structs
func MapStructList[S any, D any](source []S, destination *[]D) error {
*destination = make([]D, 0, len(source))
for _, item := range source {
var destItem D
if err := MapStruct(item, &destItem); err != nil {
return err
}
*destination = append(*destination, destItem)
}
return nil
}
// MapStruct maps a source struct to a destination struct
func MapStruct[S any, D any](source S, destination *D) error {
// Ensure destination is a non-nil pointer
destValue := reflect.ValueOf(destination)
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
return errors.New("destination must be a non-nil pointer to a struct")
}
// Ensure source is a struct
sourceValue := reflect.ValueOf(source)
if sourceValue.Kind() != reflect.Struct {
return errors.New("source must be a struct")
}
return mapStructInternal(sourceValue, destValue.Elem())
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
continue
}
sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types
if sourceField.Type() == destField.Type() {
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
// Recursively map nested structs
if err := mapStructInternal(sourceField, destField); err != nil {
return err
}
}
}
}
return nil
}

View File

@@ -0,0 +1,37 @@
package dto
type PublicOidcClientDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
CreatedBy UserDto `json:"createdBy"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
}
type OidcIdTokenDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
}

View File

@@ -0,0 +1,25 @@
package dto
import "time"
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=3,max=20"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
LastName string `json:"lastName" binding:"required,min=3,max=30"`
IsAdmin bool `json:"isAdmin"`
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}

View File

@@ -0,0 +1,42 @@
package dto
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
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("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)
}
}
}

View File

@@ -0,0 +1,23 @@
package dto
import (
"github.com/go-webauthn/webauthn/protocol"
"time"
)
type WebauthnCredentialDto struct {
ID string `json:"id"`
Name string `json:"name"`
CredentialID string `json:"credentialID"`
AttestationType string `json:"attestationType"`
Transport []protocol.AuthenticatorTransport `json:"transport"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
CreatedAt time.Time `json:"createdAt"`
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=30"`
}

View File

@@ -21,7 +21,6 @@ func RegisterJobs(db *gorm.DB) {
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions) registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens) registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes) registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
scheduler.Start() scheduler.Start()
} }
@@ -29,17 +28,24 @@ type Jobs struct {
db *gorm.DB db *gorm.DB
} }
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error { func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
} }
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error { func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error { func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", utils.FormatDateForDb(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", utils.FormatDateForDb(time.Now().AddDate(0, 0, -90))).Error
} }
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) { func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

@@ -17,7 +17,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil { if err := c.Request.ParseMultipartForm(maxSize); err != nil {
utils.HandlerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize))) utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
c.Abort() c.Abort()
return return
} }

View File

@@ -9,11 +9,12 @@ import (
) )
type JwtAuthMiddleware struct { type JwtAuthMiddleware struct {
jwtService *service.JwtService jwtService *service.JwtService
ignoreUnauthenticated bool
} }
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware { func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService} return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
} }
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc { func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
@@ -24,23 +25,29 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ") authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplitted) == 2 { if len(authorizationHeaderSplitted) == 2 {
token = authorizationHeaderSplitted[1] token = authorizationHeaderSplitted[1]
} else if m.ignoreUnauthenticated {
c.Next()
return
} else { } else {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in") utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort() c.Abort()
return return
} }
} }
claims, err := m.jwtService.VerifyAccessToken(token) claims, err := m.jwtService.VerifyAccessToken(token)
if err != nil { if err != nil && m.ignoreUnauthenticated {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in") c.Next()
return
} else if err != nil {
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort() c.Abort()
return return
} }
// Check if the user is an admin // Check if the user is an admin
if adminOnly && !claims.IsAdmin { if adminOnly && !claims.IsAdmin {
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource") utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
c.Abort() c.Abort()
return return
} }

View File

@@ -33,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst) limiter := getLimiter(ip, limit, burst)
if !limiter.Allow() { if !limiter.Allow() {
utils.HandlerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.") utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
c.Abort() c.Abort()
return return
} }

View File

@@ -1,11 +1,11 @@
package model package model
type AppConfigVariable struct { type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null" json:"key"` Key string `gorm:"primaryKey;not null"`
Type string `json:"type"` Type string
IsPublic bool `json:"-"` IsPublic bool
IsInternal bool `json:"-"` IsInternal bool
Value string `json:"value"` Value string
} }
type AppConfig struct { type AppConfig struct {
@@ -13,9 +13,11 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable BackgroundImageType AppConfigVariable
LogoImageType AppConfigVariable LogoImageType AppConfigVariable
SessionDuration AppConfigVariable SessionDuration AppConfigVariable
}
type AppConfigUpdateDto struct { EmailEnabled AppConfigVariable
AppName string `json:"appName" binding:"required"` SmtpHost AppConfigVariable
SessionDuration string `json:"sessionDuration" binding:"required"` SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable
} }

View File

@@ -0,0 +1,50 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
)
type AuditLog struct {
Base
Event AuditLogEvent
IpAddress string
UserAgent string
UserID string
Data AuditLogData
}
type AuditLogData map[string]string
type AuditLogEvent string
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
)
// Scan and Value methods for GORM to handle the custom type
func (e *AuditLogEvent) Scan(value interface{}) error {
*e = AuditLogEvent(value.(string))
return nil
}
func (e AuditLogEvent) Value() (driver.Value, error) {
return string(e), nil
}
func (d *AuditLogData) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
return json.Unmarshal(v, d)
} else {
return errors.New("type assertion to []byte failed")
}
}
func (d AuditLogData) Value() (driver.Value, error) {
return json.Marshal(d)
}

View File

@@ -8,8 +8,8 @@ import (
// Base contains common columns for all tables. // Base contains common columns for all tables.
type Base struct { type Base struct {
ID string `gorm:"primaryKey;not null" json:"id"` ID string `gorm:"primaryKey;not null"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time
} }
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) { func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {

View File

@@ -1,38 +1,22 @@
package model package model
import ( import (
"database/sql/driver"
"encoding/json"
"errors"
"gorm.io/gorm" "gorm.io/gorm"
"time" "time"
) )
type UserAuthorizedOidcClient struct { type UserAuthorizedOidcClient struct {
Scope string Scope string
UserID string `json:"userId" gorm:"primary_key;"` UserID string `gorm:"primary_key;"`
User User User User
ClientID string `json:"clientId" gorm:"primary_key;"` ClientID string `gorm:"primary_key;"`
Client OidcClient Client OidcClient
} }
type OidcClient struct {
Base
Name string `json:"name"`
Secret string `json:"-"`
CallbackURL string `json:"callbackURL"`
ImageType *string `json:"-"`
HasLogo bool `gorm:"-" json:"hasLogo"`
CreatedByID string
CreatedBy User
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
return nil
}
type OidcAuthorizationCode struct { type OidcAuthorizationCode struct {
Base Base
@@ -47,26 +31,35 @@ type OidcAuthorizationCode struct {
ClientID string ClientID string
} }
type OidcClientCreateDto struct { type OidcClient struct {
Name string `json:"name" binding:"required"` Base
CallbackURL string `json:"callbackURL" binding:"required"`
Name string
Secret string
CallbackURLs CallbackURLs
ImageType *string
HasLogo bool `gorm:"-"`
CreatedByID string
CreatedBy User
} }
type AuthorizeNewClientDto struct { func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
ClientID string `json:"clientID" binding:"required"` // Compute HasLogo field
Scope string `json:"scope" binding:"required"` c.HasLogo = c.ImageType != nil && *c.ImageType != ""
Nonce string `json:"nonce"` return nil
} }
type OidcIdTokenDto struct { type CallbackURLs []string
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"` func (cu *CallbackURLs) Scan(value interface{}) error {
ClientID string `form:"client_id"` if v, ok := value.([]byte); ok {
ClientSecret string `form:"client_secret"` return json.Unmarshal(v, cu)
} else {
return errors.New("type assertion to []byte failed")
}
} }
type AuthorizeRequest struct { func (cu CallbackURLs) Value() (driver.Value, error) {
ClientID string `json:"clientID" binding:"required"` return json.Marshal(cu)
Scope string `json:"scope" binding:"required"`
Nonce string `json:"nonce"`
} }

View File

@@ -9,13 +9,13 @@ import (
type User struct { type User struct {
Base Base
Username string `json:"username"` Username string
Email string `json:"email" ` Email string
FirstName string `json:"firstName"` FirstName string
LastName string `json:"lastName"` LastName string
IsAdmin bool `json:"isAdmin"` IsAdmin bool
Credentials []WebauthnCredential `json:"-"` Credentials []WebauthnCredential
} }
func (u User) WebAuthnID() []byte { return []byte(u.ID) } func (u User) WebAuthnID() []byte { return []byte(u.ID) }
@@ -59,19 +59,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string `json:"token"` Token string
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time
UserID string `json:"userId"` UserID string
User User User User
} }
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type LoginUserDto struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

View File

@@ -19,11 +19,11 @@ type WebauthnSession struct {
type WebauthnCredential struct { type WebauthnCredential struct {
Base Base
Name string `json:"name"` Name string
CredentialID string `json:"credentialID"` CredentialID string
PublicKey []byte `json:"-"` PublicKey []byte
AttestationType string `json:"attestationType"` AttestationType string
Transport AuthenticatorTransportList `json:"-"` Transport AuthenticatorTransportList
BackupEligible bool `json:"backupEligible"` BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"` BackupState bool `json:"backupState"`
@@ -32,15 +32,15 @@ type WebauthnCredential struct {
} }
type PublicKeyCredentialCreationOptions struct { type PublicKeyCredentialCreationOptions struct {
Response protocol.PublicKeyCredentialCreationOptions `json:"response"` Response protocol.PublicKeyCredentialCreationOptions
SessionID string `json:"session_id"` SessionID string
Timeout time.Duration `json:"timeout"` Timeout time.Duration
} }
type PublicKeyCredentialRequestOptions struct { type PublicKeyCredentialRequestOptions struct {
Response protocol.PublicKeyCredentialRequestOptions `json:"response"` Response protocol.PublicKeyCredentialRequestOptions
SessionID string `json:"session_id"` SessionID string
Timeout time.Duration `json:"timeout"` Timeout time.Duration
} }
type AuthenticatorTransportList []protocol.AuthenticatorTransport type AuthenticatorTransportList []protocol.AuthenticatorTransport
@@ -58,7 +58,3 @@ func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
func (atl AuthenticatorTransportList) Value() (driver.Value, error) { func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
return json.Marshal(atl) return json.Marshal(atl)
} }
type WebauthnCredentialUpdateDto struct {
Name string `json:"name"`
}

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "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"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
@@ -52,9 +53,34 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true, IsInternal: true,
Value: "svg", Value: "svg",
}, },
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
Value: "false",
},
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
} }
func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin() tx := s.db.Begin()
@@ -66,19 +92,19 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigU
key := field.Tag.Get("json") key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String() value := rv.FieldByName(field.Name).String()
var applicationConfigurationVariable model.AppConfigVariable var appConfigVariable model.AppConfigVariable
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil { if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback() tx.Rollback()
return nil, err return nil, err
} }
applicationConfigurationVariable.Value = value appConfigVariable.Value = value
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil { if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback() tx.Rollback()
return nil, err return nil, err
} }
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable) savedConfigVariables = append(savedConfigVariables, appConfigVariable)
} }
tx.Commit() tx.Commit()
@@ -100,7 +126,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
return s.loadDbConfigFromDb() return s.loadDbConfigFromDb()
} }
func (s *AppConfigService) ListApplicationConfiguration(showAll bool) ([]model.AppConfigVariable, error) { func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable var configuration []model.AppConfigVariable
var err error var err error

View File

@@ -0,0 +1,85 @@
package service
import (
userAgentParser "github.com/mileusna/useragent"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"log"
)
type AuditLogService struct {
db *gorm.DB
appConfigService *AppConfigService
emailService *EmailService
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
auditLog := model.AuditLog{
Event: event,
IpAddress: ipAddress,
UserAgent: userAgent,
UserID: userID,
Data: data,
}
// Save the audit log in the database
if err := s.db.Create(&auditLog).Error; err != nil {
log.Printf("Failed to create audit log: %v\n", err)
return model.AuditLog{}
}
return auditLog
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
// Count the number of times the user has logged in from the same device
var count int64
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
return createdAuditLog
}
// If the user hasn't logged in from the same device before, send an email
if count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)
title := "New device login with " + s.appConfigService.DbConfig.AppName.Value
err := s.emailService.Send(user.Email, title, "login-with-new-device", map[string]interface{}{
"ipAddress": ipAddress,
"device": s.DeviceStringFromUserAgent(userAgent),
"dateTimeString": createdAuditLog.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
})
if err != nil {
log.Printf("Failed to send email: %v\n", err)
}
}()
}
return createdAuditLog
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
pagination, err := utils.Paginate(page, pageSize, query, &logs)
return logs, pagination, err
}
func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}

View File

@@ -0,0 +1,67 @@
package service
import (
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"net/smtp"
"os"
"strings"
)
type EmailService struct {
appConfigService *AppConfigService
}
func NewEmailService(appConfigService *AppConfigService) *EmailService {
return &EmailService{
appConfigService: appConfigService}
}
// Send sends an email notification
func (s *EmailService) Send(toEmail, title, templateName string, templateParameters map[string]interface{}) error {
// Check if SMTP settings are set
if s.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
// Construct the email message
subject := fmt.Sprintf("Subject: %s\n", title)
subject += "From: " + s.appConfigService.DbConfig.SmtpFrom.Value + "\n"
subject += "To: " + toEmail + "\n"
subject += "Content-Type: text/html; charset=UTF-8\n"
body, err := os.ReadFile(fmt.Sprintf("./email-templates/%s.html", templateName))
bodyString := string(body)
if err != nil {
return fmt.Errorf("failed to read email template: %w", err)
}
// Replace template parameters
templateParameters["appName"] = s.appConfigService.DbConfig.AppName.Value
templateParameters["appUrl"] = common.EnvConfig.AppURL
for key, value := range templateParameters {
bodyString = strings.ReplaceAll(bodyString, fmt.Sprintf("{{%s}}", key), fmt.Sprintf("%v", value))
}
emailBody := []byte(subject + bodyString)
// Set up the authentication information.
auth := smtp.PlainAuth("", s.appConfigService.DbConfig.SmtpUser.Value, s.appConfigService.DbConfig.SmtpPassword.Value, s.appConfigService.DbConfig.SmtpHost.Value)
// Send the email
err = smtp.SendMail(
s.appConfigService.DbConfig.SmtpHost.Value+":"+s.appConfigService.DbConfig.SmtpPort.Value,
auth,
s.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail},
emailBody,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}

View File

@@ -4,55 +4,90 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "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"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"mime/multipart" "mime/multipart"
"os" "os"
"slices"
"strings" "strings"
"time" "time"
) )
type OidcService struct { type OidcService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
} }
func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService { func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
return &OidcService{ return &OidcService{
db: db, db: db,
jwtService: jwtService, jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
} }
} }
func (s *OidcService) Authorize(req model.AuthorizeRequest, userID string) (string, error) { func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", req.ClientID, userID) s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Scope != req.Scope { if userAuthorizedOIDCClient.Scope != input.Scope {
return "", common.ErrOidcMissingAuthorization return "", "", common.ErrOidcMissingAuthorization
} }
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce) callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
if err != nil {
return "", "", err
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
return code, callbackURL, nil
} }
func (s *OidcService) AuthorizeNewClient(req model.AuthorizeNewClientDto, userID string) (string, error) { func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err
}
callbackURL, err := getCallbackURL(client, input.CallbackURL)
if err != nil {
return "", "", err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{ userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID, UserID: userID,
ClientID: req.ClientID, ClientID: input.ClientID,
Scope: req.Scope, Scope: input.Scope,
} }
if err := s.db.Create(&userAuthorizedClient).Error; err != nil { if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
err = s.db.Model(&userAuthorizedClient).Update("scope", req.Scope).Error err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
} else { } else {
return "", err return "", "", err
} }
} }
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
return code, callbackURL, nil
} }
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) { func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
@@ -101,18 +136,18 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
return idToken, accessToken, nil return idToken, accessToken, nil
} }
func (s *OidcService) GetClient(clientID string) (*model.OidcClient, error) { func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return nil, err return model.OidcClient{}, err
} }
return &client, nil return client, nil
} }
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) { func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient var clients []model.OidcClient
query := s.db.Model(&model.OidcClient{}) query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
query = query.Where("name LIKE ?", searchPattern) query = query.Where("name LIKE ?", searchPattern)
@@ -126,34 +161,34 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
return clients, pagination, nil return clients, pagination, nil
} }
func (s *OidcService) CreateClient(input model.OidcClientCreateDto, userID string) (*model.OidcClient, error) { func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{ client := model.OidcClient{
Name: input.Name, Name: input.Name,
CallbackURL: input.CallbackURL, CallbackURLs: input.CallbackURLs,
CreatedByID: userID, CreatedByID: userID,
} }
if err := s.db.Create(&client).Error; err != nil { if err := s.db.Create(&client).Error; err != nil {
return nil, err return model.OidcClient{}, err
} }
return &client, nil return client, nil
} }
func (s *OidcService) UpdateClient(clientID string, input model.OidcClientCreateDto) (*model.OidcClient, error) { func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return nil, err return model.OidcClient{}, err
} }
client.Name = input.Name client.Name = input.Name
client.CallbackURL = input.CallbackURL client.CallbackURLs = input.CallbackURLs
if err := s.db.Save(&client).Error; err != nil { if err := s.db.Save(&client).Error; err != nil {
return nil, err return model.OidcClient{}, err
} }
return &client, nil return client, nil
} }
func (s *OidcService) DeleteClient(clientID string) error { func (s *OidcService) DeleteClient(clientID string) error {
@@ -284,6 +319,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{ profileClaims := map[string]interface{}{
"given_name": user.FirstName, "given_name": user.FirstName,
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FirstName + " " + user.LastName,
"preferred_username": user.Username, "preferred_username": user.Username,
} }
@@ -320,3 +356,14 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
return randomString, nil return randomString, nil
} }
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" {
return client.CallbackURLs[0], nil
}
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
return inputCallbackURL, nil
}
return "", common.ErrOidcInvalidCallbackURL
}

View File

@@ -61,20 +61,20 @@ func (s *TestService) SeedDatabase() error {
Base: model.Base{ Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
}, },
Name: "Nextcloud", Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURL: "http://nextcloud/auth/callback", CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
ImageType: utils.StringPointer("png"), ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID, CreatedByID: users[0].ID,
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018", ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
}, },
Name: "Immich", Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURL: "http://immich/auth/callback", CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
CreatedByID: users[0].ID, CreatedByID: users[0].ID,
}, },
} }
for _, client := range oidcClients { for _, client := range oidcClients {

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"errors" "errors"
"github.com/stonith404/pocket-id/backend/internal/common" "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"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
@@ -46,17 +47,24 @@ func (s *UserService) DeleteUser(userID string) error {
return s.db.Delete(&user).Error return s.db.Delete(&user).Error
} }
func (s *UserService) CreateUser(user *model.User) error { func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
if err := s.db.Create(user).Error; err != nil { user := model.User{
if errors.Is(err, gorm.ErrDuplicatedKey) { FirstName: input.FirstName,
return s.checkDuplicatedFields(*user) LastName: input.LastName,
} Email: input.Email,
return err Username: input.Username,
IsAdmin: input.IsAdmin,
} }
return nil if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user)
}
return model.User{}, err
}
return user, nil
} }
func (s *UserService) UpdateUser(userID string, updatedUser model.User, updateOwnUser bool) (model.User, error) { func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {
var user model.User var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err return model.User{}, err

View File

@@ -12,11 +12,13 @@ import (
) )
type WebAuthnService struct { type WebAuthnService struct {
db *gorm.DB db *gorm.DB
webAuthn *webauthn.WebAuthn webAuthn *webauthn.WebAuthn
jwtService *JwtService
auditLogService *AuditLogService
} }
func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAuthnService { func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{ webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value, RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL), RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
@@ -36,7 +38,7 @@ func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAut
} }
wa, _ := webauthn.New(webauthnConfig) wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{db: db, webAuthn: wa} return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService}
} }
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) { func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
@@ -67,10 +69,10 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
}, nil }, nil
} }
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (*model.WebauthnCredential, error) { func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil { if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return nil, err return model.WebauthnCredential{}, err
} }
session := webauthn.SessionData{ session := webauthn.SessionData{
@@ -81,12 +83,12 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
var user model.User var user model.User
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil { if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return nil, err return model.WebauthnCredential{}, err
} }
credential, err := s.webAuthn.FinishRegistration(&user, session, r) credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil { if err != nil {
return nil, err return model.WebauthnCredential{}, err
} }
credentialToStore := model.WebauthnCredential{ credentialToStore := model.WebauthnCredential{
@@ -100,10 +102,10 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
BackupState: credential.Flags.BackupState, BackupState: credential.Flags.BackupState,
} }
if err := s.db.Create(&credentialToStore).Error; err != nil { if err := s.db.Create(&credentialToStore).Error; err != nil {
return nil, err return model.WebauthnCredential{}, err
} }
return &credentialToStore, nil return credentialToStore, nil
} }
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) { func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
@@ -129,10 +131,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil }, nil
} }
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (*model.User, error) { func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil { if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return nil, err return model.User{}, "", err
} }
session := webauthn.SessionData{ session := webauthn.SessionData{
@@ -149,14 +151,21 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData) }, session, credentialAssertionData)
if err != nil { if err != nil {
return nil, common.ErrInvalidCredentials return model.User{}, "", err
} }
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil { if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return nil, err return model.User{}, "", err
} }
return user, nil token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err
}
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
return *user, token, nil
} }
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) { func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {
@@ -180,17 +189,17 @@ func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error {
return nil return nil
} }
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) error { func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) {
var credential model.WebauthnCredential var credential model.WebauthnCredential
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil { if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil {
return err return credential, err
} }
credential.Name = name credential.Name = name
if err := s.db.Save(&credential).Error; err != nil { if err := s.db.Save(&credential).Error; err != nil {
return err return credential, err
} }
return nil return credential, nil
} }

View File

@@ -0,0 +1,75 @@
package utils
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
"log"
"net/http"
"strings"
)
import (
"fmt"
)
func ControllerError(c *gin.Context, err error) {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
CustomControllerError(c, http.StatusNotFound, "Record not found")
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
CustomControllerError(c, http.StatusBadRequest, message)
return
}
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
var errorMessages []string
for _, ve := range validationErrors {
fieldName := ve.Field()
var errorMessage string
switch ve.Tag() {
case "required":
errorMessage = fmt.Sprintf("%s is required", fieldName)
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":
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)
}
errorMessages = append(errorMessages, errorMessage)
}
// Join all the error messages into a single string
combinedErrors := strings.Join(errorMessages, ", ")
return combinedErrors
}
func CustomControllerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -1,27 +0,0 @@
package utils
import (
"errors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"log"
"net/http"
"strings"
)
func UnknownHandlerError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
HandlerError(c, http.StatusNotFound, "Record not found")
return
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
}
func HandlerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -57,7 +57,7 @@ CREATE TABLE webauthn_credentials
credential_id TEXT NOT NULL UNIQUE, credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL, public_key BLOB NOT NULL,
attestation_type TEXT NOT NULL, attestation_type TEXT NOT NULL,
transport TEXT NOT NULL, transport BLOB NOT NULL,
user_id TEXT REFERENCES users user_id TEXT REFERENCES users
); );

View File

@@ -0,0 +1,23 @@
create table oidc_clients
(
id TEXT not null primary key,
created_at DATETIME,
name TEXT,
secret TEXT,
callback_url TEXT,
image_type TEXT,
created_by_id TEXT
references users
);
insert into oidc_clients(id, created_at, name, secret, callback_url, image_type, created_by_id)
select id,
created_at,
name,
secret,
json_extract(callback_urls, '$[0]'),
image_type,
created_by_id
from oidc_clients_dg_tmp;
drop table oidc_clients_dg_tmp;

View File

@@ -0,0 +1,26 @@
create table oidc_clients_dg_tmp
(
id TEXT not null primary key,
created_at DATETIME,
name TEXT,
secret TEXT,
callback_urls BLOB,
image_type TEXT,
created_by_id TEXT
references users
);
insert into oidc_clients_dg_tmp(id, created_at, name, secret, callback_urls, image_type, created_by_id)
select id,
created_at,
name,
secret,
CAST('["' || callback_url || '"]' AS BLOB),
image_type,
created_by_id
from oidc_clients;
drop table oidc_clients;
alter table oidc_clients_dg_tmp
rename to oidc_clients;

View File

@@ -0,0 +1 @@
DROP TABLE audit_logs;

View File

@@ -0,0 +1,10 @@
CREATE TABLE audit_logs
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
event TEXT NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users
);

81
docs/proxy-services.md Normal file
View File

@@ -0,0 +1,81 @@
# 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/) 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.
## Docker Setup
#### 1. Add OAuth2 proxy to the service that should be proxied.
To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma:
```yaml
# Example with Uptime Kuma
# uptime-kuma:
# image: louislam/uptime-kuma
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
command: --config /oauth2-proxy.cfg
volumes:
- "./oauth2-proxy.cfg:/oauth2-proxy.cfg"
ports:
- 4180:4180
```
#### 2. 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`. After adding the client, you will obtain the client ID and client secret.
#### 3. Create a configuration file for OAuth2 Proxy.
Create a configuration file named `oauth2-proxy.cfg` in the same directory as your `docker-compose.yml` file of the service that should be proxied (e.g. Uptime Kuma). This file will contain the necessary configurations for OAuth2 Proxy to work with Pocket ID.
Here is the recommend `oauth2-proxy.cfg` configuration:
```cfg
# Replace with your own credentials
client_id="client-id-from-pocket-id"
client_secret="client-secret-from-pocket-id"
oidc_issuer_url="https://<your-pocket-id-domain>"
# Replace with a secure random string
cookie_secret="random-string"
# Upstream servers (e.g http://uptime-kuma:3001)
upstreams="http://<service-to-be-proxied>:<port>"
# Additional Configuration
provider="oidc"
scope = "openid email profile"
# If you are using a reverse proxy in front of OAuth2 Proxy
reverse_proxy = true
# Email domains allowed for authentication
email_domains = ["*"]
# If you are using HTTPS
cookie_secure="true"
# Listen on all interfaces
http_address="0.0.0.0:4180"
```
For additional configuration options, refer to the official [OAuth2 Proxy documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview).
#### 4. Start the services.
After creating the configuration file, you can start the services using Docker Compose:
```bash
docker compose up -d
```
#### 5. Access the service.
You can now access the service through OAuth2 Proxy by visiting `http://localhost:4180`.
## 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.
You can visit the official [OAuth2 Proxy documentation](https://oauth2-proxy.github.io/oauth2-proxy/installation) for more information.

File diff suppressed because it is too large Load Diff

View File

@@ -12,46 +12,46 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.46.0", "@playwright/test": "^1.46.1",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.2.4",
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.2",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.5.24",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^8.56.7", "@types/eslint": "^9.6.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/node": "^22.1.0", "@types/node": "^22.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"cbor-js": "^0.1.0", "cbor-js": "^0.1.0",
"eslint": "^9.0.0", "eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.40.0",
"globals": "^15.0.0", "globals": "^15.9.0",
"postcss": "^8.4.38", "postcss": "^8.4.41",
"prettier": "^3.1.1", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.4", "prettier-plugin-tailwindcss": "^0.6.6",
"svelte": "^5.0.0-next.1", "svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0", "svelte-check": "^3.8.6",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.10",
"tslib": "^2.4.1", "tslib": "^2.7.0",
"typescript": "^5.0.0", "typescript": "^5.5.4",
"typescript-eslint": "^8.0.0-alpha.20", "typescript-eslint": "^8.2.0",
"vite": "^5.0.3" "vite": "^5.4.2"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"axios": "^1.7.2", "axios": "^1.7.5",
"bits-ui": "^0.21.12", "bits-ui": "^0.21.13",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"formsnap": "^1.0.1", "formsnap": "^1.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.399.0", "lucide-svelte": "^0.435.0",
"mode-watcher": "^0.4.1", "mode-watcher": "^0.4.1",
"svelte-sonner": "^0.3.27", "svelte-sonner": "^0.3.27",
"sveltekit-superforms": "^2.16.1", "sveltekit-superforms": "^2.17.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1", "tailwind-variants": "^0.2.1",
"zod": "^3.23.8" "zod": "^3.23.8"
} }

View File

@@ -2,36 +2,42 @@
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import type { FormInput } from '$lib/utils/form-util'; import type { FormInput } from '$lib/utils/form-util';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { Input } from './ui/input'; import { Input } from './ui/input';
let { let {
input = $bindable(), input = $bindable(),
label, label,
description, description,
children disabled = false,
}: { type = 'text',
input: FormInput<string | boolean | number>; children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number>;
label: string; label: string;
description?: string; description?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
const id = label.toLowerCase().replace(/ /g, '-'); const id = label.toLowerCase().replace(/ /g, '-');
</script> </script>
<div> <div {...restProps}>
<Label class="mb-0" for={id}>{label}</Label> <Label class="mb-0" for={id}>{label}</Label>
{#if description} {#if description}
<p class="text-muted-foreground text-xs mt-1">{description}</p> <p class="text-muted-foreground mt-1 text-xs">{description}</p>
{/if} {/if}
<div class="mt-2"> <div class="mt-2">
{#if children} {#if children}
{@render children()} {@render children()}
{:else} {:else if input}
<Input {id} bind:value={input.value} /> <Input {id} {type} bind:value={input.value} {disabled} />
{/if} {/if}
{#if input.error} {#if input?.error}
<p class="text-sm text-red-500">{input.error}</p> <p class="mt-1 text-sm text-red-500">{input.error}</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -23,7 +23,7 @@
<Avatar.Fallback>{initials}</Avatar.Fallback> <Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar.Root></DropdownMenu.Trigger </Avatar.Root></DropdownMenu.Trigger
> >
<DropdownMenu.Content class="w-40" align="start"> <DropdownMenu.Content class="min-w-40" align="start">
<DropdownMenu.Label class="font-normal"> <DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none"> <p class="text-sm font-medium leading-none">

View File

@@ -1,22 +1,23 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte'; import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte'; import HeaderAvatar from './header-avatar.svelte';
let isAuthPage = $derived( let isAuthPage = $derived(
!$page.error && ($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login')) !$page.error &&
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
); );
</script> </script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}"> <div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
<div class="mx-auto flex w-full max-w-[1520px] items-center justify-between px-4 md:px-10"> <div class="mx-auto flex w-full max-w-[1640px] items-center justify-between px-4 md:px-10">
<div class="flex h-16 items-center"> <div class="flex h-16 items-center">
{#if !isAuthPage} {#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" /> <Logo class="mr-3 h-10 w-10" />
<h1 class="text-lg font-medium" data-testid="application-name"> <h1 class="text-lg font-medium" data-testid="application-name">
{$applicationConfigurationStore.appName} {$appConfigStore.appName}
</h1> </h1>
{/if} {/if}
</div> </div>

View File

@@ -12,7 +12,7 @@
<tr <tr
class={cn( class={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors data-[state=selected]:bg-muted",
className className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,29 +1,29 @@
import type { import type {
AllApplicationConfiguration, AllAppConfig,
ApplicationConfigurationRawResponse AppConfigRawResponse
} from '$lib/types/application-configuration'; } from '$lib/types/application-configuration';
import APIService from './api-service'; import APIService from './api-service';
export default class ApplicationConfigurationService extends APIService { export default class AppConfigService extends APIService {
async list(showAll = false) { async list(showAll = false) {
let url = '/application-configuration'; let url = '/application-configuration';
if (showAll) { if (showAll) {
url += '/all'; url += '/all';
} }
const { data } = await this.api.get<ApplicationConfigurationRawResponse>(url); const { data } = await this.api.get<AppConfigRawResponse>(url);
const applicationConfiguration: Partial<AllApplicationConfiguration> = {}; const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => { data.forEach(({ key, value }) => {
(applicationConfiguration as any)[key] = value; (appConfig as any)[key] = value;
}); });
return applicationConfiguration as AllApplicationConfiguration; return appConfig as AllAppConfig;
} }
async update(applicationConfiguration: AllApplicationConfiguration) { async update(appConfig: AllAppConfig) {
const res = await this.api.put('/application-configuration', applicationConfiguration); const res = await this.api.put('/application-configuration', appConfig);
return res.data as AllApplicationConfiguration; return res.data as AllAppConfig;
} }
async updateFavicon(favicon: File) { async updateFavicon(favicon: File) {

View File

@@ -0,0 +1,20 @@
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class AuditLogService extends APIService {
async list(pagination?: PaginationRequest) {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const res = await this.api.get('/audit-logs', {
params: {
page,
limit
}
});
return res.data as Paginated<AuditLog>;
}
}
export default AuditLogService;

View File

@@ -1,26 +1,28 @@
import type { OidcClient, OidcClientCreate } from '$lib/types/oidc.type'; import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type'; import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service'; import APIService from './api-service';
class OidcService extends APIService { class OidcService extends APIService {
async authorize(clientId: string, scope: string, nonce?: string) { async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) {
const res = await this.api.post('/oidc/authorize', { const res = await this.api.post('/oidc/authorize', {
scope, scope,
nonce, nonce,
callbackURL,
clientId clientId
}); });
return res.data.code as string; return res.data as AuthorizeResponse;
} }
async authorizeNewClient(clientId: string, scope: string, nonce?: string) { async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
const res = await this.api.post('/oidc/authorize/new-client', { const res = await this.api.post('/oidc/authorize/new-client', {
scope, scope,
nonce, nonce,
callbackURL,
clientId clientId
}); });
return res.data.code as string; return res.data as AuthorizeResponse;
} }
async listClients(search?: string, pagination?: PaginationRequest) { async listClients(search?: string, pagination?: PaginationRequest) {

View File

@@ -1,22 +1,22 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import type { ApplicationConfiguration } from '$lib/types/application-configuration'; import type { AppConfig } from '$lib/types/application-configuration';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
const applicationConfigurationStore = writable<ApplicationConfiguration>(); const appConfigStore = writable<AppConfig>();
const applicationConfigurationService = new ApplicationConfigurationService(); const appConfigService = new AppConfigService();
const reload = async () => { const reload = async () => {
const applicationConfiguration = await applicationConfigurationService.list(); const appConfig = await appConfigService.list();
applicationConfigurationStore.set(applicationConfiguration); appConfigStore.set(appConfig);
}; };
const set = (applicationConfiguration: ApplicationConfiguration) => { const set = (appConfig: AppConfig) => {
applicationConfigurationStore.set(applicationConfiguration); appConfigStore.set(appConfig);
} };
export default { export default {
subscribe: applicationConfigurationStore.subscribe, subscribe: appConfigStore.subscribe,
reload, reload,
set set
}; };

View File

@@ -1,13 +1,18 @@
export type AllAppConfig = {
export type AllApplicationConfiguration = {
appName: string; appName: string;
sessionDuration: string; sessionDuration: string;
emailEnabled: string;
smtpHost: string;
smtpPort: string;
smtpFrom: string;
smtpUser: string;
smtpPassword: string;
}; };
export type ApplicationConfiguration = AllApplicationConfiguration; export type AppConfig = AllAppConfig;
export type ApplicationConfigurationRawResponse = { export type AppConfigRawResponse = {
key: string; key: string;
type: string; type: string;
value: string; value: string;
}[]; }[];

View File

@@ -0,0 +1,8 @@
export type AuditLog = {
id: string;
event: string;
ipAddress: string;
device: string;
createdAt: string;
data: any;
};

View File

@@ -2,7 +2,7 @@ export type OidcClient = {
id: string; id: string;
name: string; name: string;
logoURL: string; logoURL: string;
callbackURL: string; callbackURLs: [string, ...string[]];
hasLogo: boolean; hasLogo: boolean;
}; };
@@ -11,3 +11,8 @@ export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & { export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null; logo: File | null;
}; };
export type AuthorizeResponse = {
code: string;
callbackURL: string;
};

View File

@@ -1,19 +1,17 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => { export const load: LayoutServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get('access_token')); const userService = new UserService(cookies.get('access_token'));
const applicationConfigurationService = new ApplicationConfigurationService( const appConfigService = new AppConfigService(cookies.get('access_token'));
cookies.get('access_token')
);
const user = await userService const user = await userService
.getCurrent() .getCurrent()
.then((user) => user) .then((user) => user)
.catch(() => null); .catch(() => null);
const applicationConfiguration = await applicationConfigurationService const appConfig = await appConfigService
.list() .list()
.then((config) => config) .then((config) => config)
.catch((e) => { .catch((e) => {
@@ -24,6 +22,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
}); });
return { return {
user, user,
applicationConfiguration appConfig
}; };
}; };

View File

@@ -4,7 +4,7 @@
import Error from '$lib/components/error.svelte'; import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte'; import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner'; import { Toaster } from '$lib/components/ui/sonner';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -19,17 +19,17 @@
children: Snippet; children: Snippet;
} = $props(); } = $props();
const { user, applicationConfiguration } = data; const { user, appConfig } = data;
if (browser && user) { if (browser && user) {
userStore.setUser(user); userStore.setUser(user);
} }
if (applicationConfiguration) { if (appConfig) {
applicationConfigurationStore.set(applicationConfiguration); appConfigStore.set(appConfig);
} }
</script> </script>
{#if !applicationConfiguration} {#if !appConfig}
<Error <Error
message="A critical error occured. Please contact your administrator." message="A critical error occured. Please contact your administrator."
showButton={false} showButton={false}

View File

@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
scope: url.searchParams.get('scope')!, scope: url.searchParams.get('scope')!,
nonce: url.searchParams.get('nonce') || undefined, nonce: url.searchParams.get('nonce') || undefined,
state: url.searchParams.get('state')!, state: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!,
client client
}; };
}; };

View File

@@ -4,7 +4,7 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
@@ -24,7 +24,7 @@
let authorizationRequired = false; let authorizationRequired = false;
export let data: PageData; export let data: PageData;
let { scope, nonce, client, state } = data; let { scope, nonce, client, state, callbackURL } = data;
async function authorize() { async function authorize() {
isLoading = true; isLoading = true;
@@ -36,9 +36,11 @@
await webauthnService.finishLogin(authResponse); await webauthnService.finishLogin(authResponse);
} }
await oidService.authorize(client!.id, scope, nonce).then(async (code) => { await oidService
onSuccess(code); .authorize(client!.id, scope, callbackURL, nonce)
}); .then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
} catch (e) { } catch (e) {
if (e instanceof AxiosError && e.response?.status === 403) { if (e instanceof AxiosError && e.response?.status === 403) {
authorizationRequired = true; authorizationRequired = true;
@@ -52,19 +54,21 @@
async function authorizeNewClient() { async function authorizeNewClient() {
isLoading = true; isLoading = true;
try { try {
await oidService.authorizeNewClient(client!.id, scope, nonce).then(async (code) => { await oidService
onSuccess(code); .authorizeNewClient(client!.id, scope, callbackURL, nonce)
}); .then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
} catch (e) { } catch (e) {
errorMessage = getWebauthnErrorMessage(e); errorMessage = getWebauthnErrorMessage(e);
isLoading = false; isLoading = false;
} }
} }
function onSuccess(code: string) { function onSuccess(code: string, callbackURL: string) {
success = true; success = true;
setTimeout(() => { setTimeout(() => {
window.location.href = `${client!.callbackURL}?code=${code}&state=${state}`; window.location.href = `${callbackURL}?code=${code}&state=${state}`;
}, 1000); }, 1000);
} }
</script> </script>
@@ -79,16 +83,17 @@
<SignInWrapper> <SignInWrapper>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if !authorizationRequired} {#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2"> <p class="text-muted-foreground mb-10 mt-2">
{#if errorMessage} {errorMessage}. Please try again.
{errorMessage}. Please try again.
{:else}
Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account?
{/if}
</p> </p>
{:else} {/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$appConfigStore.appName}</b> account?
</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}> <div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6"> <Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5"> <Card.Header class="pb-5">

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte'; import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
@@ -40,7 +40,7 @@
</div> </div>
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$applicationConfigurationStore.appName} Sign in to {$appConfigStore.appName}
</h1> </h1>
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
Authenticate yourself with your passkey to access the admin panel Authenticate yourself with your passkey to access the admin panel

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte'; import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store.js'; import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
import type { User } from '$lib/types/user.type.js'; import type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -18,9 +18,9 @@
isLoading = true; isLoading = true;
userService userService
.exchangeOneTimeAccessToken(data.token) .exchangeOneTimeAccessToken(data.token)
.then((user :User) => { .then((user: User) => {
userStore.setUser(user); userStore.setUser(user);
goto('/settings') goto('/settings');
}) })
.catch(axiosErrorToast); .catch(axiosErrorToast);
isLoading = false; isLoading = false;
@@ -29,15 +29,15 @@
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="rounded-2xl bg-muted p-3"> <div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
</div> </div>
<h1 class="mt-5 font-playfair text-4xl font-bold">One Time Access</h1> <h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1>
<p class="mt-2 text-muted-foreground"> <p class="text-muted-foreground mt-2">
You've been granted one-time access to your {$applicationConfigurationStore.appName} account. Please note that if you continue, You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if
this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, you'll need you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
to request a new link. you'll need to request a new link.
</p> </p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button> <Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
</SignInWrapper> </SignInWrapper>

View File

@@ -9,7 +9,10 @@
children: Snippet; children: Snippet;
} = $props(); } = $props();
let links = $state([{ href: '/settings/account', label: 'My Account' }]); let links = $state([
{ href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' }
]);
if ($userStore?.isAdmin) { if ($userStore?.isAdmin) {
links = [ links = [
@@ -22,24 +25,26 @@
</script> </script>
<section> <section>
<div class="h-screen w-full"> <div class="bg-muted/40 min-h-screen w-full">
<main class="flex min-h-screen flex-1 flex-col gap-4 bg-muted/40 p-4 md:gap-8 md:p-10"> <main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
<div class="mx-auto grid w-full max-w-[1440px] gap-2"> <div>
<h1 class="text-3xl font-semibold">Settings</h1> <div class="mx-auto grid w-full gap-2">
</div> <h1 class="mb-5 text-3xl font-semibold">Settings</h1>
<div
class="mx-auto grid w-full max-w-[1440px] items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="grid gap-4 text-sm text-muted-foreground">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}>
{label}
</a>
{/each}
</nav>
<div class="flex flex-col gap-5">
{@render children()}
</div> </div>
<div
class="mx-auto grid items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
{label}
</a>
{/each}
</nav>
</div>
</div>
<div class="flex w-full flex-col gap-5">
{@render children()}
</div> </div>
</main> </main>
</div> </div>

View File

@@ -1,10 +1,8 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const applicationConfigurationService = new ApplicationConfigurationService( const appConfigService = new AppConfigService(cookies.get('access_token'));
cookies.get('access_token') const appConfig = await appConfigService.list(true);
); return { appConfig };
const applicationConfiguration = await applicationConfigurationService.list(true);
return { applicationConfiguration };
}; };

View File

@@ -1,24 +1,30 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ApplicationConfigurationForm from './application-configuration-form.svelte'; import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte'; import UpdateApplicationImages from './update-application-images.svelte';
let { data } = $props(); let { data } = $props();
let applicationConfiguration = $state(data.applicationConfiguration); let appConfig = $state(data.appConfig);
const applicationConfigurationService = new ApplicationConfigurationService(); const appConfigService = new AppConfigService();
async function updateConfiguration(configuration: AllApplicationConfiguration) { async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await applicationConfigurationService await appConfigService
.update(configuration) .update({
.then(() => toast.success('Application configuration updated successfully')) ...appConfig,
.catch(axiosErrorToast); ...updatedAppConfig
await applicationConfigurationStore.reload(); })
.catch((e) => {
axiosErrorToast(e);
throw e;
});
await appConfigStore.reload();
} }
async function updateImages( async function updateImages(
@@ -26,12 +32,10 @@
backgroundImage: File | null, backgroundImage: File | null,
favicon: File | null favicon: File | null
) { ) {
const faviconPromise = favicon const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
? applicationConfigurationService.updateFavicon(favicon) const logoPromise = logo ? appConfigService.updateLogo(logo) : Promise.resolve();
: Promise.resolve();
const logoPromise = logo ? applicationConfigurationService.updateLogo(logo) : Promise.resolve();
const backgroundImagePromise = backgroundImage const backgroundImagePromise = backgroundImage
? applicationConfigurationService.updateBackgroundImage(backgroundImage) ? appConfigService.updateBackgroundImage(backgroundImage)
: Promise.resolve(); : Promise.resolve();
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise]) await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
@@ -49,7 +53,20 @@
<Card.Title>General</Card.Title> <Card.Title>General</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<ApplicationConfigurationForm {applicationConfiguration} callback={updateConfiguration} /> <AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Email</Card.Title>
<Card.Description>
Enable email notifications to alert users when a login is detected from a new device or
location.
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
let {
callback,
appConfig
}: {
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
let emailEnabled = $state(appConfig.emailEnabled == 'true');
const updatedAppConfig = {
emailEnabled: emailEnabled.toString(),
smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom
};
const formSchema = z.object({
smtpHost: z.string().min(1),
smtpPort: z.string().min(1),
smtpUser: z.string().min(1),
smtpPassword: z.string().min(1),
smtpFrom: z.string().email()
});
const { inputs, ...form } = createForm< typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() {
const data = form.validate();
if (!data) return false;
isLoading = true;
await callback({
...data,
emailEnabled: 'true'
}).finally(() => (isLoading = false));
toast.success('Email configuration updated successfully');
return true;
}
async function onDisable() {
await callback({ emailEnabled: 'false' });
emailEnabled = false;
toast.success('Email disabled successfully');
}
async function onEnable() {
if (await onSubmit()) {
emailEnabled = true;
}
}
</script>
<form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-2 gap-5">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
</div>
<div class="mt-5 flex justify-end gap-3">
{#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button>
{:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
{/if}
</div>
</form>

View File

@@ -1,23 +1,24 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod'; import { z } from 'zod';
let { let {
callback, callback,
applicationConfiguration appConfig
}: { }: {
applicationConfiguration: AllApplicationConfiguration; appConfig: AllAppConfig;
callback: (user: AllApplicationConfiguration) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
const updatedApplicationConfiguration: AllApplicationConfiguration = { const updatedAppConfig = {
appName: applicationConfiguration.appName, appName: appConfig.appName,
sessionDuration: applicationConfiguration.sessionDuration sessionDuration: appConfig.sessionDuration
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -32,22 +33,20 @@
} }
) )
}); });
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, updatedApplicationConfiguration); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
if (!data) return; if (!data) return;
isLoading = true; isLoading = true;
await callback(data); await callback(data).finally(() => (isLoading = false));
isLoading = false; toast.success('Application configuration updated successfully');
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<FormInput label="Application Name" bind:input={$inputs.appName} /> <FormInput label="Application Name" bind:input={$inputs.appName} />
<FormInput <FormInput
label="Session Duration" label="Session Duration"
description="The duration of a session in minutes before the user has to sign in again." description="The duration of a session in minutes before the user has to sign in again."

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte'; import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte'; import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte'; import OIDCClientList from './oidc-client-list.svelte';
import { axiosErrorToast } from '$lib/utils/error-util';
import clientSecretStore from '$lib/stores/client-secret-store';
import { goto } from '$app/navigation';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
let { data } = $props(); let { data } = $props();
let clients = $state(data); let clients = $state(data);
@@ -22,7 +22,7 @@
async function createOIDCClient(client: OidcClientCreateWithLogo) { async function createOIDCClient(client: OidcClientCreateWithLogo) {
try { try {
const createdClient = await oidcService.createClient(client); const createdClient = await oidcService.createClient(client);
if(client.logo){ if (client.logo) {
await oidcService.updateClientLogo(createdClient, client.logo); await oidcService.updateClientLogo(createdClient, client.logo);
} }
const clientSecret = await oidcService.createClientSecret(createdClient.id); const clientSecret = await oidcService.createClientSecret(createdClient.id);
@@ -31,7 +31,7 @@
toast.success('OIDC client created successfully'); toast.success('OIDC client created successfully');
return true; return true;
} catch (e) { } catch (e) {
axiosErrorToast(e) axiosErrorToast(e);
return false; return false;
} }
} }
@@ -46,7 +46,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create OIDC Client</Card.Title> <Card.Title>Create OIDC Client</Card.Title>
<Card.Description>Add a new OIDC client to {$applicationConfigurationStore.appName}.</Card.Description> <Card.Description>Add a new OIDC client to {$appConfigStore.appName}.</Card.Description>
</div> </div>
{#if !expandAddClient} {#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button> <Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
@@ -10,13 +11,24 @@
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideRefreshCcw } from 'lucide-svelte'; import { LucideChevronLeft, LucideRefreshCcw } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte'; import OidcForm from '../oidc-client-form.svelte';
let { data } = $props(); let { data } = $props();
let client = $state(data); let client = $state(data);
let showAllDetails = $state(false);
const oidcService = new OidcService(); const oidcService = new OidcService();
const setupDetails = {
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: 'Disabled'
};
async function updateClient(updatedClient: OidcClientCreateWithLogo) { async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true; let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient); const dataPromise = oidcService.updateClient(client.id, updatedClient);
@@ -74,23 +86,43 @@
<Card.Title>{client.name}</Card.Title> <Card.Title>{client.name}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="flex"> <div class="flex flex-col">
<Label class="mb-0 w-44">Client ID</Label> <div class="mb-2 flex">
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span> <Label class="mb-0 w-44">Client ID</Label>
</div> <span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
<div class="mt-3 flex items-center"> </div>
<Label class="mb-0 w-44">Client secret</Label> <div class="mb-2 mt-1 flex items-center">
<span class="text-muted-foreground text-sm" data-testid="client-secret" <Label class="w-44">Client secret</Label>
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span <span class="text-muted-foreground text-sm" data-testid="client-secret"
> >{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
> >
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
{/if}
</div>
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex">
<Label class="mb-0 w-44">{key}</Label>
<span class="text-muted-foreground text-sm">{value}</span>
</div>
{/each}
</div>
{/if}
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button
>
</div>
{/if} {/if}
</div> </div>
</Card.Content> </Card.Content>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { LucideMinus, LucidePlus } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
callbackURLs = $bindable(),
error = $bindable(null),
...restProps
}: HTMLAttributes<HTMLDivElement> & {
callbackURLs: string[];
error?: string | null;
children?: Snippet;
} = $props();
const limit = 5;
</script>
<div {...restProps}>
<FormInput label="Callback URLs">
<div class="flex flex-col gap-y-2">
{#each callbackURLs as _, i}
<div class="flex gap-x-2">
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
{#if callbackURLs.length > 1}
<Button
variant="outline"
size="sm"
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
>
<LucideMinus class="h-4 w-4" />
</Button>
{/if}
</div>
{/each}
</div>
</FormInput>
{#if error}
<p class="mt-1 text-sm text-red-500">{error}</p>
{/if}
{#if callbackURLs.length < limit}
<Button
class="mt-2"
variant="secondary"
size="sm"
on:click={() => callbackURLs = [...callbackURLs, '']}
>
<LucidePlus class="mr-1 h-4 w-4" />
Add another
</Button>
{/if}
</div>

View File

@@ -10,6 +10,7 @@
} from '$lib/types/oidc.type'; } from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let { let {
callback, callback,
@@ -27,12 +28,12 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURL: existingClient?.callbackURL || '' callbackURLs: existingClient?.callbackURLs || [""]
}; };
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
callbackURL: z.string().url() callbackURLs: z.array(z.string().url()).nonempty()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;
@@ -70,32 +71,40 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="mt-3 grid grid-cols-2 gap-3"> <div class="flex flex-col gap-3 sm:flex-row">
<FormInput label="Name" bind:input={$inputs.name} /> <FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<FormInput label="Callback URL" bind:input={$inputs.callbackURL} /> <OidcCallbackUrlInput
<div class="mt-3"> class="w-full"
<Label for="logo">Logo</Label> bind:callbackURLs={$inputs.callbackURLs.value}
<div class="mt-2 flex items-end gap-3"> bind:error={$inputs.callbackURLs.error}
{#if logoDataURL} />
<div class="h-32 w-32 rounded-2xl bg-muted p-3"> </div>
<img class="m-auto max-h-full max-w-full object-contain" src={logoDataURL} alt={`${$inputs.name.value} logo`} /> <div class="mt-3">
</div> <Label for="logo">Logo</Label>
{/if} <div class="mt-2 flex items-end gap-3">
<div class="flex flex-col gap-2"> {#if logoDataURL}
<FileInput <div class="bg-muted h-32 w-32 rounded-2xl p-3">
id="logo" <img
variant="secondary" class="m-auto max-h-full max-w-full object-contain"
accept="image/png, image/jpeg, image/svg+xml" src={logoDataURL}
onchange={onLogoChange} alt={`${$inputs.name.value} logo`}
> />
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
{/if}
</div> </div>
{/if}
<div class="flex flex-col gap-2">
<FileInput
id="logo"
variant="secondary"
accept="image/png, image/jpeg, image/svg+xml"
onchange={onLogoChange}
>
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated } from '$lib/types/pagination.type';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -42,9 +42,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create User</Card.Title> <Card.Title>Create User</Card.Title>
<Card.Description <Card.Description>Add a new user to {$appConfigStore.appName}.</Card.Description>
>Add a new user to {$applicationConfigurationStore.appName}.</Card.Description
>
</div> </div>
{#if !expandAddUser} {#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button> <Button on:click={() => (expandAddUser = true)}>Add User</Button>

View File

@@ -26,9 +26,16 @@
}; };
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(2).max(50), firstName: z.string().min(2).max(30),
lastName: z.string().min(2).max(50), lastName: z.string().min(2).max(30),
username: z.string().min(2).max(50), username: z
.string()
.min(2)
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
),
email: z.string().email(), email: z.string().email(),
isAdmin: z.boolean() isAdmin: z.boolean()
}); });
@@ -66,10 +73,10 @@
<div class="items-top mt-5 flex space-x-2"> <div class="items-top mt-5 flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} /> <Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
<div class="grid gap-1.5 leading-none"> <div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="text-sm font-medium leading-none mb-0"> <Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Admin Privileges Admin Privileges
</Label> </Label>
<p class="text-[0.8rem] text-muted-foreground">Admins have full access to the admin panel.</p> <p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
</div> </div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">

View File

@@ -0,0 +1,13 @@
import AuditLogService from '$lib/services/audit-log-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const auditLogService = new AuditLogService(cookies.get('access_token'));
const auditLogs = await auditLogService.list({
limit: 15,
page: 1,
});
return {
auditLogs
};
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import AuditLogList from './audit-log-list.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Audit Log</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>Audit Log</Card.Title>
<Card.Description class="mt-1">See your account activities from the last 3 months.</Card.Description>
</Card.Header>
<Card.Content>
<AuditLogList auditLogs={data.auditLogs} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
const auditLogService = new AuditLogService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 15
});
function toFriendlyEventString(event: string) {
const words = event.split('_');
const capitalizedWords = words.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return capitalizedWords.join(' ');
}
</script>
<Table.Root>
<Table.Header class="whitespace-nowrap">
<Table.Row>
<Table.Head>Time</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>IP Address</Table.Head>
<Table.Head>Device</Table.Head>
<Table.Head>Client</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body class="whitespace-nowrap">
{#if auditLogs.data.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="text-center">No logs found</Table.Cell>
</Table.Row>
{:else}
{#each auditLogs.data as auditLog}
<Table.Row>
<Table.Cell>{new Date(auditLog.createdAt).toLocaleString()}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
</Table.Cell>
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
<Table.Cell>{auditLog.device}</Table.Cell>
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if auditLogs?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={auditLogs.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(auditLogs = await auditLogService.list({
page: p,
limit: pagination.limit
}))}
bind:page={auditLogs.pagination.currentPage}
let:pages
let:currentPage
>
<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.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={auditLogs.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}

View File

@@ -21,6 +21,33 @@ test('Update general configuration', async ({ page }) => {
await expect(page.getByLabel('Session Duration')).toHaveValue('30'); await expect(page.getByLabel('Session Duration')).toHaveValue('30');
}); });
test('Update email configuration', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
await page.getByLabel('SMTP Port').fill('587');
await page.getByLabel('SMTP User').fill('test@gmail.com');
await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByRole('button', { name: 'Enable' }).click();
await page.getByRole('status').click();
await expect(page.getByRole('status')).toHaveText('Email configuration updated successfully');
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await page.reload();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.gmail.com');
await expect(page.getByLabel('SMTP Port')).toHaveValue('587');
await expect(page.getByLabel('SMTP User')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await page.getByRole('button', { name: 'Disable' }).click();
await expect(page.getByRole('status')).toHaveText('Email disabled successfully');
});
test('Update application images', async ({ page }) => { test('Update application images', async ({ page }) => {
await page.goto('/settings/admin/application-configuration'); await page.goto('/settings/admin/application-configuration');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -23,17 +23,18 @@ export const users = {
export const oidcClients = { export const oidcClients = {
nextcloud: { nextcloud: {
id: "3654a746-35d4-4321-ac61-0bdcff2b4055", id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud', name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback' callbackUrl: 'http://nextcloud/auth/callback'
}, },
immich: { immich: {
id: "606c7782-f2b1-49e5-8ea9-26eb1b06d018", id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich', name: 'Immich',
callbackUrl: 'http://immich/auth/callback' callbackUrl: 'http://immich/auth/callback'
}, },
pingvinShare: { pingvinShare: {
name: 'Pingvin Share', name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback' callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
} }
}; };

View File

@@ -10,7 +10,11 @@ test('Create OIDC client', async ({ page }) => {
await page.getByRole('button', { name: 'Add OIDC Client' }).click(); await page.getByRole('button', { name: 'Add OIDC Client' }).click();
await page.getByLabel('Name').fill(oidcClient.name); await page.getByLabel('Name').fill(oidcClient.name);
await page.getByLabel('Callback URL').fill(oidcClient.callbackUrl);
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl!);
await page.getByLabel('logo').setInputFiles('tests/assets/pingvin-share-logo.png'); await page.getByLabel('logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
@@ -20,7 +24,8 @@ test('Create OIDC client', async ({ page }) => {
expect(clientId?.length).toBe(36); expect(clientId?.length).toBe(36);
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32); expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name); await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
await expect(page.getByLabel('Callback URL')).toHaveValue(oidcClient.callbackUrl); await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl!);
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible(); await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
await page.request await page.request
.get(`/api/oidc/clients/${clientId}/logo`) .get(`/api/oidc/clients/${clientId}/logo`)
@@ -32,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByLabel('Name').fill('Nextcloud updated');
await page.getByLabel('Callback URL').fill('http://nextcloud-updated/auth/callback'); await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback');
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png'); await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();

View File

@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
export async function cleanupBackend() { export async function cleanupBackend() {
await axios.post('/api/test/reset'); await axios.post('http://localhost/api/test/reset');
} }

View File

@@ -1,5 +1,5 @@
:80 { :80 {
reverse_proxy /api/* http://localhost:8080 reverse_proxy /api/* http://localhost:8080
reverse_proxy /.well-known/* http://localhost:8080 reverse_proxy /.well-known/* http://localhost:8080
reverse_proxy /* http://localhost:3000 reverse_proxy /* http://localhost:3000

View File

@@ -0,0 +1,16 @@
:80 {
reverse_proxy /api/* http://localhost:8080 {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /.well-known/* http://localhost:8080 {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /* http://localhost:3000 {
trusted_proxies 0.0.0.0/0
}
log {
output file /var/log/caddy/access.log
level WARN
}
}

View File

@@ -1,4 +1,3 @@
echo "Starting frontend..." echo "Starting frontend..."
node frontend/build & node frontend/build &
@@ -6,6 +5,12 @@ echo "Starting backend..."
cd backend && ./pocket-id-backend & cd backend && ./pocket-id-backend &
echo "Starting Caddy..." echo "Starting Caddy..."
caddy start --config /etc/caddy/Caddyfile &
# Check if TRUST_PROXY is set to true and use the appropriate Caddyfile
if [ "$TRUST_PROXY" = "true" ]; then
caddy start --config /etc/caddy/Caddyfile.trust-proxy &
else
caddy start --config /etc/caddy/Caddyfile &
fi
wait wait