feat: filter audit logs by date and flag sign-ins from new browsers

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 95cfae33e6
commit 80b12b910a
12 changed files with 393 additions and 58 deletions
@@ -0,0 +1,62 @@
package auditlogs
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestListAuditLogsByDateRange(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{})
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: "alice"}, Username: "alice"}).Error)
day := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
for i, at := range []time.Time{day.Add(-time.Second), day, day.Add(12 * time.Hour), day.Add(24 * time.Hour)} {
id := string(rune('a' + i))
require.NoError(t, db.Create(&AuditLog{Base: model.Base{ID: id}, UserID: "alice", Event: EventSignIn}).Error)
// Creating always stamps the current time, so the test time is set afterwards
require.NoError(t, db.Model(&AuditLog{}).Where("id = ?", id).Update("created_at", datatype.DateTime(at)).Error)
}
ids := func(logs []AuditLog) []string {
out := make([]string, len(logs))
for i, l := range logs {
out[i] = l.ID
}
return out
}
oneDay := DateRange{From: day, To: day.Add(24 * time.Hour)}
all, _, err := s.ListAllAuditLogs(t.Context(), oneDay, utils.ListRequestOptions{})
require.NoError(t, err)
require.ElementsMatch(t, []string{"b", "c"}, ids(all))
own, _, err := s.ListAuditLogsForUser(t.Context(), "alice", DateRange{From: day.Add(time.Hour)}, utils.ListRequestOptions{})
require.NoError(t, err)
require.ElementsMatch(t, []string{"c", "d"}, ids(own))
}
func TestParseDateRange(t *testing.T) {
parse := func(query string) (DateRange, error) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(t.Context(), "GET", "/api/audit-logs?"+query, nil)
return parseDateRange(c)
}
r, err := parse("from=2026-10-10T00:00:00%2B02:00")
require.NoError(t, err)
require.True(t, r.From.Equal(time.Date(2026, 10, 9, 22, 0, 0, 0, time.UTC)))
require.True(t, r.To.IsZero())
_, err = parse("to=yesterday")
require.Error(t, err)
}
+33 -2
View File
@@ -2,9 +2,11 @@ package auditlogs
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -18,6 +20,23 @@ func newHandler(service *service) *handler {
return &handler{service: service}
}
// parseDateRange reads the optional from and to query parameters, RFC 3339 times that bound the list by creation time
func parseDateRange(c *gin.Context) (DateRange, error) {
var r DateRange
for name, bound := range map[string]*time.Time{"from": &r.From, "to": &r.To} {
value := c.Query(name)
if value == "" {
continue
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return r, apperror.InvalidField(name, "invalid", "must be an RFC 3339 time")
}
*bound = t
}
return r, nil
}
// listAuditLogsForUserHandler godoc
// @Summary List audit logs
// @Description Get a paginated list of audit logs for the current user
@@ -26,16 +45,22 @@ func newHandler(service *service) *handler {
// @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")
// @Param from query string false "Only entries created at or after this RFC 3339 time"
// @Param to query string false "Only entries created before this RFC 3339 time"
// @Success 200 {object} dto.Paginated[auditLogDto]
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/audit-logs [get]
func (h *handler) listAuditLogsForUserHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
dateRange, err := parseDateRange(c)
if err != nil {
return err
}
userID := authz.PrincipalFrom(c).UserID
// Fetch audit logs for the user
logs, pagination, err := h.service.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
logs, pagination, err := h.service.ListAuditLogsForUser(c.Request.Context(), userID, dateRange, listRequestOptions)
if err != nil {
return err
}
@@ -69,13 +94,19 @@ func (h *handler) 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")
// @Param from query string false "Only entries created at or after this RFC 3339 time"
// @Param to query string false "Only entries created before this RFC 3339 time"
// @Success 200 {object} dto.Paginated[auditLogDto]
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/audit-logs/all [get]
func (h *handler) listAllAuditLogsHandler(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
dateRange, err := parseDateRange(c)
if err != nil {
return err
}
logs, pagination, err := h.service.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
logs, pagination, err := h.service.ListAllAuditLogs(c.Request.Context(), dateRange, listRequestOptions)
if err != nil {
return err
}
+24 -6
View File
@@ -4,12 +4,14 @@ import (
"context"
"fmt"
"log/slog"
"time"
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"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -66,13 +68,29 @@ func (s *service) Create(ctx context.Context, event Event, ipAddress, userAgent,
return auditLog, true
}
// DateRange bounds a list by creation time, from inclusive and to exclusive; a zero bound is open
type DateRange struct {
From time.Time
To time.Time
}
func (r DateRange) apply(query *gorm.DB) *gorm.DB {
if !r.From.IsZero() {
query = query.Where("created_at >= ?", datatype.DateTime(r.From))
}
if !r.To.IsZero() {
query = query.Where("created_at < ?", datatype.DateTime(r.To))
}
return query
}
// 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) {
func (s *service) ListAuditLogsForUser(ctx context.Context, userID string, dateRange DateRange, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) {
var logs []AuditLog
query := s.db.
query := dateRange.apply(s.db.
WithContext(ctx).
Model(&AuditLog{}).
Where("user_id = ?", userID)
Where("user_id = ?", userID))
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
return logs, pagination, err
@@ -83,13 +101,13 @@ func (s *service) DeviceStringFromUserAgent(userAgent string) string {
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}
func (s *service) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) {
func (s *service) ListAllAuditLogs(ctx context.Context, dateRange DateRange, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) {
var logs []AuditLog
query := s.db.
query := dateRange.apply(s.db.
WithContext(ctx).
Preload("User").
Model(&AuditLog{})
Model(&AuditLog{}))
if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
dialect := s.db.Name()
+40 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
userAgentParser "github.com/mileusna/useragent"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
@@ -13,7 +14,11 @@ import (
// 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)
data := Data{}
if s.isNewBrowser(ctx, tx, userID, userAgent) {
data["newBrowser"] = "true"
}
entry, created := s.Create(ctx, event, ipAddress, userAgent, userID, data, tx)
result := SignInResult{AuditLog: entry, Created: created}
if !created {
return result
@@ -43,7 +48,7 @@ func (s *service) CreateSignIn(ctx context.Context, event Event, ipAddress, user
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})
Where("event IN ?", signInEvents)
if ipAddress == "" {
query = query.Where("ip_address IS NULL")
} else {
@@ -57,6 +62,39 @@ func (s *service) CreateSignIn(ctx context.Context, event Event, ipAddress, user
return result
}
var signInEvents = []Event{EventSignIn, EventOneTimeAccessTokenSignIn, EventRemoteSignIn}
// isNewBrowser reports whether the user has signed in before, but never with this browser on this operating system
// It ignores versions, so a browser update is not a new browser, and it never flags an account's first sign-in
func (s *service) isNewBrowser(ctx context.Context, tx *gorm.DB, userID, userAgent string) bool {
var agents []string
err := tx.WithContext(ctx).Model(&AuditLog{}).
Where("user_id = ? AND event IN ?", userID, signInEvents).
Distinct().
Pluck("user_agent", &agents).
Error
if err != nil {
slog.ErrorContext(ctx, "Failed to check sign-in browsers", slog.Any("error", err))
return false
}
if len(agents) == 0 {
return false
}
family := browserFamily(userAgent)
for _, agent := range agents {
if browserFamily(agent) == family {
return false
}
}
return true
}
func browserFamily(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
return ua.Name + "|" + ua.OS
}
// 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 {
+34
View File
@@ -228,3 +228,37 @@ func TestSignInNotificationModes(t *testing.T) {
}
}
}
func TestSignInFlagsNewBrowser(t *testing.T) {
const firefoxOld = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:130.0) Gecko/20100101 Firefox/130.0"
const firefoxNew = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:144.0) Gecko/20100101 Firefox/144.0"
const chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
for _, tt := range []struct {
name string
previous []AuditLog
agent string
want bool
}{
{name: "first sign-in of the account", agent: chrome},
{name: "updated version of a known browser", previous: []AuditLog{{Event: EventSignIn, UserAgent: firefoxOld}}, agent: firefoxNew},
{name: "another browser", previous: []AuditLog{{Event: EventSignIn, UserAgent: firefoxOld}}, agent: chrome, want: true},
{name: "login code history counts", previous: []AuditLog{{Event: EventOneTimeAccessTokenSignIn, UserAgent: chrome}}, agent: chrome},
{name: "events that are not sign-ins don't count", previous: []AuditLog{{Event: EventPasskeyAdded, UserAgent: firefoxOld}}, agent: chrome},
} {
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)
for _, entry := range tt.previous {
_, created := s.Create(t.Context(), entry.Event, "192.0.2.1", entry.UserAgent, user.ID, Data{}, db)
require.True(t, created)
}
result := s.CreateSignIn(t.Context(), EventSignIn, "192.0.2.1", tt.agent, user.ID, "", db, appconfig.LoginNotificationDisabled)
require.True(t, result.Created)
require.Equal(t, tt.want, result.AuditLog.Data["newBrowser"] == "true")
})
}
}