diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 2498050f5d..909e3b66dd 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2473,6 +2473,20 @@ LEVEL = Info ;SCHEDULE = @every 168h ;OLDER_THAN = 8760h +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Cleanup expired user session records from the database +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[cron.cleanup_user_sessions] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;ENABLED = false +;RUN_AT_START = false +;NO_SUCCESS_NOTICE = false +;SCHEDULE = @every 24h +;; Retention period for logged-out sessions; abandoned sessions use this plus SESSION_LIFE_TIME +;OLDER_THAN = 720h + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Garbage collect LFS pointers in repositories diff --git a/models/auth/user_session.go b/models/auth/user_session.go new file mode 100644 index 0000000000..8179e8e58e --- /dev/null +++ b/models/auth/user_session.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ErrUserSessionNotExist is returned when a user session does not exist +type ErrUserSessionNotExist struct { + ID string +} + +func (err ErrUserSessionNotExist) Error() string { + return fmt.Sprintf("user session does not exist [id: %s]", err.ID) +} + +func (err ErrUserSessionNotExist) Unwrap() error { + return util.ErrNotExist +} + +// IsErrUserSessionNotExist checks if an error is ErrUserSessionNotExist +func IsErrUserSessionNotExist(err error) bool { + _, ok := err.(ErrUserSessionNotExist) + return ok +} + +// UserSession represents a tracked user session with metadata +type UserSession struct { + ID string `xorm:"pk VARCHAR(64)"` + UserID int64 `xorm:"INDEX NOT NULL"` + LoginIP string `xorm:"VARCHAR(45)"` + LastIP string `xorm:"VARCHAR(45)"` + PrevIP string `xorm:"VARCHAR(45)"` + UserAgent string `xorm:"TEXT"` + LoginMethod string `xorm:"VARCHAR(64)"` + AuthTokenID string `xorm:"VARCHAR(64)"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"` + LastAccessUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"` + LogoutUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL DEFAULT 0"` +} + +func init() { + db.RegisterModel(new(UserSession)) +} + +// CreateUserSession inserts a new user session record +func CreateUserSession(ctx context.Context, session *UserSession) error { + return db.Insert(ctx, session) +} + +// GetUserSessionByID returns a single session by its ID +func GetUserSessionByID(ctx context.Context, id string) (*UserSession, error) { + sess, has, err := db.Get[UserSession](ctx, builder.Eq{"id": id}) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserSessionNotExist{ID: id} + } + return sess, nil +} + +// GetUserSessionsByUserID returns all sessions for a user, ordered by creation time descending +func GetUserSessionsByUserID(ctx context.Context, userID int64) ([]*UserSession, error) { + sessions := make([]*UserSession, 0, 8) + return sessions, db.GetEngine(ctx).Where("user_id = ?", userID). + Desc("created_unix").Find(&sessions) +} + +// CountUserSessionsByUserID returns (total, active) session counts for a user +// without materializing the rows. +func CountUserSessionsByUserID(ctx context.Context, userID int64) (total, active int64, err error) { + e := db.GetEngine(ctx) + total, err = e.Where("user_id = ?", userID).Count(new(UserSession)) + if err != nil { + return 0, 0, err + } + active, err = e.Where("user_id = ? AND logout_unix = 0", userID).Count(new(UserSession)) + if err != nil { + return 0, 0, err + } + return total, active, nil +} + +// InvalidateUserSession marks a session as logged out +func InvalidateUserSession(ctx context.Context, sessionID string) error { + _, err := db.GetEngine(ctx).Where("id = ? AND logout_unix = 0", sessionID). + Cols("logout_unix"). + Update(&UserSession{LogoutUnix: timeutil.TimeStampNow()}) + return err +} + +// InvalidateAllUserSessions marks all active sessions for a user as logged out, +// optionally excluding a specific session +func InvalidateAllUserSessions(ctx context.Context, userID int64, exceptSessionID string) error { + sess := db.GetEngine(ctx).Where("user_id = ? AND logout_unix = 0", userID) + if exceptSessionID != "" { + sess = sess.And("id != ?", exceptSessionID) + } + _, err := sess.Cols("logout_unix").Update(&UserSession{LogoutUnix: timeutil.TimeStampNow()}) + return err +} + +// UpdateSessionActivity updates the last access time and IP shift logic +// using a single UPDATE statement with no prior SELECT. +// Only updates sessions that are still active (not yet logged out). +func UpdateSessionActivity(ctx context.Context, sessionID, currentIP string) error { + now := int64(timeutil.TimeStampNow()) + if currentIP == "" { + _, err := db.GetEngine(ctx).Exec( + "UPDATE user_session SET last_access_unix = ? WHERE id = ? AND logout_unix = 0", + now, sessionID, + ) + return err + } + _, err := db.GetEngine(ctx).Exec( + "UPDATE user_session SET last_access_unix = ?,"+ + " prev_ip = CASE WHEN last_ip != ? AND last_ip != '' THEN last_ip ELSE prev_ip END,"+ + " last_ip = ? WHERE id = ? AND logout_unix = 0", + now, currentIP, currentIP, sessionID, + ) + return err +} + +// CleanupExpiredUserSessions removes old session records based on retention policy. +// It deletes: +// - Sessions that were logged out more than retention ago +// - Abandoned sessions (never logged out) whose last activity is older than maxLifetime + retention +func CleanupExpiredUserSessions(ctx context.Context, retention, maxLifetime time.Duration) error { + now := int64(timeutil.TimeStampNow()) + logoutCutoff := now - int64(retention.Seconds()) + abandonedCutoff := now - int64(maxLifetime.Seconds()) - int64(retention.Seconds()) + + _, err := db.GetEngine(ctx).Where( + builder.Or( + builder.And(builder.Gt{"logout_unix": 0}, builder.Lt{"logout_unix": logoutCutoff}), + builder.And(builder.Eq{"logout_unix": 0}, builder.Lt{"last_access_unix": abandonedCutoff}), + ), + ).Delete(&UserSession{}) + return err +} + +// DeleteUserSessionsByUserID removes all session records for a user (used on user deletion) +func DeleteUserSessionsByUserID(ctx context.Context, userID int64) error { + _, err := db.GetEngine(ctx).Where("user_id = ?", userID).Delete(&UserSession{}) + return err +} diff --git a/models/auth/user_session_test.go b/models/auth/user_session_test.go new file mode 100644 index 0000000000..755e381cdc --- /dev/null +++ b/models/auth/user_session_test.go @@ -0,0 +1,255 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth_test + +import ( + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateUserSession(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + sess := &auth_model.UserSession{ + ID: "test-session-create", + UserID: 1, + LoginIP: "192.168.1.1", + LastIP: "192.168.1.1", + UserAgent: "Mozilla/5.0 Test", + LoginMethod: "form", + } + require.NoError(t, auth_model.CreateUserSession(t.Context(), sess)) + unittest.AssertExistsAndLoadBean(t, &auth_model.UserSession{ID: "test-session-create"}) +} + +func TestGetUserSessionByID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + sess := &auth_model.UserSession{ + ID: "test-session-get", + UserID: 2, + LoginIP: "10.0.0.1", + LastIP: "10.0.0.1", + UserAgent: "TestAgent", + LoginMethod: "oauth2", + } + require.NoError(t, auth_model.CreateUserSession(t.Context(), sess)) + + got, err := auth_model.GetUserSessionByID(t.Context(), "test-session-get") + require.NoError(t, err) + assert.Equal(t, int64(2), got.UserID) + assert.Equal(t, "10.0.0.1", got.LoginIP) + assert.Equal(t, "TestAgent", got.UserAgent) + + _, err = auth_model.GetUserSessionByID(t.Context(), "nonexistent") + assert.True(t, auth_model.IsErrUserSessionNotExist(err)) +} + +func TestGetUserSessionsByUserID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, id := range []string{"sess-list-1", "sess-list-2", "sess-list-3"} { + require.NoError(t, auth_model.CreateUserSession(t.Context(), &auth_model.UserSession{ + ID: id, + UserID: 5, + LoginIP: "127.0.0.1", + LastIP: "127.0.0.1", + })) + } + + sessions, err := auth_model.GetUserSessionsByUserID(t.Context(), 5) + require.NoError(t, err) + assert.Len(t, sessions, 3) + + sessions, err = auth_model.GetUserSessionsByUserID(t.Context(), 99999) + require.NoError(t, err) + assert.Empty(t, sessions) +} + +func TestInvalidateUserSession(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + require.NoError(t, auth_model.CreateUserSession(t.Context(), &auth_model.UserSession{ + ID: "sess-invalidate", + UserID: 1, + })) + + require.NoError(t, auth_model.InvalidateUserSession(t.Context(), "sess-invalidate")) + + got, err := auth_model.GetUserSessionByID(t.Context(), "sess-invalidate") + require.NoError(t, err) + assert.NotZero(t, got.LogoutUnix, "LogoutUnix should be set after invalidation") +} + +func TestInvalidateAllUserSessions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, id := range []string{"sess-all-1", "sess-all-2", "sess-all-3"} { + require.NoError(t, auth_model.CreateUserSession(t.Context(), &auth_model.UserSession{ + ID: id, + UserID: 3, + })) + } + + // Invalidate all except sess-all-2 + require.NoError(t, auth_model.InvalidateAllUserSessions(t.Context(), 3, "sess-all-2")) + + kept, err := auth_model.GetUserSessionByID(t.Context(), "sess-all-2") + require.NoError(t, err) + assert.Zero(t, kept.LogoutUnix, "excluded session should not be invalidated") + + for _, id := range []string{"sess-all-1", "sess-all-3"} { + got, err := auth_model.GetUserSessionByID(t.Context(), id) + require.NoError(t, err) + assert.NotZero(t, got.LogoutUnix, "session %s should be invalidated", id) + } +} + +func TestUpdateSessionActivity(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + require.NoError(t, auth_model.CreateUserSession(t.Context(), &auth_model.UserSession{ + ID: "sess-activity", + UserID: 1, + LastIP: "10.0.0.1", + })) + + // Update with same IP — only LastAccessUnix should change + require.NoError(t, auth_model.UpdateSessionActivity(t.Context(), "sess-activity", "10.0.0.1")) + got, err := auth_model.GetUserSessionByID(t.Context(), "sess-activity") + require.NoError(t, err) + assert.Equal(t, "10.0.0.1", got.LastIP) + assert.Empty(t, got.PrevIP) + + // Update with new IP — PrevIP should shift + require.NoError(t, auth_model.UpdateSessionActivity(t.Context(), "sess-activity", "172.16.0.1")) + got, err = auth_model.GetUserSessionByID(t.Context(), "sess-activity") + require.NoError(t, err) + assert.Equal(t, "172.16.0.1", got.LastIP) + assert.Equal(t, "10.0.0.1", got.PrevIP) + + // Updating a nonexistent session should not error + require.NoError(t, auth_model.UpdateSessionActivity(t.Context(), "nonexistent", "10.0.0.1")) + + // Updating an already-logged-out session should be a no-op + require.NoError(t, auth_model.InvalidateUserSession(t.Context(), "sess-activity")) + beforeUpdate, err := auth_model.GetUserSessionByID(t.Context(), "sess-activity") + require.NoError(t, err) + require.NoError(t, auth_model.UpdateSessionActivity(t.Context(), "sess-activity", "192.168.1.1")) + afterUpdate, err := auth_model.GetUserSessionByID(t.Context(), "sess-activity") + require.NoError(t, err) + assert.Equal(t, beforeUpdate.LastIP, afterUpdate.LastIP, "logged-out session IP should not change") + assert.Equal(t, beforeUpdate.LastAccessUnix, afterUpdate.LastAccessUnix, "logged-out session timestamp should not change") +} + +func TestDeleteUserSessionsByUserID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, id := range []string{"sess-del-1", "sess-del-2"} { + require.NoError(t, auth_model.CreateUserSession(t.Context(), &auth_model.UserSession{ + ID: id, + UserID: 4, + })) + } + + require.NoError(t, auth_model.DeleteUserSessionsByUserID(t.Context(), 4)) + + sessions, err := auth_model.GetUserSessionsByUserID(t.Context(), 4) + require.NoError(t, err) + assert.Empty(t, sessions) +} + +func TestCleanupExpiredUserSessions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + now := timeutil.TimeStampNow() + + // Active session — should survive + require.NoError(t, auth_model.CreateUserSession(t.Context(), &auth_model.UserSession{ + ID: "sess-cleanup-active", + UserID: 1, + LastAccessUnix: now, + })) + + // Old logged-out session — should be cleaned up. + // Use raw engine insert to bypass the "created" auto-fill. + _, err := db.GetEngine(t.Context()).Insert(&auth_model.UserSession{ + ID: "sess-cleanup-old", + UserID: 1, + LogoutUnix: timeutil.TimeStamp(int64(now) - 86400*60), + LastAccessUnix: timeutil.TimeStamp(int64(now) - 86400*60), + CreatedUnix: timeutil.TimeStamp(int64(now) - 86400*60), + }) + require.NoError(t, err) + + retention := 30 * 24 * time.Hour // 30 days + maxLifetime := 24 * time.Hour // 1 day + require.NoError(t, auth_model.CleanupExpiredUserSessions(t.Context(), retention, maxLifetime)) + + // Active session should still exist + _, err = auth_model.GetUserSessionByID(t.Context(), "sess-cleanup-active") + require.NoError(t, err) + + // Old session should be gone + _, err = auth_model.GetUserSessionByID(t.Context(), "sess-cleanup-old") + assert.True(t, auth_model.IsErrUserSessionNotExist(err)) +} + +func TestCleanupExpiredUserSessionsAbandoned(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + now := timeutil.TimeStampNow() + retention := 30 * 24 * time.Hour // 30 days + maxLifetime := 24 * time.Hour // 1 day + + cutoff := int64(now) - int64(maxLifetime.Seconds()) - int64(retention.Seconds()) + + // Abandoned session clearly older than cutoff — should be cleaned up. + _, err := db.GetEngine(t.Context()).Insert(&auth_model.UserSession{ + ID: "sess-cleanup-abandoned-old", + UserID: 1, + LastAccessUnix: timeutil.TimeStamp(cutoff - 1), + CreatedUnix: timeutil.TimeStamp(cutoff - 1), + }) + require.NoError(t, err) + + // Abandoned session exactly at cutoff — should be preserved (strict < comparison). + _, err = db.GetEngine(t.Context()).Insert(&auth_model.UserSession{ + ID: "sess-cleanup-abandoned-boundary", + UserID: 1, + LastAccessUnix: timeutil.TimeStamp(cutoff), + CreatedUnix: timeutil.TimeStamp(cutoff), + }) + require.NoError(t, err) + + // Abandoned session newer than cutoff — should be preserved. + _, err = db.GetEngine(t.Context()).Insert(&auth_model.UserSession{ + ID: "sess-cleanup-abandoned-new", + UserID: 1, + LastAccessUnix: timeutil.TimeStamp(cutoff + 1), + CreatedUnix: timeutil.TimeStamp(cutoff + 1), + }) + require.NoError(t, err) + + require.NoError(t, auth_model.CleanupExpiredUserSessions(t.Context(), retention, maxLifetime)) + + // Clearly old abandoned session should be gone. + _, err = auth_model.GetUserSessionByID(t.Context(), "sess-cleanup-abandoned-old") + assert.True(t, auth_model.IsErrUserSessionNotExist(err)) + + // Boundary and newer abandoned sessions should still exist. + _, err = auth_model.GetUserSessionByID(t.Context(), "sess-cleanup-abandoned-boundary") + require.NoError(t, err) + + _, err = auth_model.GetUserSessionByID(t.Context(), "sess-cleanup-abandoned-new") + require.NoError(t, err) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..83f42821fb 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Add database-backed user session tracking", v1_27.AddUserSessionTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..3292213ba3 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import "xorm.io/xorm" + +func AddUserSessionTable(x *xorm.Engine) error { + type UserSession struct { + ID string `xorm:"pk VARCHAR(64)"` + UserID int64 `xorm:"INDEX NOT NULL"` + LoginIP string `xorm:"VARCHAR(45)"` + LastIP string `xorm:"VARCHAR(45)"` + PrevIP string `xorm:"VARCHAR(45)"` + UserAgent string `xorm:"TEXT"` + LoginMethod string `xorm:"VARCHAR(64)"` + AuthTokenID string `xorm:"VARCHAR(64)"` + CreatedUnix int64 `xorm:"INDEX NOT NULL"` + LastAccessUnix int64 `xorm:"INDEX NOT NULL"` + LogoutUnix int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(UserSession)) + return err +} diff --git a/modules/session/store.go b/modules/session/store.go index 0217ed97ac..f7bd82ef66 100644 --- a/modules/session/store.go +++ b/modules/session/store.go @@ -22,6 +22,24 @@ type mockStoreContextKeyStruct struct{} var MockStoreContextKey = mockStoreContextKeyStruct{} +// globalProvider holds a reference to the active VirtualSessionProvider +// so we can destroy sessions by ID without needing the http request/response. +var globalProvider *VirtualSessionProvider + +// SetGlobalProvider stores the active session provider for use by DestroySessionByID. +func SetGlobalProvider(p *VirtualSessionProvider) { + globalProvider = p +} + +// DestroySessionByID destroys a session by its ID through the underlying provider. +// This works regardless of which session backend is configured (file, db, redis, etc). +func DestroySessionByID(sid string) error { + if globalProvider == nil { + return nil + } + return globalProvider.Destroy(sid) +} + // RegenerateSession regenerates the underlying session and returns the new store func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, error) { for _, f := range BeforeRegenerateSession { diff --git a/modules/session/virtual.go b/modules/session/virtual.go index 597b9e55c1..642382466f 100644 --- a/modules/session/virtual.go +++ b/modules/session/virtual.go @@ -6,6 +6,7 @@ package session import ( "fmt" "sync" + "time" "code.gitea.io/gitea/modules/json" @@ -16,10 +17,27 @@ import ( postgres "gitea.com/go-chi/session/postgres" ) +// tombstoneTTL is how long a destroyed session ID is remembered so that +// concurrent requests releasing after destruction cannot recreate the session +// or be re-authenticated. +const tombstoneTTL = 10 * time.Minute + // VirtualSessionProvider represents a shadowed session provider implementation. +// It wraps a real session provider and adds "tombstone" tracking for destroyed +// sessions so that concurrent requests (e.g. EventSource) cannot accidentally +// recreate a session file by calling Release() after the file was deleted. type VirtualSessionProvider struct { lock sync.RWMutex provider session.Provider + + // destroyedSIDs tracks recently destroyed session IDs. + // When a session is destroyed, concurrent requests that already hold + // a FileStore reference may call Release() and recreate the file. + // By tracking destroyed IDs, Read() returns an inert VirtualStore + // that prevents re-authentication and avoids recreating the file. + // Entries self-expire after tombstoneTTL via time.AfterFunc so the map + // stays bounded regardless of session GC interval. + destroyedSIDs sync.Map // sid -> time.Time } // Init initializes the cookie session provider with the given config. @@ -52,11 +70,22 @@ func (o *VirtualSessionProvider) Init(gcLifetime int64, config string) error { default: return fmt.Errorf("VirtualSessionProvider: Unknown Provider: %s", opts.Provider) } + SetGlobalProvider(o) return o.provider.Init(gcLifetime, opts.ProviderConfig) } // Read returns raw session store by session ID. func (o *VirtualSessionProvider) Read(sid string) (session.RawStore, error) { + // Check tombstone first: if this session was recently destroyed, return + // an inert store regardless of whether the file was recreated by a + // concurrent request's Release(). Also re-delete the file to clean up. + if _, destroyed := o.destroyedSIDs.Load(sid); destroyed { + o.lock.Lock() + _ = o.provider.Destroy(sid) + o.lock.Unlock() + return NewInertVirtualStore(sid), nil + } + o.lock.RLock() defer o.lock.RUnlock() if exist, err := o.provider.Exist(sid); err == nil && exist { @@ -77,6 +106,11 @@ func (o *VirtualSessionProvider) Exist(sid string) (bool, error) { func (o *VirtualSessionProvider) Destroy(sid string) error { o.lock.Lock() defer o.lock.Unlock() + o.destroyedSIDs.Store(sid, time.Now()) + // Self-expire the tombstone so the map stays bounded between session GCs. + time.AfterFunc(tombstoneTTL, func() { + o.destroyedSIDs.Delete(sid) + }) return o.provider.Destroy(sid) } @@ -96,7 +130,18 @@ func (o *VirtualSessionProvider) Count() (int, error) { // GC calls GC to clean expired sessions. func (o *VirtualSessionProvider) GC() { + o.lock.Lock() + defer o.lock.Unlock() + o.provider.GC() + + // Re-destroy any sessions that may have been recreated by concurrent + // requests releasing after destruction. Tombstones themselves expire + // via time.AfterFunc in Destroy() so no manual cleanup is needed here. + o.destroyedSIDs.Range(func(key, _ any) bool { + _ = o.provider.Destroy(key.(string)) + return true + }) } func init() { @@ -105,11 +150,12 @@ func init() { // VirtualStore represents a virtual session store implementation. type VirtualStore struct { - p *VirtualSessionProvider - sid string - lock sync.RWMutex - data map[any]any - released bool + p *VirtualSessionProvider + sid string + lock sync.RWMutex + data map[any]any + released bool + invalidated bool // true for destroyed sessions — all writes are no-ops } // NewVirtualStore creates and returns a virtual session store. @@ -121,8 +167,22 @@ func NewVirtualStore(p *VirtualSessionProvider, sid string, kv map[any]any) *Vir } } +// NewInertVirtualStore creates a VirtualStore for a destroyed (tombstoned) session. +// It silently ignores all Set and Release calls so that concurrent requests +// cannot inadvertently recreate the session file or store authentication data. +func NewInertVirtualStore(sid string) *VirtualStore { + return &VirtualStore{ + sid: sid, + data: make(map[any]any), + invalidated: true, + } +} + // Set sets value to given key in session. func (s *VirtualStore) Set(key, val any) error { + if s.invalidated { + return nil + } s.lock.Lock() defer s.lock.Unlock() @@ -154,6 +214,9 @@ func (s *VirtualStore) ID() string { // Release releases resource and save data to provider. func (s *VirtualStore) Release() error { + if s.invalidated { + return nil + } s.lock.Lock() defer s.lock.Unlock() // Now need to lock the provider diff --git a/modules/session/virtual_test.go b/modules/session/virtual_test.go new file mode 100644 index 0000000000..6599e91c2b --- /dev/null +++ b/modules/session/virtual_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package session + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVirtualStore_BasicOperations(t *testing.T) { + p := &VirtualSessionProvider{provider: &mockProvider{}} + store := NewVirtualStore(p, "test-sid", make(map[any]any)) + + assert.Equal(t, "test-sid", store.ID()) + + require.NoError(t, store.Set("uid", int64(42))) + assert.Equal(t, int64(42), store.Get("uid")) + + require.NoError(t, store.Delete("uid")) + assert.Nil(t, store.Get("uid")) +} + +func TestInertVirtualStore_IgnoresWrites(t *testing.T) { + store := NewInertVirtualStore("dead-sid") + + assert.Equal(t, "dead-sid", store.ID()) + + // Set should be silently ignored + require.NoError(t, store.Set("uid", int64(42))) + assert.Nil(t, store.Get("uid")) + + // Release should be a no-op + require.NoError(t, store.Release()) +} + +func TestVirtualSessionProvider_DestroyTombstone(t *testing.T) { + mp := &mockProvider{sessions: map[string]map[any]any{ + "sid-1": {"uid": int64(1)}, + }} + vsp := &VirtualSessionProvider{provider: mp} + + // Before destroy, Read returns data from the mock provider + store, err := vsp.Read("sid-1") + require.NoError(t, err) + assert.Equal(t, int64(1), store.Get("uid")) + + // Destroy the session + require.NoError(t, vsp.Destroy("sid-1")) + + // Simulate concurrent request recreating the session file: + // the mock provider now has the session again + mp.sessions["sid-1"] = map[any]any{"uid": int64(1)} + + // Read after destroy should return inert store due to tombstone + store, err = vsp.Read("sid-1") + require.NoError(t, err) + assert.Nil(t, store.Get("uid"), "tombstoned session should return empty store") + + // The inert store should ignore writes and releases + require.NoError(t, store.Set("uid", int64(99))) + assert.Nil(t, store.Get("uid")) + require.NoError(t, store.Release()) +} + +func TestVirtualSessionProvider_ReadNonExistent(t *testing.T) { + mp := &mockProvider{sessions: map[string]map[any]any{}} + vsp := &VirtualSessionProvider{provider: mp} + + // Read for a session that doesn't exist returns a VirtualStore + store, err := vsp.Read("no-such-sid") + require.NoError(t, err) + assert.Nil(t, store.Get("uid")) + assert.Equal(t, "no-such-sid", store.ID()) +} + +func TestVirtualSessionProvider_ExistAlwaysTrue(t *testing.T) { + vsp := &VirtualSessionProvider{provider: &mockProvider{}} + + exists, err := vsp.Exist("anything") + require.NoError(t, err) + assert.True(t, exists) +} + +func TestDestroySessionByID_NilProvider(t *testing.T) { + // Ensure DestroySessionByID doesn't panic when globalProvider is nil + old := globalProvider + globalProvider = nil + defer func() { globalProvider = old }() + + assert.NoError(t, DestroySessionByID("anything")) +} + +// mockProvider is a minimal in-memory session.Provider for testing +type mockProvider struct { + sessions map[string]map[any]any +} + +func (m *mockProvider) Init(_ int64, _ string) error { return nil } + +func (m *mockProvider) Read(sid string) (RawStore, error) { + if m.sessions == nil { + m.sessions = make(map[string]map[any]any) + } + data, ok := m.sessions[sid] + if !ok { + data = make(map[any]any) + m.sessions[sid] = data + } + return &mockStore{sid: sid, data: data}, nil +} + +func (m *mockProvider) Exist(sid string) (bool, error) { + if m.sessions == nil { + return false, nil + } + _, ok := m.sessions[sid] + return ok, nil +} + +func (m *mockProvider) Destroy(sid string) error { + delete(m.sessions, sid) + return nil +} + +func (m *mockProvider) Regenerate(oldsid, sid string) (RawStore, error) { + data := m.sessions[oldsid] + delete(m.sessions, oldsid) + if data == nil { + data = make(map[any]any) + } + m.sessions[sid] = data + return &mockStore{sid: sid, data: data}, nil +} + +func (m *mockProvider) Count() (int, error) { + return len(m.sessions), nil +} + +func (m *mockProvider) GC() {} + +// mockStore is a minimal in-memory RawStore for testing +type mockStore struct { + sid string + data map[any]any +} + +func (s *mockStore) Set(key, val any) error { s.data[key] = val; return nil } +func (s *mockStore) Get(key any) any { return s.data[key] } +func (s *mockStore) Delete(key any) error { delete(s.data, key); return nil } +func (s *mockStore) ID() string { return s.sid } +func (s *mockStore) Release() error { return nil } +func (s *mockStore) Flush() error { + for k := range s.data { + delete(s.data, k) + } + return nil +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..fd1c65f739 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -922,6 +922,23 @@ "settings.webauthn_delete_key_desc": "If you remove a security key, you can no longer sign in with it. Continue?", "settings.webauthn_key_loss_warning": "If you lose your security keys, you will lose access to your account.", "settings.webauthn_alternative_tip": "You may want to configure an additional authentication method.", + "settings.sessions": "Sessions", + "settings.sessions_desc": "These are the sessions associated with your account. Revoke any sessions that you do not recognize.", + "settings.sessions.manage": "Manage Sessions", + "settings.sessions.current": "Current", + "settings.sessions.ended": "Ended", + "settings.sessions.active": "Active", + "settings.sessions.login_ip": "Login IP", + "settings.sessions.last_ip": "Last IP", + "settings.sessions.prev_ip": "Previous IP", + "settings.sessions.created": "Created", + "settings.sessions.last_active": "Last active", + "settings.sessions.revoke": "Revoke", + "settings.sessions.revoke_all": "Revoke All Other Sessions", + "settings.sessions.revoke_success": "Session has been revoked.", + "settings.sessions.revoke_all_success": "All other sessions have been revoked.", + "settings.sessions.none": "No sessions found.", + "settings.sessions.session_not_found": "Session not found.", "settings.manage_account_links": "Manage Linked Accounts", "settings.manage_account_links_desc": "These external accounts are linked to your Gitea account.", "settings.account_links_not_available": "No external accounts are currently linked to your Gitea account.", @@ -2997,6 +3014,8 @@ "admin.dashboard.sync_branch.started": "Branches Sync started", "admin.dashboard.sync_tag.started": "Tags Sync started", "admin.dashboard.rebuild_issue_indexer": "Rebuild issue indexer", + "admin.dashboard.cleanup_user_sessions": "Cleanup expired user session records", + "admin.settings.sessions.revoke_all": "Revoke All Sessions", "admin.dashboard.sync_repo_licenses": "Sync repo licenses", "admin.users.user_manage_panel": "User Account Management", "admin.users.new_account": "Create User Account", diff --git a/routers/web/admin/sessions.go b/routers/web/admin/sessions.go new file mode 100644 index 0000000000..9d86729b89 --- /dev/null +++ b/routers/web/admin/sessions.go @@ -0,0 +1,147 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/session" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const tplUserSessions templates.TplName = "admin/user/sessions" + +// UserSessions shows all sessions for a user +func UserSessions(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Redirect(setting.AppSubURL + "/-/admin/users") + } else { + ctx.ServerError("GetUserByID", err) + } + return + } + + ctx.Data["Title"] = fmt.Sprintf("%s - %s", ctx.Tr("admin.users.details"), ctx.Tr("settings.sessions")) + ctx.Data["PageIsAdminUsers"] = true + ctx.Data["User"] = u + + sessions, err := auth_model.GetUserSessionsByUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("GetUserSessionsByUserID", err) + return + } + + ctx.Data["Sessions"] = sessions + + activeCount := 0 + for _, s := range sessions { + if s.LogoutUnix == 0 { + activeCount++ + } + } + ctx.Data["ActiveCount"] = activeCount + + ctx.HTML(http.StatusOK, tplUserSessions) +} + +// RevokeUserSession revokes a single session for a user (admin action) +func RevokeUserSession(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + form := web.GetForm(ctx).(*forms.RevokeSessionForm) + if form.SessionID == "" { + ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found")) + ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID)) + return + } + + // Verify the session belongs to the target user + sess, err := auth_model.GetUserSessionByID(ctx, form.SessionID) + if err != nil { + if auth_model.IsErrUserSessionNotExist(err) { + ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found")) + ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID)) + return + } + ctx.ServerError("GetUserSessionByID", err) + return + } + if sess.UserID != u.ID { + ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found")) + ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID)) + return + } + + if err := auth_model.InvalidateUserSession(ctx, form.SessionID); err != nil { + ctx.ServerError("InvalidateUserSession", err) + return + } + + if err := session.DestroySessionByID(form.SessionID); err != nil { + log.Error("Failed to destroy chi-session %s: %v", form.SessionID, err) + } + + // Delete the specific remember-me auth token so the browser can't auto-sign back in + if sess.AuthTokenID != "" { + if err := auth_model.DeleteAuthTokenByID(ctx, sess.AuthTokenID); err != nil { + log.Error("Failed to delete auth token %s: %v", sess.AuthTokenID, err) + } + } + + ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_success")) + ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID)) +} + +// RevokeAllUserSessions revokes all sessions for a user (admin action) +func RevokeAllUserSessions(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + sessions, err := auth_model.GetUserSessionsByUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("GetUserSessionsByUserID", err) + return + } + + // Destroy active chi-sessions first, then bulk-update DB metadata. + // DestroySessionByID is what actually revokes the session; InvalidateAllUserSessions + // only sets logout_unix for audit purposes (there is no per-request DB validity check). + for _, s := range sessions { + if s.LogoutUnix == 0 { + if err := session.DestroySessionByID(s.ID); err != nil { + log.Error("Failed to destroy chi-session %s: %v", s.ID, err) + } + } + } + + if err := auth_model.InvalidateAllUserSessions(ctx, u.ID, ""); err != nil { + ctx.ServerError("InvalidateAllUserSessions", err) + return + } + + // Delete remember-me auth tokens so revoked sessions can't be restored + if err := auth_model.DeleteAuthTokensByUserID(ctx, u.ID); err != nil { + log.Error("Failed to delete auth tokens for user %d: %v", u.ID, err) + } + + ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_all_success")) + ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID)) +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 19499fdab5..76cc78e67e 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -305,6 +305,14 @@ func ViewUser(ctx *context.Context) { ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template ctx.Data["OrgsTotal"] = len(orgs) + sessionsTotal, sessionsActive, err := auth.CountUserSessionsByUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("CountUserSessionsByUserID", err) + return + } + ctx.Data["SessionsTotal"] = sessionsTotal + ctx.Data["SessionsActive"] = sessionsActive + ctx.HTML(http.StatusOK, tplUserView) } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 0503bd02f8..89045d97ed 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "html/template" + "net" "net/http" "net/url" "strings" @@ -126,6 +127,24 @@ func autoSignIn(ctx *context.Context) (bool, error) { return false, fmt.Errorf("unable to updateSession: %w", err) } + // Create tracked user session record for remember-me login + ip := ctx.RemoteAddr() + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if err := auth.CreateUserSession(ctx, &auth.UserSession{ + ID: ctx.Session.ID(), + UserID: u.ID, + LoginIP: ip, + LastIP: ip, + UserAgent: ctx.Req.UserAgent(), + LoginMethod: "remember_me", + AuthTokenID: nt.ID, + LastAccessUnix: timeutil.TimeStampNow(), + }); err != nil { + log.Error("Failed to create user session record: %v", err) + } + if err := resetLocale(ctx, u); err != nil { return false, err } @@ -352,6 +371,7 @@ func SignInPost(ctx *context.Context) { // User will need to use 2FA TOTP or WebAuthn, save data "twofaUid": u.ID, "twofaRemember": form.Remember, + "_loginMethod": "password", } if hasTOTPtwofa { // User will need to use WebAuthn, save data @@ -382,13 +402,14 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { } func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) { + var authTokenID string if remember { nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) if err != nil { ctx.ServerError("CreateAuthTokenForUserID", err) return } - + authTokenID = nt.ID ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) } @@ -417,6 +438,29 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) { return } + // Create tracked user session record + loginMethod := "password" + if method, ok := ctx.Session.Get("_loginMethod").(string); ok && method != "" { + loginMethod = method + } + _ = ctx.Session.Delete("_loginMethod") + ip := ctx.RemoteAddr() + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if err := auth.CreateUserSession(ctx, &auth.UserSession{ + ID: ctx.Session.ID(), + UserID: u.ID, + LoginIP: ip, + LastIP: ip, + UserAgent: ctx.Req.UserAgent(), + LoginMethod: loginMethod, + AuthTokenID: authTokenID, + LastAccessUnix: timeutil.TimeStampNow(), + }); err != nil { + log.Error("Failed to create user session record: %v", err) + } + // Language setting of the user overwrites the one previously set // If the user does not have a locale set, we save the current one. if u.Language == "" { @@ -462,6 +506,9 @@ func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) { // HandleSignOut resets the session and sets the cookies func HandleSignOut(ctx *context.Context) { + if err := auth.InvalidateUserSession(ctx, ctx.Session.ID()); err != nil { + log.Error("Failed to invalidate user session: %v", err) + } _ = ctx.Session.Flush() _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) ctx.DeleteSiteCookie(setting.CookieRememberName) diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 8645aedbde..1c9368d4fd 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -9,6 +9,7 @@ import ( "fmt" "html" "io" + "net" "net/http" "net/url" "sort" @@ -23,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/context" @@ -391,6 +393,23 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m return } + // Create tracked user session record for OAuth2 login + ip := ctx.RemoteAddr() + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if err := auth.CreateUserSession(ctx, &auth.UserSession{ + ID: ctx.Session.ID(), + UserID: u.ID, + LoginIP: ip, + LastIP: ip, + UserAgent: ctx.Req.UserAgent(), + LoginMethod: "oauth2:" + authSource.Name, + LastAccessUnix: timeutil.TimeStampNow(), + }); err != nil { + log.Error("Failed to create user session record: %v", err) + } + if err := resetLocale(ctx, u); err != nil { ctx.ServerError("resetLocale", err) return @@ -411,6 +430,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, "twofaRemember": false, + "_loginMethod": "oauth2:" + authSource.Name, }); err != nil { ctx.ServerError("updateSession", err) return diff --git a/routers/web/user/setting/security/sessions.go b/routers/web/user/setting/security/sessions.go new file mode 100644 index 0000000000..7227080e30 --- /dev/null +++ b/routers/web/user/setting/security/sessions.go @@ -0,0 +1,129 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package security + +import ( + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/session" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const tplSettingsSecuritySessions templates.TplName = "user/settings/security/sessions" + +// Sessions renders the user's active sessions page +func Sessions(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.sessions") + ctx.Data["PageIsSettingsSecurity"] = true + + sessions, err := auth_model.GetUserSessionsByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserSessionsByUserID", err) + return + } + + ctx.Data["Sessions"] = sessions + ctx.Data["CurrentSessionID"] = ctx.Session.ID() + + otherActive := 0 + for _, s := range sessions { + if s.LogoutUnix == 0 && s.ID != ctx.Session.ID() { + otherActive++ + } + } + ctx.Data["OtherActiveCount"] = otherActive + + ctx.HTML(http.StatusOK, tplSettingsSecuritySessions) +} + +// RevokeSession revokes a single user session +func RevokeSession(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RevokeSessionForm) + if form.SessionID == "" { + ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions") + return + } + + // Verify the session belongs to the current user + sess, err := auth_model.GetUserSessionByID(ctx, form.SessionID) + if err != nil { + if auth_model.IsErrUserSessionNotExist(err) { + ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions") + return + } + ctx.ServerError("GetUserSessionByID", err) + return + } + if sess.UserID != ctx.Doer.ID { + ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions") + return + } + + // Mark as logged out + if err := auth_model.InvalidateUserSession(ctx, form.SessionID); err != nil { + ctx.ServerError("InvalidateUserSession", err) + return + } + + // Destroy the chi-session record via the provider + if err := session.DestroySessionByID(form.SessionID); err != nil { + log.Error("Failed to destroy chi-session %s: %v", form.SessionID, err) + } + + // Delete the specific remember-me auth token so the browser can't auto-sign back in + if sess.AuthTokenID != "" { + if err := auth_model.DeleteAuthTokenByID(ctx, sess.AuthTokenID); err != nil { + log.Error("Failed to delete auth token %s: %v", sess.AuthTokenID, err) + } + } + + ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions") +} + +// RevokeAllSessions revokes all sessions except the current one +func RevokeAllSessions(ctx *context.Context) { + currentSessionID := ctx.Session.ID() + + // Get all active sessions for the user to destroy their chi-sessions + sessions, err := auth_model.GetUserSessionsByUserID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserSessionsByUserID", err) + return + } + + // Destroy active chi-sessions first, then bulk-update DB metadata. + // DestroySessionByID is what actually revokes the session; InvalidateAllUserSessions + // only sets logout_unix for audit purposes (there is no per-request DB validity check). + for _, s := range sessions { + if s.ID != currentSessionID && s.LogoutUnix == 0 { + if err := session.DestroySessionByID(s.ID); err != nil { + log.Error("Failed to destroy chi-session %s: %v", s.ID, err) + } + } + } + + // Invalidate all sessions except current + if err := auth_model.InvalidateAllUserSessions(ctx, ctx.Doer.ID, currentSessionID); err != nil { + ctx.ServerError("InvalidateAllUserSessions", err) + return + } + + // Delete remember-me auth tokens so revoked sessions can't be restored + if err := auth_model.DeleteAuthTokensByUserID(ctx, ctx.Doer.ID); err != nil { + log.Error("Failed to delete auth tokens for user %d: %v", ctx.Doer.ID, err) + } + + ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_all_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions") +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..25d856d0c2 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -4,6 +4,7 @@ package web import ( + "net" "net/http" "strings" @@ -19,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" @@ -97,6 +99,30 @@ type AuthMiddleware struct { MiddlewareHandler func(*context.Context) } +const sessionActivityThreshold = 5 * 60 // 5 minutes in seconds + +// updateUserSessionActivity updates the user session's last access time with throttling. +// It reads "_last_tracked" from session data (already loaded, no extra DB read) and +// only writes to DB if more than 5 minutes have elapsed. The DB write is a single +// UPDATE statement with no prior SELECT. +func updateUserSessionActivity(ctx *context.Context) { + now := timeutil.TimeStampNow() + if lastTracked, ok := ctx.Session.Get("_last_tracked").(int64); ok { + if int64(now)-lastTracked < sessionActivityThreshold { + return + } + } + ip := ctx.RemoteAddr() + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if err := auth_model.UpdateSessionActivity(ctx, ctx.Session.ID(), ip); err != nil { + log.Error("Failed to update session activity: %v", err) + return + } + _ = ctx.Session.Set("_last_tracked", int64(now)) +} + func newWebAuthMiddleware() *AuthMiddleware { type keyAllowOAuth2 struct{} type keyAllowBasic struct{} @@ -161,6 +187,11 @@ func newWebAuthMiddleware() *AuthMiddleware { // ensure the session uid is deleted _ = ctx.Session.Delete("uid") } + + // Throttled session activity tracking for signed-in web sessions + if ctx.IsSigned && !ctx.IsBasicAuth { + updateUserSessionActivity(ctx) + } } return webAuth } @@ -651,6 +682,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/toggle_visibility", security.ToggleOpenIDVisibility) }, openIDSignInEnabled) m.Post("/account_link", security.DeleteAccountLink) + m.Group("/sessions", func() { + m.Get("", security.Sessions) + m.Post("/revoke", web.Bind(forms.RevokeSessionForm{}), security.RevokeSession) + m.Post("/revoke_all", security.RevokeAllSessions) + }) }) m.Group("/applications", func() { @@ -788,6 +824,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/{userid}/delete", admin.DeleteUser) m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost) m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) + m.Group("/{userid}/sessions", func() { + m.Get("", admin.UserSessions) + m.Post("/revoke", web.Bind(forms.RevokeSessionForm{}), admin.RevokeUserSession) + m.Post("/revoke_all", admin.RevokeAllUserSessions) + }) }) m.Group("/badges", func() { diff --git a/services/auth/auth.go b/services/auth/auth.go index ebe8277f77..53762c0624 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -7,13 +7,16 @@ package auth import ( "errors" "fmt" + "net" "net/http" + auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/auth/webauthn" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/session" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web/middleware" user_service "code.gitea.io/gitea/services/user" ) @@ -39,7 +42,7 @@ func Init() { } // handleSignIn clears existing session variables and stores new ones for the specified user object -func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) { +func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User, loginMethod string) { // We need to regenerate the session... newSess, err := session.RegenerateSession(resp, req) if err != nil { @@ -65,6 +68,23 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore log.Error(fmt.Sprintf("Error setting session: %v", err)) } + // Create tracked user session record + ip := req.RemoteAddr + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if err := auth_model.CreateUserSession(req.Context(), &auth_model.UserSession{ + ID: sess.ID(), + UserID: user.ID, + LoginIP: ip, + LastIP: ip, + UserAgent: req.UserAgent(), + LoginMethod: loginMethod, + LastAccessUnix: timeutil.TimeStampNow(), + }); err != nil { + log.Error("Failed to create user session record: %v", err) + } + // Language setting of the user overwrites the one previously set // If the user does not have a locale set, we save the current one. if len(user.Language) == 0 { diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index e9b08d75f2..bbfdf3f790 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -119,7 +119,7 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da if r.CreateSession { if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) { - handleSignIn(w, req, sess, user) + handleSignIn(w, req, sess, user, "reverse_proxy") } } store.GetData()["IsReverseProxy"] = true diff --git a/services/auth/sspi.go b/services/auth/sspi.go index c21978f55a..bb204b4e49 100644 --- a/services/auth/sspi.go +++ b/services/auth/sspi.go @@ -121,7 +121,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, } if s.CreateSession { - handleSignIn(w, req, sess, user) + handleSignIn(w, req, sess, user, "sspi") } log.Trace("SSPI Authorization: Logged in user %-v", user) diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 74fb12430d..eb63904d52 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -8,6 +8,7 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git/gitcmd" @@ -171,6 +172,21 @@ func registerDeleteOldSystemNotices() { }) } +func registerCleanupUserSessions() { + RegisterTaskFatal("cleanup_user_sessions", &OlderThanConfig{ + BaseConfig: BaseConfig{ + Enabled: false, + RunAtStart: false, + Schedule: "@every 24h", + }, + OlderThan: time.Hour * 24 * 30, // 30 day retention + }, func(ctx context.Context, _ *user_model.User, config Config) error { + olderThanConfig := config.(*OlderThanConfig) + maxLifetime := time.Duration(setting.SessionConfig.Maxlifetime) * time.Second + return auth_model.CleanupExpiredUserSessions(ctx, olderThanConfig.OlderThan, maxLifetime) + }) +} + type GCLFSConfig struct { BaseConfig OlderThan time.Duration @@ -239,4 +255,5 @@ func initExtendedTasks() { registerDeleteOldSystemNotices() registerGCLFS() registerRebuildIssueIndexer() + registerCleanupUserSessions() } diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 3f65e8c551..bb37e5c8f8 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -430,6 +430,17 @@ func (f *WebauthnDeleteForm) Validate(req *http.Request, errs binding.Errors) bi return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// RevokeSessionForm for revoking a user session +type RevokeSessionForm struct { + SessionID string `binding:"Required"` +} + +// Validate validates the fields +func (f *RevokeSessionForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // PackageSettingForm form for package settings type PackageSettingForm struct { Action string diff --git a/services/user/delete.go b/services/user/delete.go index e5c2908ada..704d93452e 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -193,6 +193,10 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) return fmt.Errorf("DeleteAuthTokensByUserID: %w", err) } + if err := auth_model.DeleteUserSessionsByUserID(ctx, u.ID); err != nil { + return fmt.Errorf("DeleteUserSessionsByUserID: %w", err) + } + if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil { return fmt.Errorf("delete: %w", err) } diff --git a/templates/admin/user/sessions.tmpl b/templates/admin/user/sessions.tmpl new file mode 100644 index 0000000000..adc4e37fd5 --- /dev/null +++ b/templates/admin/user/sessions.tmpl @@ -0,0 +1,64 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user sessions")}} + +
+

