Auth: Store session login_at as NULL when no client IP is set #5707

This commit is contained in:
Michael Mayer 2026-06-29 23:28:12 +00:00
parent b2116154d3
commit 1b572c0da7
5 changed files with 70 additions and 4 deletions

View file

@ -66,7 +66,7 @@ type Session struct {
data *SessionData `gorm:"-" yaml:"-"` data *SessionData `gorm:"-" yaml:"-"`
RefID string `gorm:"type:VARBINARY(16);default:'';" json:"ID" yaml:"-"` RefID string `gorm:"type:VARBINARY(16);default:'';" json:"ID" yaml:"-"`
LoginIP string `gorm:"size:64;column:login_ip" json:"LoginIP" yaml:"-"` LoginIP string `gorm:"size:64;column:login_ip" json:"LoginIP" yaml:"-"`
LoginAt time.Time `json:"LoginAt" yaml:"-"` LoginAt *time.Time `json:"LoginAt" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt"` CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt"` UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt"`
Status int `gorm:"-" json:"Status" yaml:"-"` Status int `gorm:"-" json:"Status" yaml:"-"`
@ -1053,7 +1053,7 @@ func (m *Session) SetClientIP(ip string) {
if m.LoginIP == "" { if m.LoginIP == "" {
m.LoginIP = ip m.LoginIP = ip
m.LoginAt = Now() m.LoginAt = TimeStamp()
} }
return return

View file

@ -95,4 +95,47 @@ func TestAddClientSession(t *testing.T) {
t.Logf("sess: %#v", sess) t.Logf("sess: %#v", sess)
}) })
t.Run("NoClientIPPersistsNullLoginAt", func(t *testing.T) {
// Sessions created without a client IP (e.g. via "photoprism auth add")
// must persist login_at as SQL NULL, not a zero "0000-00-00" datetime
// that strict MySQL/MariaDB sql_modes reject with Error 1292.
// A value-typed LoginAt leaves Go's zero time.Time, which go-sql-driver/mysql serializes as the literal 0000-00-00.
sess, err := AddClientSession("", unix.Day, "metrics", authn.GrantCLI, nil)
assert.NoError(t, err)
if sess == nil {
t.Fatal("session must not be nil")
}
assert.Nil(t, sess.LoginAt)
var nullCount int
if err = UnscopedDb().Table("auth_sessions").
Where("id = ? AND login_at IS NULL", sess.ID).
Count(&nullCount).Error; err != nil {
t.Fatal(err)
}
assert.Equal(t, 1, nullCount, "login_at must be NULL when no client IP was set")
})
t.Run("ClientIPSetsLoginAt", func(t *testing.T) {
sess := NewClientSession("", unix.Day, "metrics", authn.GrantClientCredentials, nil)
sess.SetClientIP("203.0.113.7")
assert.NoError(t, sess.Create())
if sess.LoginAt == nil {
t.Fatal("login_at must be set when a client IP is present")
}
var nullCount int
if err := UnscopedDb().Table("auth_sessions").
Where("id = ? AND login_at IS NULL", sess.ID).
Count(&nullCount).Error; err != nil {
t.Fatal(err)
}
assert.Equal(t, 0, nullCount, "login_at must not be NULL when a client IP was set")
})
} }

View file

@ -129,7 +129,7 @@ var SessionFixtures = SessionMap{
user: UserFixtures.Pointer("bob"), user: UserFixtures.Pointer("bob"),
UserUID: UserFixtures.Pointer("bob").UserUID, UserUID: UserFixtures.Pointer("bob").UserUID,
UserName: UserFixtures.Pointer("bob").UserName, UserName: UserFixtures.Pointer("bob").UserName,
LoginAt: Now().Add(-24), LoginAt: TimePointer(Now().Add(-24)),
CreatedAt: Now().Add(-24), CreatedAt: Now().Add(-24),
UpdatedAt: Now().Add(-24), UpdatedAt: Now().Add(-24),
}, },
@ -184,7 +184,7 @@ var SessionFixtures = SessionMap{
user: UserFixtures.Pointer("friend"), user: UserFixtures.Pointer("friend"),
UserUID: UserFixtures.Pointer("friend").UserUID, UserUID: UserFixtures.Pointer("friend").UserUID,
UserName: UserFixtures.Pointer("friend").UserName, UserName: UserFixtures.Pointer("friend").UserName,
LoginAt: Now().Add(-12), LoginAt: TimePointer(Now().Add(-12)),
CreatedAt: Now().Add(-20), CreatedAt: Now().Add(-20),
UpdatedAt: Now().Add(-12), UpdatedAt: Now().Add(-12),
}, },

View file

@ -23,6 +23,15 @@ func TimeStamp() *time.Time {
return &t return &t
} }
// TimePointer returns a reference to the specified time, or nil if it is the zero value.
func TimePointer(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
// Time returns a reference to the specified time in UTC, truncated to seconds, or nil if it is invalid. // Time returns a reference to the specified time in UTC, truncated to seconds, or nil if it is invalid.
func Time(s string) *time.Time { func Time(s string) *time.Time {
if t, err := time.Parse(time.RFC3339, s); err == nil { if t, err := time.Parse(time.RFC3339, s); err == nil {

View file

@ -137,6 +137,20 @@ func TestTimeStamp(t *testing.T) {
} }
} }
func TestTimePointer(t *testing.T) {
t.Run("NonZero", func(t *testing.T) {
now := Now()
result := TimePointer(now)
if result == nil {
t.Fatal("result must not be nil")
}
assert.Equal(t, now, *result)
})
t.Run("Zero", func(t *testing.T) {
assert.Nil(t, TimePointer(time.Time{}))
})
}
func TestTime(t *testing.T) { func TestTime(t *testing.T) {
result := Time("2022-01-02T13:04:05+01:00") result := Time("2022-01-02T13:04:05+01:00")