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 + + + +
+
+ +
Warning
+
+
+

New Sign-In Detected

+
+
+

IP Address

+

{{ipAddress}}

+
+
+

Device

+

{{device}}

+
+
+

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.

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 @@ + +
+
+ + + + + +
+
+ {#if emailEnabled} + + + {:else} + + {/if} +
+
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