+ {{ctx.Locale.Tr "settings.sessions"}} — {{.User.Name}} ({{ctx.Locale.Tr "admin.total" (len .Sessions)}}) +
+ {{if gt .ActiveCount 0}} +
+ +
+ {{end}} + {{ctx.Locale.Tr "admin.users.details"}} +
+

+
+ {{if .Sessions}} +
+ {{range .Sessions}} +
+
+ {{svg "octicon-device-desktop" 32}} +
+
+
+ {{StringUtils.EllipsisString .UserAgent 60}} + {{if .LogoutUnix}} + {{ctx.Locale.Tr "settings.sessions.ended"}} + {{else}} + {{ctx.Locale.Tr "settings.sessions.active"}} + {{end}} +
+
+ {{svg "octicon-globe"}} {{ctx.Locale.Tr "settings.sessions.login_ip"}}: {{.LoginIP}} + {{if .LastIP}} + | {{ctx.Locale.Tr "settings.sessions.last_ip"}}: {{.LastIP}} + {{end}} + {{if and .PrevIP (ne .PrevIP .LastIP) (ne .PrevIP .LoginIP)}} + | {{ctx.Locale.Tr "settings.sessions.prev_ip"}}: {{.PrevIP}} + {{end}} +
+
+ {{svg "octicon-key"}} {{.LoginMethod}} + | {{svg "octicon-calendar"}} {{DateUtils.AbsoluteShort .CreatedUnix}} + | {{ctx.Locale.Tr "settings.sessions.last_active"}}: {{DateUtils.TimeSince .LastAccessUnix}} +
+
+ {{if not .LogoutUnix}} +
+
+ + +
+
+ {{end}} +
+ {{end}} +
+ {{else}} +

