mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 14:52:57 +03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11ed44733 | ||
|
|
541481721f | ||
|
|
0e95e9c56f | ||
|
|
fcf08a4d89 | ||
|
|
0b4101ccce | ||
|
|
27ea1fc2d3 | ||
|
|
f637a89f57 | ||
|
|
058084ed64 | ||
|
|
9370292fe5 | ||
|
|
46eef1fcb7 | ||
|
|
e784093342 | ||
|
|
653d948f73 | ||
|
|
a1302ef7bf | ||
|
|
5f44fef85f | ||
|
|
3613ac261c | ||
|
|
760c8e83bb |
@@ -1,2 +1,3 @@
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
TRUST_PROXY=false
|
||||
TRUST_PROXY=false
|
||||
MAXMIND_LICENSE_KEY=
|
||||
@@ -11,20 +11,35 @@ jobs:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Download GeoLite2 City database
|
||||
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||
- name: 'Login to GitHub Container Registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.repository_owner}}
|
||||
password: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
@@ -32,6 +47,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
3
.github/workflows/e2e-tests.yml
vendored
3
.github/workflows/e2e-tests.yml
vendored
@@ -16,9 +16,6 @@ jobs:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Create dummy GeoLite2 City database
|
||||
run: touch ./backend/GeoLite2-City.mmdb
|
||||
|
||||
- name: Build Docker Image
|
||||
run: docker build -t stonith404/pocket-id .
|
||||
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,3 +1,40 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* add option to specify the Max Mind license key for the Geolite2 db
|
||||
|
||||
### Features
|
||||
|
||||
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
|
||||
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
|
||||
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
|
||||
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -21,7 +21,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
|
||||
|
||||
# Stage 3: Production Image
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache caddy
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk add --no-cache caddy curl su-exec
|
||||
COPY ./reverse-proxy /etc/caddy/
|
||||
|
||||
WORKDIR /app
|
||||
@@ -31,7 +34,6 @@ 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/migrations ./backend/migrations
|
||||
COPY --from=backend-builder /app/backend/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
|
||||
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||
|
||||
@@ -41,5 +43,5 @@ RUN chmod +x ./scripts/*.sh
|
||||
EXPOSE 80
|
||||
ENV APP_ENV=production
|
||||
|
||||
# Use a shell form to run both the frontend and backend
|
||||
CMD ["sh", "./scripts/docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
35
README.md
35
README.md
@@ -11,7 +11,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
## Setup
|
||||
|
||||
> [!WARNING]
|
||||
> Pocket ID is in its early stages and may contain bugs.
|
||||
> Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
|
||||
|
||||
### Before you start
|
||||
|
||||
@@ -68,10 +68,6 @@ Required tools:
|
||||
cd ..
|
||||
pm2 start pocket-id-backend --name pocket-id-backend
|
||||
|
||||
# Optional: Download the GeoLite2 city database.
|
||||
# If not downloaded the ip location in the audit log will be empty.
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
@@ -97,7 +93,7 @@ proxy_buffer_size 256k;
|
||||
|
||||
## 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.
|
||||
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/oauth2-proxy) to add authentication to your services that don't support OIDC.
|
||||
|
||||
See the [guide](docs/proxy-services.md) for more information.
|
||||
|
||||
@@ -130,9 +126,6 @@ docker compose up -d
|
||||
cd ..
|
||||
pm2 start pocket-id-backend --name pocket-id-backend
|
||||
|
||||
# Optional: Update the GeoLite2 city database
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
@@ -146,16 +139,20 @@ docker compose up -d
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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. |
|
||||
| `MAXMIND_LICENSE_KEY` | `-` | yes | # License key for MaxMind GeoIP2lite. |
|
||||
| License key for the GeoLite2 database. This is necessary to get the location of the ip adresses in the audit log. If not specified, the location will be unknown. You can get the license key here. |
|
||||
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
|
||||
| `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. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `GEOLITE_DB_PATH` | `data/GeoLite2-City.mmdb` | no | The path where the GeoLite2 database should be stored. |
|
||||
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
11
backend/email-templates/test_html.tmpl
Normal file
11
backend/email-templates/test_html.tmpl
Normal file
@@ -0,0 +1,11 @@
|
||||
{{ define "base" -}}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>This is a test email.</p>
|
||||
</div>
|
||||
{{ end -}}
|
||||
3
backend/email-templates/test_text.tmpl
Normal file
3
backend/email-templates/test_text.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
{{ define "base" -}}
|
||||
This is a test email.
|
||||
{{ end -}}
|
||||
@@ -30,12 +30,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
|
||||
// Initialize services
|
||||
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
||||
emailService, err := service.NewEmailService(appConfigService, db, templateDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create email service: %s", err)
|
||||
}
|
||||
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
||||
geoLiteService := service.NewGeoLiteService()
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService)
|
||||
@@ -58,7 +59,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
|
||||
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
|
||||
|
||||
@@ -14,6 +14,8 @@ type EnvConfigSchema struct {
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
}
|
||||
|
||||
var EnvConfig = &EnvConfigSchema{
|
||||
@@ -24,6 +26,8 @@ var EnvConfig = &EnvConfigSchema{
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
EmailTemplatesPath: "./email-templates",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -58,7 +58,7 @@ func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcInvalidCallbackURLError struct{}
|
||||
|
||||
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" }
|
||||
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" }
|
||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type FileTypeNotSupportedError struct{}
|
||||
@@ -102,7 +102,7 @@ func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyR
|
||||
type ClientIdOrSecretNotProvidedError struct{}
|
||||
|
||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||
return "Client id and secret not provided"
|
||||
return "Client id or secret not provided"
|
||||
}
|
||||
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -146,3 +146,17 @@ func (e *AccountEditNotAllowedError) Error() string {
|
||||
return "You are not allowed to edit your account"
|
||||
}
|
||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidCodeVerifierError struct{}
|
||||
|
||||
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||
return "Invalid code verifier"
|
||||
}
|
||||
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcMissingCodeChallengeError struct{}
|
||||
|
||||
func (e *OidcMissingCodeChallengeError) Error() string {
|
||||
return "Missing code challenge"
|
||||
}
|
||||
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -14,10 +14,13 @@ import (
|
||||
func NewAppConfigController(
|
||||
group *gin.RouterGroup,
|
||||
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
|
||||
appConfigService *service.AppConfigService) {
|
||||
appConfigService *service.AppConfigService,
|
||||
emailService *service.EmailService,
|
||||
) {
|
||||
|
||||
acc := &AppConfigController{
|
||||
appConfigService: appConfigService,
|
||||
emailService: emailService,
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
||||
@@ -29,10 +32,13 @@ func NewAppConfigController(
|
||||
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
|
||||
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
|
||||
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
|
||||
|
||||
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
|
||||
}
|
||||
|
||||
type AppConfigController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
emailService *service.EmailService
|
||||
}
|
||||
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
err := acc.emailService.SendTestEmail()
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||
@@ -80,7 +79,10 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
var input dto.OidcIdTokenDto
|
||||
// Disable cors for this endpoint
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
var input dto.OidcCreateTokensDto
|
||||
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
c.Error(err)
|
||||
@@ -91,16 +93,11 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
clientSecret := input.ClientSecret
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if clientID == "" || clientSecret == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = c.Request.BasicAuth()
|
||||
if !ok {
|
||||
c.Error(&common.ClientIdOrSecretNotProvidedError{})
|
||||
return
|
||||
}
|
||||
if clientID == "" && clientSecret == "" {
|
||||
clientID, clientSecret, _ = c.Request.BasicAuth()
|
||||
}
|
||||
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
|
||||
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
|
||||
@@ -22,4 +22,5 @@ type AppConfigUpdateDto struct {
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
SmtpUser string `json:"smtpUser"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||
}
|
||||
|
||||
@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
|
||||
type OidcClientDto struct {
|
||||
PublicOidcClientDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
CreatedBy UserDto `json:"createdBy"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
type OidcIdTokenDto struct {
|
||||
type OidcCreateTokensDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type CorsMiddleware struct{}
|
||||
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
|
||||
}
|
||||
|
||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||
return cors.New(cors.Config{
|
||||
AllowOrigins: []string{common.EnvConfig.AppURL},
|
||||
AllowMethods: []string{"*"},
|
||||
AllowHeaders: []string{"*"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
})
|
||||
return func(c *gin.Context) {
|
||||
// Allow all origins for the token endpoint
|
||||
if c.FullPath() == "/api/oidc/token" {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,11 @@ type AppConfig struct {
|
||||
LogoLightImageType AppConfigVariable
|
||||
LogoDarkImageType AppConfigVariable
|
||||
|
||||
EmailEnabled AppConfigVariable
|
||||
SmtpHost AppConfigVariable
|
||||
SmtpPort AppConfigVariable
|
||||
SmtpFrom AppConfigVariable
|
||||
SmtpUser AppConfigVariable
|
||||
SmtpPassword AppConfigVariable
|
||||
EmailEnabled AppConfigVariable
|
||||
SmtpHost AppConfigVariable
|
||||
SmtpPort AppConfigVariable
|
||||
SmtpFrom AppConfigVariable
|
||||
SmtpUser AppConfigVariable
|
||||
SmtpPassword AppConfigVariable
|
||||
SmtpSkipCertVerify AppConfigVariable
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt datatype.DateTime
|
||||
Code string
|
||||
Scope string
|
||||
Nonce string
|
||||
CodeChallenge *string
|
||||
CodeChallengeMethodSha256 *bool
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
@@ -39,6 +41,7 @@ type OidcClient struct {
|
||||
CallbackURLs CallbackURLs
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
|
||||
@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
return descriptors
|
||||
}
|
||||
|
||||
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||
|
||||
type OneTimeAccessToken struct {
|
||||
Base
|
||||
Token string
|
||||
|
||||
@@ -95,6 +95,11 @@ var defaultDbConfig = model.AppConfig{
|
||||
Key: "smtpPassword",
|
||||
Type: "string",
|
||||
},
|
||||
SmtpSkipCertVerify: model.AppConfigVariable{
|
||||
Key: "smtpSkipCertVerify",
|
||||
Type: "bool",
|
||||
DefaultValue: "false",
|
||||
},
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
|
||||
@@ -2,28 +2,27 @@ package service
|
||||
|
||||
import (
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
type AuditLogService struct {
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
emailService *EmailService
|
||||
geoliteService *GeoLiteService
|
||||
}
|
||||
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
country, city, err := s.GetIpLocation(ipAddress)
|
||||
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get IP location: %v\n", err)
|
||||
}
|
||||
@@ -97,29 +96,3 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
||||
ua := userAgentParser.Parse(userAgent)
|
||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||
}
|
||||
|
||||
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
|
||||
db, err := maxminddb.Open("GeoLite2-City.mmdb")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
addr := netip.MustParseAddr(ipAddress)
|
||||
|
||||
var record struct {
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
Country struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
err = db.Lookup(addr).Decode(&record)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||
"gorm.io/gorm"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
ttemplate "text/template"
|
||||
@@ -17,11 +21,12 @@ import (
|
||||
|
||||
type EmailService struct {
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
htmlTemplates map[string]*htemplate.Template
|
||||
textTemplates map[string]*ttemplate.Template
|
||||
}
|
||||
|
||||
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
||||
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
|
||||
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
|
||||
|
||||
return &EmailService{
|
||||
appConfigService: appConfigService,
|
||||
db: db,
|
||||
htmlTemplates: htmlTemplates,
|
||||
textTemplates: textTemplates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (srv *EmailService) SendTestEmail() error {
|
||||
var user model.User
|
||||
if err := srv.db.First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendEmail(srv,
|
||||
email.Address{
|
||||
Email: user.Email,
|
||||
Name: user.FullName(),
|
||||
}, TestTemplate, nil)
|
||||
}
|
||||
|
||||
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||
// Check if SMTP settings are set
|
||||
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
||||
@@ -71,26 +90,100 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
|
||||
)
|
||||
c.Body(body)
|
||||
|
||||
// Set up the authentication information.
|
||||
// Set up the TLS configuration
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
|
||||
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
}
|
||||
|
||||
// Connect to the SMTP server
|
||||
port := srv.appConfigService.DbConfig.SmtpPort.Value
|
||||
var client *smtp.Client
|
||||
if port == "465" {
|
||||
client, err = srv.connectToSmtpServerUsingImplicitTLS(
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
|
||||
tlsConfig,
|
||||
)
|
||||
} else {
|
||||
client, err = srv.connectToSmtpServerUsingStartTLS(
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
|
||||
tlsConfig,
|
||||
)
|
||||
}
|
||||
defer client.Quit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
// Set up the authentication
|
||||
auth := smtp.PlainAuth("",
|
||||
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||
)
|
||||
|
||||
// Send the email
|
||||
err = smtp.SendMail(
|
||||
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
|
||||
auth,
|
||||
srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||
[]string{toEmail.Email},
|
||||
[]byte(c.String()),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate SMTP client: %w", err)
|
||||
}
|
||||
|
||||
// Send the email
|
||||
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
||||
return fmt.Errorf("send email content: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
|
||||
conn, err := net.Dial("tcp", serverAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
|
||||
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(toEmail.Email); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start data: %w", err)
|
||||
}
|
||||
_, err = w.Write([]byte(c.String()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write email data: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||
},
|
||||
}
|
||||
|
||||
var TestTemplate = email.Template[struct{}]{
|
||||
Path: "test",
|
||||
Title: func(data *email.TemplateData[struct{}]) string {
|
||||
return "Test email"
|
||||
},
|
||||
}
|
||||
|
||||
type NewLoginTemplateData struct {
|
||||
IPAddress string
|
||||
Country string
|
||||
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
||||
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}
|
||||
|
||||
142
backend/internal/service/geolite_service.go
Normal file
142
backend/internal/service/geolite_service.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeoLiteService struct{}
|
||||
|
||||
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
|
||||
func NewGeoLiteService() *GeoLiteService {
|
||||
service := &GeoLiteService{}
|
||||
|
||||
go func() {
|
||||
if err := service.updateDatabase(); err != nil {
|
||||
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// GetLocationByIP returns the country and city of the given IP address.
|
||||
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
||||
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
addr := netip.MustParseAddr(ipAddress)
|
||||
|
||||
var record struct {
|
||||
City struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"city"`
|
||||
Country struct {
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
err = db.Lookup(addr).Decode(&record)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||
}
|
||||
|
||||
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
||||
func (s *GeoLiteService) updateDatabase() error {
|
||||
if s.isDatabaseUpToDate() {
|
||||
log.Println("GeoLite2 City database is up-to-date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Updating GeoLite2 City database...")
|
||||
|
||||
// Download and extract the database
|
||||
downloadUrl := fmt.Sprintf(
|
||||
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
|
||||
common.EnvConfig.MaxMindLicenseKey,
|
||||
)
|
||||
// Download the database tar.gz file
|
||||
resp, err := http.Get(downloadUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download database: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download database, received HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Extract the database file directly to the target path
|
||||
if err := s.extractDatabase(resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to extract database: %w", err)
|
||||
}
|
||||
|
||||
log.Println("GeoLite2 City database successfully updated.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDatabaseUpToDate checks if the database file is older than 14 days.
|
||||
func (s *GeoLiteService) isDatabaseUpToDate() bool {
|
||||
info, err := os.Stat(common.EnvConfig.GeoLiteDBPath)
|
||||
if err != nil {
|
||||
// If the file doesn't exist, treat it as not up-to-date
|
||||
return false
|
||||
}
|
||||
return time.Since(info.ModTime()) < 14*24*time.Hour
|
||||
}
|
||||
|
||||
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
|
||||
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
|
||||
gzr, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzr)
|
||||
|
||||
// Iterate over the files in the tar archive
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar archive: %w", err)
|
||||
}
|
||||
|
||||
// Check if the file is the GeoLite2-City.mmdb file
|
||||
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
|
||||
outFile, err := os.Create(common.EnvConfig.GeoLiteDBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create target database file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Write the file contents directly to the target location
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
return fmt.Errorf("failed to write database file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("GeoLite2-City.mmdb not found in archive")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
@@ -39,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
||||
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
|
||||
|
||||
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
}
|
||||
|
||||
if userAuthorizedOIDCClient.Scope != input.Scope {
|
||||
return "", "", &common.OidcMissingAuthorizationError{}
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -64,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
callbackURL, err := getCallbackURL(client, input.CallbackURL)
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
}
|
||||
|
||||
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -83,7 +93,7 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
||||
}
|
||||
}
|
||||
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -93,31 +103,41 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
|
||||
return code, callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
|
||||
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
|
||||
if grantType != "authorization_code" {
|
||||
return "", "", &common.OidcGrantTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
return "", "", &common.OidcClientSecretInvalidError{}
|
||||
// Verify the client secret if the client is not public
|
||||
if !client.IsPublic {
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", "", &common.OidcMissingClientCredentialsError{}
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
|
||||
if err != nil {
|
||||
return "", "", &common.OidcClientSecretInvalidError{}
|
||||
}
|
||||
}
|
||||
|
||||
var authorizationCodeMetaData model.OidcAuthorizationCode
|
||||
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||
err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
|
||||
if err != nil {
|
||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
|
||||
// If the client is public, the code verifier must match the code challenge
|
||||
if client.IsPublic {
|
||||
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
|
||||
return "", "", &common.OidcInvalidCodeVerifierError{}
|
||||
}
|
||||
}
|
||||
|
||||
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||
return "", "", &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
@@ -186,6 +206,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
||||
|
||||
client.Name = input.Name
|
||||
client.CallbackURLs = input.CallbackURLs
|
||||
client.IsPublic = input.IsPublic
|
||||
|
||||
if err := s.db.Save(&client).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
@@ -331,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
profileClaims := map[string]interface{}{
|
||||
"given_name": user.FirstName,
|
||||
"family_name": user.LastName,
|
||||
"name": user.FirstName + " " + user.LastName,
|
||||
"name": user.FullName(),
|
||||
"preferred_username": user.Username,
|
||||
}
|
||||
|
||||
@@ -358,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) {
|
||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||
|
||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||
Code: randomString,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
CodeChallenge: &codeChallenge,
|
||||
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
|
||||
@@ -380,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
|
||||
return randomString, nil
|
||||
}
|
||||
|
||||
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
|
||||
if !codeChallengeMethodSha256 {
|
||||
return codeVerifier == codeChallenge
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the codeVerifier
|
||||
h := sha256.New()
|
||||
h.Write([]byte(codeVerifier))
|
||||
codeVerifierHash := h.Sum(nil)
|
||||
|
||||
// Base64 URL encode the verifier hash
|
||||
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
|
||||
|
||||
return encodedVerifierHash == codeChallenge
|
||||
}
|
||||
|
||||
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
||||
if inputCallbackURL == "" {
|
||||
return client.CallbackURLs[0], nil
|
||||
}
|
||||
|
||||
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
3
backend/migrations/20241115131129_pkce.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
|
||||
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
|
||||
ALTER TABLE oidc_clients DROP COLUMN is_public;
|
||||
3
backend/migrations/20241115131129_pkce.up.sql
Normal file
3
backend/migrations/20241115131129_pkce.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
|
||||
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
|
||||
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;
|
||||
@@ -6,4 +6,11 @@ services:
|
||||
ports:
|
||||
- 3000:80
|
||||
volumes:
|
||||
- "./data:/app/backend/data"
|
||||
- "./data:/app/backend/data"
|
||||
# Optional healthcheck
|
||||
healthcheck:
|
||||
test: "curl -f http://localhost/health"
|
||||
interval: 1m30s
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
start_period: 10s
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.14.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
|
||||
25
frontend/src/lib/components/checkbox-with-label.svelte
Normal file
25
frontend/src/lib/components/checkbox-with-label.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
let {
|
||||
id,
|
||||
checked = $bindable(),
|
||||
label,
|
||||
description
|
||||
}: { id: string; checked: boolean; label: string; description?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox {id} bind:checked />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for={id} class="mb-0 text-sm font-medium leading-none">
|
||||
{label}
|
||||
</Label>
|
||||
{#if description}
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,6 +53,10 @@ export default class AppConfigService extends APIService {
|
||||
await this.api.put(`/application-configuration/background-image`, formData);
|
||||
}
|
||||
|
||||
async sendTestEmail() {
|
||||
await this.api.post('/application-configuration/test-email');
|
||||
}
|
||||
|
||||
async getVersionInformation() {
|
||||
const response = (
|
||||
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
|
||||
|
||||
@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
class OidcService extends APIService {
|
||||
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
||||
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||
const res = await this.api.post('/oidc/authorize', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
}
|
||||
|
||||
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
||||
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
|
||||
const res = await this.api.post('/oidc/authorize/new-client', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AllAppConfig = AppConfig & {
|
||||
smtpFrom: string;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
smtpSkipCertVerify: boolean;
|
||||
};
|
||||
|
||||
export type AppConfigRawResponse = {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type OidcClient = {
|
||||
logoURL: string;
|
||||
callbackURLs: [string, ...string[]];
|
||||
hasLogo: boolean;
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||
|
||||
@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
nonce: url.searchParams.get('nonce') || undefined,
|
||||
state: url.searchParams.get('state')!,
|
||||
callbackURL: url.searchParams.get('redirect_uri')!,
|
||||
client
|
||||
client,
|
||||
codeChallenge: url.searchParams.get('code_challenge')!,
|
||||
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
let authorizationRequired = false;
|
||||
|
||||
export let data: PageData;
|
||||
let { scope, nonce, client, state, callbackURL } = data;
|
||||
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||
|
||||
async function authorize() {
|
||||
isLoading = true;
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
await oidService
|
||||
.authorize(client!.id, scope, callbackURL, nonce)
|
||||
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
@@ -55,7 +55,7 @@
|
||||
isLoading = true;
|
||||
try {
|
||||
await oidService
|
||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
|
||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
|
||||
20
frontend/src/routes/health/+server.ts
Normal file
20
frontend/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import AppConfigService from '$lib/services/app-config-service';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const appConfigService = new AppConfigService();
|
||||
let backendOk = true;
|
||||
await appConfigService.list().catch(() => (backendOk = false));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: backendOk ? 'HEALTHY' : 'UNHEALTHY'
|
||||
}),
|
||||
{
|
||||
status: backendOk ? 200 : 500,
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AppConfigService from '$lib/services/app-config-service';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -14,7 +16,9 @@
|
||||
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
const appConfigService = new AppConfigService();
|
||||
|
||||
let isSendingTestEmail = $state(false);
|
||||
let emailEnabled = $state(appConfig.emailEnabled);
|
||||
|
||||
const updatedAppConfig = {
|
||||
@@ -23,7 +27,8 @@
|
||||
smtpPort: appConfig.smtpPort,
|
||||
smtpUser: appConfig.smtpUser,
|
||||
smtpPassword: appConfig.smtpPassword,
|
||||
smtpFrom: appConfig.smtpFrom
|
||||
smtpFrom: appConfig.smtpFrom,
|
||||
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -31,19 +36,20 @@
|
||||
smtpPort: z.number().min(1),
|
||||
smtpUser: z.string().min(1),
|
||||
smtpPassword: z.string().min(1),
|
||||
smtpFrom: z.string().email()
|
||||
smtpFrom: z.string().email(),
|
||||
smtpSkipCertVerify: z.boolean()
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
|
||||
|
||||
async function onSubmit() {
|
||||
console.log('submit');
|
||||
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;
|
||||
}
|
||||
@@ -59,22 +65,43 @@
|
||||
emailEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTestEmail() {
|
||||
isSendingTestEmail = true;
|
||||
await appConfigService
|
||||
.sendTestEmail()
|
||||
.then(() => toast.success('Test email sent successfully to your Email address.'))
|
||||
.catch(() =>
|
||||
toast.error('Failed to send test email. Check the server logs for more information.')
|
||||
)
|
||||
.finally(() => (isSendingTestEmail = false));
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="mt-5 grid grid-cols-2 gap-5">
|
||||
<div class="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||
<FormInput label="SMTP Port" type="number" 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} />
|
||||
<CheckboxWithLabel
|
||||
id="skip-cert-verify"
|
||||
label="Skip Certificate Verification"
|
||||
description="This can be useful for self-signed certificates."
|
||||
bind:checked={$inputs.smtpSkipCertVerify.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
||||
{#if emailEnabled}
|
||||
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
||||
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button>
|
||||
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
||||
>Send Test Email</Button
|
||||
>
|
||||
|
||||
<Button onclick={onSubmit} type="submit">Save</Button>
|
||||
{:else}
|
||||
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
|
||||
<Button onclick={onEnable}>Enable</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
@@ -51,28 +52,18 @@
|
||||
description="The duration of a session in minutes before the user has to sign in again."
|
||||
bind:input={$inputs.sessionDuration}
|
||||
/>
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||
Enable Self-Account Editing
|
||||
</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
Whether the users should be able to edit their own account details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||
Emails Verified
|
||||
</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
Whether the user's email should be marked as verified for the OIDC clients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxWithLabel
|
||||
id="self-account-editing"
|
||||
label="Enable Self-Account Editing"
|
||||
description="Whether the users should be able to edit their own account details."
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="emails-verified"
|
||||
label="Emails Verified"
|
||||
description="Whether the user's email should be marked as verified for the OIDC clients."
|
||||
bind:checked={$inputs.emailsVerified.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
'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`
|
||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
|
||||
};
|
||||
|
||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||
@@ -34,6 +35,8 @@
|
||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
||||
|
||||
client.isPublic = updatedClient.isPublic;
|
||||
|
||||
await Promise.all([dataPromise, imagePromise])
|
||||
.then(() => {
|
||||
toast.success('OIDC client updated successfully');
|
||||
@@ -93,27 +96,29 @@
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div class="mb-2 mt-1 flex items-center">
|
||||
<Label class="w-44">Client secret</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
{$clientSecretStore}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<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 !client.isPublic}
|
||||
<div class="mb-2 mt-1 flex items-center">
|
||||
<Label class="w-44">Client secret</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
{$clientSecretStore}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<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}
|
||||
{#if showAllDetails}
|
||||
<div transition:slide>
|
||||
{#each Object.entries(setupDetails) as [key, value]}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FileInput from '$lib/components/file-input.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -28,12 +29,14 @@
|
||||
|
||||
const client: OidcClientCreate = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURLs: existingClient?.callbackURLs || [""]
|
||||
callbackURLs: existingClient?.callbackURLs || [''],
|
||||
isPublic: existingClient?.isPublic || false
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
callbackURLs: z.array(z.string().url()).nonempty()
|
||||
callbackURLs: z.array(z.string().url()).nonempty(),
|
||||
isPublic: z.boolean()
|
||||
});
|
||||
|
||||
type FormSchema = typeof formSchema;
|
||||
@@ -71,15 +74,21 @@
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<div class="grid grid-cols-2 gap-3 sm:flex-row">
|
||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||
<OidcCallbackUrlInput
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||
bind:error={$inputs.callbackURLs.error}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="public-client"
|
||||
label="Public Client"
|
||||
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
|
||||
bind:checked={$inputs.isPublic.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="mt-8">
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
@@ -70,15 +69,12 @@
|
||||
<FormInput label="Username" bind:input={$inputs.username} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-top mt-5 flex space-x-2">
|
||||
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
|
||||
<div class="grid gap-1.5 leading-none">
|
||||
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
|
||||
Admin Privileges
|
||||
</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxWithLabel
|
||||
id="admin-privileges"
|
||||
label="Admin Privileges"
|
||||
description="Admins have full access to the admin panel."
|
||||
bind:checked={$inputs.isAdmin.value}
|
||||
/>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
|
||||
31
scripts/docker/create-user.sh
Normal file
31
scripts/docker/create-user.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
# If we aren't running as root, just exec the CMD
|
||||
[ "$(id -u)" -ne 0 ] && exec "$@"
|
||||
|
||||
|
||||
echo "Creating user and group..."
|
||||
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
# Check if the group with PGID exists; if not, create it
|
||||
if ! getent group pocket-id-group > /dev/null 2>&1; then
|
||||
addgroup -g "$PGID" pocket-id-group
|
||||
fi
|
||||
|
||||
# Check if a user with PUID exists; if not, create it
|
||||
if ! id -u pocket-id > /dev/null 2>&1; then
|
||||
if ! getent passwd "$PUID" > /dev/null 2>&1; then
|
||||
adduser -u "$PUID" -G pocket-id-group pocket-id
|
||||
else
|
||||
# If a user with the PUID already exists, use that user
|
||||
existing_user=$(getent passwd "$PUID" | cut -d: -f1)
|
||||
echo "Using existing user: $existing_user"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Change ownership of the /app directory
|
||||
mkdir -p /app/backend/data
|
||||
find /app/backend/data \( ! -group "${PGID}" -o ! -user "${PUID}" \) -exec chown "${PUID}:${PGID}" {} +
|
||||
|
||||
# Switch to the non-root user
|
||||
exec su-exec "$PUID:$PGID" "$@"
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the license key environment variable is set
|
||||
if [ -z "$MAXMIND_LICENSE_KEY" ]; then
|
||||
echo "Error: MAXMIND_LICENSE_KEY environment variable is not set."
|
||||
echo "Please set it using 'export MAXMIND_LICENSE_KEY=your_license_key' and try again."
|
||||
exit 1
|
||||
fi
|
||||
echo $MAXMIND_LICENSE_KEY
|
||||
# GeoLite2 City Database URL
|
||||
URL="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz"
|
||||
|
||||
# Download directory
|
||||
DOWNLOAD_DIR="./geolite2_db"
|
||||
TARGET_PATH=./backend/GeoLite2-City.mmdb
|
||||
mkdir -p $DOWNLOAD_DIR
|
||||
|
||||
# Download the database
|
||||
echo "Downloading GeoLite2 City database..."
|
||||
curl -L -o "$DOWNLOAD_DIR/GeoLite2-City.tar.gz" "$URL"
|
||||
|
||||
# Extract the downloaded file
|
||||
echo "Extracting GeoLite2 City database..."
|
||||
tar -xzf "$DOWNLOAD_DIR/GeoLite2-City.tar.gz" -C $DOWNLOAD_DIR --strip-components=1
|
||||
|
||||
mv "$DOWNLOAD_DIR/GeoLite2-City.mmdb" $TARGET_PATH
|
||||
|
||||
# Clean up
|
||||
rm -rf "$DOWNLOAD_DIR"
|
||||
|
||||
echo "GeoLite2 City database downloaded and extracted to $TARGET_PATH"
|
||||
Reference in New Issue
Block a user