mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 13:54:48 +02:00
Add user session management and auditing
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ package session
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
|
||||
@@ -17,9 +18,19 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
destroyedSIDs sync.Map // sid -> time.Time
|
||||
}
|
||||
|
||||
// Init initializes the cookie session provider with the given config.
|
||||
@@ -52,11 +63,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 +99,7 @@ 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())
|
||||
return o.provider.Destroy(sid)
|
||||
}
|
||||
|
||||
@@ -96,7 +119,33 @@ 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()
|
||||
|
||||
// Clean up tombstone entries and re-destroy any files that may have
|
||||
// been recreated by concurrent requests releasing after destruction.
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
var stale []string
|
||||
var active []string
|
||||
o.destroyedSIDs.Range(func(key, value any) bool {
|
||||
sid := key.(string)
|
||||
if value.(time.Time).Before(cutoff) {
|
||||
stale = append(stale, sid)
|
||||
} else {
|
||||
active = append(active, sid)
|
||||
}
|
||||
return true
|
||||
})
|
||||
for _, sid := range stale {
|
||||
o.destroyedSIDs.Delete(sid)
|
||||
}
|
||||
if len(active) > 0 {
|
||||
for _, sid := range active {
|
||||
_ = o.provider.Destroy(sid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -105,11 +154,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 +171,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 +218,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user