0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-14 21:47:38 +02:00

Merge 3e2fe1fed46d04bc4a9611f928bb3eee5885fe57 into 0a3aaeafe7bef9d6935422f4b91c77c216c01b21

This commit is contained in:
Eric Lesiuta 2026-05-09 14:09:37 -04:00 committed by GitHub
commit c6c2736d10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1312 additions and 11 deletions

View File

@ -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

156
models/auth/user_session.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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",

View File

@ -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))
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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")
}

View File

@ -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() {

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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()
}

View File

@ -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

View File

@ -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)
}

View File

@ -0,0 +1,64 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user sessions")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.sessions"}}{{.User.Name}} ({{ctx.Locale.Tr "admin.total" (len .Sessions)}})
<div class="ui right">
{{if gt .ActiveCount 0}}
<form class="tw-inline" action="{{AppSubUrl}}/-/admin/users/{{.User.ID}}/sessions/revoke_all" method="post">
<button class="ui red tiny button">{{ctx.Locale.Tr "admin.settings.sessions.revoke_all"}}</button>
</form>
{{end}}
<a class="ui primary tiny button" href="{{AppSubUrl}}/-/admin/users/{{.User.ID}}">{{ctx.Locale.Tr "admin.users.details"}}</a>
</div>
</h4>
<div class="ui attached segment">
{{if .Sessions}}
<div class="flex-divided-list items-with-main">
{{range .Sessions}}
<div class="item">
<div class="item-leading">
{{svg "octicon-device-desktop" 32}}
</div>
<div class="item-main">
<div class="item-title">
{{StringUtils.EllipsisString .UserAgent 60}}
{{if .LogoutUnix}}
<span class="ui grey label">{{ctx.Locale.Tr "settings.sessions.ended"}}</span>
{{else}}
<span class="ui green label">{{ctx.Locale.Tr "settings.sessions.active"}}</span>
{{end}}
</div>
<div class="item-body">
<span class="flex-text-inline">{{svg "octicon-globe"}} {{ctx.Locale.Tr "settings.sessions.login_ip"}}: {{.LoginIP}}</span>
{{if .LastIP}}
<span class="flex-text-inline">| {{ctx.Locale.Tr "settings.sessions.last_ip"}}: {{.LastIP}}</span>
{{end}}
{{if and .PrevIP (ne .PrevIP .LastIP) (ne .PrevIP .LoginIP)}}
<span class="flex-text-inline">| {{ctx.Locale.Tr "settings.sessions.prev_ip"}}: {{.PrevIP}}</span>
{{end}}
</div>
<div class="item-body">
<span class="flex-text-inline">{{svg "octicon-key"}} {{.LoginMethod}}</span>
<span class="flex-text-inline">| {{svg "octicon-calendar"}} {{DateUtils.AbsoluteShort .CreatedUnix}}</span>
<span class="flex-text-inline">| {{ctx.Locale.Tr "settings.sessions.last_active"}}: {{DateUtils.TimeSince .LastAccessUnix}}</span>
</div>
</div>
{{if not .LogoutUnix}}
<div class="item-trailing">
<form action="{{AppSubUrl}}/-/admin/users/{{$.User.ID}}/sessions/revoke" method="post">
<input type="hidden" name="session_id" value="{{.ID}}">
<button class="ui red tiny button">{{ctx.Locale.Tr "settings.sessions.revoke"}}</button>
</form>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p>{{ctx.Locale.Tr "settings.sessions.none"}}</p>
{{end}}
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@ -34,6 +34,12 @@
<div class="ui attached segment">
{{template "explore/user_list" .}}
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.sessions"}} ({{ctx.Locale.Tr "admin.total" .SessionsTotal}}, {{ctx.Locale.Tr "settings.sessions.active"}}: {{.SessionsActive}})
</h4>
<div class="ui attached segment">
<a class="ui primary tiny button" href="{{AppSubUrl}}/-/admin/users/{{.User.ID}}/sessions">{{ctx.Locale.Tr "settings.sessions.manage"}}</a>
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@ -1,6 +1,15 @@
{{template "user/settings/layout_head" (dict "pageClass" "user settings security")}}
{{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.sessions"}}
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "settings.sessions_desc"}}</p>
<a class="ui primary button" href="{{AppSubUrl}}/user/settings/security/sessions">{{ctx.Locale.Tr "settings.sessions.manage"}}</a>
</div>
{{if not ($.UserDisabledFeatures.Contains "manage_mfa")}}
{{template "user/settings/security/twofa" .}}
{{template "user/settings/security/webauthn" .}}

View File

@ -0,0 +1,65 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings security")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.sessions"}}
{{if gt .OtherActiveCount 0}}
<div class="ui right">
<form class="tw-inline" action="{{AppSubUrl}}/user/settings/security/sessions/revoke_all" method="post">
<button class="ui red tiny button">{{ctx.Locale.Tr "settings.sessions.revoke_all"}}</button>
</form>
</div>
{{end}}
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "settings.sessions_desc"}}</p>
{{if .Sessions}}
<div class="flex-divided-list items-with-main">
{{range .Sessions}}
<div class="item {{if eq .ID $.CurrentSessionID}}tw-bg-green-50{{end}}">
<div class="item-leading">
{{svg "octicon-device-desktop" 32}}
</div>
<div class="item-main">
<div class="item-title">
{{StringUtils.EllipsisString .UserAgent 60}}
{{if eq .ID $.CurrentSessionID}}
<span class="ui green label">{{ctx.Locale.Tr "settings.sessions.current"}}</span>
{{end}}
{{if .LogoutUnix}}
<span class="ui grey label">{{ctx.Locale.Tr "settings.sessions.ended"}}</span>
{{end}}
</div>
<div class="item-body">
<span class="flex-text-inline">{{svg "octicon-globe"}} {{ctx.Locale.Tr "settings.sessions.login_ip"}}: {{.LoginIP}}</span>
{{if .LastIP}}
<span class="flex-text-inline">| {{ctx.Locale.Tr "settings.sessions.last_ip"}}: {{.LastIP}}</span>
{{end}}
{{if and .PrevIP (ne .PrevIP .LastIP) (ne .PrevIP .LoginIP)}}
<span class="flex-text-inline">| {{ctx.Locale.Tr "settings.sessions.prev_ip"}}: {{.PrevIP}}</span>
{{end}}
</div>
<div class="item-body">
<span class="flex-text-inline">{{svg "octicon-key"}} {{.LoginMethod}}</span>
<span class="flex-text-inline">| {{svg "octicon-calendar"}} {{DateUtils.AbsoluteShort .CreatedUnix}}</span>
<span class="flex-text-inline">| {{ctx.Locale.Tr "settings.sessions.last_active"}}: {{DateUtils.TimeSince .LastAccessUnix}}</span>
</div>
</div>
{{if and (not .LogoutUnix) (ne .ID $.CurrentSessionID)}}
<div class="item-trailing">
<form action="{{AppSubUrl}}/user/settings/security/sessions/revoke" method="post">
<input type="hidden" name="session_id" value="{{.ID}}">
<button class="ui red tiny button">{{ctx.Locale.Tr "settings.sessions.revoke"}}</button>
</form>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p>{{ctx.Locale.Tr "settings.sessions.none"}}</p>
{{end}}
</div>
</div>
{{template "user/settings/layout_footer" .}}

View File

@ -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) {