mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-17 23:10:52 +02:00
Merge 3e2fe1fed46d04bc4a9611f928bb3eee5885fe57 into 0a3aaeafe7bef9d6935422f4b91c77c216c01b21
This commit is contained in:
commit
c6c2736d10
@ -2473,6 +2473,20 @@ LEVEL = Info
|
|||||||
;SCHEDULE = @every 168h
|
;SCHEDULE = @every 168h
|
||||||
;OLDER_THAN = 8760h
|
;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
|
;; 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)
|
// 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(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
|
||||||
|
newMigration(332, "Add database-backed user session tracking", v1_27.AddUserSessionTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
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{}
|
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
|
// RegenerateSession regenerates the underlying session and returns the new store
|
||||||
func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, error) {
|
func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, error) {
|
||||||
for _, f := range BeforeRegenerateSession {
|
for _, f := range BeforeRegenerateSession {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ package session
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
|
||||||
@ -16,10 +17,27 @@ import (
|
|||||||
postgres "gitea.com/go-chi/session/postgres"
|
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.
|
// 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 {
|
type VirtualSessionProvider struct {
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
provider session.Provider
|
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.
|
// Init initializes the cookie session provider with the given config.
|
||||||
@ -52,11 +70,22 @@ func (o *VirtualSessionProvider) Init(gcLifetime int64, config string) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("VirtualSessionProvider: Unknown Provider: %s", opts.Provider)
|
return fmt.Errorf("VirtualSessionProvider: Unknown Provider: %s", opts.Provider)
|
||||||
}
|
}
|
||||||
|
SetGlobalProvider(o)
|
||||||
return o.provider.Init(gcLifetime, opts.ProviderConfig)
|
return o.provider.Init(gcLifetime, opts.ProviderConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read returns raw session store by session ID.
|
// Read returns raw session store by session ID.
|
||||||
func (o *VirtualSessionProvider) Read(sid string) (session.RawStore, error) {
|
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()
|
o.lock.RLock()
|
||||||
defer o.lock.RUnlock()
|
defer o.lock.RUnlock()
|
||||||
if exist, err := o.provider.Exist(sid); err == nil && exist {
|
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 {
|
func (o *VirtualSessionProvider) Destroy(sid string) error {
|
||||||
o.lock.Lock()
|
o.lock.Lock()
|
||||||
defer o.lock.Unlock()
|
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)
|
return o.provider.Destroy(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,7 +130,18 @@ func (o *VirtualSessionProvider) Count() (int, error) {
|
|||||||
|
|
||||||
// GC calls GC to clean expired sessions.
|
// GC calls GC to clean expired sessions.
|
||||||
func (o *VirtualSessionProvider) GC() {
|
func (o *VirtualSessionProvider) GC() {
|
||||||
|
o.lock.Lock()
|
||||||
|
defer o.lock.Unlock()
|
||||||
|
|
||||||
o.provider.GC()
|
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() {
|
func init() {
|
||||||
@ -105,11 +150,12 @@ func init() {
|
|||||||
|
|
||||||
// VirtualStore represents a virtual session store implementation.
|
// VirtualStore represents a virtual session store implementation.
|
||||||
type VirtualStore struct {
|
type VirtualStore struct {
|
||||||
p *VirtualSessionProvider
|
p *VirtualSessionProvider
|
||||||
sid string
|
sid string
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
data map[any]any
|
data map[any]any
|
||||||
released bool
|
released bool
|
||||||
|
invalidated bool // true for destroyed sessions — all writes are no-ops
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVirtualStore creates and returns a virtual session store.
|
// 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.
|
// Set sets value to given key in session.
|
||||||
func (s *VirtualStore) Set(key, val any) error {
|
func (s *VirtualStore) Set(key, val any) error {
|
||||||
|
if s.invalidated {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
defer s.lock.Unlock()
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
@ -154,6 +214,9 @@ func (s *VirtualStore) ID() string {
|
|||||||
|
|
||||||
// Release releases resource and save data to provider.
|
// Release releases resource and save data to provider.
|
||||||
func (s *VirtualStore) Release() error {
|
func (s *VirtualStore) Release() error {
|
||||||
|
if s.invalidated {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
defer s.lock.Unlock()
|
defer s.lock.Unlock()
|
||||||
// Now need to lock the provider
|
// 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_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_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.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": "Manage Linked Accounts",
|
||||||
"settings.manage_account_links_desc": "These external accounts are linked to your Gitea account.",
|
"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.",
|
"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_branch.started": "Branches Sync started",
|
||||||
"admin.dashboard.sync_tag.started": "Tags Sync started",
|
"admin.dashboard.sync_tag.started": "Tags Sync started",
|
||||||
"admin.dashboard.rebuild_issue_indexer": "Rebuild issue indexer",
|
"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.dashboard.sync_repo_licenses": "Sync repo licenses",
|
||||||
"admin.users.user_manage_panel": "User Account Management",
|
"admin.users.user_manage_panel": "User Account Management",
|
||||||
"admin.users.new_account": "Create User Account",
|
"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["Users"] = orgs // needed to be able to use explore/user_list template
|
||||||
ctx.Data["OrgsTotal"] = len(orgs)
|
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)
|
ctx.HTML(http.StatusOK, tplUserView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@ -126,6 +127,24 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
|||||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
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 {
|
if err := resetLocale(ctx, u); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@ -352,6 +371,7 @@ func SignInPost(ctx *context.Context) {
|
|||||||
// User will need to use 2FA TOTP or WebAuthn, save data
|
// User will need to use 2FA TOTP or WebAuthn, save data
|
||||||
"twofaUid": u.ID,
|
"twofaUid": u.ID,
|
||||||
"twofaRemember": form.Remember,
|
"twofaRemember": form.Remember,
|
||||||
|
"_loginMethod": "password",
|
||||||
}
|
}
|
||||||
if hasTOTPtwofa {
|
if hasTOTPtwofa {
|
||||||
// User will need to use WebAuthn, save data
|
// 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) {
|
func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||||
|
var authTokenID string
|
||||||
if remember {
|
if remember {
|
||||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
ctx.ServerError("CreateAuthTokenForUserID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authTokenID = nt.ID
|
||||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
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
|
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
|
// 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 the user does not have a locale set, we save the current one.
|
||||||
if u.Language == "" {
|
if u.Language == "" {
|
||||||
@ -462,6 +506,9 @@ func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
|
|||||||
|
|
||||||
// HandleSignOut resets the session and sets the cookies
|
// HandleSignOut resets the session and sets the cookies
|
||||||
func HandleSignOut(ctx *context.Context) {
|
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.Flush()
|
||||||
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
|
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
|
||||||
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
@ -23,6 +24,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/session"
|
"code.gitea.io/gitea/modules/session"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
source_service "code.gitea.io/gitea/services/auth/source"
|
source_service "code.gitea.io/gitea/services/auth/source"
|
||||||
"code.gitea.io/gitea/services/auth/source/oauth2"
|
"code.gitea.io/gitea/services/auth/source/oauth2"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
@ -391,6 +393,23 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
return
|
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 {
|
if err := resetLocale(ctx, u); err != nil {
|
||||||
ctx.ServerError("resetLocale", err)
|
ctx.ServerError("resetLocale", err)
|
||||||
return
|
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.
|
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||||
"twofaUid": u.ID,
|
"twofaUid": u.ID,
|
||||||
"twofaRemember": false,
|
"twofaRemember": false,
|
||||||
|
"_loginMethod": "oauth2:" + authSource.Name,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
ctx.ServerError("updateSession", err)
|
ctx.ServerError("updateSession", err)
|
||||||
return
|
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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/storage"
|
"code.gitea.io/gitea/modules/storage"
|
||||||
"code.gitea.io/gitea/modules/structs"
|
"code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/validation"
|
"code.gitea.io/gitea/modules/validation"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
@ -97,6 +99,30 @@ type AuthMiddleware struct {
|
|||||||
MiddlewareHandler func(*context.Context)
|
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 {
|
func newWebAuthMiddleware() *AuthMiddleware {
|
||||||
type keyAllowOAuth2 struct{}
|
type keyAllowOAuth2 struct{}
|
||||||
type keyAllowBasic struct{}
|
type keyAllowBasic struct{}
|
||||||
@ -161,6 +187,11 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
|||||||
// ensure the session uid is deleted
|
// ensure the session uid is deleted
|
||||||
_ = ctx.Session.Delete("uid")
|
_ = ctx.Session.Delete("uid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttled session activity tracking for signed-in web sessions
|
||||||
|
if ctx.IsSigned && !ctx.IsBasicAuth {
|
||||||
|
updateUserSessionActivity(ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return webAuth
|
return webAuth
|
||||||
}
|
}
|
||||||
@ -651,6 +682,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Post("/toggle_visibility", security.ToggleOpenIDVisibility)
|
m.Post("/toggle_visibility", security.ToggleOpenIDVisibility)
|
||||||
}, openIDSignInEnabled)
|
}, openIDSignInEnabled)
|
||||||
m.Post("/account_link", security.DeleteAccountLink)
|
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() {
|
m.Group("/applications", func() {
|
||||||
@ -788,6 +824,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Post("/{userid}/delete", admin.DeleteUser)
|
m.Post("/{userid}/delete", admin.DeleteUser)
|
||||||
m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost)
|
m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost)
|
||||||
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
|
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() {
|
m.Group("/badges", func() {
|
||||||
|
|||||||
@ -7,13 +7,16 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/auth/webauthn"
|
"code.gitea.io/gitea/modules/auth/webauthn"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/optional"
|
"code.gitea.io/gitea/modules/optional"
|
||||||
"code.gitea.io/gitea/modules/session"
|
"code.gitea.io/gitea/modules/session"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
user_service "code.gitea.io/gitea/services/user"
|
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
|
// 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...
|
// We need to regenerate the session...
|
||||||
newSess, err := session.RegenerateSession(resp, req)
|
newSess, err := session.RegenerateSession(resp, req)
|
||||||
if err != nil {
|
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))
|
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
|
// 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 the user does not have a locale set, we save the current one.
|
||||||
if len(user.Language) == 0 {
|
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 r.CreateSession {
|
||||||
if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) {
|
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
|
store.GetData()["IsReverseProxy"] = true
|
||||||
|
|||||||
@ -121,7 +121,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.CreateSession {
|
if s.CreateSession {
|
||||||
handleSignIn(w, req, sess, user)
|
handleSignIn(w, req, sess, user, "sspi")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace("SSPI Authorization: Logged in user %-v", user)
|
log.Trace("SSPI Authorization: Logged in user %-v", user)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
activities_model "code.gitea.io/gitea/models/activities"
|
activities_model "code.gitea.io/gitea/models/activities"
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
"code.gitea.io/gitea/models/system"
|
"code.gitea.io/gitea/models/system"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/git/gitcmd"
|
"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 {
|
type GCLFSConfig struct {
|
||||||
BaseConfig
|
BaseConfig
|
||||||
OlderThan time.Duration
|
OlderThan time.Duration
|
||||||
@ -239,4 +255,5 @@ func initExtendedTasks() {
|
|||||||
registerDeleteOldSystemNotices()
|
registerDeleteOldSystemNotices()
|
||||||
registerGCLFS()
|
registerGCLFS()
|
||||||
registerRebuildIssueIndexer()
|
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)
|
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
|
// PackageSettingForm form for package settings
|
||||||
type PackageSettingForm struct {
|
type PackageSettingForm struct {
|
||||||
Action string
|
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)
|
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 {
|
if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil {
|
||||||
return fmt.Errorf("delete: %w", err)
|
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">
|
<div class="ui attached segment">
|
||||||
{{template "explore/user_list" .}}
|
{{template "explore/user_list" .}}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{{template "admin/layout_footer" .}}
|
{{template "admin/layout_footer" .}}
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
{{template "user/settings/layout_head" (dict "pageClass" "user settings security")}}
|
{{template "user/settings/layout_head" (dict "pageClass" "user settings security")}}
|
||||||
{{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
|
{{if not ($.UserDisabledFeatures.Contains "manage_mfa" "manage_credentials")}}
|
||||||
<div class="user-setting-content">
|
<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")}}
|
{{if not ($.UserDisabledFeatures.Contains "manage_mfa")}}
|
||||||
{{template "user/settings/security/twofa" .}}
|
{{template "user/settings/security/twofa" .}}
|
||||||
{{template "user/settings/security/webauthn" .}}
|
{{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)
|
AddTokenAuth(token)
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
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{})
|
crons := DecodeJSON(t, resp, []api.Cron{})
|
||||||
assert.Len(t, crons, 29)
|
assert.Len(t, crons, 30)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Execute", func(t *testing.T) {
|
t.Run("Execute", func(t *testing.T) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user