diff --git a/backend/internal/appconfig/login_notification.go b/backend/internal/appconfig/login_notification.go
new file mode 100644
index 00000000..62b2d9e8
--- /dev/null
+++ b/backend/internal/appconfig/login_notification.go
@@ -0,0 +1,33 @@
+package appconfig
+
+import "encoding/json"
+
+const (
+ LoginNotificationDisabled = "disabled"
+ LoginNotificationAlways = "always"
+ LoginNotificationIPAndUserAgent = "ipAndUserAgent"
+ LoginNotificationBrowserRecognition = "browserRecognition"
+)
+
+// UnmarshalJSON preserves the notification policy when loading state written before notification modes existed
+func (m *AppConfigModel) UnmarshalJSON(data []byte) error {
+ type config AppConfigModel
+ value := struct {
+ *config
+ LegacyNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled"`
+ }{config: (*config)(m)}
+ if err := json.Unmarshal(data, &value); err != nil {
+ return err
+ }
+ if m.EmailLoginNotificationMode == "" && value.LegacyNotificationEnabled != "" {
+ m.EmailLoginNotificationMode = legacyLoginNotificationMode(value.LegacyNotificationEnabled)
+ }
+ return nil
+}
+
+func legacyLoginNotificationMode(enabled AppConfigValue) AppConfigValue {
+ if enabled.IsTrue() {
+ return LoginNotificationBrowserRecognition
+ }
+ return LoginNotificationDisabled
+}
diff --git a/backend/internal/appconfig/migration.go b/backend/internal/appconfig/migration.go
index 0b2086ae..126ea0da 100644
--- a/backend/internal/appconfig/migration.go
+++ b/backend/internal/appconfig/migration.go
@@ -59,6 +59,9 @@ func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, erro
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
// Start from the default configuration, then override with the values from the legacy config
dest := getDefaultConfig()
+ if legacyCfg["emailLoginNotificationMode"] == "" {
+ dest.EmailLoginNotificationMode = legacyLoginNotificationMode(AppConfigValue(legacyCfg["emailLoginNotificationEnabled"]))
+ }
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go
index 30dc726b..bdb88793 100644
--- a/backend/internal/appconfig/model.go
+++ b/backend/internal/appconfig/model.go
@@ -35,7 +35,7 @@ type AppConfigModel struct {
SmtpPassword AppConfigValue `json:"smtpPassword" env:"SMTP_PASSWORD" sensitive:"true"`
SmtpTls AppConfigValue `json:"smtpTls" env:"SMTP_TLS"`
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" env:"SMTP_SKIP_CERT_VERIFY" type:"bool"`
- EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" env:"EMAIL_LOGIN_NOTIFICATION_ENABLED" type:"bool"`
+ EmailLoginNotificationMode AppConfigValue `json:"emailLoginNotificationMode" env:"EMAIL_LOGIN_NOTIFICATION_MODE"`
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" env:"EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED" type:"bool" public:"true"`
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" env:"EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED" type:"bool" public:"true"`
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" env:"EMAIL_API_KEY_EXPIRATION_ENABLED" type:"bool"`
@@ -133,15 +133,15 @@ func getDefaultConfig() *AppConfigModel {
SignupDefaultCustomClaims: "[]",
AccentColor: "default",
// Email
- RequireUserEmail: "true",
- SmtpHost: "",
- SmtpPort: "",
- SmtpFrom: "",
- SmtpUser: "",
- SmtpPassword: "",
- SmtpTls: "none",
- SmtpSkipCertVerify: "false",
- EmailLoginNotificationEnabled: "false",
+ RequireUserEmail: "true",
+ SmtpHost: "",
+ SmtpPort: "",
+ SmtpFrom: "",
+ SmtpUser: "",
+ SmtpPassword: "",
+ SmtpTls: "none",
+ SmtpSkipCertVerify: "false",
+ EmailLoginNotificationMode: LoginNotificationDisabled,
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
EmailOneTimeAccessAsAdminEnabled: "false",
EmailApiKeyExpirationEnabled: "false",
diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go
index d6c8c123..b4507e83 100644
--- a/backend/internal/appconfig/service.go
+++ b/backend/internal/appconfig/service.go
@@ -236,6 +236,17 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
}
}
+ // TODO: Remove in next major version (v3)
+ // Preserve the old environment setting unless an explicit notification mode replaces it
+ if _, ok := os.LookupEnv("EMAIL_LOGIN_NOTIFICATION_MODE"); !ok {
+ if enabled, exists := os.LookupEnv("EMAIL_LOGIN_NOTIFICATION_ENABLED"); exists {
+ if enabled != "true" && enabled != "false" {
+ return nil, errors.New("EMAIL_LOGIN_NOTIFICATION_ENABLED must be true or false")
+ }
+ dest.EmailLoginNotificationMode = legacyLoginNotificationMode(AppConfigValue(enabled))
+ }
+ }
+
// Validate the resolved configuration before exposing values to the rest of the application
err := validateEnvConfig(dest)
if err != nil {
diff --git a/backend/internal/auditlogs/cleanup_job.go b/backend/internal/auditlogs/cleanup_job.go
index 5ca8321d..271a0d0a 100644
--- a/backend/internal/auditlogs/cleanup_job.go
+++ b/backend/internal/auditlogs/cleanup_job.go
@@ -9,52 +9,58 @@ import (
"github.com/italypaleale/francis/builtin/cronjob"
"gorm.io/gorm"
- "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
const (
- // cleanupJobInterval is how often the audit log cleanup job runs
+ // cleanupJobInterval is how often each audit cleanup job runs
cleanupJobInterval = 24 * time.Hour
// cleanupJobJitter spreads each occurrence around its scheduled time, so the cleanup jobs don't all hit the database at once
cleanupJobJitter = 5 * time.Minute
)
-type cleanupJob struct {
+type cleanupJobs struct {
db *gorm.DB
retentionDays int
}
-// newCleanupJob returns the cron job actor that deletes audit logs past the retention window
-func newCleanupJob(db *gorm.DB, retentionDays int) (*cronjob.CronJob, error) {
- job := &cleanupJob{
- db: db,
- retentionDays: retentionDays,
+// newCleanupJobs returns the cron job actors for audit retention
+func newCleanupJobs(db *gorm.DB, retentionDays int) ([]*cronjob.CronJob, error) {
+ jobs := &cleanupJobs{db: db, retentionDays: retentionDays}
+
+ clearAuditLogs, err := newCleanupJob("ClearAuditLogs", jobs.clearAuditLogs)
+ if err != nil {
+ return nil, err
}
+ return []*cronjob.CronJob{clearAuditLogs}, nil
+}
+
+// newCleanupJob applies the shared daily schedule to each audit cleanup
+func newCleanupJob(name string, fn func(context.Context) error) (*cronjob.CronJob, error) {
cronActor, err := cronjob.New(
- "ClearAuditLogs",
- cronjob.WithJob(job.clearAuditLogs),
+ name,
+ cronjob.WithJob(fn),
cronjob.WithInterval(cleanupJobInterval),
cronjob.WithJitter(cleanupJobJitter),
- // Also run right after the job is first registered, so rows that aged out while Pocket ID wasn't running are removed at startup
+ // Also run right after the job is first registered, so rows that expired while Pocket ID wasn't running are removed at startup
cronjob.WithImmediate(),
cronjob.WithLogger(slog.Default()),
)
if err != nil {
- return nil, fmt.Errorf("error creating audit log cleanup cron job: %w", err)
+ return nil, fmt.Errorf("error creating %s cron job: %w", name, err)
}
return cronActor, nil
}
// clearAuditLogs deletes audit logs older than the configured retention window
-func (j *cleanupJob) clearAuditLogs(ctx context.Context) error {
+func (j *cleanupJobs) clearAuditLogs(ctx context.Context) error {
cutoff := time.Now().AddDate(0, 0, -j.retentionDays)
st := j.db.
WithContext(ctx).
- Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(cutoff))
+ Delete(&AuditLog{}, "created_at < ?", datatype.DateTime(cutoff))
if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
diff --git a/backend/internal/auditlogs/cleanup_job_test.go b/backend/internal/auditlogs/cleanup_job_test.go
index fc236098..d6fe025e 100644
--- a/backend/internal/auditlogs/cleanup_job_test.go
+++ b/backend/internal/auditlogs/cleanup_job_test.go
@@ -12,69 +12,35 @@ import (
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
-func TestModuleRegistersAuditLogCleanupCronJob(t *testing.T) {
+func TestModuleCleanupDeletesLogsPastRetention(t *testing.T) {
+ const retentionDays = 7
db := testutils.NewDatabaseForTest(t)
+ // Preserve a recent record while proving that the registered job honors the configured retention window
+ require.NoError(t, db.Create(&AuditLog{Base: model.Base{ID: "expired-log"}, Event: EventSignIn}).Error)
+ require.NoError(t, db.Create(&AuditLog{Base: model.Base{ID: "recent-log"}, Event: EventSignIn}).Error)
+ require.NoError(t, db.Model(&AuditLog{}).Where("id = ?", "expired-log").Update("created_at", datatype.DateTime(time.Now().AddDate(0, 0, -retentionDays-1))).Error)
+
testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
t.Helper()
_, err := New(Dependencies{
- DB: db,
- Actors: host,
- RetentionDays: 90,
+ DB: db, Actors: host, RetentionDays: retentionDays,
})
require.NoError(t, err)
})
+
+ require.Eventually(t, func() bool {
+ var remaining []string
+ if db.Model(&AuditLog{}).Pluck("id", &remaining).Error != nil {
+ return false
+ }
+ return len(remaining) == 1 && remaining[0] == "recent-log"
+ }, 5*time.Second, 10*time.Millisecond)
}
-func TestModuleRequiresActorHostForCleanupJob(t *testing.T) {
- db := testutils.NewDatabaseForTest(t)
-
- _, err := New(Dependencies{DB: db, RetentionDays: 90})
- require.ErrorContains(t, err, "actor host is required")
-
- // With the cleanup disabled there is nothing to register, so the actor host is not needed
- _, err = New(Dependencies{DB: db, RetentionDays: 90, CleanupDisabled: true})
+func TestNewCleanupJobsPreserveAuditActorName(t *testing.T) {
+ jobs, err := newCleanupJobs(testutils.NewDatabaseForTest(t), 90)
require.NoError(t, err)
-}
-
-func TestAuditLogCleanupJobDeletesLogsPastRetention(t *testing.T) {
- const retentionDays = 90
-
- db := testutils.NewDatabaseForTest(t)
- user := model.User{
- Base: model.Base{ID: "cleanup-job-user"},
- Username: "cleanup-job-user",
- FirstName: "Cleanup",
- LastName: "Job",
- DisplayName: "Cleanup Job",
- }
- err := db.Create(&user).Error
- require.NoError(t, err)
-
- err = db.Create(&model.AuditLog{Base: model.Base{ID: "log-old"}, Event: model.AuditLogEventSignIn, UserID: user.ID}).Error
- require.NoError(t, err)
- err = db.Create(&model.AuditLog{Base: model.Base{ID: "log-recent"}, Event: model.AuditLogEventSignIn, UserID: user.ID}).Error
- require.NoError(t, err)
-
- // BeforeCreate stamps CreatedAt, so the log past the retention window is backdated directly
- oldCreatedAt := datatype.DateTime(time.Now().AddDate(0, 0, -retentionDays-1))
- err = db.Model(&model.AuditLog{}).Where("id = ?", "log-old").Update("created_at", oldCreatedAt).Error
- require.NoError(t, err)
-
- job := &cleanupJob{db: db, retentionDays: retentionDays}
- err = job.clearAuditLogs(t.Context())
- require.NoError(t, err)
-
- var remaining []string
- err = db.Model(&model.AuditLog{}).Pluck("id", &remaining).Error
- require.NoError(t, err)
- require.Equal(t, []string{"log-recent"}, remaining)
-}
-
-func TestNewCleanupJobCreatesCronActor(t *testing.T) {
- db := testutils.NewDatabaseForTest(t)
-
- cronActor, err := newCleanupJob(db, 90)
- require.NoError(t, err)
- require.Equal(t, "cronjob.ClearAuditLogs", cronActor.ActorType())
+ require.Len(t, jobs, 1)
+ require.Equal(t, "cronjob.ClearAuditLogs", jobs[0].ActorType())
}
diff --git a/backend/internal/dto/audit_log_dto.go b/backend/internal/auditlogs/dto.go
similarity index 93%
rename from backend/internal/dto/audit_log_dto.go
rename to backend/internal/auditlogs/dto.go
index edef87ff..8a301e7f 100644
--- a/backend/internal/dto/audit_log_dto.go
+++ b/backend/internal/auditlogs/dto.go
@@ -1,10 +1,10 @@
-package dto
+package auditlogs
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
-type AuditLogDto struct {
+type auditLogDto struct {
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/auditlogs/handler.go
similarity index 56%
rename from backend/internal/controller/audit_log_controller.go
rename to backend/internal/auditlogs/handler.go
index a6ac201d..f95b7b76 100644
--- a/backend/internal/controller/audit_log_controller.go
+++ b/backend/internal/auditlogs/handler.go
@@ -1,34 +1,20 @@
-package controller
+package auditlogs
import (
"net/http"
- "github.com/pocket-id/pocket-id/backend/internal/dto"
- "github.com/pocket-id/pocket-id/backend/internal/httpserver"
- "github.com/pocket-id/pocket-id/backend/internal/middleware"
- "github.com/pocket-id/pocket-id/backend/internal/utils"
-
"github.com/gin-gonic/gin"
- "github.com/pocket-id/pocket-id/backend/internal/service"
+
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
)
-// NewAuditLogController creates a new controller for audit log management
-// @Summary Audit log controller
-// @Description Initializes API endpoints for accessing audit logs
-// @Tags Audit Logs
-func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
- alc := AuditLogController{
- auditLogService: auditLogService,
- }
-
- group.GET("/audit-logs/all", authMiddleware.Add(), httpserver.Handle(alc.listAllAuditLogsHandler))
- group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), httpserver.Handle(alc.listAuditLogsForUserHandler))
- group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), httpserver.Handle(alc.listClientNamesHandler))
- group.GET("/audit-logs/filters/users", authMiddleware.Add(), httpserver.Handle(alc.listUserNamesWithIdsHandler))
+type handler struct {
+ service *service
}
-type AuditLogController struct {
- auditLogService *service.AuditLogService
+func newHandler(service *service) *handler {
+ return &handler{service: service}
}
// listAuditLogsForUserHandler godoc
@@ -39,22 +25,22 @@ type AuditLogController struct {
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
-// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
+// @Success 200 {object} dto.Paginated[auditLogDto]
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/audit-logs [get]
-func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) error {
+func (h *handler) listAuditLogsForUserHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
// Fetch audit logs for the user
- logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
+ logs, pagination, err := h.service.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil {
return err
}
// Map the audit logs to DTOs
- var logsDtos []dto.AuditLogDto
+ var logsDtos []auditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
return err
@@ -62,12 +48,12 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) error
// Add device information to the logs
for i, logsDto := range logsDtos {
- logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
+ logsDto.Device = h.service.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}
- c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
+ c.JSON(http.StatusOK, dto.Paginated[auditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
@@ -82,31 +68,31 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) error
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
-// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
+// @Success 200 {object} dto.Paginated[auditLogDto]
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/audit-logs/all [get]
-func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) error {
+func (h *handler) listAllAuditLogsHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
- logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
+ logs, pagination, err := h.service.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err != nil {
return err
}
- var logsDtos []dto.AuditLogDto
+ var logsDtos []auditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
return err
}
for i, logsDto := range logsDtos {
- logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
+ logsDto.Device = h.service.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.Username = logs[i].User.Username
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}
- c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
+ c.JSON(http.StatusOK, dto.Paginated[auditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
@@ -120,8 +106,8 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) error {
// @Success 200 {array} string "List of client names"
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/audit-logs/filters/client-names [get]
-func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) error {
- names, err := alc.auditLogService.ListClientNames(c.Request.Context())
+func (h *handler) listClientNamesHandler(c *gin.Context) error {
+ names, err := h.service.ListClientNames(c.Request.Context())
if err != nil {
return err
}
@@ -137,8 +123,8 @@ func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) error {
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/audit-logs/filters/users [get]
-func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) error {
- users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
+func (h *handler) listUserNamesWithIdsHandler(c *gin.Context) error {
+ users, err := h.service.ListUsernamesWithIds(c.Request.Context())
if err != nil {
return err
}
diff --git a/backend/internal/auditlogs/handler_test.go b/backend/internal/auditlogs/handler_test.go
new file mode 100644
index 00000000..e99b86ea
--- /dev/null
+++ b/backend/internal/auditlogs/handler_test.go
@@ -0,0 +1,117 @@
+package auditlogs
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+func TestAuditLogRoutesPreservePermissionsAndResponses(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ module, err := New(Dependencies{DB: db, CleanupDisabled: true})
+ require.NoError(t, err)
+
+ // Different owners make accidental loss of the current-user filter visible
+ for _, id := range []string{"alice", "bob"} {
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: id}, Username: id}).Error)
+ require.NoError(t, db.Create(&AuditLog{
+ Base: model.Base{ID: id + "-log"}, UserID: id, Event: EventSignIn,
+ IpAddress: new("192.0.2.1"), UserAgent: "Firefox", Country: "Switzerland", City: "Zurich",
+ Data: Data{"clientName": id + "-client", "actorUsername": "administrator"},
+ }).Error)
+ }
+
+ // Keep authentication lightweight while exercising which middleware each route receives
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Next()
+ if len(c.Errors) > 0 {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": c.Errors.String()})
+ }
+ })
+ module.RegisterRoutes(router.Group("/api"), auditLogTestAuth(true), auditLogTestAuth(false))
+
+ for _, path := range []string{"/audit-logs", "/audit-logs/all", "/audit-logs/filters/client-names", "/audit-logs/filters/users"} {
+ for _, role := range []string{"", "user", "admin"} {
+ t.Run(path+"/"+role, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api"+path, nil)
+ request.Header.Set("X-Test-Role", role)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ status := http.StatusOK
+ if role == "" {
+ status = http.StatusUnauthorized
+ } else if role != "admin" && path != "/audit-logs" {
+ status = http.StatusForbidden
+ }
+ require.Equal(t, status, response.Code, response.Body.String())
+ if status != http.StatusOK {
+ return
+ }
+
+ assertAuditLogRouteResponse(t, path, response)
+ })
+ }
+ }
+}
+
+func auditLogTestAuth(adminRequired bool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ role := c.GetHeader("X-Test-Role")
+ if role == "" {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+ if adminRequired && role != "admin" {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+ c.Set("userID", "alice")
+ }
+}
+
+func assertAuditLogRouteResponse(t *testing.T, path string, response *httptest.ResponseRecorder) {
+ t.Helper()
+ switch path {
+ case "/audit-logs", "/audit-logs/all":
+ var body struct {
+ Data []map[string]any `json:"data"`
+ Pagination map[string]any `json:"pagination"`
+ }
+ require.NoError(t, json.Unmarshal(response.Body.Bytes(), &body))
+ require.NotEmpty(t, body.Pagination)
+ count := 1
+ if path == "/audit-logs/all" {
+ count = 2
+ }
+ require.Len(t, body.Data, count)
+ for _, entry := range body.Data {
+ require.Equal(t, "SIGN_IN", entry["event"])
+ require.Equal(t, "192.0.2.1", entry["ipAddress"])
+ require.Equal(t, "administrator", entry["actorUsername"])
+ require.Contains(t, entry, "device")
+ require.Contains(t, entry, "createdAt")
+ require.NotContains(t, entry, "userAgent")
+ if path == "/audit-logs" {
+ require.Equal(t, "alice", entry["userID"])
+ } else {
+ require.Equal(t, entry["userID"], entry["username"])
+ }
+ }
+ case "/audit-logs/filters/client-names":
+ var names []string
+ require.NoError(t, json.Unmarshal(response.Body.Bytes(), &names))
+ require.ElementsMatch(t, []string{"alice-client", "bob-client"}, names)
+ case "/audit-logs/filters/users":
+ var users map[string]string
+ require.NoError(t, json.Unmarshal(response.Body.Bytes(), &users))
+ require.Equal(t, map[string]string{"alice": "alice", "bob": "bob"}, users)
+ }
+}
diff --git a/backend/internal/auditlogs/known_browser.go b/backend/internal/auditlogs/known_browser.go
new file mode 100644
index 00000000..19700492
--- /dev/null
+++ b/backend/internal/auditlogs/known_browser.go
@@ -0,0 +1,86 @@
+package auditlogs
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v4/jwt"
+)
+
+const (
+ KnownBrowserLifetime = 180 * 24 * time.Hour
+ knownBrowserJWTType = "known-browser"
+)
+
+type browserTokenService interface {
+ generate(userID string, lifetime time.Duration) (string, error)
+ verify(token, userID string) error
+}
+
+type browserTokens struct {
+ signer SessionTokenService
+ appURL string
+}
+
+// generate creates a notification-only browser marker using the shared session signer
+func (s *browserTokens) generate(userID string, lifetime time.Duration) (string, error) {
+ if s.signer == nil {
+ return "", errors.New("session token signer is not initialized")
+ }
+ if userID == "" || lifetime <= 0 {
+ return "", errors.New("user ID and positive lifetime are required for a known-browser token")
+ }
+
+ // Bind recognition to one user and instance, with a purpose that access-token validation rejects
+ now := time.Now()
+ token, err := jwt.NewBuilder().
+ Subject(userID).
+ Issuer(s.appURL).
+ Audience([]string{s.appURL}).
+ IssuedAt(now).
+ Expiration(now.Add(lifetime)).
+ Claim("type", knownBrowserJWTType).
+ Build()
+ if err != nil {
+ return "", fmt.Errorf("failed to build known-browser token: %w", err)
+ }
+
+ // The shared signer owns the private key and pinned signing algorithm
+ return s.signer.SignSessionToken(token)
+}
+
+// verify validates recognition without granting authentication or reading browser state
+func (s *browserTokens) verify(token, userID string) error {
+ if s.signer == nil {
+ return errors.New("session token signer is not initialized")
+ }
+ if userID == "" {
+ return errors.New("user ID is required for a known-browser token")
+ }
+
+ _, err := s.signer.VerifySessionToken(token,
+ jwt.WithIssuer(s.appURL),
+ jwt.WithAudience(s.appURL),
+ jwt.WithSubject(userID),
+ jwt.WithRequiredClaim(jwt.IssuedAtKey),
+ jwt.WithRequiredClaim(jwt.ExpirationKey),
+ jwt.WithClaimValue("type", knownBrowserJWTType),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to verify known-browser token: %w", err)
+ }
+ return nil
+}
+
+func (s *service) rememberBrowser(userID, token string) (bool, string, error) {
+ // Only a valid token for the authenticated user can suppress the new-browser notification
+ known := token != "" && s.browserTokens.verify(token, userID) == nil
+
+ // Renew recognition after every successful sign-in without persisting browser state
+ renewed, err := s.browserTokens.generate(userID, KnownBrowserLifetime)
+ if err != nil {
+ return known, token, err
+ }
+ return known, renewed, nil
+}
diff --git a/backend/internal/auditlogs/known_browser_test.go b/backend/internal/auditlogs/known_browser_test.go
new file mode 100644
index 00000000..2df8c1ea
--- /dev/null
+++ b/backend/internal/auditlogs/known_browser_test.go
@@ -0,0 +1,89 @@
+package auditlogs
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v4/jwk"
+ "github.com/lestrrat-go/jwx/v4/jwt"
+ "github.com/stretchr/testify/require"
+
+ jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
+)
+
+type browserTokenTestSigner struct{ key jwk.Key }
+
+func (s browserTokenTestSigner) SignSessionToken(token jwt.Token) (string, error) {
+ signed, err := jwt.Sign(token, jwt.WithKey(jwkutils.SessionKeyAlg(), s.key))
+ return string(signed), err
+}
+
+func (s browserTokenTestSigner) VerifySessionToken(token string, options ...jwt.ValidateOption) (jwt.Token, error) {
+ parseOptions := []jwt.ParseOption{jwt.WithKey(jwkutils.SessionKeyAlg(), s.key), jwt.WithValidate(true)}
+ for _, option := range options {
+ parseOptions = append(parseOptions, option)
+ }
+ return jwt.ParseString(token, parseOptions...)
+}
+
+func newBrowserTokenService(t *testing.T) *browserTokens {
+ t.Helper()
+ key, err := jwkutils.GenerateSessionKey()
+ require.NoError(t, err)
+ return &browserTokens{signer: browserTokenTestSigner{key: key}, appURL: "https://test.example.com"}
+}
+
+func TestKnownBrowserToken(t *testing.T) {
+ s := newBrowserTokenService(t)
+ const lifetime = 180 * 24 * time.Hour
+ encoded, err := s.generate("user", lifetime)
+ require.NoError(t, err)
+ require.NoError(t, s.verify(encoded, "user"))
+ require.Error(t, s.verify(encoded, "other-user"))
+ require.Error(t, s.verify(encoded, ""))
+
+ token, err := jwt.ParseString(encoded, jwt.WithKey(jwkutils.SessionKeyAlg(), s.signer.(browserTokenTestSigner).key))
+ require.NoError(t, err)
+ issued, ok := token.IssuedAt()
+ require.True(t, ok)
+ expires, ok := token.Expiration()
+ require.True(t, ok)
+ require.Equal(t, lifetime, expires.Sub(issued))
+}
+
+func TestKnownBrowserTokenRejectsInvalidClaims(t *testing.T) {
+ s := newBrowserTokenService(t)
+ for _, tt := range []struct {
+ name string
+ claim string
+ value any
+ }{
+ {"expired", jwt.ExpirationKey, time.Now().Add(-time.Second)},
+ {"missing expiry", jwt.ExpirationKey, nil},
+ {"missing issued at", jwt.IssuedAtKey, nil},
+ {"future issued at", jwt.IssuedAtKey, time.Now().Add(time.Hour)},
+ {"wrong issuer", jwt.IssuerKey, "https://other.example.com"},
+ {"missing issuer", jwt.IssuerKey, nil},
+ {"wrong audience", jwt.AudienceKey, []string{"other"}},
+ {"missing audience", jwt.AudienceKey, nil},
+ {"missing user", jwt.SubjectKey, nil},
+ {"wrong purpose", "type", "access-token"},
+ {"missing purpose", "type", nil},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ // Sign invalid claims with the real key so validation, rather than signature failure, rejects them
+ token, err := jwt.NewBuilder().Subject("user").Issuer(s.appURL).
+ Audience([]string{s.appURL}).IssuedAt(time.Now()).Expiration(time.Now().Add(time.Hour)).
+ Claim("type", knownBrowserJWTType).Build()
+ require.NoError(t, err)
+ if tt.value == nil {
+ require.NoError(t, token.Remove(tt.claim))
+ } else {
+ require.NoError(t, token.Set(tt.claim, tt.value))
+ }
+ encoded, err := jwt.Sign(token, jwt.WithKey(jwkutils.SessionKeyAlg(), s.signer.(browserTokenTestSigner).key))
+ require.NoError(t, err)
+ require.Error(t, s.verify(string(encoded), "user"))
+ })
+ }
+}
diff --git a/backend/internal/auditlogs/model.go b/backend/internal/auditlogs/model.go
new file mode 100644
index 00000000..d73e230c
--- /dev/null
+++ b/backend/internal/auditlogs/model.go
@@ -0,0 +1,60 @@
+package auditlogs
+
+import (
+ "database/sql/driver"
+ "encoding/json"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+)
+
+type AuditLog struct {
+ model.Base
+
+ Event Event `sortable:"true" filterable:"true"`
+ IpAddress *string `sortable:"true"`
+ Country string `sortable:"true"`
+ City string `sortable:"true"`
+ UserAgent string `sortable:"true"`
+ Username string `gorm:"-"`
+ Data Data
+
+ UserID string `filterable:"true"`
+ User model.User
+}
+
+type Data map[string]string //nolint:recvcheck
+
+type Event string //nolint:recvcheck
+
+const (
+ EventSignIn Event = "SIGN_IN"
+ EventOneTimeAccessTokenSignIn Event = "TOKEN_SIGN_IN"
+ EventRemoteSignIn Event = "REMOTE_SIGN_IN"
+ EventAccountCreated Event = "ACCOUNT_CREATED"
+ EventClientAuthorization Event = "CLIENT_AUTHORIZATION"
+ EventNewClientAuthorization Event = "NEW_CLIENT_AUTHORIZATION"
+ EventDeviceCodeAuthorization Event = "DEVICE_CODE_AUTHORIZATION"
+ EventNewDeviceCodeAuthorization Event = "NEW_DEVICE_CODE_AUTHORIZATION"
+ EventPasskeyAdded Event = "PASSKEY_ADDED"
+ EventPasskeyRemoved Event = "PASSKEY_REMOVED"
+)
+
+// Scan and Value methods for GORM to handle the custom type
+
+func (e *Event) Scan(value any) error {
+ *e = Event(value.(string))
+ return nil
+}
+
+func (e Event) Value() (driver.Value, error) {
+ return string(e), nil
+}
+
+func (d *Data) Scan(value any) error {
+ return utils.UnmarshalJSONFromDatabase(d, value)
+}
+
+func (d Data) Value() (driver.Value, error) {
+ return json.Marshal(d)
+}
diff --git a/backend/internal/auditlogs/module.go b/backend/internal/auditlogs/module.go
index 4beb5dd3..111e17c7 100644
--- a/backend/internal/auditlogs/module.go
+++ b/backend/internal/auditlogs/module.go
@@ -1,44 +1,100 @@
-// Package auditlogs owns the background maintenance of the audit log table.
+// Package auditlogs owns audit records, their HTTP API, sign-in notifications, and retention cleanup
package auditlogs
import (
+ "context"
"errors"
"fmt"
+ "time"
+ "github.com/gin-gonic/gin"
francishost "github.com/italypaleale/francis/host"
+ "github.com/lestrrat-go/jwx/v4/jwt"
"gorm.io/gorm"
+
+ "github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/httpserver"
+ "github.com/pocket-id/pocket-id/backend/internal/iplocation"
)
+type NewLoginEmailSender interface {
+ SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device, method string, dateTime time.Time) error
+}
+
+type SessionTokenService interface {
+ SignSessionToken(token jwt.Token) (string, error)
+ VerifySessionToken(token string, options ...jwt.ValidateOption) (jwt.Token, error)
+}
+
type Dependencies struct {
DB *gorm.DB
Actors francishost.Host
+ EmailSender NewLoginEmailSender
+ IPLocator iplocation.Resolver
+ AppConfig appconfig.AppConfigResolver
+ Signer SessionTokenService
+ AppURL string
+
// RetentionDays is how long audit logs are kept before the cleanup job deletes them
RetentionDays int
- // CleanupDisabled skips registering the cleanup cron job, for example in tests
+ // CleanupDisabled skips registering cleanup cron jobs, for example in tests
CleanupDisabled bool
}
-type Module struct{}
+type Module struct {
+ service *service
+ handler *handler
+}
func New(deps Dependencies) (*Module, error) {
- // Register the cleanup job for audit logs past the retention window
+ // Register audit retention cleanup before the actor host starts
if !deps.CleanupDisabled {
if deps.Actors == nil {
return nil, errors.New("actor host is required for the audit log cleanup cron job")
}
- cleanupJob, err := newCleanupJob(deps.DB, deps.RetentionDays)
+ jobs, err := newCleanupJobs(deps.DB, deps.RetentionDays)
if err != nil {
return nil, err
}
- err = deps.Actors.RegisterBuiltInActor(cleanupJob)
- if err != nil {
- return nil, fmt.Errorf("error registering audit log cleanup cron actor: %w", err)
+ for _, cj := range jobs {
+ if err := deps.Actors.RegisterBuiltInActor(cj); err != nil {
+ return nil, fmt.Errorf("error registering audit log cleanup cron actor %q: %w", cj.ActorType(), err)
+ }
}
}
- return &Module{}, nil
+ service := newService(deps.DB, deps.EmailSender, deps.IPLocator, deps.AppConfig, &browserTokens{signer: deps.Signer, appURL: deps.AppURL})
+ return &Module{service: service, handler: newHandler(service)}, nil
+}
+
+// RegisterRoutes mounts audit-log queries with the existing admin and current-user permissions
+func (m *Module) RegisterRoutes(group *gin.RouterGroup, adminAuth, userAuth gin.HandlerFunc) {
+ group.GET("/audit-logs/all", adminAuth, httpserver.Handle(m.handler.listAllAuditLogsHandler))
+ group.GET("/audit-logs", userAuth, httpserver.Handle(m.handler.listAuditLogsForUserHandler))
+ group.GET("/audit-logs/filters/client-names", adminAuth, httpserver.Handle(m.handler.listClientNamesHandler))
+ group.GET("/audit-logs/filters/users", adminAuth, httpserver.Handle(m.handler.listUserNamesWithIdsHandler))
+}
+
+// Create records an event within the caller's transaction
+func (m *Module) Create(ctx context.Context, event Event, ipAddress, userAgent, userID string, data Data, tx *gorm.DB) (AuditLog, bool) {
+ return m.service.Create(ctx, event, ipAddress, userAgent, userID, data, tx)
+}
+
+// CreateSignIn prepares browser recognition and notification delivery within the caller's transaction
+func (m *Module) CreateSignIn(ctx context.Context, event Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, notificationMode appconfig.AppConfigValue) SignInResult {
+ return m.service.CreateSignIn(ctx, event, ipAddress, userAgent, userID, browserToken, tx, notificationMode)
+}
+
+// SendSignInNotification must be called only after the login commits successfully
+func (m *Module) SendSignInNotification(ctx context.Context, result SignInResult) {
+ m.service.SendSignInNotification(ctx, result)
+}
+
+// DeviceStringFromUserAgent describes a browser for audit records and device-login approval
+func (m *Module) DeviceStringFromUserAgent(userAgent string) string {
+ return m.service.DeviceStringFromUserAgent(userAgent)
}
diff --git a/backend/internal/auditlogs/service.go b/backend/internal/auditlogs/service.go
new file mode 100644
index 00000000..89deab8b
--- /dev/null
+++ b/backend/internal/auditlogs/service.go
@@ -0,0 +1,193 @@
+package auditlogs
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ userAgentParser "github.com/mileusna/useragent"
+ "gorm.io/gorm"
+
+ "github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/iplocation"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+)
+
+type service struct {
+ db *gorm.DB
+ emailSender NewLoginEmailSender
+ ipLocator iplocation.Resolver
+ appConfigService appconfig.AppConfigResolver
+ browserTokens browserTokenService
+}
+
+func newService(db *gorm.DB, emailSender NewLoginEmailSender, ipLocator iplocation.Resolver, appConfigService appconfig.AppConfigResolver, browserTokens browserTokenService) *service {
+ return &service{
+ db: db,
+ emailSender: emailSender,
+ ipLocator: ipLocator,
+ appConfigService: appConfigService,
+ browserTokens: browserTokens,
+ }
+}
+
+// Create creates a new audit log entry in the database
+func (s *service) Create(ctx context.Context, event Event, ipAddress, userAgent, userID string, data Data, tx *gorm.DB) (AuditLog, bool) {
+ country, city, err := s.ipLocator.GetLocationByIP(ctx, ipAddress)
+ if err != nil {
+ // Log the error but don't interrupt the operation
+ slog.WarnContext(ctx, "Failed to get IP location", slog.String("ip", ipAddress), slog.Any("error", err))
+ }
+
+ auditLog := AuditLog{
+ Event: event,
+ Country: country,
+ City: city,
+ UserAgent: userAgent,
+ UserID: userID,
+ Data: data,
+ }
+
+ if ipAddress != "" {
+ // Only set ipAddress if not empty, because on Postgres we use INET columns that don't allow non-null empty values
+ auditLog.IpAddress = &ipAddress
+ }
+
+ // Save the audit log in the database
+ err = tx.
+ WithContext(ctx).
+ Create(&auditLog).
+ Error
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create audit log", "error", err)
+ return AuditLog{}, false
+ }
+
+ return auditLog, true
+}
+
+// ListAuditLogsForUser retrieves all audit logs for a given user ID
+func (s *service) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) {
+ var logs []AuditLog
+ query := s.db.
+ WithContext(ctx).
+ Model(&AuditLog{}).
+ Where("user_id = ?", userID)
+
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
+ return logs, pagination, err
+}
+
+func (s *service) DeviceStringFromUserAgent(userAgent string) string {
+ ua := userAgentParser.Parse(userAgent)
+ return ua.Name + " on " + ua.OS + " " + ua.OSVersion
+}
+
+func (s *service) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) {
+ var logs []AuditLog
+
+ query := s.db.
+ WithContext(ctx).
+ Preload("User").
+ Model(&AuditLog{})
+
+ if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
+ dialect := s.db.Name()
+ switch dialect {
+ case "sqlite":
+ query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
+ case "postgres":
+ query = query.Where("data->>'clientName' IN ?", clientName)
+ default:
+ return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
+ }
+ }
+
+ if locations, ok := listRequestOptions.Filters["location"]; ok {
+ mapped := make([]string, 0, len(locations))
+ for _, v := range locations {
+ if s, ok := v.(string); ok {
+ switch s {
+ case "internal":
+ mapped = append(mapped, "Internal Network")
+ case "external":
+ mapped = append(mapped, "External Network")
+ }
+ }
+ }
+ if len(mapped) > 0 {
+ query = query.Where("country IN ?", mapped)
+ }
+ }
+
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
+ if err != nil {
+ return nil, pagination, err
+ }
+
+ return logs, pagination, nil
+}
+
+func (s *service) ListUsernamesWithIds(ctx context.Context) (users map[string]string, err error) {
+ query := s.db.
+ WithContext(ctx).
+ Joins("User").
+ Model(&AuditLog{}).
+ Select(`DISTINCT "User".id, "User".username`).
+ Where(`"User".username IS NOT NULL`)
+
+ type Result struct {
+ ID string `gorm:"column:id"`
+ Username string `gorm:"column:username"`
+ }
+
+ var results []Result
+ err = query.Find(&results).Error
+ if err != nil {
+ return nil, fmt.Errorf("failed to query user IDs: %w", err)
+ }
+
+ users = make(map[string]string, len(results))
+ for _, result := range results {
+ users[result.ID] = result.Username
+ }
+
+ return users, nil
+}
+
+func (s *service) ListClientNames(ctx context.Context) (clientNames []string, err error) {
+ dialect := s.db.Name()
+ query := s.db.
+ WithContext(ctx).
+ Model(&AuditLog{})
+
+ switch dialect {
+ case "sqlite":
+ query = query.
+ Select("DISTINCT json_extract(data, '$.clientName') AS client_name").
+ Where("json_extract(data, '$.clientName') IS NOT NULL")
+ case "postgres":
+ query = query.
+ Select("DISTINCT data->>'clientName' AS client_name").
+ Where("data->>'clientName' IS NOT NULL")
+ default:
+ return nil, fmt.Errorf("unsupported database dialect: %s", dialect)
+ }
+
+ type Result struct {
+ ClientName string `gorm:"column:client_name"`
+ }
+
+ var results []Result
+ err = query.Find(&results).Error
+ if err != nil {
+ return nil, fmt.Errorf("failed to query client IDs: %w", err)
+ }
+
+ clientNames = make([]string, len(results))
+ for i, result := range results {
+ clientNames[i] = result.ClientName
+ }
+
+ return clientNames, nil
+}
diff --git a/backend/internal/auditlogs/signin.go b/backend/internal/auditlogs/signin.go
new file mode 100644
index 00000000..347040d4
--- /dev/null
+++ b/backend/internal/auditlogs/signin.go
@@ -0,0 +1,137 @@
+package auditlogs
+
+import (
+ "context"
+ "log/slog"
+
+ "gorm.io/gorm"
+
+ "github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+)
+
+// CreateSignIn records a successful login and applies the global notification policy
+// The caller must send the notification only after the login commits
+func (s *service) CreateSignIn(ctx context.Context, event Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, notificationMode appconfig.AppConfigValue) SignInResult {
+ entry, created := s.Create(ctx, event, ipAddress, userAgent, userID, Data{}, tx)
+ result := SignInResult{AuditLog: entry, Created: created}
+ if !created {
+ return result
+ }
+
+ // Only browser recognition uses a persistent cookie; other modes rely on the selected notification policy
+ switch notificationMode {
+ case appconfig.LoginNotificationAlways:
+ result.Notify = true
+ return result
+ case appconfig.LoginNotificationBrowserRecognition:
+ known, token, err := s.rememberBrowser(userID, browserToken)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to remember sign-in browser", slog.Any("error", err))
+ }
+ result.KnownBrowserToken = token
+ if known {
+ return result
+ }
+ case appconfig.LoginNotificationIPAndUserAgent:
+ // Check sign-in history without reading or renewing the browser token
+ default:
+ return result
+ }
+
+ // Only earlier successful sign-ins for this user can satisfy the fallback
+ var count int64
+ query := tx.WithContext(ctx).Model(&AuditLog{}).
+ Where("user_id = ? AND user_agent = ? AND id <> ?", userID, userAgent, entry.ID).
+ Where("event IN ?", []Event{EventSignIn, EventOneTimeAccessTokenSignIn, EventRemoteSignIn})
+ if ipAddress == "" {
+ query = query.Where("ip_address IS NULL")
+ } else {
+ query = query.Where("ip_address = ?", ipAddress)
+ }
+ if err := query.Count(&count).Error; err != nil {
+ slog.ErrorContext(ctx, "Failed to check sign-in history", slog.Any("error", err))
+ return result
+ }
+ result.Notify = count == 0
+ return result
+}
+
+// SendSignInNotification sends only after the caller has completed the login successfully
+func (s *service) SendSignInNotification(ctx context.Context, result SignInResult) {
+ if !result.Created || !result.Notify {
+ return
+ }
+ entry := result.AuditLog
+ ipAddress := ""
+ if entry.IpAddress != nil {
+ ipAddress = *entry.IpAddress
+ }
+ go func() {
+ // This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
+ // We still want to have a context derived from the request's to carry over tracing info
+ innerCtx := context.WithoutCancel(ctx)
+
+ // This runs after the request has completed, so we resolve the current config rather than threading the request's snapshot into the goroutine
+ dbConfig, innerErr := s.appConfigService.GetConfig(innerCtx)
+ if innerErr != nil {
+ slog.ErrorContext(innerCtx, "Failed to load app configuration to send notification email", slog.Any("error", innerErr))
+ return
+ }
+
+ // Note we don't use the transaction here because this is running in background
+ var user model.User
+ innerErr = s.db.
+ WithContext(innerCtx).
+ Where("id = ?", entry.UserID).
+ First(&user).
+ Error
+ if innerErr != nil {
+ slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
+ return
+ }
+
+ if user.Email == nil {
+ return
+ }
+
+ innerErr = s.emailSender.SendNewLogin(
+ innerCtx,
+ dbConfig,
+ user.FullName(),
+ *user.Email,
+ ipAddress,
+ entry.Country,
+ entry.City,
+ s.DeviceStringFromUserAgent(entry.UserAgent),
+ signInMethod(entry.Event),
+ entry.CreatedAt.UTC(),
+ )
+ if innerErr != nil {
+ slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", *user.Email))
+ return
+ }
+ }()
+}
+
+// signInMethod describes the successful login rather than the credential used to approve another device
+func signInMethod(event Event) string {
+ switch event { //nolint:exhaustive // Other audit events are not sign-ins
+ case EventSignIn:
+ return "Passkey"
+ case EventOneTimeAccessTokenSignIn:
+ return "One-time code"
+ case EventRemoteSignIn:
+ return "Another device (QR code)"
+ default:
+ return "Unknown"
+ }
+}
+
+// SignInResult defers notification delivery until the login has committed
+type SignInResult struct {
+ AuditLog AuditLog
+ Created bool
+ Notify bool
+ KnownBrowserToken string
+}
diff --git a/backend/internal/auditlogs/signin_test.go b/backend/internal/auditlogs/signin_test.go
new file mode 100644
index 00000000..68d6c886
--- /dev/null
+++ b/backend/internal/auditlogs/signin_test.go
@@ -0,0 +1,230 @@
+package auditlogs
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+type signInLocationResolver struct{}
+
+func (signInLocationResolver) GetLocationByIP(context.Context, string) (string, string, error) {
+ return "Switzerland", "Zurich", nil
+}
+
+func TestSignInBrowserRecognition(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ ip string
+ agent string
+ cookie string
+ otherUser bool
+ previousEvent Event
+ wantNotify bool
+ }{
+ {name: "cookie recognizes changed IPv6 prefix and browser version", ip: "2001:db8:2::1", agent: "browser/2", cookie: "known"},
+ {name: "missing cookie falls back to exact IP and agent", ip: "2001:db8:1::1", agent: "browser/1"},
+ {name: "unknown cookie falls back to exact IP and agent", ip: "2001:db8:1::1", agent: "browser/1", cookie: "forged"},
+ {name: "same IPv6 subnet is not an exact match", ip: "2001:db8:1::2", agent: "browser/1", wantNotify: true},
+ {name: "same IP with changed agent is unknown", ip: "2001:db8:1::1", agent: "browser/2", wantNotify: true},
+ {name: "forged cookie does not suppress notification", ip: "192.0.2.1", agent: "browser/1", cookie: "forged", wantNotify: true},
+ {name: "cookie and history are scoped to user", ip: "2001:db8:1::1", agent: "browser/1", cookie: "known", otherUser: true, wantNotify: true},
+ {name: "unrelated audit event does not establish familiarity", ip: "2001:db8:1::1", agent: "browser/1", previousEvent: EventClientAuthorization, wantNotify: true},
+ {name: "login code history establishes familiarity", ip: "2001:db8:1::1", agent: "browser/1", previousEvent: EventOneTimeAccessTokenSignIn},
+ {name: "QR login history establishes familiarity", ip: "2001:db8:1::1", agent: "browser/1", previousEvent: EventRemoteSignIn},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{})
+ user := model.User{Base: model.Base{ID: "user"}, Username: "user"}
+ require.NoError(t, db.Create(&user).Error)
+ historyUserID := user.ID
+ if tt.otherUser {
+ other := model.User{Base: model.Base{ID: "other"}, Username: "other"}
+ require.NoError(t, db.Create(&other).Error)
+ historyUserID = other.ID
+ }
+ event := tt.previousEvent
+ if event == "" {
+ event = EventSignIn
+ }
+ _, created := s.Create(t.Context(), event, "2001:db8:1::1", "browser/1", historyUserID, Data{}, db)
+ require.True(t, created)
+ s.browserTokens = browserTokenStub{userID: historyUserID}
+
+ result := s.CreateSignIn(t.Context(), EventSignIn, tt.ip, tt.agent, user.ID, tt.cookie, db, appconfig.LoginNotificationBrowserRecognition)
+ require.True(t, result.Created)
+ require.Equal(t, tt.wantNotify, result.Notify)
+
+ require.Equal(t, "renewed:"+user.ID, result.KnownBrowserToken)
+ require.Equal(t, tt.ip, *result.AuditLog.IpAddress)
+ require.Equal(t, tt.agent, result.AuditLog.UserAgent)
+ })
+ }
+}
+
+func TestSignInWithoutAddress(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{})
+ user := model.User{Base: model.Base{ID: "user"}, Username: "user"}
+ require.NoError(t, db.Create(&user).Error)
+ first := s.CreateSignIn(t.Context(), EventSignIn, "", "browser", user.ID, "", db, appconfig.LoginNotificationBrowserRecognition)
+ require.True(t, first.Notify)
+ require.Nil(t, first.AuditLog.IpAddress)
+ second := s.CreateSignIn(t.Context(), EventSignIn, "", "browser", user.ID, "", db, appconfig.LoginNotificationBrowserRecognition)
+ require.False(t, second.Notify)
+}
+
+func TestSignInAuditRollsBackWithLogin(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{})
+ user := model.User{Base: model.Base{ID: "user"}, Username: "user"}
+ require.NoError(t, db.Create(&user).Error)
+ tx := db.Begin()
+ require.NoError(t, tx.Error)
+ result := s.CreateSignIn(t.Context(), EventSignIn, "192.0.2.1", "browser", user.ID, "", tx, appconfig.LoginNotificationBrowserRecognition)
+ require.True(t, result.Created)
+ require.True(t, result.Notify)
+ require.NoError(t, tx.Rollback().Error)
+ var count int64
+ require.NoError(t, db.Model(&AuditLog{}).Count(&count).Error)
+ require.Zero(t, count)
+}
+
+type notificationConfig struct{ config *appconfig.AppConfigModel }
+
+func (c notificationConfig) GetConfig(context.Context) (*appconfig.AppConfigModel, error) {
+ return c.config, nil
+}
+
+type loginNotification struct{ recipient, ip, method string }
+type loginNotificationSender struct{ sent chan loginNotification }
+
+func (s loginNotificationSender) SendNewLogin(_ context.Context, _ *appconfig.AppConfigModel, _, email, ip, _, _, _, method string, _ time.Time) error {
+ s.sent <- loginNotification{recipient: email, ip: ip, method: method}
+ return nil
+}
+
+func TestSignInNotificationsForEveryMethod(t *testing.T) {
+ for _, tt := range []struct {
+ event Event
+ method string
+ }{
+ {EventSignIn, "Passkey"},
+ {EventOneTimeAccessTokenSignIn, "One-time code"},
+ {EventRemoteSignIn, "Another device (QR code)"},
+ } {
+ t.Run(tt.method, func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ email := "user@example.test"
+ user := model.User{Base: model.Base{ID: "user"}, Username: "user", Email: &email}
+ require.NoError(t, db.Create(&user).Error)
+ sender := loginNotificationSender{sent: make(chan loginNotification, 1)}
+ config := &appconfig.AppConfigModel{EmailLoginNotificationMode: appconfig.LoginNotificationBrowserRecognition}
+ service := newService(db, sender, signInLocationResolver{}, notificationConfig{config}, browserTokenStub{})
+
+ // Each successful sign-in method delivers a notification for an unfamiliar browser
+ result := service.CreateSignIn(t.Context(), tt.event, "192.0.2.1", "browser", user.ID, "", db, appconfig.LoginNotificationBrowserRecognition)
+ require.True(t, result.Created)
+ require.True(t, result.Notify)
+ service.SendSignInNotification(t.Context(), result)
+ select {
+ case sent := <-sender.sent:
+ require.Equal(t, loginNotification{recipient: email, ip: "192.0.2.1", method: tt.method}, sent)
+ case <-time.After(5 * time.Second):
+ t.Fatal("sign-in notification was not delivered")
+ }
+ })
+ }
+}
+
+// browserTokenStub exercises sign-in decisions while JWT validation is covered by the token service tests
+type browserTokenStub struct {
+ userID string
+ signErr error
+}
+
+func (s browserTokenStub) generate(userID string, _ time.Duration) (string, error) {
+ if s.signErr != nil {
+ return "", s.signErr
+ }
+ return "renewed:" + userID, nil
+}
+
+func (s browserTokenStub) verify(token, userID string) error {
+ if token == "renewed:"+userID || (token == "known" && userID == s.userID) {
+ return nil
+ }
+ return errors.New("invalid browser token")
+}
+
+func TestSignInBrowserSigningFailurePreservesLogin(t *testing.T) {
+ for _, token := range []string{"", "invalid-token", "known"} {
+ t.Run(token, func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{
+ userID: "user", signErr: errors.New("signing failed"),
+ })
+ user := model.User{Base: model.Base{ID: "user"}, Username: "user"}
+ require.NoError(t, db.Create(&user).Error)
+ tx := db.Begin()
+ require.NoError(t, tx.Error)
+ result := s.CreateSignIn(t.Context(), EventSignIn, "192.0.2.1", "browser", user.ID, token, tx, appconfig.LoginNotificationBrowserRecognition)
+ require.NoError(t, tx.Commit().Error)
+ require.True(t, result.Created)
+ require.Equal(t, token, result.KnownBrowserToken)
+ require.Equal(t, token != "known", result.Notify)
+ require.NoError(t, db.First(&AuditLog{}, "id = ?", result.AuditLog.ID).Error)
+ })
+ }
+}
+
+func TestSignInNotificationModes(t *testing.T) {
+ for _, event := range []Event{EventSignIn, EventOneTimeAccessTokenSignIn, EventRemoteSignIn} {
+ for _, tt := range []struct {
+ mode appconfig.AppConfigValue
+ knownHistory bool
+ wantNotify bool
+ }{
+ {appconfig.LoginNotificationDisabled, false, false},
+ {appconfig.LoginNotificationDisabled, true, false},
+ {appconfig.LoginNotificationAlways, false, true},
+ {appconfig.LoginNotificationAlways, true, true},
+ {appconfig.LoginNotificationIPAndUserAgent, false, true},
+ {appconfig.LoginNotificationIPAndUserAgent, true, false},
+ {appconfig.LoginNotificationBrowserRecognition, false, false},
+ {appconfig.LoginNotificationBrowserRecognition, true, false},
+ } {
+ t.Run(string(event)+"/"+string(tt.mode)+"/"+strconv.FormatBool(tt.knownHistory), func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ // A nil token service proves cookie-free modes never verify or issue browser tokens
+ s := newService(db, nil, signInLocationResolver{}, nil, nil)
+ if tt.mode == appconfig.LoginNotificationBrowserRecognition {
+ s.browserTokens = browserTokenStub{userID: "user"}
+ }
+ user := model.User{Base: model.Base{ID: "user"}, Username: "user"}
+ require.NoError(t, db.Create(&user).Error)
+ if tt.knownHistory {
+ _, created := s.Create(t.Context(), EventSignIn, "192.0.2.1", "browser", user.ID, Data{}, db)
+ require.True(t, created)
+ }
+ result := s.CreateSignIn(t.Context(), event, "192.0.2.1", "browser", user.ID, "known", db, tt.mode)
+ require.True(t, result.Created)
+ require.Equal(t, tt.wantNotify, result.Notify)
+ if tt.mode == appconfig.LoginNotificationBrowserRecognition {
+ require.Equal(t, "renewed:user", result.KnownBrowserToken)
+ } else {
+ require.Empty(t, result.KnownBrowserToken)
+ }
+ })
+ }
+ }
+}
diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go
index b327d055..edf4c631 100644
--- a/backend/internal/bootstrap/router_bootstrap.go
+++ b/backend/internal/bootstrap/router_bootstrap.go
@@ -175,7 +175,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule)
svc.ldapSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
- controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
+ svc.auditLogsModule.RegisterRoutes(apiGroup, authMiddleware.Add(), authMiddleware.WithAdminNotRequired().Add())
controller.NewUserGroupController(apiGroup, authMiddleware, svc.appConfigService, svc.userGroupService)
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go
index 8439ba62..88ee9359 100644
--- a/backend/internal/bootstrap/services_bootstrap.go
+++ b/backend/internal/bootstrap/services_bootstrap.go
@@ -6,6 +6,8 @@ import (
"net/http"
francishost "github.com/italypaleale/francis/host"
+ "gorm.io/gorm"
+
"github.com/pocket-id/pocket-id/backend/internal/api"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
@@ -27,7 +29,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
- "gorm.io/gorm"
)
type services struct {
@@ -36,7 +37,6 @@ type services struct {
emailModule *email.Module
geoLiteModule *geolite.Module
ipLocator iplocation.Resolver
- auditLogService *service.AuditLogService
jwtService *service.JwtService
userService *service.UserService
customClaimService *service.CustomClaimService
@@ -94,8 +94,17 @@ func initServices(
return nil, fmt.Errorf("failed to create IP location resolver: %w", err)
}
- svc.auditLogService = service.NewAuditLogService(db, svc.emailModule, svc.ipLocator, svc.appConfigService)
+ svc.jwtService, err = service.NewJwtService(ctx, db, instanceID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create JWT service: %w", err)
+ }
+
svc.auditLogsModule, err = auditlogs.New(auditlogs.Dependencies{
+ Signer: svc.jwtService,
+ AppURL: common.EnvConfig.AppURL,
+ EmailSender: svc.emailModule,
+ IPLocator: svc.ipLocator,
+ AppConfig: svc.appConfigService,
DB: db,
Actors: actors,
RetentionDays: common.EnvConfig.AuditLogRetentionDays,
@@ -106,18 +115,13 @@ func initServices(
return nil, fmt.Errorf("failed to create audit logs module: %w", err)
}
- svc.jwtService, err = service.NewJwtService(ctx, db, instanceID)
- if err != nil {
- return nil, fmt.Errorf("failed to create JWT service: %w", err)
- }
-
svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{
DB: db,
Actors: actors,
AppURL: common.EnvConfig.AppURL,
Signer: svc.jwtService,
- AuditLog: svc.auditLogService,
+ AuditLog: svc.auditLogsModule,
AppConfig: svc.appConfigService,
// Disable in test environment
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
@@ -131,7 +135,7 @@ func initServices(
Actors: actors,
Signer: svc.jwtService,
Reauth: svc.webauthnModule,
- AuditLog: svc.auditLogService,
+ AuditLog: svc.auditLogsModule,
IPLocator: svc.ipLocator,
AppConfig: svc.appConfigService,
})
@@ -166,7 +170,7 @@ func initServices(
Signer: svc.jwtService,
CustomClaims: svc.customClaimService,
Reauth: svc.webauthnModule,
- AuditLog: svc.auditLogService,
+ AuditLog: svc.auditLogsModule,
APIAccess: svc.apiModule,
// Disable in test environment
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
@@ -186,7 +190,7 @@ func initServices(
}
svc.userGroupService = service.NewUserGroupService(db, svc.scimSyncModule, backchannelLogoutService)
- svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.customClaimService, svc.appImagesService, svc.scimSyncModule, backchannelLogoutService, fileStorage)
+ svc.userService = service.NewUserService(db, svc.jwtService, svc.customClaimService, svc.appImagesService, svc.scimSyncModule, backchannelLogoutService, fileStorage)
svc.ldapSyncModule, err = ldapsync.New(ldapsync.Dependencies{
DB: db,
@@ -221,7 +225,7 @@ func initServices(
DB: db,
Actors: actors,
Signer: svc.jwtService,
- AuditLog: svc.auditLogService,
+ AuditLog: svc.auditLogsModule,
UserCreator: svc.userService,
AppConfig: svc.appConfigService,
ScimSync: svc.scimSyncModule,
@@ -234,7 +238,7 @@ func initServices(
DB: db,
Actors: actors,
Signer: svc.jwtService,
- AuditLog: svc.auditLogService,
+ AuditLog: svc.auditLogsModule,
UserProvider: svc.userService,
EmailSender: svc.emailModule,
AppConfig: svc.appConfigService,
diff --git a/backend/internal/devicelogin/handler.go b/backend/internal/devicelogin/handler.go
index 4cc677a2..4350139b 100644
--- a/backend/internal/devicelogin/handler.go
+++ b/backend/internal/devicelogin/handler.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
@@ -72,7 +73,8 @@ func (h *handler) exchangeRequest(c *gin.Context) error {
requestID := c.Param("id")
deviceToken, _ := c.Cookie(cookie.DeviceLoginTokenCookieName)
sessionDuration := dbConfig.SessionDuration.AsDurationMinutes()
- user, accessToken, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent(), sessionDuration)
+ browserToken, _ := c.Cookie(cookie.KnownBrowserCookieName)
+ user, tokens, status, err := h.service.Exchange(c.Request.Context(), requestID, deviceToken, c.ClientIP(), c.Request.UserAgent(), browserToken, sessionDuration, dbConfig.EmailLoginNotificationMode)
if err != nil {
if c.Request.Context().Err() != nil {
// Context canceled = the client stopped the request
@@ -88,7 +90,10 @@ func (h *handler) exchangeRequest(c *gin.Context) error {
}
maxAge := int(sessionDuration.Seconds())
- cookie.AddAccessTokenCookie(c, maxAge, accessToken)
+ cookie.AddAccessTokenCookie(c, maxAge, tokens.AccessToken)
+ if tokens.KnownBrowserToken != "" {
+ cookie.AddKnownBrowserCookie(c, tokens.KnownBrowserToken, int(auditlogs.KnownBrowserLifetime.Seconds()))
+ }
c.JSON(http.StatusOK, dto.UserDto(user))
return nil
}
diff --git a/backend/internal/devicelogin/module.go b/backend/internal/devicelogin/module.go
index bf0875ba..c322c83b 100644
--- a/backend/internal/devicelogin/module.go
+++ b/backend/internal/devicelogin/module.go
@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -24,7 +25,9 @@ type ReauthenticationTokenConsumer interface {
}
type AuditLogger interface {
- Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
+ CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, notificationMode appconfig.AppConfigValue) auditlogs.SignInResult
+ SendSignInNotification(ctx context.Context, result auditlogs.SignInResult)
+ Create(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, data auditlogs.Data, tx *gorm.DB) (auditlogs.AuditLog, bool)
DeviceStringFromUserAgent(userAgent string) string
}
diff --git a/backend/internal/devicelogin/service.go b/backend/internal/devicelogin/service.go
index 76a2b4af..1198285a 100644
--- a/backend/internal/devicelogin/service.go
+++ b/backend/internal/devicelogin/service.go
@@ -11,7 +11,9 @@ import (
"github.com/italypaleale/francis/actor"
"gorm.io/gorm"
+ "github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -153,9 +155,9 @@ func (s *Service) Decide(ctx context.Context, code, decision, userID, reauthenti
return actorResultError(result.Code)
}
-func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent string, sessionDuration time.Duration) (dto.UserDto, string, RequestStatus, error) {
+func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddress, userAgent, browserToken string, sessionDuration time.Duration, notificationMode appconfig.AppConfigValue) (dto.UserDto, model.LoginTokens, RequestStatus, error) {
if requestID == "" || deviceToken == "" || sessionDuration <= 0 {
- return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired()
+ return dto.UserDto{}, model.LoginTokens{}, "", apperror.DeviceLoginRequestInvalidOrExpired()
}
deviceTokenHash := utils.CreateSha256Hash(deviceToken)
@@ -168,12 +170,12 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
// Poll the actor's activation cache so the long-lived HTTP request does not repeatedly query the database
result, err := s.peek(ctx, requestID, requestActorMethodPoll, requestActorPollInput{DeviceTokenHash: deviceTokenHash})
if err != nil {
- return dto.UserDto{}, "", "", err
+ return dto.UserDto{}, model.LoginTokens{}, "", err
}
err = actorResultError(result.Code)
if err != nil {
- return dto.UserDto{}, "", result.Status, err
+ return dto.UserDto{}, model.LoginTokens{}, result.Status, err
}
switch result.Status {
@@ -181,7 +183,7 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
// Validate the approved user before consuming so lookup failures leave the request untouched
user, userDTO, err := s.loadExchangeUser(ctx, result.UserID)
if err != nil {
- return dto.UserDto{}, "", result.Status, err
+ return dto.UserDto{}, model.LoginTokens{}, result.Status, err
}
// Consume inside the actor so only one concurrent exchange can mint a token
@@ -189,42 +191,43 @@ func (s *Service) Exchange(ctx context.Context, requestID, deviceToken, ipAddres
DeviceTokenHash: deviceTokenHash,
})
if err != nil {
- return dto.UserDto{}, "", "", err
+ return dto.UserDto{}, model.LoginTokens{}, "", err
}
err = actorResultError(consume.Code)
if err != nil {
- return dto.UserDto{}, "", consume.Status, err
+ return dto.UserDto{}, model.LoginTokens{}, consume.Status, err
}
// Mint the session with login-code semantics because the waiting device did not perform WebAuthn
accessToken, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword, sessionDuration)
if err != nil {
- return dto.UserDto{}, "", consume.Status, err
+ return dto.UserDto{}, model.LoginTokens{}, consume.Status, err
}
// Record the successful remote sign-in after the request has been consumed
- _, created := s.auditLog.Create(ctx, model.AuditLogEventRemoteSignIn, ipAddress, userAgent, user.ID, model.AuditLogData{}, s.db)
- if !created {
- return dto.UserDto{}, "", consume.Status, errors.New("failed to create device login audit log")
+ signIn := s.auditLog.CreateSignIn(ctx, auditlogs.EventRemoteSignIn, ipAddress, userAgent, user.ID, browserToken, s.db, notificationMode)
+ if !signIn.Created {
+ return dto.UserDto{}, model.LoginTokens{}, consume.Status, errors.New("failed to create device login audit log")
}
- return userDTO, accessToken, consume.Status, nil
+ s.auditLog.SendSignInNotification(ctx, signIn)
+ return userDTO, model.LoginTokens{AccessToken: accessToken, KnownBrowserToken: signIn.KnownBrowserToken}, consume.Status, nil
case RequestStatusPending:
// no-op
case RequestStatusDenied:
- return dto.UserDto{}, "", result.Status, apperror.DeviceLoginDenied()
+ return dto.UserDto{}, model.LoginTokens{}, result.Status, apperror.DeviceLoginDenied()
default:
- return dto.UserDto{}, "", "", apperror.DeviceLoginRequestInvalidOrExpired()
+ return dto.UserDto{}, model.LoginTokens{}, "", apperror.DeviceLoginRequestInvalidOrExpired()
}
select {
case <-ticker.C:
// no-op
case <-timeout.C:
- return dto.UserDto{}, "", RequestStatusPending, nil
+ return dto.UserDto{}, model.LoginTokens{}, RequestStatusPending, nil
case <-ctx.Done():
- return dto.UserDto{}, "", "", ctx.Err()
+ return dto.UserDto{}, model.LoginTokens{}, "", ctx.Err()
}
}
}
diff --git a/backend/internal/devicelogin/service_test.go b/backend/internal/devicelogin/service_test.go
index deec05e9..6ec4d5c1 100644
--- a/backend/internal/devicelogin/service_test.go
+++ b/backend/internal/devicelogin/service_test.go
@@ -16,7 +16,9 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
+ "github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -70,15 +72,16 @@ func (f *fakeTokenService) generatedToken() (string, string, time.Duration, int)
}
type auditEntry struct {
- event model.AuditLogEvent
+ event auditlogs.Event
ipAddress string
userAgent string
userID string
}
type fakeAuditLogger struct {
- mu sync.Mutex
- entries []auditEntry
+ mu sync.Mutex
+ entries []auditEntry
+ notifications []auditlogs.SignInResult
}
type fakeIPLocationResolver struct {
@@ -91,11 +94,11 @@ func (f *fakeIPLocationResolver) GetLocationByIP(context.Context, string) (strin
return f.country, f.city, f.err
}
-func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
+func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, _ auditlogs.Data, _ *gorm.DB) (auditlogs.AuditLog, bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.entries = append(f.entries, auditEntry{event: event, ipAddress: ipAddress, userAgent: userAgent, userID: userID})
- return model.AuditLog{}, true
+ return auditlogs.AuditLog{}, true
}
func (f *fakeAuditLogger) DeviceStringFromUserAgent(userAgent string) string {
@@ -154,11 +157,14 @@ func TestRequestLifecycle(t *testing.T) {
err = fixture.service.Decide(t.Context(), strings.ToLower(request.Code), "approve", user.ID, "fresh-proof")
require.NoError(t, err)
- exchangedUser, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent", testSessionDuration)
+ exchangedUser, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "198.51.100.20", "target-agent", "", testSessionDuration, appconfig.LoginNotificationBrowserRecognition)
require.NoError(t, err)
require.Equal(t, RequestStatusApproved, status)
require.Equal(t, user.ID, exchangedUser.ID)
- require.Equal(t, "device-login-access-token", accessToken)
+ require.Equal(t, "device-login-access-token", accessToken.AccessToken)
+ require.Len(t, fixture.auditLog.notifications, 1)
+ require.True(t, fixture.auditLog.notifications[0].Notify)
+ require.Equal(t, auditlogs.EventRemoteSignIn, fixture.auditLog.notifications[0].AuditLog.Event)
signedUserID, authenticationMethod, sessionDuration, generated := fixture.signer.generatedToken()
require.Equal(t, user.ID, signedUserID)
@@ -168,15 +174,15 @@ func TestRequestLifecycle(t *testing.T) {
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
entry := fixture.auditLog.lastEntry()
- require.Equal(t, model.AuditLogEventRemoteSignIn, entry.event)
+ require.Equal(t, auditlogs.EventRemoteSignIn, entry.event)
require.Equal(t, "198.51.100.20", entry.ipAddress)
require.Equal(t, "target-agent", entry.userAgent)
require.Equal(t, user.ID, entry.userID)
- _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
- _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", testSessionDuration)
+ _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, _, _, generated = fixture.signer.generatedToken()
require.Equal(t, 1, generated)
@@ -199,7 +205,7 @@ func TestPendingAndDeniedRequests(t *testing.T) {
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
require.NoError(t, err)
- user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ user, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, status)
require.Empty(t, user.ID)
@@ -219,7 +225,7 @@ func TestPendingExchangeObservesDecisionDuringLongPoll(t *testing.T) {
}
result := make(chan exchangeOutcome, 1)
go func() {
- _, _, status, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, status, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
result <- exchangeOutcome{status: status, err: exchangeErr}
}()
@@ -242,14 +248,14 @@ func TestRejectsInvalidAndExpiredRequestsWhileActorIsActive(t *testing.T) {
require.NoError(t, err)
unknownRequestID := strings.Repeat("a", 64)
- _, _, _, err = fixture.service.Exchange(t.Context(), unknownRequestID, "device-token", "", "", testSessionDuration)
+ _, _, _, err = fixture.service.Exchange(t.Context(), unknownRequestID, "device-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, err = fixture.service.Inspect(t.Context(), unknownRequestID)
assertInvalidRequestError(t, err)
err = fixture.service.Decide(t.Context(), unknownRequestID, "deny", "device-login-user", "")
assertInvalidRequestError(t, err)
- _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", testSessionDuration)
+ _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, "wrong-token", "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
state := getRequestActorState(t, fixture.actors, request.ID)
@@ -260,7 +266,7 @@ func TestRejectsInvalidAndExpiredRequestsWhileActorIsActive(t *testing.T) {
assertInvalidRequestError(t, err)
err = fixture.service.Decide(t.Context(), request.Code, "deny", "device-login-user", "")
assertInvalidRequestError(t, err)
- _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
}
@@ -279,7 +285,7 @@ func TestRejectsDisabledUserAtExchange(t *testing.T) {
require.NoError(t, err)
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
- _, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, accessToken, _, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
require.Empty(t, accessToken)
require.Equal(t, RequestStatusApproved, getRequestActorState(t, fixture.actors, request.ID).Status)
@@ -300,14 +306,14 @@ func TestFailedTokenGenerationConsumesApprovedRequest(t *testing.T) {
require.NoError(t, err)
require.NoError(t, fixture.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
- _, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, accessToken, status, err := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.EqualError(t, err, "token generation failed")
require.Empty(t, accessToken)
require.Equal(t, RequestStatusApproved, status)
requireRequestActorStateDeleted(t, fixture.actors, request.ID)
require.Equal(t, 0, fixture.auditLog.entryCount())
- _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, _, err = fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
}
@@ -356,8 +362,8 @@ func TestConcurrentExchangeAllowsOnlyOneSuccess(t *testing.T) {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
- _, token, _, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
- results <- exchangeResult{token: token, err: exchangeErr}
+ _, token, _, exchangeErr := fixture.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
+ results <- exchangeResult{token: token.AccessToken, err: exchangeErr}
}()
}
waitGroup.Wait()
@@ -416,7 +422,7 @@ func TestRequestStateSurvivesActorHostRestart(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "persistent-agent", strings.TrimPrefix(info.Device, "Parsed "))
require.NoError(t, secondModule.service.Decide(t.Context(), request.Code, "deny", "device-login-user", ""))
- _, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, status, err := secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.True(t, apperror.IsCode(err, apperror.CodeDeviceLoginDenied))
require.Equal(t, RequestStatusDenied, status)
}
@@ -435,14 +441,14 @@ func TestCompletedExchangeIsInvalidAfterActorHostRestart(t *testing.T) {
request, deviceToken, err := firstModule.service.Create(t.Context(), "", "persistent-agent")
require.NoError(t, err)
require.NoError(t, firstModule.service.Decide(t.Context(), request.Code, "approve", user.ID, "fresh-proof"))
- _, _, firstStatus, err := firstModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, firstStatus, err := firstModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
require.NoError(t, err)
require.Equal(t, RequestStatusApproved, firstStatus)
stopFirst()
secondModule, stopSecond := startPersistentDeviceLoginHost(t, db, deps)
defer stopSecond()
- _, _, _, err = secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, _, err = secondModule.service.Exchange(t.Context(), request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
assertInvalidRequestError(t, err)
_, _, _, generated := deps.Signer.(*fakeTokenService).generatedToken()
@@ -462,7 +468,7 @@ func TestPendingExchangeStopsWhenRequestIsCanceled(t *testing.T) {
result := make(chan error, 1)
go func() {
close(started)
- _, _, _, exchangeErr := fixture.service.Exchange(ctx, request.ID, deviceToken, "", "", testSessionDuration)
+ _, _, _, exchangeErr := fixture.service.Exchange(ctx, request.ID, deviceToken, "", "", "", testSessionDuration, appconfig.LoginNotificationDisabled)
result <- exchangeErr
}()
@@ -590,3 +596,15 @@ func freeLoopbackAddress(t *testing.T) string {
require.NoError(t, listener.Close())
return address
}
+
+func (f *fakeAuditLogger) CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, mode appconfig.AppConfigValue) auditlogs.SignInResult {
+ entry, created := f.Create(ctx, event, ipAddress, userAgent, userID, auditlogs.Data{}, tx)
+ entry.Event = event
+ return auditlogs.SignInResult{AuditLog: entry, Created: created, Notify: mode != appconfig.LoginNotificationDisabled, KnownBrowserToken: "recognized-browser"}
+}
+
+func (f *fakeAuditLogger) SendSignInNotification(_ context.Context, result auditlogs.SignInResult) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.notifications = append(f.notifications, result)
+}
diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go
index f12765f1..a4b678f3 100644
--- a/backend/internal/dto/app_config_dto.go
+++ b/backend/internal/dto/app_config_dto.go
@@ -57,7 +57,7 @@ type AppConfigUpdateDto struct {
WebauthnAuthenticatorAttachment string `json:"webauthnAuthenticatorAttachment" binding:"required,oneof=any platform cross-platform"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required,boolean_string"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required,boolean_string"`
- EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required,boolean_string"`
+ EmailLoginNotificationMode string `json:"emailLoginNotificationMode" binding:"required,oneof=disabled always ipAndUserAgent browserRecognition"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required,boolean_string"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required,boolean_string"`
CIMDURLAllowlist string `json:"cimdUrlAllowlist" binding:"omitempty,cimd_url_allowlist"`
diff --git a/backend/internal/dto/dto_mapper_test.go b/backend/internal/dto/dto_mapper_test.go
index f244b9ee..da468442 100644
--- a/backend/internal/dto/dto_mapper_test.go
+++ b/backend/internal/dto/dto_mapper_test.go
@@ -7,10 +7,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
+type stringEnum string
+
type sourceStruct struct {
AString string
AStringPtr *string
@@ -28,7 +29,7 @@ type sourceStruct struct {
EmptyStringPtrToString *string
NilStringPtrToString *string
IntToInt64 int
- AuditLogEventToString model.AuditLogEvent
+ StringEnumToString stringEnum
}
type destStruct struct {
@@ -48,7 +49,7 @@ type destStruct struct {
EmptyStringPtrToString string
NilStringPtrToString string
IntToInt64 int64
- AuditLogEventToString string
+ StringEnumToString string
}
type embeddedStruct struct {
@@ -83,7 +84,7 @@ func TestMapStruct(t *testing.T) {
EmptyStringPtrToString: new(""),
NilStringPtrToString: nil,
IntToInt64: 99,
- AuditLogEventToString: model.AuditLogEventAccountCreated,
+ StringEnumToString: stringEnum("example"),
}
var dst destStruct
err := MapStruct(src, &dst)
@@ -110,7 +111,7 @@ func TestMapStruct(t *testing.T) {
assert.Empty(t, dst.EmptyStringPtrToString)
assert.Empty(t, dst.NilStringPtrToString)
assert.Equal(t, int64(99), dst.IntToInt64)
- assert.Equal(t, "ACCOUNT_CREATED", dst.AuditLogEventToString)
+ assert.Equal(t, "example", dst.StringEnumToString)
}
func TestMapStructList(t *testing.T) {
diff --git a/backend/internal/email/module.go b/backend/internal/email/module.go
index 38e318e9..01d64984 100644
--- a/backend/internal/email/module.go
+++ b/backend/internal/email/module.go
@@ -110,7 +110,7 @@ func (m *Module) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig
})
}
-func (m *Module) SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device string, dateTime time.Time) error {
+func (m *Module) SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device, method string, dateTime time.Time) error {
return send(ctx, m, dbConfig, address{
name: userFullName,
email: userEmail,
@@ -119,6 +119,7 @@ func (m *Module) SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfig
Country: country,
City: city,
Device: device,
+ Method: method,
DateTime: dateTime,
})
}
diff --git a/backend/internal/email/module_test.go b/backend/internal/email/module_test.go
index c067ee80..7aa1134d 100644
--- a/backend/internal/email/module_test.go
+++ b/backend/internal/email/module_test.go
@@ -83,9 +83,9 @@ func TestModuleSendsEveryEmailType(t *testing.T) {
{
name: "new login",
subject: "New device login with Pocket ID Test",
- bodyContains: []string{"NEW SIGN-IN DETECTED", "Zurich, Switzerland", "192.0.2.10", "Firefox on Linux", "January 2, 2030 at 3:04 PM UTC"},
+ bodyContains: []string{"NEW SIGN-IN DETECTED", "Zurich, Switzerland", "192.0.2.10", "Firefox on Linux", "Sign-in method", "One-time code", "January 2, 2030 at 3:04 PM UTC"},
send: func(ctx context.Context, config *appconfig.AppConfigModel) error {
- return module.SendNewLogin(ctx, config, user.FullName(), userEmail, "192.0.2.10", "Switzerland", "Zurich", "Firefox on Linux", eventTime)
+ return module.SendNewLogin(ctx, config, user.FullName(), userEmail, "192.0.2.10", "Switzerland", "Zurich", "Firefox on Linux", "One-time code", eventTime)
},
},
{
@@ -362,3 +362,17 @@ func readSMTPData(reader *bufio.Reader) (string, error) {
}
}
}
+
+func TestNewLoginRendersSignInMethodInBothTemplates(t *testing.T) {
+ module, err := New(nil)
+ require.NoError(t, err)
+ text, html, err := renderBody(module, newLoginTemplate, &templateData[newLoginTemplateData]{
+ AppName: "Pocket ID", AppURL: "https://id.example.test", LogoURL: "https://id.example.test/logo.png",
+ Data: &newLoginTemplateData{IPAddress: "192.0.2.1", Device: "Firefox on Linux", Method: "One-time code", DateTime: time.Now()},
+ })
+ require.NoError(t, err)
+ for _, body := range []string{text, html} {
+ assert.Contains(t, body, "Sign-in method")
+ assert.Contains(t, body, "One-time code")
+ }
+}
diff --git a/backend/internal/email/templates.go b/backend/internal/email/templates.go
index 051130ba..9a40cedf 100644
--- a/backend/internal/email/templates.go
+++ b/backend/internal/email/templates.go
@@ -47,6 +47,7 @@ type newLoginTemplateData struct {
Country string
City string
Device string
+ Method string
DateTime time.Time
}
diff --git a/backend/internal/ldapsync/service_test.go b/backend/internal/ldapsync/service_test.go
index 66edfc23..a40f2db9 100644
--- a/backend/internal/ldapsync/service_test.go
+++ b/backend/internal/ldapsync/service_test.go
@@ -441,7 +441,6 @@ func newTestLdapService(t *testing.T, client ldapClient) (*Service, *gorm.DB) {
userService := service.NewUserService(
db,
nil,
- nil,
service.NewCustomClaimService(db),
service.NewAppImagesService(map[string]string{}, fileStorage),
nil,
diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go
index 9a938b0b..16b34035 100644
--- a/backend/internal/middleware/auth_middleware_test.go
+++ b/backend/internal/middleware/auth_middleware_test.go
@@ -39,7 +39,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
jwtService, err := service.NewJwtService(t.Context(), db, instanceID)
require.NoError(t, err)
- userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil, nil)
+ userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil)
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db, CleanupDisabled: true})
require.NoError(t, err)
diff --git a/backend/internal/middleware/error_handler_test.go b/backend/internal/middleware/error_handler_test.go
index e30c2953..dd103831 100644
--- a/backend/internal/middleware/error_handler_test.go
+++ b/backend/internal/middleware/error_handler_test.go
@@ -226,7 +226,7 @@ func TestValidationResponseUsesAppConfigTypeMessages(t *testing.T) {
WebauthnAuthenticatorAttachment: "any",
EmailOneTimeAccessAsAdminEnabled: "false",
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
- EmailLoginNotificationEnabled: "false",
+ EmailLoginNotificationMode: "disabled",
EmailApiKeyExpirationEnabled: "false",
EmailVerificationEnabled: "false",
AutoCreateOIDCClientSecret: "true",
diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go
deleted file mode 100644
index 4b9e5371..00000000
--- a/backend/internal/model/audit_log.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package model
-
-import (
- "database/sql/driver"
- "encoding/json"
-
- "github.com/pocket-id/pocket-id/backend/internal/utils"
-)
-
-type AuditLog struct {
- Base
-
- Event AuditLogEvent `sortable:"true" filterable:"true"`
- IpAddress *string `sortable:"true"`
- Country string `sortable:"true"`
- City string `sortable:"true"`
- UserAgent string `sortable:"true"`
- Username string `gorm:"-"`
- Data AuditLogData
-
- UserID string `filterable:"true"`
- User User
-}
-
-type AuditLogData map[string]string //nolint:recvcheck
-
-type AuditLogEvent string //nolint:recvcheck
-
-const (
- AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
- AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
- AuditLogEventRemoteSignIn AuditLogEvent = "REMOTE_SIGN_IN"
- AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
- AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
- AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
- AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
- AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION"
- AuditLogEventPasskeyAdded AuditLogEvent = "PASSKEY_ADDED"
- AuditLogEventPasskeyRemoved AuditLogEvent = "PASSKEY_REMOVED"
-)
-
-// Scan and Value methods for GORM to handle the custom type
-
-func (e *AuditLogEvent) Scan(value any) error {
- *e = AuditLogEvent(value.(string))
- return nil
-}
-
-func (e AuditLogEvent) Value() (driver.Value, error) {
- return string(e), nil
-}
-
-func (d *AuditLogData) Scan(value any) error {
- return utils.UnmarshalJSONFromDatabase(d, value)
-}
-
-func (d AuditLogData) Value() (driver.Value, error) {
- return json.Marshal(d)
-}
diff --git a/backend/internal/model/login_tokens.go b/backend/internal/model/login_tokens.go
new file mode 100644
index 00000000..4ed67e9c
--- /dev/null
+++ b/backend/internal/model/login_tokens.go
@@ -0,0 +1,7 @@
+package model
+
+// LoginTokens carries the separate authentication and browser recognition cookies
+type LoginTokens struct {
+ AccessToken string
+ KnownBrowserToken string
+}
diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go
index bb0ab836..a5f9bc29 100644
--- a/backend/internal/oidc/authorization_service.go
+++ b/backend/internal/oidc/authorization_service.go
@@ -12,12 +12,14 @@ import (
"time"
"github.com/ory/fosite"
+ "gorm.io/gorm"
+
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
- "gorm.io/gorm"
)
func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger, apiAccess APIAccessProvider) *authorizationService {
@@ -277,12 +279,12 @@ func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req a
grantResourceIndicator(req.requester, audience, grantedScopes)
- authorizationEvent := model.AuditLogEventClientAuthorization
+ authorizationEvent := auditlogs.EventClientAuthorization
if !hasAlreadyAuthorizedClient {
- authorizationEvent = model.AuditLogEventNewClientAuthorization
+ authorizationEvent = auditlogs.EventNewClientAuthorization
}
if s.auditLog != nil {
- s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db))
+ s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, auditlogs.Data{"clientName": req.client.Name}, dbFromContext(ctx, s.db))
}
return authorizationResult{Session: session}, nil
@@ -691,7 +693,7 @@ func (s *authorizationService) completeConsentStep(ctx context.Context, interact
return err
}
if !hasAlreadyAuthorizedClient && s.auditLog != nil {
- s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
+ s.auditLog.Create(ctx, auditlogs.EventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, auditlogs.Data{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
}
interactionSession.ConsentRequired = false
return nil
diff --git a/backend/internal/oidc/authorization_service_test.go b/backend/internal/oidc/authorization_service_test.go
index a6249835..3f4f1a78 100644
--- a/backend/internal/oidc/authorization_service_test.go
+++ b/backend/internal/oidc/authorization_service_test.go
@@ -8,23 +8,24 @@ import (
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
+ "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
- "gorm.io/gorm"
)
type fakeAuditLogger struct {
- events []model.AuditLogEvent
- data []model.AuditLogData
+ events []auditlogs.Event
+ data []auditlogs.Data
}
-func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, data model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
+func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, _, _, _ string, data auditlogs.Data, _ *gorm.DB) (auditlogs.AuditLog, bool) {
f.events = append(f.events, event)
f.data = append(f.data, data)
- return model.AuditLog{}, true
+ return auditlogs.AuditLog{}, true
}
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
@@ -59,8 +60,8 @@ func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
require.NoError(t, err)
require.False(t, authorization.RequiresInteraction)
- require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
- require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
+ require.Equal(t, []auditlogs.Event{auditlogs.EventClientAuthorization}, auditLogger.events)
+ require.Equal(t, auditlogs.Data{"clientName": "Test Client"}, auditLogger.data[0])
}
func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
@@ -167,8 +168,8 @@ func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T)
require.NoError(t, err)
require.NotEmpty(t, response.RedirectURL)
- require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
- require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
+ require.Equal(t, []auditlogs.Event{auditlogs.EventNewClientAuthorization}, auditLogger.events)
+ require.Equal(t, auditlogs.Data{"clientName": "Test Client"}, auditLogger.data[0])
}
func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.T) {
@@ -234,7 +235,7 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
})
require.NoError(t, err)
require.False(t, authorization.RequiresInteraction)
- require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
+ require.Equal(t, []auditlogs.Event{auditlogs.EventClientAuthorization}, auditLogger.events)
}
// TestAuthorizationServiceRequiresConsentForScopelessAPIAccess guards that a token audienced to a custom API always needs
@@ -1169,7 +1170,7 @@ func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).Where("user_id = ? AND client_id = ?", userID, clientID).Count(&count).Error)
require.Equal(t, int64(1), count)
- require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
+ require.Equal(t, []auditlogs.Event{auditlogs.EventNewClientAuthorization}, auditLogger.events)
}
// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent
diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go
index 4f301e54..75b9ab57 100644
--- a/backend/internal/oidc/device_service.go
+++ b/backend/internal/oidc/device_service.go
@@ -9,11 +9,13 @@ import (
"github.com/ory/fosite"
"github.com/ory/fosite/handler/rfc8628"
+ "gorm.io/gorm"
+
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
- "gorm.io/gorm"
)
type deviceService struct {
@@ -143,11 +145,11 @@ func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID,
return err
}
- event := model.AuditLogEventDeviceCodeAuthorization
+ event := auditlogs.EventDeviceCodeAuthorization
if !hasAlreadyAuthorizedClient {
- event = model.AuditLogEventNewDeviceCodeAuthorization
+ event = auditlogs.EventNewDeviceCodeAuthorization
}
- s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": client.Name}, dbFromContext(ctx, s.db))
+ s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, auditlogs.Data{"clientName": client.Name}, dbFromContext(ctx, s.db))
deviceCodeSignature, err := s.store.AcceptDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature, request)
if err != nil {
diff --git a/backend/internal/oidc/module.go b/backend/internal/oidc/module.go
index ebf51922..8bf890ec 100644
--- a/backend/internal/oidc/module.go
+++ b/backend/internal/oidc/module.go
@@ -10,9 +10,11 @@ import (
"github.com/gin-gonic/gin"
francishost "github.com/italypaleale/francis/host"
"github.com/lestrrat-go/jwx/v4/jwa"
- "github.com/pocket-id/pocket-id/backend/internal/model"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"gorm.io/gorm"
+
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
)
type Config struct {
@@ -37,7 +39,7 @@ type ReauthenticationTokenConsumer interface {
}
type AuditLogger interface {
- Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
+ Create(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, data auditlogs.Data, tx *gorm.DB) (auditlogs.AuditLog, bool)
}
type Dependencies struct {
diff --git a/backend/internal/onetimeaccess/handler.go b/backend/internal/onetimeaccess/handler.go
index efc48e4d..1c79ca34 100644
--- a/backend/internal/onetimeaccess/handler.go
+++ b/backend/internal/onetimeaccess/handler.go
@@ -9,6 +9,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
@@ -147,7 +148,8 @@ func (h *handler) exchangeToken(c *gin.Context) error {
}
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
- user, token, err := h.service.ExchangeToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
+ browserToken, _ := c.Cookie(cookie.KnownBrowserCookieName)
+ user, tokens, err := h.service.ExchangeToken(c.Request.Context(), cfg, loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent(), browserToken)
if err != nil {
return err
}
@@ -159,7 +161,10 @@ func (h *handler) exchangeToken(c *gin.Context) error {
}
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
- cookie.AddAccessTokenCookie(c, maxAge, token)
+ cookie.AddAccessTokenCookie(c, maxAge, tokens.AccessToken)
+ if tokens.KnownBrowserToken != "" {
+ cookie.AddKnownBrowserCookie(c, tokens.KnownBrowserToken, int(auditlogs.KnownBrowserLifetime.Seconds()))
+ }
c.JSON(http.StatusOK, userDto)
return nil
diff --git a/backend/internal/onetimeaccess/module.go b/backend/internal/onetimeaccess/module.go
index 8daf7e17..d7c401bf 100644
--- a/backend/internal/onetimeaccess/module.go
+++ b/backend/internal/onetimeaccess/module.go
@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -24,7 +25,9 @@ type TokenService interface {
}
type AuditLogger interface {
- Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
+ CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, notificationMode appconfig.AppConfigValue) auditlogs.SignInResult
+ SendSignInNotification(ctx context.Context, result auditlogs.SignInResult)
+ Create(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, data auditlogs.Data, tx *gorm.DB) (auditlogs.AuditLog, bool)
}
type UserProvider interface {
diff --git a/backend/internal/onetimeaccess/service.go b/backend/internal/onetimeaccess/service.go
index 4779e031..55576c6c 100644
--- a/backend/internal/onetimeaccess/service.go
+++ b/backend/internal/onetimeaccess/service.go
@@ -14,6 +14,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -152,7 +153,7 @@ func (s *Service) CreateToken(ctx context.Context, userID string, ttl time.Durat
return token, nil
}
-func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
+func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConfigModel, token, deviceToken, ipAddress, userAgent, browserToken string) (model.User, model.LoginTokens, error) {
token = utils.NormalizeUnambiguousString(token)
// Consume the token by invoking its actor: this atomically validates it and, if valid, deletes it.
@@ -161,38 +162,38 @@ func (s *Service) ExchangeToken(ctx context.Context, dbConfig *appconfig.AppConf
DeviceToken: deviceToken,
})
if err != nil {
- return model.User{}, "", fmt.Errorf("error invoking one-time access token actor: %w", err)
+ return model.User{}, model.LoginTokens{}, fmt.Errorf("error invoking one-time access token actor: %w", err)
}
var consumeRes tokenConsumeResponse
err = res.Decode(&consumeRes)
if err != nil {
- return model.User{}, "", fmt.Errorf("error decoding one-time access token actor response: %w", err)
+ return model.User{}, model.LoginTokens{}, fmt.Errorf("error decoding one-time access token actor response: %w", err)
}
switch consumeRes.Status {
case tokenConsumeNotFound:
- return model.User{}, "", apperror.TokenInvalidOrExpired()
+ return model.User{}, model.LoginTokens{}, apperror.TokenInvalidOrExpired()
case tokenConsumeDeviceMismatch:
- return model.User{}, "", apperror.DeviceCodeInvalid()
+ return model.User{}, model.LoginTokens{}, apperror.DeviceCodeInvalid()
case tokenConsumeOK:
// All good, continue below
default:
- return model.User{}, "", fmt.Errorf("unexpected status from one-time access token actor: %s", consumeRes.Status)
+ return model.User{}, model.LoginTokens{}, fmt.Errorf("unexpected status from one-time access token actor: %s", consumeRes.Status)
}
// The token has now been consumed. From this point on, if we hit an error we compensate by restoring the token (this is best-effort).
- user, accessToken, err := s.completeTokenExchange(ctx, dbConfig, consumeRes.State, ipAddress, userAgent)
+ user, accessToken, err := s.completeTokenExchange(ctx, dbConfig, consumeRes.State, ipAddress, userAgent, browserToken)
if err != nil {
s.restoreToken(ctx, token, consumeRes.State)
- return model.User{}, "", err
+ return model.User{}, model.LoginTokens{}, err
}
return user, accessToken, nil
}
// completeTokenExchange performs the work that follows consuming a token: loading the user, validating it, and issuing an access token.
-func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig.AppConfigModel, state TokenState, ipAddress, userAgent string) (model.User, string, error) {
+func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig.AppConfigModel, state TokenState, ipAddress, userAgent, browserToken string) (model.User, model.LoginTokens, error) {
var user model.User
err := s.db.
WithContext(ctx).
@@ -200,13 +201,13 @@ func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
- return model.User{}, "", apperror.TokenInvalidOrExpired()
+ return model.User{}, model.LoginTokens{}, apperror.TokenInvalidOrExpired()
} else if err != nil {
- return model.User{}, "", err
+ return model.User{}, model.LoginTokens{}, err
}
if user.Disabled {
- return model.User{}, "", apperror.UserDisabled()
+ return model.User{}, model.LoginTokens{}, apperror.UserDisabled()
}
accessToken, err := s.signer.GenerateAccessToken(
@@ -215,18 +216,14 @@ func (s *Service) completeTokenExchange(ctx context.Context, dbConfig *appconfig
dbConfig.SessionDuration.AsDurationMinutes(),
)
if err != nil {
- return model.User{}, "", err
+ return model.User{}, model.LoginTokens{}, err
}
- s.auditLog.Create(
- ctx, model.AuditLogEventOneTimeAccessTokenSignIn,
- ipAddress, userAgent,
- user.ID,
- model.AuditLogData{},
- s.db,
- )
+ // Recognize the receiving browser after the login code has been consumed and the user validated
+ signIn := s.auditLog.CreateSignIn(ctx, auditlogs.EventOneTimeAccessTokenSignIn, ipAddress, userAgent, user.ID, browserToken, s.db, dbConfig.EmailLoginNotificationMode)
+ s.auditLog.SendSignInNotification(ctx, signIn)
- return user, accessToken, nil
+ return user, model.LoginTokens{AccessToken: accessToken, KnownBrowserToken: signIn.KnownBrowserToken}, nil
}
// restoreToken restores a token that was consumed but whose exchange could not be completed.
diff --git a/backend/internal/onetimeaccess/service_test.go b/backend/internal/onetimeaccess/service_test.go
index bc184800..4bcc939e 100644
--- a/backend/internal/onetimeaccess/service_test.go
+++ b/backend/internal/onetimeaccess/service_test.go
@@ -12,6 +12,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -23,12 +24,13 @@ func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (
}
type fakeAuditLogger struct {
- events []model.AuditLogEvent
+ events []auditlogs.Event
+ notifications []auditlogs.SignInResult
}
-func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
+func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, _, _, _ string, _ auditlogs.Data, _ *gorm.DB) (auditlogs.AuditLog, bool) {
f.events = append(f.events, event)
- return model.AuditLog{}, true
+ return auditlogs.AuditLog{}, true
}
type fakeUserProvider struct {
@@ -103,10 +105,14 @@ func TestExchangeTokenSuccess(t *testing.T) {
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
- exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent")
+ dbConfig.EmailLoginNotificationMode = appconfig.LoginNotificationBrowserRecognition
+ exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent", "")
require.NoError(t, err)
require.Equal(t, user.ID, exchangedUser.ID)
- require.NotEmpty(t, accessToken)
+ require.NotEmpty(t, accessToken.AccessToken)
+ require.Len(t, auditLog.notifications, 1)
+ require.True(t, auditLog.notifications[0].Notify)
+ require.Equal(t, auditlogs.EventOneTimeAccessTokenSignIn, auditLog.notifications[0].AuditLog.Event)
// The token must have been consumed
var state TokenState
@@ -114,7 +120,7 @@ func TestExchangeTokenSuccess(t *testing.T) {
require.ErrorIs(t, err, actor.ErrStateNotFound)
// A sign-in audit log must have been created
- require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events)
+ require.Equal(t, []auditlogs.Event{auditlogs.EventOneTimeAccessTokenSignIn}, auditLog.events)
}
func TestExchangeTokenAcceptsAmbiguousAliases(t *testing.T) {
@@ -134,7 +140,7 @@ func TestExchangeTokenAcceptsAmbiguousAliases(t *testing.T) {
}, &actor.SetStateOpts{TTL: time.Minute}))
dbConfig := appconfig.NewTestConfig(nil)
- exchangedUser, _, err := svc.ExchangeToken(t.Context(), dbConfig, "aIObc2", "", "", "")
+ exchangedUser, _, err := svc.ExchangeToken(t.Context(), dbConfig, "aIObc2", "", "", "", "")
require.NoError(t, err)
require.Equal(t, user.ID, exchangedUser.ID)
}
@@ -144,7 +150,7 @@ func TestExchangeTokenInvalidToken(t *testing.T) {
svc, _, _ := newServiceForTest(t, db)
dbConfig := appconfig.NewTestConfig(nil)
- _, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")
+ _, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "", "")
require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
}
@@ -165,7 +171,7 @@ func TestExchangeTokenDeviceMismatch(t *testing.T) {
require.NotNil(t, deviceToken)
dbConfig := appconfig.NewTestConfig(nil)
- _, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")
+ _, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "", "")
require.True(t, apperror.IsCode(err, apperror.CodeDeviceCodeInvalid))
@@ -192,7 +198,7 @@ func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
require.NoError(t, err)
dbConfig := appconfig.NewTestConfig(nil)
- exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")
+ exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "", "")
require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
require.Empty(t, exchangedUser.ID)
@@ -205,4 +211,15 @@ func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
require.Equal(t, user.ID, state.UserID)
require.Empty(t, auditLog.events)
+ require.Empty(t, auditLog.notifications)
+}
+
+func (f *fakeAuditLogger) CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, mode appconfig.AppConfigValue) auditlogs.SignInResult {
+ entry, created := f.Create(ctx, event, ipAddress, userAgent, userID, auditlogs.Data{}, tx)
+ entry.Event = event
+ return auditlogs.SignInResult{AuditLog: entry, Created: created, Notify: mode != appconfig.LoginNotificationDisabled, KnownBrowserToken: "recognized-browser"}
+}
+
+func (f *fakeAuditLogger) SendSignInNotification(_ context.Context, result auditlogs.SignInResult) {
+ f.notifications = append(f.notifications, result)
}
diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go
deleted file mode 100644
index f475ed9a..00000000
--- a/backend/internal/service/audit_log_service.go
+++ /dev/null
@@ -1,274 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "log/slog"
- "time"
-
- userAgentParser "github.com/mileusna/useragent"
- "github.com/pocket-id/pocket-id/backend/internal/appconfig"
- "github.com/pocket-id/pocket-id/backend/internal/iplocation"
- "github.com/pocket-id/pocket-id/backend/internal/model"
- "github.com/pocket-id/pocket-id/backend/internal/utils"
- "gorm.io/gorm"
-)
-
-type NewLoginEmailSender interface {
- SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device string, dateTime time.Time) error
-}
-
-type AuditLogService struct {
- db *gorm.DB
- emailSender NewLoginEmailSender
- ipLocator iplocation.Resolver
- appConfigService *appconfig.AppConfigService
-}
-
-func NewAuditLogService(db *gorm.DB, emailSender NewLoginEmailSender, ipLocator iplocation.Resolver, appConfigService *appconfig.AppConfigService) *AuditLogService {
- return &AuditLogService{
- db: db,
- emailSender: emailSender,
- ipLocator: ipLocator,
- appConfigService: appConfigService,
- }
-}
-
-// Create creates a new audit log entry in the database
-func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) {
- country, city, err := s.ipLocator.GetLocationByIP(ctx, ipAddress)
- if err != nil {
- // Log the error but don't interrupt the operation
- slog.WarnContext(ctx, "Failed to get IP location", slog.String("ip", ipAddress), slog.Any("error", err))
- }
-
- auditLog := model.AuditLog{
- Event: event,
- Country: country,
- City: city,
- UserAgent: userAgent,
- UserID: userID,
- Data: data,
- }
-
- if ipAddress != "" {
- // Only set ipAddress if not empty, because on Postgres we use INET columns that don't allow non-null empty values
- auditLog.IpAddress = &ipAddress
- }
-
- // Save the audit log in the database
- err = tx.
- WithContext(ctx).
- Create(&auditLog).
- Error
- if err != nil {
- slog.Error("Failed to create audit log", "error", err)
- return model.AuditLog{}, false
- }
-
- return auditLog, true
-}
-
-// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
-// emailLoginNotificationEnabled gates whether the notification email is sent, so the caller decides using the config it already loaded
-func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog {
- createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
- if !ok {
- // At this point the transaction has been canceled already, and error has been logged
- return createdAuditLog
- }
-
- // Count the number of times the user has logged in from the same device
- var count int64
- stmt := tx.
- WithContext(ctx).
- Model(&model.AuditLog{}).
- Where("user_id = ? AND user_agent = ?", userID, userAgent)
- if ipAddress == "" {
- // An empty IP address is stored as NULL in the database
- stmt = stmt.Where("ip_address IS NULL")
- } else {
- stmt = stmt.Where("ip_address = ?", ipAddress)
- }
- err := stmt.Count(&count).Error
- if err != nil {
- slog.ErrorContext(ctx, "Failed to count audit logs", slog.Any("error", err))
- return createdAuditLog
- }
-
- // If the user hasn't logged in from the same device before and email notifications are enabled, send an email
- if emailLoginNotificationEnabled && count <= 1 {
- go func() {
- // This runs in background, so use a context without cancellation (or it would be stopped when the request ends)
- // We still want to have a context derived from the request's to carry over tracing info
- innerCtx := context.WithoutCancel(ctx)
-
- // This runs after the request has completed, so we resolve the current config rather than threading the request's snapshot into the goroutine
- dbConfig, innerErr := s.appConfigService.GetConfig(innerCtx)
- if innerErr != nil {
- slog.ErrorContext(innerCtx, "Failed to load app configuration to send notification email", slog.Any("error", innerErr))
- return
- }
-
- // Note we don't use the transaction here because this is running in background
- var user model.User
- innerErr = s.db.
- WithContext(innerCtx).
- Where("id = ?", userID).
- First(&user).
- Error
- if innerErr != nil {
- slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
- return
- }
-
- if user.Email == nil {
- return
- }
-
- innerErr = s.emailSender.SendNewLogin(
- innerCtx,
- dbConfig,
- user.FullName(),
- *user.Email,
- ipAddress,
- createdAuditLog.Country,
- createdAuditLog.City,
- s.DeviceStringFromUserAgent(userAgent),
- createdAuditLog.CreatedAt.UTC(),
- )
- if innerErr != nil {
- slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", *user.Email))
- return
- }
- }()
- }
-
- return createdAuditLog
-}
-
-// ListAuditLogsForUser retrieves all audit logs for a given user ID
-func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
- var logs []model.AuditLog
- query := s.db.
- WithContext(ctx).
- Model(&model.AuditLog{}).
- Where("user_id = ?", userID)
-
- pagination, err := utils.PaginateFilterAndSort(listRequestOptions, 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
-}
-
-func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
- var logs []model.AuditLog
-
- query := s.db.
- WithContext(ctx).
- Preload("User").
- Model(&model.AuditLog{})
-
- if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
- dialect := s.db.Name()
- switch dialect {
- case "sqlite":
- query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
- case "postgres":
- query = query.Where("data->>'clientName' IN ?", clientName)
- default:
- return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
- }
- }
-
- if locations, ok := listRequestOptions.Filters["location"]; ok {
- mapped := make([]string, 0, len(locations))
- for _, v := range locations {
- if s, ok := v.(string); ok {
- switch s {
- case "internal":
- mapped = append(mapped, "Internal Network")
- case "external":
- mapped = append(mapped, "External Network")
- }
- }
- }
- if len(mapped) > 0 {
- query = query.Where("country IN ?", mapped)
- }
- }
-
- pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
- if err != nil {
- return nil, pagination, err
- }
-
- return logs, pagination, nil
-}
-
-func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[string]string, err error) {
- query := s.db.
- WithContext(ctx).
- Joins("User").
- Model(&model.AuditLog{}).
- Select(`DISTINCT "User".id, "User".username`).
- Where(`"User".username IS NOT NULL`)
-
- type Result struct {
- ID string `gorm:"column:id"`
- Username string `gorm:"column:username"`
- }
-
- var results []Result
- err = query.Find(&results).Error
- if err != nil {
- return nil, fmt.Errorf("failed to query user IDs: %w", err)
- }
-
- users = make(map[string]string, len(results))
- for _, result := range results {
- users[result.ID] = result.Username
- }
-
- return users, nil
-}
-
-func (s *AuditLogService) ListClientNames(ctx context.Context) (clientNames []string, err error) {
- dialect := s.db.Name()
- query := s.db.
- WithContext(ctx).
- Model(&model.AuditLog{})
-
- switch dialect {
- case "sqlite":
- query = query.
- Select("DISTINCT json_extract(data, '$.clientName') AS client_name").
- Where("json_extract(data, '$.clientName') IS NOT NULL")
- case "postgres":
- query = query.
- Select("DISTINCT data->>'clientName' AS client_name").
- Where("data->>'clientName' IS NOT NULL")
- default:
- return nil, fmt.Errorf("unsupported database dialect: %s", dialect)
- }
-
- type Result struct {
- ClientName string `gorm:"column:client_name"`
- }
-
- var results []Result
- err = query.Find(&results).Error
- if err != nil {
- return nil, fmt.Errorf("failed to query client IDs: %w", err)
- }
-
- clientNames = make([]string, len(results))
- for i, result := range results {
- clientNames[i] = result.ClientName
- }
-
- return clientNames, nil
-}
diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go
index d23f2895..cf3fac1f 100644
--- a/backend/internal/service/jwt_service.go
+++ b/backend/internal/service/jwt_service.go
@@ -356,13 +356,7 @@ func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod s
return "", fmt.Errorf("failed to set '%s' claim in token: %w", common.AuthenticationMethodsClaim, err)
}
- // Session tokens are signed with the symmetric session key
- signed, err := jwt.Sign(token, jwt.WithKey(jwkutils.SessionKeyAlg(), s.sessionKey))
- if err != nil {
- return "", fmt.Errorf("failed to sign token: %w", err)
- }
-
- return string(signed), nil
+ return s.SignSessionToken(token)
}
// GenerateLogoutToken creates a logout token for OIDC Back-Channel Logout 1.0
@@ -402,25 +396,49 @@ func (s *JwtService) GenerateLogoutToken(userID string, clientID string) (string
return string(signed), nil
}
-func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
+// SignSessionToken signs feature-owned claims with the private symmetric session key
+func (s *JwtService) SignSessionToken(token jwt.Token) (string, error) {
+ if s.sessionKey == nil {
+ return "", errors.New("session key is not initialized")
+ }
+
+ signed, err := jwt.Sign(token, jwt.WithKey(jwkutils.SessionKeyAlg(), s.sessionKey))
+ if err != nil {
+ return "", fmt.Errorf("failed to sign token: %w", err)
+ }
+ return string(signed), nil
+}
+
+// VerifySessionToken pins signature verification while the caller supplies its feature's validation rules
+func (s *JwtService) VerifySessionToken(tokenString string, options ...jwt.ValidateOption) (jwt.Token, error) {
if s.sessionKey == nil {
return nil, errors.New("session key is not initialized")
}
- token, err := jwt.ParseString(
- tokenString,
+ parseOptions := []jwt.ParseOption{
jwt.WithValidate(true),
jwt.WithKey(jwkutils.SessionKeyAlg(), s.sessionKey),
+ }
+ for _, option := range options {
+ parseOptions = append(parseOptions, option)
+ }
+ token, err := jwt.ParseString(tokenString, parseOptions...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse token: %w", err)
+ }
+ return token, nil
+}
+
+func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
+ if s.sessionKey == nil {
+ return nil, errors.New("session key is not initialized")
+ }
+ return s.VerifySessionToken(tokenString,
jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(s.envConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
)
- if err != nil {
- return nil, fmt.Errorf("failed to parse token: %w", err)
- }
-
- return token, nil
}
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index 0edb70a9..7a173679 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -31,7 +31,6 @@ import (
type UserService struct {
db *gorm.DB
jwtService *JwtService
- auditLogService *AuditLogService
customClaimService *CustomClaimService
appImagesService *AppImagesService
scimSyncScheduler ScimSyncScheduler
@@ -39,11 +38,10 @@ type UserService struct {
fileStorage storage.FileStorage
}
-func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, fileStorage storage.FileStorage) *UserService {
+func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, fileStorage storage.FileStorage) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
- auditLogService: auditLogService,
customClaimService: customClaimService,
appImagesService: appImagesService,
scimSyncScheduler: scimSyncScheduler,
diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go
index ada9e1a6..c6523e71 100644
--- a/backend/internal/service/user_service_test.go
+++ b/backend/internal/service/user_service_test.go
@@ -26,7 +26,6 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
userService := NewUserService(
db,
nil,
- nil,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go
index 642e6202..7875229d 100644
--- a/backend/internal/usersignup/module.go
+++ b/backend/internal/usersignup/module.go
@@ -10,6 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -20,7 +21,7 @@ type TokenService interface {
}
type AuditLogger interface {
- Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
+ Create(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, data auditlogs.Data, tx *gorm.DB) (auditlogs.AuditLog, bool)
}
type UserCreator interface {
diff --git a/backend/internal/usersignup/service.go b/backend/internal/usersignup/service.go
index 6a11e1d4..350a06f7 100644
--- a/backend/internal/usersignup/service.go
+++ b/backend/internal/usersignup/service.go
@@ -14,6 +14,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -115,11 +116,11 @@ func (s *Service) createSignedUpUser(ctx context.Context, config *appconfig.AppC
}
if tokenProvided {
- s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
+ s.auditLog.Create(ctx, auditlogs.EventAccountCreated, ipAddress, userAgent, user.ID, auditlogs.Data{
"signupToken": token,
}, tx)
} else {
- s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
+ s.auditLog.Create(ctx, auditlogs.EventAccountCreated, ipAddress, userAgent, user.ID, auditlogs.Data{
"method": "open_signup",
}, tx)
}
diff --git a/backend/internal/usersignup/service_test.go b/backend/internal/usersignup/service_test.go
index 190e8792..0090f002 100644
--- a/backend/internal/usersignup/service_test.go
+++ b/backend/internal/usersignup/service_test.go
@@ -11,6 +11,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -37,8 +38,8 @@ func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (
type fakeAuditLogger struct{}
-func (fakeAuditLogger) Create(_ context.Context, _ model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
- return model.AuditLog{}, true
+func (fakeAuditLogger) Create(_ context.Context, _ auditlogs.Event, _, _, _ string, _ auditlogs.Data, _ *gorm.DB) (auditlogs.AuditLog, bool) {
+ return auditlogs.AuditLog{}, true
}
func newSignupServiceForTest(t *testing.T, db *gorm.DB, userCreator UserCreator) *Service {
diff --git a/backend/internal/utils/cookie/add_cookie.go b/backend/internal/utils/cookie/add_cookie.go
index 247ddbd1..6b616598 100644
--- a/backend/internal/utils/cookie/add_cookie.go
+++ b/backend/internal/utils/cookie/add_cookie.go
@@ -32,3 +32,7 @@ func addCookie(c *gin.Context, name, value string, maxAge int, path string) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(name, value, maxAge, path, "", true, true)
}
+
+func AddKnownBrowserCookie(c *gin.Context, token string, maxAge int) {
+ addCookie(c, KnownBrowserCookieName, token, maxAge, "/")
+}
diff --git a/backend/internal/utils/cookie/cookie_names.go b/backend/internal/utils/cookie/cookie_names.go
index ca23cba7..8c8f3848 100644
--- a/backend/internal/utils/cookie/cookie_names.go
+++ b/backend/internal/utils/cookie/cookie_names.go
@@ -6,6 +6,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
)
+var KnownBrowserCookieName = "__Host-known_browser"
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
var DeviceTokenCookieName = "__Secure-device_token" // #nosec G101 -- cookie name, not a credential
@@ -14,6 +15,7 @@ var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" // #nose
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
+ KnownBrowserCookieName = "known_browser"
AccessTokenCookieName = "access_token"
SessionIdCookieName = "session"
DeviceTokenCookieName = "device_token"
diff --git a/backend/internal/webauthn/handler.go b/backend/internal/webauthn/handler.go
index c6a63045..401af4c3 100644
--- a/backend/internal/webauthn/handler.go
+++ b/backend/internal/webauthn/handler.go
@@ -13,6 +13,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -106,7 +107,8 @@ func (h *handler) verifyLogin(c *gin.Context) error {
return apperror.InvalidWebAuthnResponse(err)
}
- user, token, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
+ browserToken, _ := c.Cookie(cookie.KnownBrowserCookieName)
+ user, tokens, err := h.service.VerifyLogin(c.Request.Context(), dbConfig, sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent(), browserToken)
if err != nil {
return err
}
@@ -117,7 +119,10 @@ func (h *handler) verifyLogin(c *gin.Context) error {
}
maxAge := int(dbConfig.SessionDuration.AsDurationMinutes().Seconds())
- cookie.AddAccessTokenCookie(c, maxAge, token)
+ cookie.AddAccessTokenCookie(c, maxAge, tokens.AccessToken)
+ if tokens.KnownBrowserToken != "" {
+ cookie.AddKnownBrowserCookie(c, tokens.KnownBrowserToken, int(auditlogs.KnownBrowserLifetime.Seconds()))
+ }
c.JSON(http.StatusOK, userDto)
return nil
diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go
index 24d8dfe2..99f9ef3a 100644
--- a/backend/internal/webauthn/module.go
+++ b/backend/internal/webauthn/module.go
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -23,8 +24,9 @@ type TokenService interface {
}
type AuditLogger interface {
- Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
- CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB, emailLoginNotificationEnabled bool) model.AuditLog
+ CreateSignIn(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, notificationMode appconfig.AppConfigValue) auditlogs.SignInResult
+ SendSignInNotification(ctx context.Context, result auditlogs.SignInResult)
+ Create(ctx context.Context, event auditlogs.Event, ipAddress, userAgent, userID string, data auditlogs.Data, tx *gorm.DB) (auditlogs.AuditLog, bool)
}
type Dependencies struct {
diff --git a/backend/internal/webauthn/service.go b/backend/internal/webauthn/service.go
index cdeedd05..a2e2d4b7 100644
--- a/backend/internal/webauthn/service.go
+++ b/backend/internal/webauthn/service.go
@@ -15,6 +15,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
+ "github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -204,8 +205,8 @@ func (s *Service) VerifyRegistration(ctx context.Context, dbConfig *appconfig.Ap
return model.WebauthnCredential{}, fmt.Errorf("failed to store WebAuthn credential: %w", err)
}
- auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.ID), "passkeyName": passkeyName}
- s.auditLog.Create(ctx, model.AuditLogEventPasskeyAdded, ipAddress, r.UserAgent(), userID, auditLogData, tx)
+ auditLogData := auditlogs.Data{"credentialID": hex.EncodeToString(credential.ID), "passkeyName": passkeyName}
+ s.auditLog.Create(ctx, auditlogs.EventPasskeyAdded, ipAddress, r.UserAgent(), userID, auditLogData, tx)
err = tx.Commit().Error
if err != nil {
@@ -255,7 +256,7 @@ func (s *Service) BeginLogin(ctx context.Context, dbConfig *appconfig.AppConfigM
}, nil
}
-func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
+func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent, browserToken string) (model.User, model.LoginTokens, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -268,10 +269,10 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ?", sessionID)
if result.Error != nil {
- return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
+ return model.User{}, model.LoginTokens{}, fmt.Errorf("failed to load WebAuthn session: %w", result.Error)
}
if result.RowsAffected == 0 {
- return model.User{}, "", apperror.InvalidWebAuthnSession()
+ return model.User{}, model.LoginTokens{}, apperror.InvalidWebAuthnSession()
}
session := gowebauthn.SessionData{
@@ -301,29 +302,35 @@ func (s *Service) VerifyLogin(ctx context.Context, dbConfig *appconfig.AppConfig
}, session, credentialAssertionData)
if err != nil {
- return model.User{}, "", classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed)
+ return model.User{}, model.LoginTokens{}, classifyPasskeyError(err, apperror.WebAuthnAuthenticationFailed)
}
if user == nil {
- return model.User{}, "", apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user"))
+ return model.User{}, model.LoginTokens{}, apperror.WebAuthnAuthenticationFailed(errors.New("WebAuthn response did not resolve to a user"))
}
if user.Disabled {
- return model.User{}, "", apperror.UserDisabled()
+ return model.User{}, model.LoginTokens{}, apperror.UserDisabled()
}
token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant, dbConfig.SessionDuration.AsDurationMinutes())
if err != nil {
- return model.User{}, "", err
+ return model.User{}, model.LoginTokens{}, err
}
- s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx, dbConfig.EmailLoginNotificationEnabled.IsTrue())
+ // Prepare browser recognition and the notification within the login transaction
+ signIn := s.auditLog.CreateSignIn(ctx, auditlogs.EventSignIn, ipAddress, userAgent, user.ID, browserToken, tx, dbConfig.EmailLoginNotificationMode)
+ if !signIn.Created {
+ return model.User{}, model.LoginTokens{}, errors.New("failed to create sign-in audit log")
+ }
err = tx.Commit().Error
if err != nil {
- return model.User{}, "", err
+ return model.User{}, model.LoginTokens{}, err
}
- return *user, token, nil
+ // Deliver the notification only after the login transaction has committed
+ s.auditLog.SendSignInNotification(ctx, signIn)
+ return *user, model.LoginTokens{AccessToken: token, KnownBrowserToken: signIn.KnownBrowserToken}, nil
}
func (s *Service) ListCredentials(ctx context.Context, userID string) ([]model.WebauthnCredential, error) {
@@ -356,7 +363,7 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia
return apperror.NotFound("Passkey")
}
- auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
+ auditLogData := auditlogs.Data{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
if actorUserID != "" && actorUserID != userID {
var actor model.User
err := tx.
@@ -372,7 +379,7 @@ func (s *Service) DeleteCredential(ctx context.Context, userID string, credentia
auditLogData["actorUserID"] = actorUserID
auditLogData["actorUsername"] = actor.Username
}
- s.auditLog.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
+ s.auditLog.Create(ctx, auditlogs.EventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
err := tx.Commit().Error
if err != nil {
diff --git a/backend/internal/webauthn/service_test.go b/backend/internal/webauthn/service_test.go
index 283c210f..3ee0dc61 100644
--- a/backend/internal/webauthn/service_test.go
+++ b/backend/internal/webauthn/service_test.go
@@ -371,7 +371,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
t.Run("login rejects an unknown session", func(t *testing.T) {
service := setupService(t)
- _, token, err := service.VerifyLogin(t.Context(), &appconfig.AppConfigModel{}, "does-not-exist", nil, "127.0.0.1", "test-agent")
+ _, token, err := service.VerifyLogin(t.Context(), &appconfig.AppConfigModel{}, "does-not-exist", nil, "127.0.0.1", "test-agent", "")
assert.Empty(t, token)
require.Error(t, err)
diff --git a/backend/resources/email-templates/login-with-new-device_html.tmpl b/backend/resources/email-templates/login-with-new-device_html.tmpl
index 06211b1b..6faaebe2 100644
--- a/backend/resources/email-templates/login-with-new-device_html.tmpl
+++ b/backend/resources/email-templates/login-with-new-device_html.tmpl
@@ -1 +1 @@
-{{define "root"}}
A new sign-in to your {{.AppName}} account was detectedA new sign-in to your {{.AppName}} account was detected
New sign-in detectedYour {{.AppName}} account was recently accessed from a new IP address or browser. If this was you, no further action is needed. Approximate location {{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}} |
IP address {{.Data.IPAddress}} |
Time {{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}} |
|
If you don't recognize this activity, review the passkeys in your {{.AppName}} account settings and remove any you don't recognize. |
|
|
|
{{end}}
\ No newline at end of file
+{{define "root"}}A new sign-in to your {{.AppName}} account was detectedA new sign-in to your {{.AppName}} account was detected
New sign-in detectedYour {{.AppName}} account was recently accessed from a new IP address or browser. If this was you, no further action is needed. Approximate location {{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}} |
IP address {{.Data.IPAddress}} |
Sign-in method {{.Data.Method}} |
Time {{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}} |
|
If you don't recognize this activity, review the passkeys in your {{.AppName}} account settings and remove any you don't recognize. |
|
|
|
{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/login-with-new-device_text.tmpl b/backend/resources/email-templates/login-with-new-device_text.tmpl
index e858802a..a80fcefd 100644
--- a/backend/resources/email-templates/login-with-new-device_text.tmpl
+++ b/backend/resources/email-templates/login-with-new-device_text.tmpl
@@ -11,6 +11,9 @@ IP address
Device
{{.Data.Device}}
+Sign-in method
+{{.Data.Method}}
+
Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}
diff --git a/email-templates/emails/login-with-new-device.tsx b/email-templates/emails/login-with-new-device.tsx
index d5400b45..07fda3d2 100644
--- a/email-templates/emails/login-with-new-device.tsx
+++ b/email-templates/emails/login-with-new-device.tsx
@@ -8,6 +8,7 @@ interface SignInData {
location: string;
ipAddress: string;
device: string;
+ method: string;
dateTime: string;
}
@@ -28,6 +29,7 @@ export const NewSignInEmail = ({ data, ...props }: NewSignInEmailProps) => (
{ label: 'Approximate location', value: data.location },
{ label: 'IP address', value: data.ipAddress },
{ label: 'Device', value: data.device },
+ { label: 'Sign-in method', value: data.method },
{ label: 'Time', value: data.dateTime }
]}
/>
@@ -48,6 +50,7 @@ NewSignInEmail.TemplateProps = {
'{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}',
ipAddress: '{{.Data.IPAddress}}',
device: '{{.Data.Device}}',
+ method: '{{.Data.Method}}',
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}'
}
};
@@ -58,6 +61,7 @@ NewSignInEmail.PreviewProps = {
location: 'San Francisco, USA',
ipAddress: '203.0.113.42',
device: 'Chrome on macOS',
+ method: 'Passkey',
dateTime: 'January 2, 2026 at 3:04 PM UTC'
}
};
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 4e6c066e..a0dca327 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -202,6 +202,14 @@
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
+ "email_login_notification_description": "Choose when users receive an email after signing in.",
+ "login_notification_disabled_description": "Do not send sign-in notification emails or use browser recognition cookies.",
+ "login_notification_always": "Every sign-in",
+ "login_notification_always_description": "Send an email after every successful sign-in. No browser recognition cookie is used.",
+ "login_notification_ip_and_user_agent": "New IP address or browser",
+ "login_notification_ip_and_user_agent_description": "Send an email when the exact combination of IP address and browser User-Agent is not in the user's sign-in history. No browser recognition cookie is used.",
+ "login_notification_browser_recognition": "Unrecognized browser",
+ "login_notification_browser_recognition_description": "Send an email unless the browser is recognized by its cookie or a matching IP address and User-Agent in the user's sign-in history.",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts
index 47257921..2f1937e3 100644
--- a/frontend/src/lib/types/application-configuration.type.ts
+++ b/frontend/src/lib/types/application-configuration.type.ts
@@ -31,7 +31,7 @@ export type AllAppConfig = AppConfig & {
smtpPassword: string;
smtpTls: 'none' | 'starttls' | 'tls';
smtpSkipCertVerify: boolean;
- emailLoginNotificationEnabled: boolean;
+ emailLoginNotificationMode: 'disabled' | 'always' | 'ipAndUserAgent' | 'browserRecognition';
emailApiKeyExpirationEnabled: boolean;
// LDAP
ldapUrl: string;
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte
index 532b9682..fe0e337a 100644
--- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte
+++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte
@@ -30,6 +30,22 @@
tls: 'TLS'
};
+ const notificationOptions = $derived({
+ disabled: { label: m.never(), description: m.login_notification_disabled_description() },
+ always: {
+ label: m.login_notification_always(),
+ description: m.login_notification_always_description()
+ },
+ ipAndUserAgent: {
+ label: m.login_notification_ip_and_user_agent(),
+ description: m.login_notification_ip_and_user_agent_description()
+ },
+ browserRecognition: {
+ label: m.login_notification_browser_recognition(),
+ description: m.login_notification_browser_recognition_description()
+ }
+ });
+
let isSendingTestEmail = $state(false);
const formSchema = z
@@ -49,7 +65,12 @@
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
emailVerificationEnabled: z.boolean(),
emailOneTimeAccessAsAdminEnabled: z.boolean(),
- emailLoginNotificationEnabled: z.boolean(),
+ emailLoginNotificationMode: z.enum([
+ 'disabled',
+ 'always',
+ 'ipAndUserAgent',
+ 'browserRecognition'
+ ]),
emailApiKeyExpirationEnabled: z.boolean()
})
.superRefine((data, ctx) => {
@@ -63,7 +84,6 @@
'emailOneTimeAccessAsUnauthenticatedEnabled',
'emailVerificationEnabled',
'emailOneTimeAccessAsAdminEnabled',
- 'emailLoginNotificationEnabled',
'emailApiKeyExpirationEnabled'
];
@@ -84,7 +104,8 @@
const anyProvided = requiredSmtpFields.some((f) => !!data[f]);
requireFieldsWhen(anyProvided, m.smtp_field_required_when_other_provided());
- const emailEnabled = emailFields.some((f) => data[f]);
+ const emailEnabled =
+ data.emailLoginNotificationMode !== 'disabled' || emailFields.some((f) => data[f]);
requireFieldsWhen(emailEnabled, m.smtp_field_required_when_email_enabled());
});
@@ -194,12 +215,37 @@
{m.enabled_emails()}
-
+
+
+ {m.email_login_notification()}
+ {m.email_login_notification_description()}
+
+
+ ($inputs.emailLoginNotificationMode.value =
+ value as AllAppConfig['emailLoginNotificationMode'])}
+ >
+
+ {notificationOptions[$inputs.emailLoginNotificationMode.value].label}
+
+
+
+ {#each Object.entries(notificationOptions) as [value, option] (value)}
+
+
+ {option.label}
+ {option.description}
+
+
+ {/each}
+
+
+
+
{
await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByLabel('Email Login Notification').click();
+ await page.getByRole('option', { name: /^Unrecognized browser/ }).click();
await page.getByLabel('Email Login Code Requested by User').click();
await page.getByLabel('Email Login Code from Admin').click();
await page.getByLabel('API Key Expiration').click();
@@ -259,7 +260,7 @@ test('Update email configuration', async ({ page }) => {
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 expect(page.getByLabel('Email Login Notification')).toBeChecked();
+ await expect(page.getByLabel('Email Login Notification')).toContainText('Unrecognized browser');
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
await expect(page.getByLabel('API Key Expiration')).toBeChecked();