From 9121239dd7c14a2107a984f9f94f54227489a63a Mon Sep 17 00:00:00 2001
From: Elias Schneider
Date: Mon, 9 Sep 2024 10:29:41 +0200
Subject: [PATCH] feat: add audit log with email notification (#26)
---
.env.example | 1 +
CONTRIBUTING.md | 2 +-
Dockerfile | 3 +-
README.md | 1 +
.../login-with-new-device.html | 119 ++++++++++++++++++
backend/go.mod | 1 +
backend/go.sum | 2 +
.../internal/bootstrap/router_bootstrap.go | 10 +-
.../controller/app_config_controller.go | 18 +--
.../controller/audit_log_controller.go | 56 +++++++++
.../internal/controller/oidc_controller.go | 4 +-
.../controller/webauthn_controller.go | 14 +--
backend/internal/dto/app_config_dto.go | 6 +
backend/internal/dto/audit_log_dto.go | 17 +++
backend/internal/job/db_cleanup.go | 8 +-
backend/internal/model/app_config.go | 7 ++
backend/internal/model/audit_log.go | 50 ++++++++
.../internal/service/app_config_service.go | 39 ++++--
backend/internal/service/audit_log_service.go | 85 +++++++++++++
backend/internal/service/email_service.go | 67 ++++++++++
backend/internal/service/oidc_service.go | 34 +++--
backend/internal/service/webauthn_service.go | 27 ++--
.../20240908123031_audit_log.down.sql | 1 +
.../20240908123031_audit_log.up.sql | 10 ++
frontend/src/lib/components/form-input.svelte | 6 +-
.../src/lib/components/header/header.svelte | 4 +-
.../lib/components/ui/table/table-row.svelte | 2 +-
...ation-service.ts => app-config-service.ts} | 20 +--
.../src/lib/services/audit-log-service.ts | 20 +++
.../stores/application-configuration-store.ts | 20 +--
.../lib/types/application-configuration.ts | 21 ++--
frontend/src/lib/types/audit-log.type.ts | 8 ++
frontend/src/routes/+layout.server.ts | 10 +-
frontend/src/routes/+layout.svelte | 10 +-
frontend/src/routes/authorize/+page.svelte | 4 +-
frontend/src/routes/login/+page.svelte | 4 +-
.../src/routes/login/[token]/+page.svelte | 18 +--
frontend/src/routes/settings/+layout.svelte | 11 +-
.../application-configuration/+page.server.ts | 10 +-
.../application-configuration/+page.svelte | 53 +++++---
.../forms/app-config-email-form.svelte | 80 ++++++++++++
.../app-config-general-form.svelte} | 22 ++--
.../settings/admin/oidc-clients/+page.svelte | 14 +--
.../routes/settings/admin/users/+page.svelte | 6 +-
.../routes/settings/audit-log/+page.server.ts | 13 ++
.../routes/settings/audit-log/+page.svelte | 20 +++
.../settings/audit-log/audit-log-list.svelte | 95 ++++++++++++++
.../tests/application-configuration.spec.ts | 27 ++++
Caddyfile => reverse-proxy/Caddyfile | 2 +-
reverse-proxy/Caddyfile.trust-proxy | 16 +++
scripts/docker-entrypoint.sh | 9 +-
51 files changed, 944 insertions(+), 163 deletions(-)
create mode 100644 backend/email-templates/login-with-new-device.html
create mode 100644 backend/internal/controller/audit_log_controller.go
create mode 100644 backend/internal/dto/audit_log_dto.go
create mode 100644 backend/internal/model/audit_log.go
create mode 100644 backend/internal/service/audit_log_service.go
create mode 100644 backend/internal/service/email_service.go
create mode 100644 backend/migrations/20240908123031_audit_log.down.sql
create mode 100644 backend/migrations/20240908123031_audit_log.up.sql
rename frontend/src/lib/services/{application-configuration-service.ts => app-config-service.ts} (61%)
create mode 100644 frontend/src/lib/services/audit-log-service.ts
create mode 100644 frontend/src/lib/types/audit-log.type.ts
create mode 100644 frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte
rename frontend/src/routes/settings/admin/application-configuration/{application-configuration-form.svelte => forms/app-config-general-form.svelte} (65%)
create mode 100644 frontend/src/routes/settings/audit-log/+page.server.ts
create mode 100644 frontend/src/routes/settings/audit-log/+page.svelte
create mode 100644 frontend/src/routes/settings/audit-log/audit-log-list.svelte
rename Caddyfile => reverse-proxy/Caddyfile (79%)
create mode 100644 reverse-proxy/Caddyfile.trust-proxy
diff --git a/.env.example b/.env.example
index f178de6..fae5478 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1,2 @@
PUBLIC_APP_URL=http://localhost
+TRUST_PROXY=false
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2a6985a..c1007e2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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.
#### Setup
-Run `caddy run --config Caddyfile` in the root folder.
+Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
### Testing
diff --git a/Dockerfile b/Dockerfile
index 8c4ea8a..e2a8da6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,7 +22,7 @@ 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
-COPY ./Caddyfile /etc/caddy/Caddyfile
+COPY ./reverse-proxy /etc/caddy/
WORKDIR /app
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/migrations ./backend/migrations
+COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts
diff --git a/README.md b/README.md
index 5dc0dc1..d824ac1 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,7 @@ docker compose up -d
| 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. |
diff --git a/backend/email-templates/login-with-new-device.html b/backend/email-templates/login-with-new-device.html
new file mode 100644
index 0000000..be251d4
--- /dev/null
+++ b/backend/email-templates/login-with-new-device.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+ Pocket ID
+
+
+
+
+
+
+
New Sign-In Detected
+
+
+
IP Address
+
{{ipAddress}}
+
+
+
+
Sign-In Time
+
{{dateTimeString}}
+
+
+
+ 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.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/go.mod b/backend/go.mod
index 5b75c16..15f79f1 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -14,6 +14,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
+ github.com/mileusna/useragent v1.3.4
golang.org/x/crypto v0.26.0
golang.org/x/time v0.6.0
gorm.io/driver/sqlite v1.5.6
diff --git a/backend/go.sum b/backend/go.sum
index 1f661c2..e728d1c 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -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-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/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/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go
index d61c3b9..417afd8 100644
--- a/backend/internal/bootstrap/router_bootstrap.go
+++ b/backend/internal/bootstrap/router_bootstrap.go
@@ -28,13 +28,14 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(gin.Logger())
// Initialize services
- webauthnService := service.NewWebAuthnService(db, appConfigService)
+ emailService := service.NewEmailService(appConfigService)
+ auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService)
+ webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService)
- oidcService := service.NewOidcService(db, jwtService)
+ oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
testService := service.NewTestService(db, appConfigService)
- // Add global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
@@ -45,10 +46,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Set up API routes
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.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
+ controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go
index 7df077a..35dc019 100644
--- a/backend/internal/controller/app_config_controller.go
+++ b/backend/internal/controller/app_config_controller.go
@@ -20,9 +20,9 @@ func NewAppConfigController(
acc := &AppConfigController{
appConfigService: appConfigService,
}
- group.GET("/application-configuration", acc.listApplicationConfigurationHandler)
- group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllApplicationConfigurationHandler)
- group.PUT("/application-configuration", acc.updateApplicationConfigurationHandler)
+ group.GET("/application-configuration", acc.listAppConfigHandler)
+ group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
+ group.PUT("/application-configuration", acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
@@ -36,8 +36,8 @@ type AppConfigController struct {
appConfigService *service.AppConfigService
}
-func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Context) {
- configuration, err := acc.appConfigService.ListApplicationConfiguration(false)
+func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
+ configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
utils.ControllerError(c, err)
return
@@ -52,8 +52,8 @@ func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Conte
c.JSON(200, configVariablesDto)
}
-func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Context) {
- configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
+func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
+ configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
utils.ControllerError(c, err)
return
@@ -68,14 +68,14 @@ func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Co
c.JSON(200, configVariablesDto)
}
-func (acc *AppConfigController) updateApplicationConfigurationHandler(c *gin.Context) {
+func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
- savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input)
+ savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
utils.ControllerError(c, err)
return
diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go
new file mode 100644
index 0000000..e6e1616
--- /dev/null
+++ b/backend/internal/controller/audit_log_controller.go
@@ -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,
+ })
+}
diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go
index 89bdaf2..0bab3fb 100644
--- a/backend/internal/controller/oidc_controller.go
+++ b/backend/internal/controller/oidc_controller.go
@@ -46,7 +46,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
return
}
- code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"))
+ code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
@@ -73,7 +73,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
return
}
- code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"))
+ code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go
index 7617150..e0c6bc1 100644
--- a/backend/internal/controller/webauthn_controller.go
+++ b/backend/internal/controller/webauthn_controller.go
@@ -15,8 +15,8 @@ import (
"golang.org/x/time/rate"
)
-func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, jwtService *service.JwtService) {
- wc := &WebauthnController{webAuthnService: webauthnService, jwtService: jwtService}
+func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
+ wc := &WebauthnController{webAuthnService: webauthnService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
@@ -32,7 +32,6 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
type WebauthnController struct {
webAuthnService *service.WebAuthnService
- jwtService *service.JwtService
}
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
@@ -95,7 +94,8 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
}
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 errors.Is(err, common.ErrInvalidCredentials) {
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
@@ -105,12 +105,6 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return
}
- token, err := wc.jwtService.GenerateAccessToken(user)
- if err != nil {
- utils.ControllerError(c, err)
- return
- }
-
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go
index 2b2b2bc..b061cd6 100644
--- a/backend/internal/dto/app_config_dto.go
+++ b/backend/internal/dto/app_config_dto.go
@@ -14,4 +14,10 @@ type AppConfigVariableDto struct {
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"`
}
diff --git a/backend/internal/dto/audit_log_dto.go b/backend/internal/dto/audit_log_dto.go
new file mode 100644
index 0000000..65d6c55
--- /dev/null
+++ b/backend/internal/dto/audit_log_dto.go
@@ -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"`
+}
diff --git a/backend/internal/job/db_cleanup.go b/backend/internal/job/db_cleanup.go
index fa7ac04..da2dc27 100644
--- a/backend/internal/job/db_cleanup.go
+++ b/backend/internal/job/db_cleanup.go
@@ -21,7 +21,6 @@ func RegisterJobs(db *gorm.DB) {
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
-
scheduler.Start()
}
@@ -29,17 +28,24 @@ type Jobs struct {
db *gorm.DB
}
+// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() 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 {
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 {
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) {
diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go
index 6a7583b..dfa243f 100644
--- a/backend/internal/model/app_config.go
+++ b/backend/internal/model/app_config.go
@@ -13,4 +13,11 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable
LogoImageType AppConfigVariable
SessionDuration AppConfigVariable
+
+ EmailEnabled AppConfigVariable
+ SmtpHost AppConfigVariable
+ SmtpPort AppConfigVariable
+ SmtpFrom AppConfigVariable
+ SmtpUser AppConfigVariable
+ SmtpPassword AppConfigVariable
}
diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go
new file mode 100644
index 0000000..cf392e1
--- /dev/null
+++ b/backend/internal/model/audit_log.go
@@ -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)
+}
diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go
index 7d77eea..e1575c8 100644
--- a/backend/internal/service/app_config_service.go
+++ b/backend/internal/service/app_config_service.go
@@ -53,9 +53,34 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true,
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 dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
+func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin()
@@ -67,19 +92,19 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpd
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
- var applicationConfigurationVariable model.AppConfigVariable
- if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
+ var appConfigVariable model.AppConfigVariable
+ if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
- applicationConfigurationVariable.Value = value
- if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
+ appConfigVariable.Value = value
+ if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
- savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable)
+ savedConfigVariables = append(savedConfigVariables, appConfigVariable)
}
tx.Commit()
@@ -101,7 +126,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
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 err error
diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go
new file mode 100644
index 0000000..ab6adb6
--- /dev/null
+++ b/backend/internal/service/audit_log_service.go
@@ -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
+}
diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go
new file mode 100644
index 0000000..429f8cf
--- /dev/null
+++ b/backend/internal/service/email_service.go
@@ -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
+}
diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go
index 3705ba6..7236d2b 100644
--- a/backend/internal/service/oidc_service.go
+++ b/backend/internal/service/oidc_service.go
@@ -17,18 +17,22 @@ import (
)
type OidcService struct {
- db *gorm.DB
- jwtService *JwtService
+ db *gorm.DB
+ 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{
- db: db,
- jwtService: jwtService,
+ db: db,
+ jwtService: jwtService,
+ appConfigService: appConfigService,
+ auditLogService: auditLogService,
}
}
-func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
+func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
@@ -42,10 +46,16 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
- return code, callbackURL, err
+ if err != nil {
+ return "", "", err
+ }
+
+ s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
+
+ return code, callbackURL, nil
}
-func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID string) (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
@@ -71,7 +81,13 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
- return code, callbackURL, err
+ 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) {
diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/service/webauthn_service.go
index 68b51f8..1e39e49 100644
--- a/backend/internal/service/webauthn_service.go
+++ b/backend/internal/service/webauthn_service.go
@@ -12,11 +12,13 @@ import (
)
type WebAuthnService struct {
- db *gorm.DB
- webAuthn *webauthn.WebAuthn
+ db *gorm.DB
+ 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{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
@@ -36,7 +38,7 @@ func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAut
}
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) {
@@ -129,10 +131,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, 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
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
- return model.User{}, err
+ return model.User{}, "", err
}
session := webauthn.SessionData{
@@ -149,14 +151,21 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData)
if err != nil {
- return model.User{}, err
+ return model.User{}, "", err
}
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
- return model.User{}, 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) {
diff --git a/backend/migrations/20240908123031_audit_log.down.sql b/backend/migrations/20240908123031_audit_log.down.sql
new file mode 100644
index 0000000..4abd973
--- /dev/null
+++ b/backend/migrations/20240908123031_audit_log.down.sql
@@ -0,0 +1 @@
+DROP TABLE audit_logs;
\ No newline at end of file
diff --git a/backend/migrations/20240908123031_audit_log.up.sql b/backend/migrations/20240908123031_audit_log.up.sql
new file mode 100644
index 0000000..90165b6
--- /dev/null
+++ b/backend/migrations/20240908123031_audit_log.up.sql
@@ -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
+);
\ No newline at end of file
diff --git a/frontend/src/lib/components/form-input.svelte b/frontend/src/lib/components/form-input.svelte
index fc2f636..10f4231 100644
--- a/frontend/src/lib/components/form-input.svelte
+++ b/frontend/src/lib/components/form-input.svelte
@@ -9,12 +9,16 @@
input = $bindable(),
label,
description,
+ disabled = false,
+ type = 'text',
children,
...restProps
}: HTMLAttributes & {
input?: FormInput;
label: string;
description?: string;
+ disabled?: boolean;
+ type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
children?: Snippet;
} = $props();
@@ -30,7 +34,7 @@
{#if children}
{@render children()}
{:else if input}
-
+
{/if}
{#if input?.error}
{input.error}
diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte
index 2410caa..fec15eb 100644
--- a/frontend/src/lib/components/header/header.svelte
+++ b/frontend/src/lib/components/header/header.svelte
@@ -1,6 +1,6 @@
-{#if !applicationConfiguration}
+{#if !appConfig}
Do you want to sign in to {client.name} with your
- {$applicationConfigurationStore.appName} account?
+ {$appConfigStore.appName} account?
{:else if authorizationRequired}
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index 4998536..99e62d3 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
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 { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
@@ -40,7 +40,7 @@
- Sign in to {$applicationConfigurationStore.appName}
+ Sign in to {$appConfigStore.appName}
Authenticate yourself with your passkey to access the admin panel
diff --git a/frontend/src/routes/login/[token]/+page.svelte b/frontend/src/routes/login/[token]/+page.svelte
index 762ab74..c501661 100644
--- a/frontend/src/routes/login/[token]/+page.svelte
+++ b/frontend/src/routes/login/[token]/+page.svelte
@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
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 type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -18,9 +18,9 @@
isLoading = true;
userService
.exchangeOneTimeAccessToken(data.token)
- .then((user :User) => {
+ .then((user: User) => {
userStore.setUser(user);
- goto('/settings')
+ goto('/settings');
})
.catch(axiosErrorToast);
isLoading = false;
@@ -29,15 +29,15 @@
-
-
One Time Access
-
- You've been granted one-time access to your {$applicationConfigurationStore.appName} account. Please note that if you continue,
- this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, you'll need
- to request a new link.
+
One Time Access
+
+ You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if
+ you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
+ you'll need to request a new link.
Continue
diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte
index 7efd1b9..6f2ba6e 100644
--- a/frontend/src/routes/settings/+layout.svelte
+++ b/frontend/src/routes/settings/+layout.svelte
@@ -9,7 +9,10 @@
children: Snippet;
} = $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) {
links = [
@@ -22,10 +25,8 @@
-
-
+
+
Settings
diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.server.ts b/frontend/src/routes/settings/admin/application-configuration/+page.server.ts
index 0c30081..566b010 100644
--- a/frontend/src/routes/settings/admin/application-configuration/+page.server.ts
+++ b/frontend/src/routes/settings/admin/application-configuration/+page.server.ts
@@ -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';
export const load: PageServerLoad = async ({ cookies }) => {
- const applicationConfigurationService = new ApplicationConfigurationService(
- cookies.get('access_token')
- );
- const applicationConfiguration = await applicationConfigurationService.list(true);
- return { applicationConfiguration };
+ const appConfigService = new AppConfigService(cookies.get('access_token'));
+ const appConfig = await appConfigService.list(true);
+ return { appConfig };
};
diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte
index d04c9e5..ef0374d 100644
--- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte
+++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte
@@ -1,24 +1,30 @@
+
+
diff --git a/frontend/src/routes/settings/admin/application-configuration/application-configuration-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte
similarity index 65%
rename from frontend/src/routes/settings/admin/application-configuration/application-configuration-form.svelte
rename to frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte
index 81ab900..a551f30 100644
--- a/frontend/src/routes/settings/admin/application-configuration/application-configuration-form.svelte
+++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte
@@ -1,23 +1,24 @@
diff --git a/frontend/src/routes/settings/admin/oidc-clients/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/+page.svelte
index b30a874..269390f 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/+page.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/+page.svelte
@@ -1,17 +1,17 @@
+
+
+ Audit Log
+
+
+
+
+ Audit Log
+ See your account activities from the last 3 months.
+
+
+
+
+
diff --git a/frontend/src/routes/settings/audit-log/audit-log-list.svelte b/frontend/src/routes/settings/audit-log/audit-log-list.svelte
new file mode 100644
index 0000000..5da2bb4
--- /dev/null
+++ b/frontend/src/routes/settings/audit-log/audit-log-list.svelte
@@ -0,0 +1,95 @@
+
+
+
+
+
+ Time
+ Event
+ IP Address
+ Device
+ Client
+
+
+
+ {#if auditLogs.data.length === 0}
+
+ No logs found
+
+ {:else}
+ {#each auditLogs.data as auditLog}
+
+ {new Date(auditLog.createdAt).toLocaleString()}
+
+ {toFriendlyEventString(auditLog.event)}
+
+ {auditLog.ipAddress}
+ {auditLog.device}
+ {auditLog.data.clientName}
+
+ {/each}
+ {/if}
+
+
+
+{#if auditLogs?.data?.length ?? 0 > 0}
+
+ (auditLogs = await auditLogService.list({
+ page: p,
+ limit: pagination.limit
+ }))}
+ bind:page={auditLogs.pagination.currentPage}
+ let:pages
+ let:currentPage
+ >
+
+
+
+
+ {#each pages as page (page.key)}
+ {#if page.type === 'ellipsis'}
+
+
+
+ {:else}
+
+
+ {page.value}
+
+
+ {/if}
+ {/each}
+
+
+
+
+
+{/if}
diff --git a/frontend/tests/application-configuration.spec.ts b/frontend/tests/application-configuration.spec.ts
index d8d0b09..cce1174 100644
--- a/frontend/tests/application-configuration.spec.ts
+++ b/frontend/tests/application-configuration.spec.ts
@@ -21,6 +21,33 @@ test('Update general configuration', async ({ page }) => {
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 }) => {
await page.goto('/settings/admin/application-configuration');
diff --git a/Caddyfile b/reverse-proxy/Caddyfile
similarity index 79%
rename from Caddyfile
rename to reverse-proxy/Caddyfile
index 4ae284a..ac3249a 100644
--- a/Caddyfile
+++ b/reverse-proxy/Caddyfile
@@ -1,5 +1,5 @@
:80 {
- reverse_proxy /api/* http://localhost:8080
+ reverse_proxy /api/* http://localhost:8080
reverse_proxy /.well-known/* http://localhost:8080
reverse_proxy /* http://localhost:3000
diff --git a/reverse-proxy/Caddyfile.trust-proxy b/reverse-proxy/Caddyfile.trust-proxy
new file mode 100644
index 0000000..068a98d
--- /dev/null
+++ b/reverse-proxy/Caddyfile.trust-proxy
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh
index cff6abf..0f694ec 100644
--- a/scripts/docker-entrypoint.sh
+++ b/scripts/docker-entrypoint.sh
@@ -1,4 +1,3 @@
-
echo "Starting frontend..."
node frontend/build &
@@ -6,6 +5,12 @@ echo "Starting backend..."
cd backend && ./pocket-id-backend &
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
\ No newline at end of file