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:
commit
c6c2736d10
@ -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
156
models/auth/user_session.go
Normal 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
|
||||
}
|
||||
255
models/auth/user_session_test.go
Normal file
255
models/auth/user_session_test.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
27
models/migrations/v1_27/v332.go
Normal file
27
models/migrations/v1_27/v332.go
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
160
modules/session/virtual_test.go
Normal file
160
modules/session/virtual_test.go
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
147
routers/web/admin/sessions.go
Normal file
147
routers/web/admin/sessions.go
Normal 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))
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
129
routers/web/user/setting/security/sessions.go
Normal file
129
routers/web/user/setting/security/sessions.go
Normal 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")
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
64
templates/admin/user/sessions.tmpl
Normal file
64
templates/admin/user/sessions.tmpl
Normal 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" .}}
|
||||
@ -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" .}}
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
65
templates/user/settings/security/sessions.tmpl
Normal file
65
templates/user/settings/security/sessions.tmpl
Normal 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" .}}
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user