Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0ec08290 | ||
|
|
9121239dd7 | ||
|
|
4010ee27d6 | ||
|
|
4e7574a297 | ||
|
|
8038a111dd | ||
|
|
c6f83a581a | ||
|
|
8ad632e6c1 | ||
|
|
903b0b3918 | ||
|
|
fd21ce5aac | ||
|
|
e7861df95a | ||
|
|
8e27320649 | ||
|
|
2b9413c757 | ||
|
|
fd5a881cfb | ||
|
|
28ed064668 | ||
|
|
5446b46b65 | ||
|
|
0ce6045657 | ||
|
|
3fe24a04de | ||
|
|
6769cc8c10 | ||
|
|
97f7fc4e28 | ||
|
|
fc47c2a2a4 | ||
|
|
f1a6c8db85 | ||
|
|
552d7ccfa5 | ||
|
|
e45b0b3ed0 | ||
|
|
8166e2ead7 | ||
|
|
ae7aeb0945 | ||
|
|
16f273ffce | ||
|
|
9f49e5577e |
@@ -1 +1,2 @@
|
|||||||
PUBLIC_APP_URL=http://localhost
|
PUBLIC_APP_URL=http://localhost
|
||||||
|
TRUST_PROXY=false
|
||||||
54
CHANGELOG.md
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
12
README.md
@@ -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. |
|
||||||
|
|||||||
119
backend/email-templates/login-with-new-device.html
Normal 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>
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.7 MiB |
@@ -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 |
@@ -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" {
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/internal/controller/audit_log_controller.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
23
backend/internal/dto/app_config_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
17
backend/internal/dto/audit_log_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
81
backend/internal/dto/dto_mapper.go
Normal 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
|
||||||
|
}
|
||||||
37
backend/internal/dto/oidc_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
25
backend/internal/dto/user_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
42
backend/internal/dto/validations.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/internal/dto/webauthn_dto.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
50
backend/internal/model/audit_log.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
85
backend/internal/service/audit_log_service.go
Normal 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
|
||||||
|
}
|
||||||
67
backend/internal/service/email_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
75
backend/internal/utils/controller_error_util.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
1
backend/migrations/20240908123031_audit_log.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE audit_logs;
|
||||||
10
backend/migrations/20240908123031_audit_log.up.sql
Normal 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
@@ -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.
|
||||||
581
frontend/package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
20
frontend/src/lib/services/audit-log-service.ts
Normal 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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}[];
|
}[];
|
||||||
|
|||||||
8
frontend/src/lib/types/audit-log.type.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type AuditLog = {
|
||||||
|
id: string;
|
||||||
|
event: string;
|
||||||
|
ipAddress: string;
|
||||||
|
device: string;
|
||||||
|
createdAt: string;
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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."
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
13
frontend/src/routes/settings/audit-log/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
20
frontend/src/routes/settings/audit-log/+page.svelte
Normal 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>
|
||||||
95
frontend/src/routes/settings/audit-log/audit-log-list.svelte
Normal 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}
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 578 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 58 KiB |
@@ -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'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
16
reverse-proxy/Caddyfile.trust-proxy
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||