mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-30 04:29:05 +01:00
feat: filter audit logs by date and flag sign-ins from new browsers
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user