{{ctx.Locale.Tr "settings.sessions.none"}}

+ {{end}} +
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/user/view.tmpl b/templates/admin/user/view.tmpl index bb1b4991d8..433f0f284b 100644 --- a/templates/admin/user/view.tmpl +++ b/templates/admin/user/view.tmpl @@ -34,6 +34,12 @@
{{template "explore/user_list" .}}
+

+ {{ctx.Locale.Tr "settings.sessions"}} ({{ctx.Locale.Tr "admin.total" .SessionsTotal}}, {{ctx.Locale.Tr "settings.sessions.active"}}: {{.SessionsActive}}) +

+
+ {{ctx.Locale.Tr "settings.sessions.manage"}} +
{{template "admin/layout_footer" .}} diff --git a/templates/user/settings/security/security.tmpl b/templates/user/settings/security/security.tmpl index 90e44ba941..9936cf5ff6 100644 --- a/templates/user/settings/security/security.tmpl +++ b/templates/user/settings/security/security.tmpl @@ -1,6 +1,15 @@ {{template "user/settings/layout_head" (dict "pageClass" "user settings security")}} {{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
+ +

+ {{ctx.Locale.Tr "settings.sessions"}} +

+
+

{{ctx.Locale.Tr "settings.sessions_desc"}}

+ {{ctx.Locale.Tr "settings.sessions.manage"}} +
+ {{if not ($.UserDisabledFeatures.Contains "manage_mfa")}} {{template "user/settings/security/twofa" .}} {{template "user/settings/security/webauthn" .}} diff --git a/templates/user/settings/security/sessions.tmpl b/templates/user/settings/security/sessions.tmpl new file mode 100644 index 0000000000..7c095b4b04 --- /dev/null +++ b/templates/user/settings/security/sessions.tmpl @@ -0,0 +1,65 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings security")}} +
+ +

+ {{ctx.Locale.Tr "settings.sessions"}} + {{if gt .OtherActiveCount 0}} +
+
+ +
+
+ {{end}} +

+
+

{{ctx.Locale.Tr "settings.sessions_desc"}}

+ {{if .Sessions}} +
+ {{range .Sessions}} +
+
+ {{svg "octicon-device-desktop" 32}} +
+
+
+ {{StringUtils.EllipsisString .UserAgent 60}} + {{if eq .ID $.CurrentSessionID}} + {{ctx.Locale.Tr "settings.sessions.current"}} + {{end}} + {{if .LogoutUnix}} + {{ctx.Locale.Tr "settings.sessions.ended"}} + {{end}} +
+
+ {{svg "octicon-globe"}} {{ctx.Locale.Tr "settings.sessions.login_ip"}}: {{.LoginIP}} + {{if .LastIP}} + | {{ctx.Locale.Tr "settings.sessions.last_ip"}}: {{.LastIP}} + {{end}} + {{if and .PrevIP (ne .PrevIP .LastIP) (ne .PrevIP .LoginIP)}} + | {{ctx.Locale.Tr "settings.sessions.prev_ip"}}: {{.PrevIP}} + {{end}} +
+
+ {{svg "octicon-key"}} {{.LoginMethod}} + | {{svg "octicon-calendar"}} {{DateUtils.AbsoluteShort .CreatedUnix}} + | {{ctx.Locale.Tr "settings.sessions.last_active"}}: {{DateUtils.TimeSince .LastAccessUnix}} +
+
+ {{if and (not .LogoutUnix) (ne .ID $.CurrentSessionID)}} +
+
+ + +
+
+ {{end}} +
+ {{end}} +
+ {{else}} +

{{ctx.Locale.Tr "settings.sessions.none"}}

+ {{end}} +
+ +
+{{template "user/settings/layout_footer" .}} diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index 291cd21984..a3f62b4a50 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -300,10 +300,10 @@ func TestAPICron(t *testing.T) { AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "29", resp.Header().Get("X-Total-Count")) + assert.Equal(t, "30", resp.Header().Get("X-Total-Count")) crons := DecodeJSON(t, resp, []api.Cron{}) - assert.Len(t, crons, 29) + assert.Len(t, crons, 30) }) t.Run("Execute", func(t *testing.T) {