mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-02 06:39:32 +02:00
Add user session management and auditing
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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 retentionSeconds ago
|
||||
// - Abandoned sessions (never logged out) whose last activity is older than maxLifetime + retentionSeconds
|
||||
func CleanupExpiredUserSessions(ctx context.Context, retentionSeconds, maxLifetime int64) error {
|
||||
now := int64(timeutil.TimeStampNow())
|
||||
logoutCutoff := now - retentionSeconds
|
||||
abandonedCutoff := now - maxLifetime - retentionSeconds
|
||||
|
||||
_, 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
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
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)
|
||||
|
||||
retentionSeconds := int64(86400 * 30) // 30 days
|
||||
maxLifetime := int64(86400) // 1 day
|
||||
require.NoError(t, auth_model.CleanupExpiredUserSessions(t.Context(), retentionSeconds, 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()
|
||||
retentionSeconds := int64(86400 * 30) // 30 days
|
||||
maxLifetime := int64(86400) // 1 day
|
||||
|
||||
cutoff := int64(now) - maxLifetime - retentionSeconds
|
||||
|
||||
// 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(), retentionSeconds, 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)
|
||||
}
|
||||
Reference in New Issue
Block a user